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 6df51077c5..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 @@ -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/architecture/2026-06-21-bounded-llm-request-recovery.md -2026-06-21-bounded-llm-request-recovery.md: 9c9d8a02595b988535158c9aa0ec43d6f1fa0c89 -2026-06-21-bounded-llm-request-recovery.zh.md: bb9e430eaf87789452fd4cc89085d7d635f54ed1 +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 9c9d8a0259..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,6 +4,8 @@ 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. 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. @@ -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 only the current `LlmFailure`; the loop owns no retry policy or attempt history. Each recovery plugin keeps a private per-agent counter for its own handled failures and clears it at terminal `agent/settled`. Alternating transient and context-overflow failures therefore consume the `dsh-llm-retry` and compact-basic 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 without retrying or entering the rest of its captured waterfall. 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. 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 request failures terminal. +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 @@ -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 only current failure facts; each plugin clears its private per-agent counter at terminal idle, and alternating transient/context-overflow integration tests prove the two policies consume only their own finite budgets. -- `dsh-llm-retry` validates every config field at Loader startup, delegates all ineligible paths with `next()`, and makes at most `maxTransientRetries + 1` provider requests when no other policy applies. -- HMR-during-backoff tests prove disposal unregisters the listener, aborts and awaits its captured callbacks, makes no retry request after disposal, and leaves no timer or promise alive. +- `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 failed turn 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 bb9e430eaf..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,6 +4,8 @@ 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`。未被处理的失败是终态;处理失败的监听器修复策略自有状态,返回 `{ kind: 'retry' }`,并停止 waterfall 委托。[重试动作决策](../simplification/2026-07-27-request-error-retry-action.md)规定这一返回契约。 @@ -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`;循环不拥有重试策略或尝试历史。每个恢复插件为自身处理的失败维护一个逐 agent 的私有计数器,并在终态 `agent/settled` 时清零。因此,暂时性失败与上下文溢出交替出现时,`dsh-llm-retry` 与 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 清理会先注销监听器,再中止并等待活跃回调;被捕获回调的生命期信号中止时,回调会直接返回,不重试,也不进入其捕获 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()`。这保留了与上下文溢出恢复及后续策略插件的组合能力。对自身处理的失败,它会记录并等待延迟,然后在不委托的情况下返回 `{ kind: 'retry' }`。轮次取消和插件释放会结束等待且不返回重试动作,此后仍以循环的取消/释放检查为准。 -agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次性 CLI(命令行界面)和 ACP(Agent Client Protocol)示例组合使用同一有界策略。库消费方仍需显式组合插件:省略该插件时,请求失败保持终态。 +agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次性 CLI(命令行界面)和 ACP(Agent Client Protocol)示例组合使用同一套按提供方路由的策略。库消费方仍需显式组合插件:省略该插件时,请求失败保持终态。 ### 由单一层负责可见的尝试 @@ -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` 只携带当前失败事实;每个插件在终态空闲时清零其逐 agent 私有计数器,暂时性失败/上下文溢出交替发生的集成测试证明两种策略只消耗各自的有限预算。 -- `dsh-llm-retry` 在 Loader 启动时验证每个配置字段,使用 `next()` 委托所有不合格路径,而且在没有其他策略时最多发起 `maxTransientRetries + 1` 次提供方请求。 -- 退避期间执行 HMR 的测试证明:释放过程会注销监听器、中止并等待其捕获的回调,释放后不发起重试请求,也不留下存活的定时器或 promise。 +- `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-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-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-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-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-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-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-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-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-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-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-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-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-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/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/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 e6d004b85c..9896ef6734 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: 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/scaffold.ts b/apps/web/tests/scaffold.ts index d57b934068..4d48bb7fcc 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' @@ -112,6 +113,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. */ @@ -165,6 +172,9 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise /** 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 } ``` @@ -124,8 +122,9 @@ Source: [`packages/core/agent-loop/src/index.ts:147`](../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,9 +183,9 @@ 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` @@ -264,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'] } @@ -586,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. */ @@ -601,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` @@ -638,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` @@ -676,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. */ @@ -691,29 +694,20 @@ 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` @@ -1201,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` @@ -1760,7 +1756,7 @@ Source: [`packages/core/tools/src/index.ts:578`](../packages/core/tools/src/inde ## `@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. */ @@ -1806,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` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 3853120d65..1741aba456 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -96,7 +96,7 @@ A step or turn errored. The machine reports a failure here (plus the logger) eve Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:419`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:423`](../../packages/core/agent/src/types.ts) ### `agent/inbox/dequeue` — emit @@ -227,16 +227,20 @@ Handle a model-request failure after its failed step has closed but before the f * @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 + * 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, 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) · [RequestErrorAction](../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:377`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:381`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -283,7 +287,7 @@ One drain chain reached its terminal turn: that turn's `turn/end` is already com 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:406`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:410`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -353,7 +357,7 @@ The turn is about to close: the model owes no response (no live tool calls, no f Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:392`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:396`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` @@ -544,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/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 4636eab20e..888d173df3 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -721,6 +721,13 @@ registerAdapter(providers: string[], adapter: LlmAdapter): () => 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. @@ -1950,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/llm-streaming.i18n.yaml b/docs/core-data-structures/llm-streaming.i18n.yaml index 8574840a6a..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: a6aaaf3fed2ab25821efdf308c7297526ee7f3b5 -llm-streaming.zh.md: 521df7d1dad620cea322865fbefa8e4e2615fa78 +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 a6aaaf3fed..db46deee28 100644 --- a/docs/core-data-structures/llm-streaming.md +++ b/docs/core-data-structures/llm-streaming.md @@ -59,8 +59,8 @@ 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 and facts 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 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). @@ -69,6 +69,10 @@ Every adapter MUST obey these, and every consumer may rely on them: 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 521df7d1da..fbff50bf1f 100644 --- a/docs/core-data-structures/llm-streaming.zh.md +++ b/docs/core-data-structures/llm-streaming.zh.md @@ -59,8 +59,8 @@ 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`。处理该错误的 listener 在其 await 的修复完成后返回 `{ kind: 'retry' }`;若未恢复,结构化失败会成为轮次错误,并且该次尝试不会提交正常 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)。 @@ -69,6 +69,10 @@ interface LlmFailure { 该契约由两个有意保持独立的实现锁定:`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/skills.i18n.yaml b/docs/core-data-structures/skills.i18n.yaml index 81c105f515..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: 1b82da1c59f0159404e6ea792bf95ea4477e0a03 -skills.zh.md: 38450cce01aa81e7898aefffed7f4e27d09b0204 +# 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 1b82da1c59..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. */ diff --git a/docs/core-data-structures/skills.zh.md b/docs/core-data-structures/skills.zh.md index 38450cce01..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. */ 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 5ff92cfcf2..269c972e47 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -11,18 +11,18 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `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:419`](../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/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:377`](../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/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:406`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | +| `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:392`](../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) | +| `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) | @@ -30,10 +30,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `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: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: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) | +| `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), [`llm-retry`](../packages/llm/llm-retry), [`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/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` | diff --git a/docs/module-graph.md b/docs/module-graph.md index 445d25c235..e00757e9b8 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -258,6 +258,7 @@ flowchart TD 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 @@ -897,7 +898,7 @@ flowchart TD | [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/support/invariants) | | [`storage`](../packages/storage/storage) | `storage` | [`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) | +| [`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) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 013778b633..3336676eac 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -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 } ``` 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/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/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/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 e193c45893..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 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/empty-response-retry/session.jsonl b/examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl index 69b5a3033e..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,7 +7,7 @@ {"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":"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}} diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl index 8bbcdce51d..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":"d198d24f-84a1-43fd-949a-68c0bed774f1","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":"d198d24f-84a1-43fd-949a-68c0bed774f1","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 57fb6e7182..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":"6ab15565-ea18-4ff2-9245-9fbe784defb9","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":"6ab15565-ea18-4ff2-9245-9fbe784defb9","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 39640f7337..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,8 +14,8 @@ {"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":"91c62564-8228-4e09-8afb-f9bcdd5d7ca3","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":"91c62564-8228-4e09-8afb-f9bcdd5d7ca3","outcome":"allowed-once"}} +{"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}} 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 2f43472b97..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":"161ec4d8-e80a-45c0-a586-9453a5d09766","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}} -{"type":"approval/decided","seq":58,"time":1783962235813,"data":{"id":"161ec4d8-e80a-45c0-a586-9453a5d09766","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/session-query-spill/session.jsonl b/examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl index fe94785585..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\": 1785122211371,\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/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 55e7f33b2c..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 diff --git a/examples/cordis-agent/cordis.yml b/examples/cordis-agent/cordis.yml index 369886c9e5..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. @@ -64,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. @@ -72,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 9d6b409433..7d9da3f7a2 100644 --- a/examples/cordis-agent/tests/cordis-tools.e2e.ts +++ b/examples/cordis-agent/tests/cordis-tools.e2e.ts @@ -37,15 +37,15 @@ 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({ 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) @@ -54,18 +54,18 @@ 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({ content: [{ type: 'text', text: 'Now unmount the plugin you just mounted.' }], source: { kind: 'user' } }) + 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 () => { @@ -74,7 +74,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif 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 ' @@ -115,13 +115,13 @@ 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({ 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 ' @@ -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({ content: [{ type: 'text', text: 'Now unmount ONLY the provider plugin (the one that provided shouter).' }], source: { kind: 'user' } }) + 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/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/tests/code-mode.e2e.ts b/examples/headless-agent/tests/code-mode.e2e.ts index 824bdfa580..bee296333e 100644 --- a/examples/headless-agent/tests/code-mode.e2e.ts +++ b/examples/headless-agent/tests/code-mode.e2e.ts @@ -251,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) @@ -262,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), }; @@ -290,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, }) 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/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index aee715a279..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', 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/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/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 026bed95b4..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| "▌ " - style 0-0 fg=bright-blue -5| "▌ You " - style 0-0 fg=bright-blue - style 2-4 fg=bright-blue bold -6| "▌ Reply with exactly the word: ONE. No tools. " - style 0-0 fg=bright-blue -7| "▌ " - style 0-0 fg=bright-blue +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| " Entering plan mode (applies from the next step). Use /plan off to leave. " - style 1-72 fg=bright-black -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 " +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| "▌ " - style 0-0 fg=bright-blue -20| "▌ You " - style 0-0 fg=bright-blue - style 2-4 fg=bright-blue bold -21| "▌ Reply with exactly the word: TWO. No tools. " - style 0-0 fg=bright-blue -22| "▌ " - style 0-0 fg=bright-blue -23| -24| " Context · plan-mode " - style 1-19 dim -25| " The user switched this session back to the default mode. " - style 1-56 fg=bright-black -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 9636895dbe..45e0e5538d 100644 --- a/examples/tui-agent/tests/tui.snapshot.ts +++ b/examples/tui-agent/tests/tui.snapshot.ts @@ -2,7 +2,7 @@ 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' @@ -26,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' @@ -227,6 +227,7 @@ async function mountScenarioContext( 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) @@ -264,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) @@ -296,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}`, @@ -406,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/lefthook.yml b/lefthook.yml index 7a9822a719..ab7986ee2c 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -1,6 +1,6 @@ # 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: diff --git a/package.json b/package.json index acf31f7d23..ee9e7fa912 100644 --- a/package.json +++ b/package.json @@ -98,7 +98,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", @@ -107,6 +107,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/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 56923eff77..5d2abeb7da 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: 453922dafd1eb7a617cb2d1c93ac1daa2e7273c6 -README.zh.md: 88992176165ab11050a30c7df381479796908ba2 +README.md: 32651291253077098bc43a930cf4ce11d29b1ed8 +README.zh.md: ea8f398541d7af6136b29c3365a78c4ea3a1e85d diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 453922dafd..3265129125 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -8,7 +8,7 @@ The no-session hero renders the frontend Session Intent from the Session list pr 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 8899217616..ea8f398541 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/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 ( +
= { 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/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx index b253562921..aa9451b413 100644 --- a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx @@ -167,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-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 8134ddb9d6..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, @@ -166,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) diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index 79a30a1e3b..4d417fb78e 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -173,6 +173,8 @@ export class BasicCompactService extends CompactService { _step, _error, failure, + _priorFailures, + _retryPolicy, signal, next, ) => { diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index c3f9ff8d90..e73594678b 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -1296,7 +1296,7 @@ describe('automatic listener and loader composition', () => { const failure: LlmFailure = { message: error.message, code: error.code ?? 'UNKNOWN' } const turn = owner.session.events.findLast(event => event.type === 'turn/start')?.data.turn ?? 1 return agentEvents(ctx, owner).waterfall( - 'agent/request-error', turn, 1, error, failure, signal, next, + 'agent/request-error', turn, 1, error, failure, [], undefined, signal, next, ).then(action => action?.kind === 'retry') } diff --git a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts index d12cc63596..b93e03b3af 100644 --- a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts +++ b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact' -import { CONTEXT_WINDOW_EXCEEDED_CODE, LlmError } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, GenerateOptions, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' +import { CONTEXT_WINDOW_EXCEEDED_CODE, LlmError, resolveRetryPolicy } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, GenerateOptions, LlmResolvedModelInfo, ResolvedRetryPolicy, StreamChunk } from '@deepseek-ai/dsh-llm' import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import type { Agent } from '@deepseek-ai/dsh-agent' @@ -73,6 +73,11 @@ class StepwiseToolAdapter extends LlmAdapter { class OverflowRecoveryAdapter extends LlmAdapter { readonly conversationRequests: GenerateOptions[] = [] readonly summaryRequests: GenerateOptions[] = [] + private readonly retryPolicy = resolveRetryPolicy({ + mode: 'normal', + maxRetries: 1, + backoff: { initialDelayMs: 1, maxDelayMs: 1, jitterRatio: 0 }, + }, 'compaction test provider retryPolicy') constructor( private readonly delivery: 'thrown' | 'in-band', @@ -90,6 +95,10 @@ class OverflowRecoveryAdapter extends LlmAdapter { }) } + override providerRetryPolicy(_provider: string): ResolvedRetryPolicy { + return this.retryPolicy + } + override async * stream(options: GenerateOptions): AsyncIterable { // The cache-reusing summarizer replays the conversation prefix and marks // its call only by the compaction instruction in the trailing user message. @@ -375,12 +384,7 @@ describe('context-overflow recovery across the real loop and compact-basic', () const adapter = new OverflowRecoveryAdapter('thrown', true) await mountAgentLoopTestDependencies(ctx) await mountInvariants(ctx) - await ctx.plugin(LlmRetry, { - maxTransientRetries: 1, - initialDelayMs: 1, - maxDelayMs: 1, - jitterRatio: 0, - }) + await ctx.plugin(LlmRetry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(TokenMeterService) ctx.llm.registerAdapter(['mock'], adapter) diff --git a/packages/cordis/README.i18n.yaml b/packages/cordis/README.i18n.yaml index 1b70a52e70..fbc8586428 100644 --- a/packages/cordis/README.i18n.yaml +++ b/packages/cordis/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: b3a70b07c57ae1a2e3dd975a64c840d90c5a84ad -README.zh.md: 5832310cfe14a299ac5998a28bcbbb500caf86b5 +# pnpm run verify-translation-pairing --write packages/cordis/README.md +README.md: a47b9ba20789bb6b9a36b1af9b3942b90e61b365 +README.zh.md: cc91e68f0dfa9fb343c332eba2579c2a077beb64 diff --git a/packages/cordis/README.md b/packages/cordis/README.md index b3a70b07c5..a47b9ba207 100644 --- a/packages/cordis/README.md +++ b/packages/cordis/README.md @@ -6,4 +6,4 @@ Model-facing tools over the live cordis runtime the agent itself runs inside: in | Package | Role | ctx key | |---|---|---| -| [`tool-cordis/`](tool-cordis/README.md) | The `cordis_inspect` / `cordis_mount` / `cordis_unmount` tools: read the runtime, evaluate model-written plugin code in a `node:vm` sandbox, and manage the dynamic mounts under one group fiber | registers on `ctx.tools` | +| [`tool-cordis/`](tool-cordis/README.md) | The `cordis_inspect` / `cordis_mount` / `cordis_unmount` tools: read the current-process runtime and manage in-memory temporary Plugins under one owned group fiber | registers on `ctx.tools` | diff --git a/packages/cordis/README.zh.md b/packages/cordis/README.zh.md index 5832310cfe..cc91e68f0d 100644 --- a/packages/cordis/README.zh.md +++ b/packages/cordis/README.zh.md @@ -2,8 +2,8 @@ [English](README.md) | 中文 -面向模型、作用于 agent(智能体)自身所在实时 Cordis 运行时的工具:检查已加载插件与服务接口、挂载模型编写的插件,以及再次释放这些插件。设计归档见[工具集 Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。 +面向模型、作用于 agent(智能体)所在实时 Cordis 运行时的工具:检查当前 DSH 进程,并挂载或卸载仅存于内存的临时 Plugin。设计归档见[工具集 Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。 | 包(package) | 角色 | ctx 键 | |---|---|---| -| [`tool-cordis/`](tool-cordis/README.md) | `cordis_inspect`/`cordis_mount`/`cordis_unmount` 工具:读取运行时、在 `node:vm` 沙箱中求值模型编写的插件代码,并在同一个分组 fiber 下管理动态挂载 | 注册到 `ctx.tools` | +| [`tool-cordis/`](tool-cordis/README.md) | `cordis_inspect`/`cordis_mount`/`cordis_unmount` 工具:读取当前进程运行时,并在一个自有分组 fiber 下管理临时 Plugin | 注册到 `ctx.tools` | diff --git a/packages/cordis/tool-cordis/README.i18n.yaml b/packages/cordis/tool-cordis/README.i18n.yaml index 23535e1b06..64d5ac7711 100644 --- a/packages/cordis/tool-cordis/README.i18n.yaml +++ b/packages/cordis/tool-cordis/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: 022e25decad650e03aa621cfa2b3f33ccc8d9743 -README.zh.md: 99a79209a2a895ee8e11e3b7345a305da514ac1e +# pnpm run verify-translation-pairing --write packages/cordis/tool-cordis/README.md +README.md: 5b58e665dae95aea0d0ad094238fef5d3dc0fb97 +README.zh.md: 11e5be11dca84247b19888fefa7d70e5d74da174 diff --git a/packages/cordis/tool-cordis/README.md b/packages/cordis/tool-cordis/README.md index 022e25deca..5b58e665da 100644 --- a/packages/cordis/tool-cordis/README.md +++ b/packages/cordis/tool-cordis/README.md @@ -2,17 +2,19 @@ English | [中文](README.zh.md) -The self-referential cordis toolset: three model-facing tools over the live runtime the agent runs inside. Design home — sandbox semantics, mount lifecycle, cross-mount composition, the generated API catalog, standing decisions: [the toolset Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). +The self-referential Cordis toolset: three model-facing tools over the live runtime in the current DSH process. Design home — sandbox semantics, temporary-plugin lifecycle and composition, the generated API catalog, standing decisions: [the toolset Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). ## What it does -- `cordis_inspect` — read-only report over the runtime: services, the loaded-plugin list, registered tools, the dynamic-mount table, and the catalog-backed `api` / `events` references. An exact `name` with `what: "api"` or `what: "events"` narrows the report and adds the original source JSDoc. -- `cordis_mount` — evaluates model-written JavaScript (the body of an async function) in a `node:vm` sandbox; the code must `return` a cordis plugin, which is mounted under the `cordis-dynamic` group fiber and tracked as `dyn-`. -- `cordis_unmount` — disposes one mount by id, returning only after quiescence. +- `cordis_inspect` — read-only report over the current process: services, all live plugin fibers, registered tools, the `cordis_mount` temporary-Plugin subset, and the catalog-backed `api` / `events` references. An exact `name` with `what: "api"` or `what: "events"` narrows the report and adds the original source JSDoc. +- `cordis_mount` — evaluates model-written JavaScript now and saves it nowhere; the code must return an in-memory temporary Plugin tracked as `dyn-`. +- `cordis_unmount` — unmounts one `dyn-` temporary Plugin and returns only after its owned effects reach quiescence. It cannot remove Loader, configured, or installed Plugins. Exact model-facing schemas: [the generated tool catalog](../../../docs/tool-catalog.md). -Canonical successes are the inspection string, mount `{ id, pluginName, state, provides, waitingFor }`, and unmount `{ id, pluginName }`. Native renderers preserve the existing prose, so programs can use `mounted.id` while ordinary function calling still sees `mounted dyn-1 (...)`. +Canonical successes are the inspection string, mount `{ id, pluginName, state, provides, waitingFor }`, and unmount `{ id, pluginName }`. Native rendering says whether the temporary Plugin is running or pending and that it remains available until unmounted or DSH restarts; unmount confirms that it was removed. + +Temporary Plugins live only in the shared DSH process memory. They remain active across later turns and may affect other sessions in that process, but disappear after `cordis_unmount`, toolset unload, or DSH restart. They create no Plugin file, install no package, change no `cordis.yml` or personal/project configuration, do not survive restart, and cannot be promoted automatically. To keep an experiment, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. ## Trust stance @@ -22,7 +24,7 @@ The sandbox isolates globals but is not a security boundary. Node globals are ab | Field | Default | Meaning | |---|---|---| -| `vmTimeoutMs` | `5000` | Bound on the SYNCHRONOUS portion of mount-code evaluation; an async body escapes it | +| `vmTimeoutMs` | `5000` | Bound on the SYNCHRONOUS portion of temporary-Plugin code evaluation; an async body escapes it | ## The generated API catalog @@ -30,7 +32,7 @@ The sandbox isolates globals but is not a security boundary. Node globals are ab ## Rendering -All three tools render `generic` cards (`read` / `execute` / `delete`); `cordis_mount` carries the mount code as `rawInput`. Presenters are pure functions of the args; results keep the default text rendering. +All three tools render `generic` cards (`read` / `execute` / `delete`); `cordis_mount` carries the temporary-Plugin code as `rawInput`. Presenters are pure functions of the args; results keep the default text rendering. ## Export shape @@ -56,7 +58,7 @@ Prefix-stable while this tool view is unchanged. Scoping or plugin lifecycle cha #### What the model sees -Inspect joins selected sections exactly as `##
` then a newline and the data-dependent body, with one blank line between sections. Its broad API/event reports omit JSDoc; `name` with `what: "api"` or `what: "events"` returns one exact target with its original JSDoc. Mount returns `mounted (plugin "", state: )`, optionally inserting ` — waiting for service(s): (activates when provided)` before the closing parenthesis. Unmount returns `unmounted (plugin "")`; an unknown id becomes `Error: no dynamic plugin with id "" (list mounts with cordis_inspect what:"dynamic")`. The submitted mount program remains in the assistant tool-call history. +Inspect joins selected sections exactly as `##
` then a newline and the data-dependent body, with one blank line between sections; `what: "temporary"` uses the `## Temporary Plugins` heading. Each temporary-Plugin row reports running/pending state, provided and awaited services, and its lifetime until unmounted or DSH restart. The empty state explains that `cordis_mount` Plugins disappear on restart. Broad API/event reports omit JSDoc; `name` with `what: "api"` or `what: "events"` returns one exact target with its original JSDoc. Mount returns `Temporary Plugin is running (...)` or `Temporary Plugin is pending (...)`; unmount returns `Temporary Plugin was unmounted and removed.` The submitted program remains in assistant tool-call history. #### Token effect @@ -66,19 +68,19 @@ Inspect output and mount code are data-dependent and resent until compaction; li Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. -### Later requests after a mount +### Later requests after cordis_mount #### What the model sees -A mounted plugin may register tools, prompt contributions, or listeners that change later requests for the scopes it targets; unmount removes those contributions after quiescence. +A temporary Plugin may register tools, prompt contributions, or listeners that change later requests for the scopes it targets; `cordis_unmount` removes those contributions after quiescence. #### Token effect -Indirect token impact equals the mounted plugin's contributions and lasts only for the mount lifetime. +Indirect token impact equals the temporary Plugin's contributions and lasts only for its process-local lifetime. #### KV Cache effect -Mounting or unmounting a prompt or tool contribution changes later request prefixes and may invalidate reuse from the first changed contribution; an unchanged mount set remains prefix-stable. +Mounting or unmounting a prompt or tool contribution changes later request prefixes and may invalidate reuse from the first changed contribution; an unchanged temporary-Plugin set remains prefix-stable. ## Known Limitations and Deferred Work diff --git a/packages/cordis/tool-cordis/README.zh.md b/packages/cordis/tool-cordis/README.zh.md index 99a79209a2..11e5be11dc 100644 --- a/packages/cordis/tool-cordis/README.zh.md +++ b/packages/cordis/tool-cordis/README.zh.md @@ -2,17 +2,19 @@ [English](README.md) | 中文 -自引用 cordis 工具集:三个面向模型的工具,操作 agent 所处的存活运行时。设计归属(沙箱语义、挂载生命周期、跨挂载组合、生成的 API 目录、既定决策)见[工具集 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。 +自引用 Cordis 工具集:三个面向模型的工具,操作当前 DSH 进程中的存活运行时。设计归属(沙箱语义、临时 Plugin 生命周期与组合、生成的 API 目录、既定决策)见[工具集 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。 ## 功能 -- `cordis_inspect`:运行时的只读报告,包括服务、已加载插件列表、已注册工具、动态挂载表,以及目录支持的 `api`/`events` 参考。精确的 `name` 配合 `what: "api"` 或 `what: "events"` 可缩窄报告,并附上原始源代码 JSDoc。 -- `cordis_mount`:在 `node:vm` 沙箱中求值模型编写的 JavaScript(一个 async 函数的主体);代码必须 `return` 一个 cordis 插件,系统将其挂载在 `cordis-dynamic` 分组 fiber 下,并以 `dyn-` 跟踪。 -- `cordis_unmount`:按 id 释放一项挂载,只在完全停稳后返回。 +- `cordis_inspect`:当前进程运行时的只读报告,包括服务、全部存活 Plugin fiber、已注册工具、`cordis_mount` 临时 Plugin 子集,以及目录支持的 `api`/`events` 参考。精确的 `name` 配合 `what: "api"` 或 `what: "events"` 可缩窄报告,并附上原始源代码 JSDoc。 +- `cordis_mount`:立即求值模型编写的 JavaScript 且不保存到任何位置;代码必须返回一个以 `dyn-` 跟踪、仅存于内存的临时 Plugin。 +- `cordis_unmount`:卸载一个 `dyn-` 临时 Plugin,并只在其自有效果完全停稳后返回;它不能删除 Loader、配置或已安装的 Plugin。 精确的面向模型 schema 见[生成的工具目录](../../../docs/tool-catalog.md)。 -规范成功值分别为检查字符串、挂载 `{ id, pluginName, state, provides, waitingFor }`,以及卸载 `{ id, pluginName }`。原生 renderer 保留现有文本,因此程序可以使用 `mounted.id`,普通 Function Calling 仍会看到 `mounted dyn-1 (...)`。 +规范成功值分别为检查字符串、挂载 `{ id, pluginName, state, provides, waitingFor }`,以及卸载 `{ id, pluginName }`。原生 renderer 会说明临时 Plugin 正在运行还是等待中,并说明它可用至被卸载或 DSH 重启;卸载结果确认它已移除。 + +临时 Plugin 只存在于共享 DSH 进程内存中。它可跨后续 turn 保持活跃,也可能影响同一进程中的其他 session,但会在 `cordis_unmount`、工具集卸载或 DSH 重启后消失。它不会创建 Plugin 文件、安装 package、修改 `cordis.yml` 或个人/项目配置、跨重启存续,也不能自动转为正式 Plugin。若要保留实验结果,应让 Agent 通过常规开发流程实现普通的本地、项目或仓库 Plugin。 ## 信任立场 @@ -22,7 +24,7 @@ | 字段 | 默认值 | 含义 | |---|---|---| -| `vmTimeoutMs` | `5000` | 挂载代码求值中同步部分的边界;async 主体可逃出该边界 | +| `vmTimeoutMs` | `5000` | 临时 Plugin 代码求值中同步部分的边界;async 主体可逃出该边界 | ## 生成的 API 目录 @@ -30,7 +32,7 @@ ## 渲染 -三个工具都渲染 `generic` 卡片(`read`/`execute`/`delete`);`cordis_mount` 以 `rawInput` 携带挂载代码。presenter 是 args 的纯函数;结果保留默认文本渲染。 +三个工具都渲染 `generic` 卡片(`read`/`execute`/`delete`);`cordis_mount` 以 `rawInput` 携带临时 Plugin 代码。presenter 是 args 的纯函数;结果保留默认文本渲染。 ## 导出形状 @@ -56,7 +58,7 @@ Namespace 插件:命名导出 `name`/`inject`/`Config`/`apply`,无默 #### 模型看到的内容 -检查会精确地用 `##
` 加换行及数据相关主体来拼接选中区段,各区段之间留一个空行。宽泛的 API/事件报告省略 JSDoc;`name` 配合 `what: "api"` 或 `what: "events"` 返回一个精确目标及其原始 JSDoc。挂载返回 `mounted (plugin "", state: )`,并可在右括号前插入 ` — waiting for service(s): (activates when provided)`。卸载返回 `unmounted (plugin "")`;未知 id 会变成 `Error: no dynamic plugin with id "" (list mounts with cordis_inspect what:"dynamic")`。提交的挂载程序保留在 assistant 工具调用历史中。 +检查会精确地用 `##
` 加换行及数据相关主体来拼接选中区段,各区段之间留一个空行;`what: "temporary"` 使用 `## Temporary Plugins` 标题。每个临时 Plugin 行都会报告 running/pending 状态、提供与等待的服务,以及持续至卸载或 DSH 重启的生命周期;空状态说明 `cordis_mount` Plugin 会在重启时消失。宽泛的 API/事件报告省略 JSDoc;`name` 配合 `what: "api"` 或 `what: "events"` 返回一个精确目标及其原始 JSDoc。挂载返回 `Temporary Plugin is running (...)` 或 `Temporary Plugin is pending (...)`;卸载返回 `Temporary Plugin was unmounted and removed.`。提交的程序保留在 assistant 工具调用历史中。 #### Token 影响 @@ -66,19 +68,19 @@ Namespace 插件:命名导出 `name`/`inject`/`Config`/`apply`,无默 仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 配置项失效。 -### 挂载后的后续请求 +### cordis_mount 后的后续请求 #### 模型看到的内容 -已挂载插件可以注册工具、提示词贡献或监听器,改变其目标 scope 的后续请求;卸载会在完全停稳后移除这些贡献。 +临时 Plugin 可以注册工具、提示词贡献或监听器,改变其目标 scope 的后续请求;`cordis_unmount` 会在完全停稳后移除这些贡献。 #### Token 影响 -间接 token 影响等于已挂载插件的贡献,且只在挂载生命周期内持续。 +间接 token 影响等于临时 Plugin 的贡献,且只在其进程内生命周期内持续。 #### KV Cache 影响 -挂载或卸载提示词/工具贡献会改变后续请求前缀,并可能使从第一个变化的贡献起的复用失效;挂载集合不变时,前缀保持稳定。 +挂载或卸载提示词/工具贡献会改变后续请求前缀,并可能使从第一个变化的贡献起的复用失效;临时 Plugin 集合不变时,前缀保持稳定。 ## 已知限制与暂缓事项 diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 14198b7119..e3f81aa527 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -376,6 +376,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'listProviders(): LlmProviderInfo[]', jsDoc: '/**\n * Describe provider routes with a registered adapter.\n * @returns detached provider metadata in registration order.\n */', }, + { + signature: 'providerRetryPolicy(provider: string): ResolvedRetryPolicy', + jsDoc: '/**\n * Resolve the retry policy captured when one provider route was registered.\n * @param provider - registered provider route to inspect.\n * @returns the provider-owned policy, with normal defaults already resolved.\n */', + }, { signature: 'async listModels(provider: string): Promise', jsDoc: '/**\n * Discover models advertised by one registered provider. Catalog membership\n * is advisory and never changes routing or request validation.\n * @param provider - registered provider route to inspect.\n * @returns detached model metadata in adapter-preferred order.\n */', @@ -430,7 +434,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'set(agent: Agent, active: boolean): void', - jsDoc: '/**\n * Select whether plan mode should be active from the next turn boundary.\n * Repeated selection of the current or already-pending state is a no-op.\n *\n * @param agent The agent to switch.\n * @param active Whether plan mode should be active.\n */', + jsDoc: '/**\n * Select whether plan mode should be active from the next request boundary.\n * Repeated selection of the current or already-pending state is a no-op.\n *\n * @param agent The agent to switch.\n * @param active Whether plan mode should be active.\n */', }, ], }, @@ -1065,8 +1069,8 @@ export const EVENT_API: readonly EventApiEntry[] = [ { name: 'agent/request-error', mode: 'waterfall', - signature: '\'agent/request-error\'(this: Scoped, agent: Agent, turn: number, step: number, error: RequestError, failure: LlmFailure, signal: AbortSignal, next: () => Promise): Promise', - jsDoc: '/**\n * Handle a model-request failure after its failed step has closed but\n * before the failed turn closes. A listener returns `{ kind: \'retry\' }`\n * without calling `next()` when it owns the error, or calls `next()` to\n * delegate. The default `undefined` leaves the failure terminal.\n * @param agent - the agent whose request failed.\n * @param turn - the open turn number.\n * @param step - the failed step number.\n * @param error - the original model-request failure.\n * @param failure - serializable facts normalized at the final adapter boundary.\n * @param signal - the turn abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', + signature: '\'agent/request-error\'(this: Scoped, agent: Agent, turn: number, step: number, error: RequestError, failure: LlmFailure, priorFailures: readonly LlmFailure[], retryPolicy: ResolvedRetryPolicy | undefined, signal: AbortSignal, next: () => Promise): Promise', + jsDoc: '/**\n * Handle a model-request failure after its failed step has closed but\n * before the failed turn closes. A listener returns `{ kind: \'retry\' }`\n * without calling `next()` when it owns the error, or calls `next()` to\n * delegate. The default `undefined` leaves the failure terminal.\n * @param agent - the agent whose request failed.\n * @param turn - the open turn number.\n * @param step - the failed step number.\n * @param error - the original model-request failure.\n * @param failure - serializable facts normalized at the final adapter boundary.\n * @param priorFailures - immutable failures that already authorized another\n * retry turn in this consecutive sequence.\n * @param retryPolicy - immutable policy of the adapter registration that served\n * the failed request, or `undefined` if no final adapter served it.\n * @param signal - the turn abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', summary: 'Handle a model-request failure after its failed step has closed but before the failed turn closes.', }, { @@ -1751,7 +1755,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'LlmAdapter', - declaration: 'export abstract class LlmAdapter {\n providerInfo(provider: string): LlmProviderInfo;\n listModels(_provider: string): Promise;\n resolveModel(provider: string, model: string, _signal?: AbortSignal): Promise;\n abstract stream(options: GenerateOptions): AsyncIterable;\n}', + declaration: 'export abstract class LlmAdapter {\n providerInfo(provider: string): LlmProviderInfo;\n providerRetryPolicy(_provider: string): ResolvedRetryPolicy | undefined;\n listModels(_provider: string): Promise;\n resolveModel(provider: string, model: string, _signal?: AbortSignal): Promise;\n abstract stream(options: GenerateOptions): AsyncIterable;\n}', }, { name: 'LlmCallConfig', @@ -1929,6 +1933,22 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'RequestHeaderReason', declaration: 'export type RequestHeaderReason = \'initial\' | \'resume\' | \'change\';', }, + { + name: 'ResolvedAlwaysRetryPolicy', + declaration: 'export interface ResolvedAlwaysRetryPolicy extends ResolvedRetryBackoff {\n readonly mode: \'always\';\n}', + }, + { + name: 'ResolvedNormalRetryPolicy', + declaration: 'export interface ResolvedNormalRetryPolicy extends ResolvedRetryBackoff {\n readonly mode: \'normal\';\n readonly maxRetries: number;\n readonly retryableCodes: readonly string[];\n}', + }, + { + name: 'ResolvedRetryBackoff', + declaration: 'export interface ResolvedRetryBackoff {\n readonly initialDelayMs: number;\n readonly maxDelayMs: number;\n readonly jitterRatio: number;\n}', + }, + { + name: 'ResolvedRetryPolicy', + declaration: 'export type ResolvedRetryPolicy = ResolvedNormalRetryPolicy | ResolvedAlwaysRetryPolicy;', + }, { name: 'ResumeAgentOptions', declaration: 'export interface ResumeAgentOptions {\n readonly resumeSessionId: SessionId;\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise | void;\n}', @@ -2199,7 +2219,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SkillSource', - declaration: 'export type SkillSource = \'project-dsh\' | \'project-agents\' | \'runtime\' | \'user-dsh\' | \'user-agents\' | \'custom\' | (string & {});', + declaration: 'export type SkillSource = \'project-dsh\' | \'project-agents\' | \'runtime\' | \'user-dsh\' | \'user-agents\' | \'custom\' | \'bundled\' | (string & {});', }, { name: 'SkillSummary', @@ -2485,58 +2505,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ToolSchema', declaration: 'export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n}', }, - { - name: 'TuiComponent', - declaration: 'export interface TuiComponent {\n render(width: number): string[];\n handleInput?(data: string): void;\n wantsKeyRelease?: boolean;\n invalidate(): void;\n}', - }, - { - name: 'TuiFocusable', - declaration: 'export interface TuiFocusable {\n focused: boolean;\n}', - }, - { - name: 'TuiOverlayAnchor', - declaration: 'export type TuiOverlayAnchor = \'center\' | \'top-left\' | \'top-right\' | \'bottom-left\' | \'bottom-right\' | \'top-center\' | \'bottom-center\' | \'left-center\' | \'right-center\';', - }, - { - name: 'TuiOverlayCloseReason', - declaration: 'export type TuiOverlayCloseReason = \'closed\' | \'aborted\' | \'owner-disposed\' | \'tui-disposed\' | \'error\';', - }, - { - name: 'TuiOverlayHost', - declaration: 'export interface TuiOverlayHost {\n readonly signal: AbortSignal;\n readonly viewport: TuiViewport;\n readonly theme: TuiTheme;\n display(value: string): string;\n invalidate(): void;\n close(): void;\n}', - }, - { - name: 'TuiOverlayMargin', - declaration: 'export interface TuiOverlayMargin {\n readonly top?: number;\n readonly right?: number;\n readonly bottom?: number;\n readonly left?: number;\n}', - }, - { - name: 'TuiOverlayOptions', - declaration: 'export interface TuiOverlayOptions {\n readonly width?: number | `${number}%`;\n readonly minWidth?: number;\n readonly maxHeight?: number | `${number}%`;\n readonly anchor?: TuiOverlayAnchor;\n readonly margin?: number | TuiOverlayMargin;\n}', - }, - { - name: 'TuiOverlayOutcome', - declaration: 'export type TuiOverlayOutcome = {\n readonly reason: Exclude;\n} | {\n readonly reason: \'error\';\n readonly error: unknown;\n};', - }, - { - name: 'TuiOverlayRequest', - declaration: 'export interface TuiOverlayRequest {\n readonly create: (host: TuiOverlayHost) => TuiComponent & Partial;\n readonly options?: TuiOverlayOptions;\n readonly signal?: AbortSignal;\n}', - }, - { - name: 'TuiOverlaySession', - declaration: 'export interface TuiOverlaySession {\n readonly state: TuiOverlayState;\n readonly closed: Promise;\n close(): Promise;\n}', - }, - { - name: 'TuiOverlayState', - declaration: 'export type TuiOverlayState = \'queued\' | \'active\' | \'closed\';', - }, - { - name: 'TuiTheme', - declaration: 'export interface TuiTheme {\n readonly text: (value: string) => string;\n readonly muted: (value: string) => string;\n readonly dim: (value: string) => string;\n readonly accent: (value: string) => string;\n readonly success: (value: string) => string;\n readonly warning: (value: string) => string;\n readonly error: (value: string) => string;\n readonly bold: (value: string) => string;\n}', - }, - { - name: 'TuiViewport', - declaration: 'export interface TuiViewport {\n readonly columns: number;\n readonly rows: number;\n}', - }, { name: 'TurnEndReason', declaration: 'export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];', diff --git a/packages/cordis/tool-cordis/src/guard.ts b/packages/cordis/tool-cordis/src/guard.ts index b9bc992ad8..595dc462e0 100644 --- a/packages/cordis/tool-cordis/src/guard.ts +++ b/packages/cordis/tool-cordis/src/guard.ts @@ -684,7 +684,7 @@ function sandboxContext(ctx: Context): Context { if (ctx.get(prop) !== undefined) { throw new Error( `service "${prop}" is not injected. Declare it: inject: ['${prop}', …] on your plugin, ` - + 'so cordis parks this mount if the provider is later unmounted.', + + 'so cordis parks this temporary Plugin if the provider is later unmounted.', ) } throw new Error( diff --git a/packages/cordis/tool-cordis/src/index.ts b/packages/cordis/tool-cordis/src/index.ts index 15fd735468..5476dc0929 100644 --- a/packages/cordis/tool-cordis/src/index.ts +++ b/packages/cordis/tool-cordis/src/index.ts @@ -1,6 +1,6 @@ /** - * Self-referential runtime tools: inspect live services/plugins/tools, mount a returned plugin - * under an owned dynamic fiber, and unmount it to quiescence. Registrations are fiber effects, + * Self-referential runtime tools: inspect live services/plugins/tools, mount a returned temporary + * plugin under an owned dynamic fiber, and unmount it to quiescence. Registrations are fiber effects, * so plugin disposal removes the entire dynamic subtree. The VM and context façade prevent * accidental misuse, not hostile code: an allowed service such as `ctx.bash` reaches the real * runtime. Named exports preserve loader injection metadata. @@ -40,8 +40,8 @@ export const Config: z = z.object({ type ResolvedConfig = Required /** - * Mount the three cordis tools on `ctx.tools` and create the `cordis-dynamic` - * group fiber every dynamic mount hangs under. + * Register the three cordis tools and own every temporary plugin under one + * `cordis-dynamic` group fiber. * @param ctx - the plugin context (`tools` injected). * @param config - the schemastery-resolved {@link Config}. */ @@ -56,19 +56,21 @@ export function apply(ctx: Context, config: Config): void { ctx.tools.register(defineTool({ name: 'cordis_inspect', description: - 'Inspect the live cordis runtime that is running THIS agent. Read-only. ' + '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` (a flat list of the loaded plugins with their lifecycle states), ' + + '`plugins` (all live plugin fibers 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), ' + + '`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). ' - + 'Omit `what` to get all six sections. With `what:"api"` or `what:"events"`, pass an exact `name` ' + + '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: { what: { type: 'string', - enum: ['services', 'plugins', 'tools', 'dynamic', 'api', 'events'], + enum: ['services', 'plugins', 'tools', 'temporary', 'api', 'events'], description: 'Limit the report to one section. Omit for all sections.', }, name: { @@ -84,19 +86,19 @@ export function apply(ctx: Context, config: Config): void { if (args.name !== undefined && args.what !== 'api' && args.what !== 'events') { throw new Error('name is valid only with what:"api" or what:"events"') } - const sections: [heading: string, body: () => string[]][] = [ - ['services', () => describeServices(ctx)], - ['plugins', () => describePlugins(ctx)], + const sections: [key: string, heading: string, body: () => string[]][] = [ + ['services', 'services', () => describeServices(ctx)], + ['plugins', 'plugins', () => describePlugins(ctx)], // The calling agent's view: scoped/shadowed tools included, restricted // globals absent — "what you can call", not the global registry. - ['tools', () => describeTools(ctx, exec.agent)], - ['dynamic', () => describeDynamic(ctx, mounts)], - ['api', () => describeApi(ctx, SERVICE_API, INHERITED_CTX_API, TYPE_API, args.name)], - ['events', () => describeEvents(EVENT_API, args.name)], + ['tools', 'tools', () => describeTools(ctx, exec.agent)], + ['temporary', 'Temporary Plugins', () => describeDynamic(ctx, mounts)], + ['api', 'api', () => describeApi(ctx, SERVICE_API, INHERITED_CTX_API, TYPE_API, args.name)], + ['events', 'events', () => describeEvents(EVENT_API, args.name)], ] - const selected = sections.filter(([heading]) => args.what === undefined || args.what === heading) + const selected = sections.filter(([key]) => args.what === undefined || args.what === key) const text = selected - .map(([heading, body]) => `## ${heading}\n${body().join('\n')}`) + .map(([, heading, body]) => `## ${heading}\n${body().join('\n')}`) .join('\n\n') return Promise.resolve(text) }, @@ -106,8 +108,13 @@ export function apply(ctx: Context, config: Config): void { ctx.tools.register(defineTool({ 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 ' + '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. ' @@ -131,10 +138,10 @@ export function apply(ctx: Context, config: Config): void { + '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 ' + + '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 on unmount. ' + + '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`. ' @@ -143,7 +150,7 @@ export function apply(ctx: Context, config: Config): void { + '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. ' + + '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()` ' @@ -159,7 +166,7 @@ export function apply(ctx: Context, config: Config): void { code: { type: 'string', required: true, - 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.', }, }, output: { @@ -179,12 +186,12 @@ export function apply(ctx: Context, config: Config): void { }, }, render: (_args, value) => { - const note = value.waitingFor.length > 0 - ? ` — waiting for service(s): ${value.waitingFor.join(', ')} (activates when provided)` - : '' + const status = value.waitingFor.length > 0 + ? `is pending (plugin "${value.pluginName}"; missing services: ${value.waitingFor.join(', ')}` + : `is running (plugin "${value.pluginName}"` return [{ type: 'text', - text: `mounted ${value.id} (plugin "${value.pluginName}", state: ${value.state}${note})`, + text: `Temporary Plugin ${value.id} ${status}; available until unmounted or DSH restarts).`, }] }, }, @@ -195,13 +202,13 @@ export function apply(ctx: Context, config: Config): void { if (!isPlugin(evaluated)) { if (evaluated === undefined) { throw new Error( - 'mount code returned `undefined` — did you forget `return`?\n' + 'temporary Plugin code returned `undefined` — did you forget `return`?\n' + ' ✓ return (ctx) => { … }\n' + ' ✓ return { name: \'…\', inject: […], apply(ctx) { … } }', ) } throw new Error( - 'mount code must `return` a plugin: a function, or an object with an `apply(ctx)` method', + 'temporary Plugin code must `return` a Plugin: a function, or an object with an `apply(ctx)` method', ) } const fiber = await mountDynamic(group, evaluated) @@ -225,15 +232,13 @@ export function apply(ctx: Context, config: Config): void { ctx.tools.register(defineTool({ 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).', + '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: { id: { type: 'string', required: true, - 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.', }, }, output: { @@ -245,12 +250,12 @@ export function apply(ctx: Context, config: Config): void { pluginName: { type: 'string', required: true }, }, }, - render: (_args, value) => [{ type: 'text', text: `unmounted ${value.id} (plugin "${value.pluginName}")` }], + render: (_args, value) => [{ type: 'text', text: `Temporary Plugin ${value.id} was unmounted and removed.` }], }, async execute(args) { const mount = mounts.get(args.id) if (!mount) { - throw new Error(`no dynamic plugin with id "${args.id}" (list mounts with cordis_inspect what:"dynamic")`) + throw new Error(`no temporary Plugin with id "${args.id}" (list them with cordis_inspect what:"temporary")`) } await mount.fiber.dispose() mounts.delete(args.id) diff --git a/packages/cordis/tool-cordis/src/inspect.ts b/packages/cordis/tool-cordis/src/inspect.ts index cdcad29699..cc0f3a4cda 100644 --- a/packages/cordis/tool-cordis/src/inspect.ts +++ b/packages/cordis/tool-cordis/src/inspect.ts @@ -1,6 +1,6 @@ /** * Read-only renderers over the live runtime for `cordis_inspect`: the service list, the flat - * plugin list, the registered tools, the dynamic-mount table (with per-mount provides/waits), + * plugin list, the registered tools, the temporary-plugin table (with per-plugin provides/waits), * and the catalog-backed `api` / `events` sections. Exact-name lookups add the * original source JSDoc without inflating the default reports. * @module @deepseek-ai/dsh-tool-cordis/inspect @@ -63,8 +63,8 @@ export function describeServices(ctx: Context): string[] { /** * The `plugins` section: a flat list of every fiber the registry knows, one * line per fiber with its lifecycle state, sorted by plugin name (a plugin - * mounted more than once repeats — one line per instance). Dynamic mounts are - * listed like any other plugin; their ids live in the `dynamic` section. + * mounted more than once repeats — one line per instance). Temporary plugins are + * listed like any other plugin; their ids live in the `temporary` section. * @param ctx - the runtime whose registry is enumerated. * @returns one line per loaded plugin fiber. */ @@ -91,7 +91,7 @@ export function describeTools(ctx: Context, scope?: ScopeKey): string[] { } /** - * The `dynamic` section: one line per mount with id, plugin name, lifecycle + * The `temporary` section: one line per temporary plugin with id, plugin name, lifecycle * state, the services its subtree provides, and — for a pending mount — the * services it waits for. * @param ctx - the runtime the mounts live in. @@ -99,13 +99,14 @@ export function describeTools(ctx: Context, scope?: ScopeKey): string[] { * @returns one line per mount, or a single placeholder line when none exist. */ export function describeDynamic(ctx: Context, mounts: ReadonlyMap): string[] { - if (mounts.size === 0) return ['(no dynamic plugins mounted)'] + if (mounts.size === 0) { + return ['No temporary Plugins are running. Temporary Plugins created with cordis_mount disappear when DSH restarts.'] + } return [...mounts].map(([id, mount]) => { const provides = providedServices(ctx, mount.fiber) const waiting = missingServices(ctx, mount.fiber) - const providesNote = provides.length > 0 ? ` — provides: ${provides.join(', ')}` : '' - const waitingNote = waiting.length > 0 ? ` — waiting for: ${waiting.join(', ')}` : '' - return `- ${id}: ${mount.pluginName} [${STATE_LABELS[mount.fiber.state]}]${providesNote}${waitingNote}` + const state = mount.fiber.state === FiberState.ACTIVE ? 'running' : STATE_LABELS[mount.fiber.state] + return `- Temporary Plugin ${id}: ${mount.pluginName} [${state}] — provides: ${provides.join(', ') || 'none'}; waiting for: ${waiting.join(', ') || 'none'}; lifetime: until unmounted or DSH restarts` }) } diff --git a/packages/cordis/tool-cordis/src/mount.ts b/packages/cordis/tool-cordis/src/mount.ts index fbed4cc161..942d45eb79 100644 --- a/packages/cordis/tool-cordis/src/mount.ts +++ b/packages/cordis/tool-cordis/src/mount.ts @@ -39,8 +39,8 @@ export async function mountDynamic(group: Fiber, plugin: Plugin): Promise // while the old mount still holds the name — teach the replace recipe. if (message.includes('already registered')) { throw new Error( - `${message} — to REPLACE something an earlier mount registered, first cordis_unmount that mount's id ` - + '(find it with cordis_inspect what:"dynamic"), then mount the new version.', + `${message} — to REPLACE something an earlier temporary Plugin registered, first cordis_unmount that Plugin's id ` + + '(find it with cordis_inspect what:"temporary"), then mount the new version.', ) } throw error instanceof Error ? error : new Error(message) diff --git a/packages/cordis/tool-cordis/src/present.ts b/packages/cordis/tool-cordis/src/present.ts index 2f824003df..89e1e3b635 100644 --- a/packages/cordis/tool-cordis/src/present.ts +++ b/packages/cordis/tool-cordis/src/present.ts @@ -25,7 +25,7 @@ export function presentInspectCall(args: { what?: string; name?: string }): Gene } /** - * The `cordis_mount` call card: an execute carrying the mount code as raw input. + * The `cordis_mount` call card: an execute carrying the temporary-plugin code as raw input. * @param args - the validated call arguments. * @returns the generic call card. */ @@ -33,13 +33,13 @@ export function presentMountCall(args: { code: string }): GenericCallView { return { card: 'generic', kind: 'execute', - title: 'Mount plugin into live cordis runtime', + title: 'Mount temporary Cordis Plugin', rawInput: { code: args.code }, } } /** - * The `cordis_unmount` call card: a delete, titled with the mount id. + * The `cordis_unmount` call card: a delete, titled with the temporary-plugin id. * @param args - the validated call arguments. * @returns the generic call card. */ @@ -47,6 +47,6 @@ export function presentUnmountCall(args: { id: string }): GenericCallView { return { card: 'generic', kind: 'delete', - title: `Unmount ${args.id}`, + title: `Unmount temporary Cordis Plugin ${args.id}`, } } diff --git a/packages/cordis/tool-cordis/src/sandbox.ts b/packages/cordis/tool-cordis/src/sandbox.ts index 995881902e..3c99c7a770 100644 --- a/packages/cordis/tool-cordis/src/sandbox.ts +++ b/packages/cordis/tool-cordis/src/sandbox.ts @@ -52,7 +52,7 @@ function patchDualRealmInstanceof(sandbox: object): void { const TIMER_REDIRECT = 'Node timers are unavailable. Use the cordis timer service instead: declare inject: [\'timer\'] on your plugin ' - + 'and call ctx.setTimeout / ctx.setInterval — those are fiber effects, cleaned up automatically on unmount.' + + 'and call ctx.setTimeout / ctx.setInterval — those are fiber effects, cleaned up automatically when unmounted.' /** * The callable Node APIs the sandbox deliberately disables, each mapped to the @@ -80,7 +80,7 @@ function nodeApiTraps(): Record never> { const traps: Record never> = {} for (const [name, redirect] of Object.entries(NODE_API_REDIRECTS)) { traps[name] = () => { - throw new Error(`${name} is not available in the mount sandbox — ${redirect}`) + throw new Error(`${name} is not available in the temporary Plugin sandbox — ${redirect}`) } } return traps @@ -163,14 +163,14 @@ export async function evaluateMountCode(sandbox: object, code: string, id: strin const offendingLine = context.split('\n')[1] ?? '' if (/\bas\b/.test(offendingLine)) { throw new Error( - `mount code failed to parse:\n${context}\n` + `temporary Plugin code failed to parse:\n${context}\n` + 'The sandbox runs plain JavaScript, not TypeScript. Remove type annotations:\n' + ' ✗ { type: \'text\' as const, text: x }\n' + ' ✓ { type: \'text\', text: x }', ) } throw new Error( - `mount code failed to parse:\n${context}\n` + `temporary Plugin code failed to parse:\n${context}\n` + 'Note: `code` runs as the BODY of an async function (line numbers are offset by the 1-line wrapper). ' + 'Check bracket balance — ending the returned plugin object with `});` closes a call that was never opened; ' + 'a plain `return { … }` ends with `}` (an optional `;`), never `)`.', diff --git a/packages/cordis/tool-cordis/tests/cross-mount.spec.ts b/packages/cordis/tool-cordis/tests/cross-mount.spec.ts index b2e515b4c8..cf89828aee 100644 --- a/packages/cordis/tool-cordis/tests/cross-mount.spec.ts +++ b/packages/cordis/tool-cordis/tests/cross-mount.spec.ts @@ -12,11 +12,11 @@ describe('cross-mount provide/inject', () => { it('provider first: the consumer activates immediately and its tool reaches the provided service', async () => { const ctx = await setup() const provider = await call(ctx, 'cordis_mount', { code: PROVIDER_CODE }) - expect(text(provider)).toContain('state: active') + expect(text(provider)).toContain('is running') const consumer = await call(ctx, 'cordis_mount', { code: CONSUMER_CODE }) expect(consumer.isError).toBe(false) - expect(text(consumer)).toContain('state: active') + expect(text(consumer)).toContain('is running') // The vm-realm service value is callable across mounts, and the result // normalizes into the host realm like any dynamic tool result. @@ -29,9 +29,9 @@ describe('cross-mount provide/inject', () => { const ctx = await setup() const consumer = await call(ctx, 'cordis_mount', { code: CONSUMER_CODE }) expect(consumer.isError).toBe(false) - expect(text(consumer)).toContain('state: pending') - expect(text(consumer)).toContain('waiting for service(s): greeter') - expect(text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))).toContain('waiting for: greeter') + expect(text(consumer)).toContain('is pending') + expect(text(consumer)).toContain('missing services: greeter') + expect(text(await call(ctx, 'cordis_inspect', { what: 'temporary' }))).toContain('waiting for: greeter') expect(ctx.tools.get('greet')).toBeUndefined() await call(ctx, 'cordis_mount', { code: PROVIDER_CODE }) @@ -48,8 +48,8 @@ describe('cross-mount provide/inject', () => { const unmounted = await call(ctx, 'cordis_unmount', { id: 'dyn-1' }) expect(unmounted.isError).toBe(false) expect(ctx.tools.get('greet')).toBeUndefined() - const report = text(await call(ctx, 'cordis_inspect', { what: 'dynamic' })) - expect(report).toContain('dyn-2: greeter-consumer [pending] — waiting for: greeter') + const report = text(await call(ctx, 'cordis_inspect', { what: 'temporary' })) + expect(report).toContain('Temporary Plugin dyn-2: greeter-consumer [pending] — provides: none; waiting for: greeter; lifetime: until unmounted or DSH restarts') }) it('re-providing the service re-runs the consumer through the same guard (active again, tool back)', async () => { @@ -62,7 +62,7 @@ describe('cross-mount provide/inject', () => { await call(ctx, 'cordis_mount', { code: PROVIDER_CODE }) // dyn-3 expect(ctx.tools.get('greet')).toBeDefined() expect(text(await call(ctx, 'greet', { name: 'again' }))).toBe('hi again') - expect(text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))).toContain('dyn-2: greeter-consumer [active]') + expect(text(await call(ctx, 'cordis_inspect', { what: 'temporary' }))).toContain('Temporary Plugin dyn-2: greeter-consumer [running]') }) it('a duplicate provide fails loud with the owning fiber named, and the failed mount is disposed', async () => { @@ -71,8 +71,8 @@ describe('cross-mount provide/inject', () => { const duplicate = await call(ctx, 'cordis_mount', { code: PROVIDER_CODE }) expect(duplicate.isError).toBe(true) expect(text(duplicate)).toContain('has been registered') - const report = text(await call(ctx, 'cordis_inspect', { what: 'dynamic' })) - expect(report).toContain('dyn-1: greeter-provider') + const report = text(await call(ctx, 'cordis_inspect', { what: 'temporary' })) + expect(report).toContain('Temporary Plugin dyn-1: greeter-provider') expect(report).not.toContain('dyn-2') }) @@ -81,8 +81,8 @@ describe('cross-mount provide/inject', () => { await call(ctx, 'cordis_mount', { code: PROVIDER_CODE }) await call(ctx, 'cordis_mount', { code: CONSUMER_CODE }) - const dynamic = text(await call(ctx, 'cordis_inspect', { what: 'dynamic' })) - expect(dynamic).toContain('dyn-1: greeter-provider [active] — provides: greeter') + const dynamic = text(await call(ctx, 'cordis_inspect', { what: 'temporary' })) + expect(dynamic).toContain('Temporary Plugin dyn-1: greeter-provider [running] — provides: greeter; waiting for: none; lifetime: until unmounted or DSH restarts') const services = text(await call(ctx, 'cordis_inspect', { what: 'services' })) expect(services).toContain('- greeter (provided by greeter-provider)') @@ -126,7 +126,7 @@ describe('cross-mount provide/inject', () => { `, }) expect(consumer.isError).toBe(false) - expect(text(consumer)).toContain('state: active') + expect(text(consumer)).toContain('is running') expect(text(await call(ctx, 'answer', {}))).toBe('42/42/null') }) @@ -139,6 +139,6 @@ describe('cross-mount provide/inject', () => { expect(ctx.tools.get('greet')).toBeUndefined() const services = text(await call(ctx, 'cordis_inspect', { what: 'services' })) expect(services).toContain('- greeter (provided by greeter-provider)') - expect(text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))).toContain('dyn-1: greeter-provider [active]') + expect(text(await call(ctx, 'cordis_inspect', { what: 'temporary' }))).toContain('Temporary Plugin dyn-1: greeter-provider [running]') }) }) diff --git a/packages/cordis/tool-cordis/tests/inspect.spec.ts b/packages/cordis/tool-cordis/tests/inspect.spec.ts index 18b4d04df7..2b3b32aee8 100644 --- a/packages/cordis/tool-cordis/tests/inspect.spec.ts +++ b/packages/cordis/tool-cordis/tests/inspect.spec.ts @@ -18,7 +18,7 @@ describe('cordis_inspect', () => { const report = text(result) if (result.isError) throw new Error('expected cordis_inspect success') expect(result.value).toBe(report) - for (const heading of ['services', 'plugins', 'tools', 'dynamic', 'api', 'events']) { + for (const heading of ['services', 'plugins', 'tools', 'Temporary Plugins', 'api', 'events']) { expect(report).toContain(`## ${heading}`) } // The services section sees the real providers; the plugins list shows @@ -28,7 +28,7 @@ describe('cordis_inspect', () => { expect(report).toContain('- tool-cordis [active]') expect(report).toContain('- cordis-dynamic [active]') expect(report).toContain('- cordis_mount') - expect(report).toContain('(no dynamic plugins mounted)') + expect(report).toContain('No temporary Plugins are running. Temporary Plugins created with cordis_mount disappear when DSH restarts.') }) it('limits the report to one section via `what`', async () => { @@ -40,11 +40,12 @@ describe('cordis_inspect', () => { expect(report).not.toContain('## plugins') }) - it('shows a mount in the dynamic section and in the flat plugins list', async () => { + it('shows a temporary Plugin in its exact section and in the flat plugins list', async () => { const ctx = await setup() await call(ctx, 'cordis_mount', { code: LISTENER_CODE }) const report = text(await call(ctx, 'cordis_inspect', {})) - expect(report).toContain('- dyn-1: change-logger [active]') + expect(report).toContain('## Temporary Plugins') + expect(report).toContain('- Temporary Plugin dyn-1: change-logger [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts') expect(report).toContain('- change-logger [active]') }) diff --git a/packages/cordis/tool-cordis/tests/integration.spec.ts b/packages/cordis/tool-cordis/tests/integration.spec.ts index 9cedbd2a33..ffdc367225 100644 --- a/packages/cordis/tool-cordis/tests/integration.spec.ts +++ b/packages/cordis/tool-cordis/tests/integration.spec.ts @@ -1,12 +1,13 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' +import { CallId } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import * as ToolCordis from '../src/index.ts' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' -import { REVERSE_TOOL_CODE } from './helpers.ts' +import { call, REVERSE_TOOL_CODE, setup, text } from './helpers.ts' /** * Full-loop integration: a scripted mock model mounts a plugin that registers @@ -65,4 +66,36 @@ describe('cordis tools through the agent loop', () => { // After the unmount the self-made tool is gone from the registry. expect(ctx.tools.get('reverse_text')).toBeUndefined() }) + + it('keeps a temporary Plugin across turns, unmounts it, and does not restore it in a new runtime', async () => { + const adapter = new MockAdapter([ + toolCallResponse('mount-1', 'cordis_mount', { code: 'return { name: \'turn-marker\', apply() {} }' }), + toolCallResponse('inspect-1', 'cordis_inspect', { what: 'temporary' }), + textResponse('Turn one complete.'), + toolCallResponse('inspect-2', 'cordis_inspect', { what: 'temporary' }), + toolCallResponse('unmount-1', 'cordis_unmount', { id: 'dyn-1' }), + toolCallResponse('inspect-3', 'cordis_inspect', { what: 'temporary' }), + textResponse('Turn two complete.'), + ]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('it-cordis-turn-lifetime'), { provider: 'mock', model: 'mock' }) + + agent.followup({ content: [{ type: 'text', text: 'Mount the marker and inspect it.' }], source: { kind: 'user' } }) + await waitForIdle(ctx, agent) + agent.followup({ content: [{ type: 'text', text: 'On this later turn, inspect the marker, unmount it, then inspect again.' }], source: { kind: 'user' } }) + await waitForIdle(ctx, agent) + + const resultText = new Map( + agent.session.events + .filter(event => event.type === 'tool/result') + .map(event => [event.data.callId, event.data.content.filter(block => block.type === 'text').map(block => block.text).join('')]), + ) + expect(resultText.get(CallId('inspect-1'))).toContain('Temporary Plugin dyn-1: turn-marker [running]') + expect(resultText.get(CallId('inspect-2'))).toContain('Temporary Plugin dyn-1: turn-marker [running]') + expect(resultText.get(CallId('unmount-1'))).toBe('Temporary Plugin dyn-1 was unmounted and removed.') + expect(resultText.get(CallId('inspect-3'))).toContain('No temporary Plugins are running.') + + const restarted = await setup() + expect(text(await call(restarted, 'cordis_inspect', { what: 'temporary' }))).toContain('No temporary Plugins are running.') + }) }) diff --git a/packages/cordis/tool-cordis/tests/mount.spec.ts b/packages/cordis/tool-cordis/tests/mount.spec.ts index a354de0b37..a7b868381b 100644 --- a/packages/cordis/tool-cordis/tests/mount.spec.ts +++ b/packages/cordis/tool-cordis/tests/mount.spec.ts @@ -57,7 +57,7 @@ describe('cordis_mount', () => { provides: [], waitingFor: [], }) - expect(text(result)).toContain('mounted dyn-1 (plugin "change-logger", state: active)') + expect(text(result)).toBe('Temporary Plugin dyn-1 is running (plugin "change-logger"; available until unmounted or DSH restarts).') // Fire a REAL tools/change by registering a tool; the mounted listener logs. ctx.tools.register(dummyTool('trigger_a')) @@ -665,8 +665,7 @@ describe('cordis_mount', () => { provides: [], waitingFor: ['no-such-service'], }) - expect(text(result)).toContain('state: pending') - expect(text(result)).toContain('waiting for service(s): no-such-service') + expect(text(result)).toBe('Temporary Plugin dyn-1 is pending (plugin "waiter"; missing services: no-such-service; available until unmounted or DSH restarts).') // Unmounting a pending mount works like any other. const unmounted = await call(ctx, 'cordis_unmount', { id: 'dyn-1' }) expect(unmounted.isError).toBe(false) @@ -677,7 +676,7 @@ describe('cordis_mount', () => { const result = await call(ctx, 'cordis_mount', { code: 'throw new Error(\'boom in sandbox\')' }) expect(result.isError).toBe(true) expect(text(result)).toContain('boom in sandbox') - expect(text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))).toContain('(no dynamic plugins mounted)') + expect(text(await call(ctx, 'cordis_inspect', { what: 'temporary' }))).toContain('No temporary Plugins are running.') }) it('passes non-Error and null throws through untouched (no SyntaxError misclassification)', async () => { @@ -693,7 +692,7 @@ describe('cordis_mount', () => { const ctx = await setup() const result = await call(ctx, 'cordis_mount', { code: 'return 42' }) expect(result.isError).toBe(true) - expect(text(result)).toContain('must `return` a plugin') + expect(text(result)).toContain('must `return` a Plugin') }) it('answers a missing return with the two valid plugin forms', async () => { @@ -710,7 +709,7 @@ describe('cordis_mount', () => { }) expect(result.isError).toBe(true) expect(text(result)).toContain('apply exploded') - expect(text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))).toContain('(no dynamic plugins mounted)') + expect(text(await call(ctx, 'cordis_inspect', { what: 'temporary' }))).toContain('No temporary Plugins are running.') }) it('rolls back a plugin that collides with an existing tool name, keeping the original tool intact', async () => { @@ -754,16 +753,16 @@ describe('cordis_mount', () => { }) it.each([ - ['require(\'fs\')', 'require is not available in the mount sandbox', 'inject: [\'fs\']'], - ['setTimeout(() => {}, 5)', 'setTimeout is not available in the mount sandbox', 'ctx.setTimeout'], - ['fetch(\'https://example.com\')', 'fetch is not available in the mount sandbox', 'ctx.web'], + ['require(\'fs\')', 'require is not available in the temporary Plugin sandbox', 'inject: [\'fs\']'], + ['setTimeout(() => {}, 5)', 'setTimeout is not available in the temporary Plugin sandbox', 'ctx.setTimeout'], + ['fetch(\'https://example.com\')', 'fetch is not available in the temporary Plugin sandbox', 'ctx.web'], ])('traps the Node API call %s with a redirect to the cordis alternative', async (invocation, trapMessage, redirect) => { const ctx = await setup() const result = await call(ctx, 'cordis_mount', { code: `${invocation}\nreturn (ctx) => {}` }) expect(result.isError).toBe(true) expect(text(result)).toContain(trapMessage) expect(text(result)).toContain(redirect) - expect(text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))).toContain('(no dynamic plugins mounted)') + expect(text(await call(ctx, 'cordis_inspect', { what: 'temporary' }))).toContain('No temporary Plugins are running.') }) it('lets a mounted plugin schedule through the cordis timer service (inject: [\'timer\'])', async () => { @@ -781,7 +780,7 @@ describe('cordis_mount', () => { `, }) expect(result.isError).toBe(false) - expect(text(result)).toContain('state: active') + expect(text(result)).toContain('is running') await new Promise(resolve => setTimeout(resolve, 50)) expect(log).toHaveBeenCalledWith('[cordis:dyn-1]', 'tick') }) @@ -854,7 +853,7 @@ describe('cordis_mount', () => { const result = await call(ctx, 'cordis_mount', { code: 'while (true) {}' }) expect(result.isError).toBe(true) expect(text(result)).toMatch(/timed? ?out/i) - expect(text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))).toContain('(no dynamic plugins mounted)') + expect(text(await call(ctx, 'cordis_inspect', { what: 'temporary' }))).toContain('No temporary Plugins are running.') }) it('makes instanceof inside the sandbox see BOTH realms (patched vm constructors, host untouched)', async () => { diff --git a/packages/cordis/tool-cordis/tests/present.spec.ts b/packages/cordis/tool-cordis/tests/present.spec.ts index ed45fd1518..b9c4622054 100644 --- a/packages/cordis/tool-cordis/tests/present.spec.ts +++ b/packages/cordis/tool-cordis/tests/present.spec.ts @@ -22,13 +22,13 @@ describe('presenters', () => { expect(presentMountCall({ code: 'return (ctx) => {}' })).toEqual({ card: 'generic', kind: 'execute', - title: 'Mount plugin into live cordis runtime', + title: 'Mount temporary Cordis Plugin', rawInput: { code: 'return (ctx) => {}' }, }) }) it('cordis_unmount renders a generic delete card titled with the id', () => { - expect(presentUnmountCall({ id: 'dyn-1' })).toEqual({ card: 'generic', kind: 'delete', title: 'Unmount dyn-1' }) + expect(presentUnmountCall({ id: 'dyn-1' })).toEqual({ card: 'generic', kind: 'delete', title: 'Unmount temporary Cordis Plugin dyn-1' }) }) it('is wired onto the registered definitions through the defineTool soft-validation path', async () => { @@ -42,7 +42,7 @@ describe('presenters', () => { title: 'Inspect cordis runtime: api: tools', }) expect(ctx.tools.get('cordis_mount')!.presentCall!({ code: 'return 1' })).toMatchObject({ kind: 'execute' }) - expect(ctx.tools.get('cordis_unmount')!.presentCall!({ id: 'dyn-2' })).toMatchObject({ title: 'Unmount dyn-2' }) + expect(ctx.tools.get('cordis_unmount')!.presentCall!({ id: 'dyn-2' })).toMatchObject({ title: 'Unmount temporary Cordis Plugin dyn-2' }) // Soft validation: presenter args that fail the schema render as no card, never a throw. expect(ctx.tools.get('cordis_unmount')!.presentCall!({ id: 42 })).toBeUndefined() }) diff --git a/packages/cordis/tool-cordis/tests/sandbox-context.spec.ts b/packages/cordis/tool-cordis/tests/sandbox-context.spec.ts index 05a33848c0..02828e46ca 100644 --- a/packages/cordis/tool-cordis/tests/sandbox-context.spec.ts +++ b/packages/cordis/tool-cordis/tests/sandbox-context.spec.ts @@ -186,7 +186,7 @@ describe('sandbox context façade — inject gate on services', () => { `, }) expect(result.isError).toBe(false) - expect(text(result)).toContain('state: active') + expect(text(result)).toContain('is running') }) it('a cross-mount consumer must declare the provider — the undeclared path is refused, not left as a zombie tool', async () => { diff --git a/packages/cordis/tool-cordis/tests/tool-cordis.spec.ts b/packages/cordis/tool-cordis/tests/tool-cordis.spec.ts index d305a92883..32846e35dd 100644 --- a/packages/cordis/tool-cordis/tests/tool-cordis.spec.ts +++ b/packages/cordis/tool-cordis/tests/tool-cordis.spec.ts @@ -31,9 +31,10 @@ describe('tool registration', () => { const ctx = await setup() const names = ctx.tools.schemas().map(schema => schema.name) expect(names).toEqual(expect.arrayContaining(['cordis_inspect', 'cordis_mount', 'cordis_unmount'])) + expect(names).not.toEqual(expect.arrayContaining(['cordis_try', 'cordis_stop'])) const inspect = ctx.tools.schemas().find(schema => schema.name === 'cordis_inspect')! const props = (inspect.parameters as { properties: Record }).properties - expect(props.what?.enum).toEqual(['services', 'plugins', 'tools', 'dynamic', 'api', 'events']) + expect(props.what?.enum).toEqual(['services', 'plugins', 'tools', 'temporary', 'api', 'events']) expect(props.name?.type).toBe('string') }) }) diff --git a/packages/cordis/tool-cordis/tests/unmount-hmr.spec.ts b/packages/cordis/tool-cordis/tests/unmount-hmr.spec.ts index 92e5ee7a66..42c4c0196c 100644 --- a/packages/cordis/tool-cordis/tests/unmount-hmr.spec.ts +++ b/packages/cordis/tool-cordis/tests/unmount-hmr.spec.ts @@ -28,13 +28,13 @@ describe('cordis_unmount', () => { expect(result.isError).toBe(false) if (result.isError) throw new Error('expected cordis_unmount success') expect(result.value).toEqual({ id: 'dyn-1', pluginName: 'change-logger' }) - expect(text(result)).toContain('unmounted dyn-1') + expect(text(result)).toBe('Temporary Plugin dyn-1 was unmounted and removed.') // Immediately after the awaited unmount, the listener must be gone — no // grace period, no eventual consistency. ctx.tools.register(dummyTool('trigger_after')) expect(log).toHaveBeenCalledTimes(1) - expect(text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))).toContain('(no dynamic plugins mounted)') + expect(text(await call(ctx, 'cordis_inspect', { what: 'temporary' }))).toContain('No temporary Plugins are running.') }) it('unregisters a self-made tool on unmount', async () => { @@ -50,7 +50,7 @@ describe('cordis_unmount', () => { const ctx = await setup() const unknown = await call(ctx, 'cordis_unmount', { id: 'dyn-99' }) expect(unknown.isError).toBe(true) - expect(text(unknown)).toContain('no dynamic plugin with id "dyn-99"') + expect(text(unknown)).toContain('no temporary Plugin with id "dyn-99"') await call(ctx, 'cordis_mount', { code: LISTENER_CODE }) await call(ctx, 'cordis_unmount', { id: 'dyn-1' }) diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index 9ce4afa55c..8652771f93 100644 --- a/packages/core/agent-loop/README.i18n.yaml +++ b/packages/core/agent-loop/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/core/agent-loop/README.md -README.md: ab6df4d49f05ff00b16a210830e7fd21504a0a9f -README.zh.md: 5adb2a11ba3de2bb6fc7ff57b9d6dd07ac7f650e +README.md: c12140f27aed400b0f7b4246700473e877d37632 +README.zh.md: 6394cd86f5f3241be07ef711c76079624bce1bfe diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index ab6df4d49f..c12140f27a 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -64,7 +64,7 @@ Every provider call that reaches a successful finish appends exactly one `assist After `agent/request` returns a provider/model call config, the loop asks `ctx.llm.prepareCall()` to validate any adapter-owned reasoning effort and materialize its configured default under the active turn signal. The prepared call retains the exact adapter registration across this asynchronous resolution, `request/header` logging, and terminal dispatch, so HMR cannot mix one adapter's capability result with another adapter's request. The effective config is logged before dispatch, so a listener can change effort between steps without hidden request drift. A route with no registered adapter preserves the proposed config so an `llm/stream` listener can own and short-circuit it; unhandled terminal dispatch still fails with `NO_ADAPTER`. A new loop instance restores the last effort only when its initial provider/model route exactly matches the logged route; a route change discards that opaque model-owned ID and resolves the new model independently. -Plugin failure ends the current turn, not the loop. A model-request failure first closes its step and enters `agent/request-error` with the exact live error, normalized provider facts, and the turn signal. A handling listener returns `{ kind: 'retry' }`; the loop closes the failed turn with its error and opens one numbered retry turn without an intervening idle notification. An unhandled failure is terminal. Other failures close directly. AgentLoop owns one cancellation signal for the current admission or turn. An effective `cancel(cause)` clears pending work unless `keepInbox` is set and cooperatively aborts that signal; idle cancellation is a no-op. Durable `turn/end` records `aborted` for `user` and `parent`, while disposal records `disposed`; undispatched model tool calls receive synthetic `tool/call` and `ABORTED_BEFORE_DISPATCH` result pairs. The cancellation cause changes reporting, not how result context finalized after cancellation is handled. Disposal waits for signal-ignoring work before registry removal. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns the lifecycle and race contract. +Plugin failure ends the current turn, not the loop. Only final adapter dispatch/iteration failures and terminal in-band error or aborted finishes enter `agent/request-error`; middleware, result processing, tools, and other extension failures close directly. Recovery receives the exact live error, immutable provider facts, immutable prior failures, the immutable retry policy of the adapter registration that served the request, and the turn signal after the failed step closes; the policy is absent if no final adapter served it. A handling listener returns `{ kind: 'retry' }`; the loop closes the failed turn with its error and opens one numbered retry turn without an intervening idle notification. Success clears the consecutive history, and an unhandled failure is terminal. AgentLoop owns one cancellation signal for the current admission or turn. An effective `cancel(cause)` clears pending work unless `keepInbox` is set and cooperatively aborts that signal; idle cancellation is a no-op. Durable `turn/end` records `aborted` for `user` and `parent`, while disposal records `disposed`; undispatched model tool calls receive synthetic `tool/call` and `ABORTED_BEFORE_DISPATCH` result pairs. The cancellation cause changes reporting, not how result context finalized after cancellation is handled. Disposal waits for signal-ignoring work before registry removal. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns the lifecycle and race contract. Within a step, exclusive calls form barriers; parallel-safe calls use a bounded rolling pool and are reclassified before start. Only dispatch/body overlaps. Policy, durable results, and result context remain model-ordered. Abort stops new calls, drains started results, and retains their finalized result context without distinguishing the cancellation cause. @@ -73,7 +73,7 @@ Within a step, exclusive calls form barriers; parallel-safe calls use a bounded Everything that goes beyond "call the model, run the tools, repeat" belongs to plugins listening on the event taxonomy: - Hooks and policy: the relevant `agent/*` checkpoints plus the guarded `tools/pre-execute` → `tools/execute` → `tools/post-execute` → definition-owned `finalizeContent` → `tools/result` pipeline; exact event signatures and modes live in the [generated event catalog](../../../docs/cordis-catalog/events.md) - Compaction: pressure on `agent/step`; canonical overflow repair on `agent/request-error` -- Transient model recovery: `dsh-llm-retry` records and waits its finite backoff on `agent/request-error`, then returns a retry action +- Model-request recovery: `dsh-llm-retry` records and waits exact-provider normal or unbounded backoff on `agent/request-error`, emits non-surface `llm/retry` status, then returns a retry action - Sandbox, permission, plan mode: `tools/pre-execute` for extensible deny/ask, `tools.guard()` for monotonic owner policy, `tools/post-execute` for result decisions, and `tools/result` for final observation - Sub-agents: implemented outside the loop as `ctx.subagents` providers; in-process providers use `ctx.agents.create()` and owned `AgentHandle` teardown, while generic [`ctx.tasks`](../../tasks/tasks/) plus [`dsh-tool-subagent`](../../subagent/tool-subagent/) own background collection. - Persistence: eager write-behind from `session/event`; `session/flush` is an explicit observation barrier diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index 5adb2a11ba..6394cd86f5 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -64,7 +64,7 @@ interface Config { 在 `agent/request` 返回提供方/模型调用配置后,循环会调用 `ctx.llm.prepareCall()`,在活跃轮次信号的控制下校验由适配器持有的推理(reasoning)强度,并填入其配置默认值。准备完成的调用会在这次异步解析、`request/header` 日志记录和最终分派期间保留同一项确切的适配器注册,因此 HMR(热模块替换)不会把某个适配器的能力解析结果与另一适配器的请求混用。生效配置会在分派前写入日志,因此监听器可以在步骤之间更改推理强度,而不会产生未记录的请求变化。没有已注册适配器的路由会保留原定配置,使 `llm/stream` 监听器可以接管并短路该请求;最终分派仍会以 `NO_ADAPTER` 拒绝未得到处理的路由。新循环实例仅在初始提供方/模型路由与日志路由完全一致时恢复上次的推理强度;路由变化会丢弃由前一模型持有的不透明 ID,并单独解析新模型。 -插件失败会结束当前轮次,而不是结束循环。模型请求失败会先关闭其步骤,再带着确切的实时错误、规范化的提供方事实和轮次信号进入 `agent/request-error`。处理失败的监听器返回 `{ kind: 'retry' }`;循环用其错误关闭失败轮次,并在不插入空闲通知的情况下开启一个编号重试轮次。未被处理的失败是终态。其他失败直接关闭轮次。AgentLoop 为当前接纳或轮次拥有一个取消信号。有效的 `cancel(cause)` 在未设置 `keepInbox` 时清除待处理工作,并以协作方式中止该信号;空闲取消是空操作。持久 `turn/end` 为 `user` 和 `parent` 记录 `aborted`,dispose 则记录 `disposed`;未分发的模型工具调用会收到合成的 `tool/call` 与 `ABORTED_BEFORE_DISPATCH` 结果对。取消原因只改变报告方式,不改变对取消后已定案结果上下文的处理。Dispose 会等待忽略信号的工作完成,然后才从注册表移除。[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)规定生命周期与竞态契约。 +插件失败会结束当前轮次,而不是结束循环。只有最终适配器分发/迭代失败以及带内的终止错误或中止结束才进入 `agent/request-error`;中间件、结果处理、工具及其他扩展失败会直接关闭轮次。失败步骤关闭后,恢复逻辑会接收确切的实时错误、不可变的提供方事实、不可变的先前失败、为请求提供服务的适配器注册所对应的不可变重试策略,以及轮次信号;如果没有最终适配器为其提供服务,则该策略缺失。处理失败的监听器返回 `{ kind: 'retry' }`;循环用其错误关闭失败轮次,并在不插入空闲通知的情况下开启一个编号重试轮次。成功会清除连续失败历史;未被处理的失败是终态。AgentLoop 为当前接纳或轮次拥有一个取消信号。有效的 `cancel(cause)` 在未设置 `keepInbox` 时清除待处理工作,并以协作方式中止该信号;空闲取消是空操作。持久 `turn/end` 为 `user` 和 `parent` 记录 `aborted`,dispose(资源释放)则记录 `disposed`;未分发的模型工具调用会收到合成的 `tool/call` 与 `ABORTED_BEFORE_DISPATCH` 结果对。取消原因只改变报告方式,不改变对取消后已定案结果上下文的处理。dispose 会等待忽略信号的工作完成,然后才从注册表移除。[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)规定生命周期与竞态契约。 在步骤内,独占调用形成屏障;并行安全调用使用有界滚动池,并在启动前重新分类。只有分发/主体会重叠。策略、持久结果和结果上下文仍保持模型顺序。中止会停止新调用,drain 已启动的结果,并保留其已定案的结果上下文,不区分取消原因。 @@ -73,7 +73,7 @@ interface Config { 超出「调用模型、运行工具、重复」的所有内容,都属于监听事件分类体系的插件: - 钩子与策略:相关的 `agent/*` 检查点,加上受守卫保护的 `tools/pre-execute` → `tools/execute` → `tools/post-execute` → 定义拥有的 `finalizeContent` → `tools/result` 流水线;确切事件签名与 mode 位于生成的[事件目录](../../../docs/cordis-catalog/events.md) - 压缩(compaction):在 `agent/step` 上观测压力;在 `agent/request-error` 上修复规范溢出 -- 瞬时模型恢复:`dsh-llm-retry` 在 `agent/request-error` 上记录并等待其有限退避,然后返回重试动作 +- 模型请求恢复:`dsh-llm-retry` 在 `agent/request-error` 上记录并等待按确切提供方配置的 normal 或无界退避,发出不进入表层的 `llm/retry` 状态,然后返回重试动作 - 沙箱、权限、计划模式:使用 `tools/pre-execute` 提供可扩展的拒绝/询问,使用 `tools.guard()` 提供单调拥有方策略,使用 `tools/post-execute` 处理结果决定,并使用 `tools/result` 进行最终观测 - subagent:在循环外部实现为 `ctx.subagents` 提供方;进程内提供方使用 `ctx.agents.create()` 和拥有的 `AgentHandle` 进行 teardown,而通用的 [`ctx.tasks`](../../tasks/tasks/) 与 [`dsh-tool-subagent`](../../subagent/tool-subagent/) 负责后台收集。 - 持久化:从 `session/event` 立即后写;`session/flush` 是显式观测屏障 diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 2d25185733..f316c5e9f2 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -27,9 +27,9 @@ import type { SendOptions, } from '@deepseek-ai/dsh-agent' import { - BlockAssembler, LlmError, assertNever, deepFreeze, errorChain, isHarnessError, llmFailureOf, markAgentLoopRequest, + BlockAssembler, LlmError, assertNever, deepFreeze, errorChain, isHarnessError, llmFailureOf, llmRetryPolicyOf, markAgentLoopRequest, } from '@deepseek-ai/dsh-llm' -import type { GenerateOptions, LlmCallConfig, LlmFailure, Message, PreparedLlmCall } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, LlmCallConfig, LlmFailure, Message, PreparedLlmCall, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm' import { canonicalHeader, headerEquals } from '@deepseek-ai/dsh-session' import type { Session, SessionId, TurnEndReason, TurnTrigger, UserMessageData } from '@deepseek-ai/dsh-session' import { renderPrompt } from '@deepseek-ai/dsh-system-prompt' @@ -39,7 +39,7 @@ import { executeToolCalls } from './tool-calls.ts' /** One completed step or a final-adapter failure eligible for recovery. */ type StepOutcome = | { kind: 'completed'; continueTurn: boolean; concluded: boolean; maxTokens: boolean } - | { kind: 'request-failed'; error: RequestError; failure: LlmFailure } + | { kind: 'request-failed'; error: RequestError; failure: LlmFailure; retryPolicy: ResolvedRetryPolicy | undefined } /** * The concrete {@link Agent}: each `run()` owns one turn and repeats model @@ -303,6 +303,7 @@ export class ReactLoopAgent implements Agent { trigger: TurnTrigger, admitted: UserMessageData[] = [], inheritedOutboxLength = 0, + priorFailures: readonly LlmFailure[] = Object.freeze([]), ): Promise { // Both entries hold the invariant: kick() clears the admission slot before // awaiting run(), and a retry is entered only after the prior run clears it. @@ -317,8 +318,9 @@ export class ReactLoopAgent implements Agent { let opened = false let reason: TurnEndReason = { kind: 'completed' } let settleReason: SettleReason = { kind: 'completed' } - let retry = false - const cancelRetry = (): void => { retry = false } + let requestFailureHistory = priorFailures + let retryFailures: readonly LlmFailure[] | undefined + const cancelRetry = (): void => { retryFailures = undefined } signal.addEventListener('abort', cancelRetry, { once: true }) try { @@ -344,6 +346,7 @@ export class ReactLoopAgent implements Agent { const outcome = await this.step(turn, step, signal) switch (outcome.kind) { case 'completed': + requestFailureHistory = Object.freeze([]) if (outcome.maxTokens) reason = { kind: 'max-tokens' } // A concluding tool result is terminal: steering already in the // log waits for the next turn's request instead of reopening this @@ -361,10 +364,13 @@ export class ReactLoopAgent implements Agent { try { const action = await this.loopCtx.waterfall( agentCarrier(this), 'agent/request-error', this, turn, step, outcome.error, - outcome.failure, signal, + outcome.failure, requestFailureHistory, outcome.retryPolicy, signal, () => Promise.resolve(undefined), ) - retry = action?.kind === 'retry' && !signal.aborted + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- signal can abort while recovery is awaited. + if (action?.kind === 'retry' && !signal.aborted) { + retryFailures = Object.freeze([...requestFailureHistory, outcome.failure]) + } } catch (recoveryError: unknown) { this.loopCtx.logger.warn( `agent "${this.id}": request recovery failed at turn ${turn}, step ${step}: ${errorChain(recoveryError)}`, @@ -411,7 +417,7 @@ export class ReactLoopAgent implements Agent { this.session.append('turn/end', { turn, reason }) } } catch (error: unknown) { - retry = false + retryFailures = undefined this.loopCtx.logger.warn(`agent "${this.id}": closing turn ${turn} failed: ${errorChain(error)}`) emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, error) } @@ -422,8 +428,8 @@ export class ReactLoopAgent implements Agent { signal.removeEventListener('abort', cancelRetry) } - if (retry) { - await this.run({ kind: 'retry' }) + if (retryFailures !== undefined) { + await this.run({ kind: 'retry' }, [], 0, retryFailures) } else { // agent/settled names only committed turns: a run aborted or rejected // before turn/start has no durable turn/end for consumers to settle @@ -483,7 +489,7 @@ export class ReactLoopAgent implements Agent { } catch (error: unknown) { const facts = llmFailureOf(stream, error) if (facts !== undefined && error instanceof Error) { - return { kind: 'request-failed', error, failure: facts } + return { kind: 'request-failed', error, failure: facts, retryPolicy: llmRetryPolicyOf(stream) } } throw error } @@ -493,7 +499,7 @@ export class ReactLoopAgent implements Agent { const finish = assembler.finish if (finish.kind === 'error' || finish.kind === 'aborted') { const error = new LlmError(finish.failure.message, finish.failure.code, finish.failure) - return { kind: 'request-failed', error, failure: finish.failure } + return { kind: 'request-failed', error, failure: finish.failure, retryPolicy: llmRetryPolicyOf(stream) } } // Truncated (max-tokens) output cannot owe tool calls. diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts index f9176be9b1..bd244c0720 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts @@ -285,7 +285,9 @@ describe('request-error action edges', () => { ]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('retry-raced'), { provider: 'mock', model: 'mock' }) - ctx.on('agent/request-error', async (subject, _turn, _step, _error, _failure, signal, next) => { + ctx.on('agent/request-error', async ( + subject, _turn, _step, _error, _failure, _priorFailures, _retryPolicy, signal, next, + ) => { await next() subject.cancel({ kind: 'user' }) expect(signal.aborted).toBe(true) diff --git a/packages/core/agent-loop/tests/request-error.spec.ts b/packages/core/agent-loop/tests/request-error.spec.ts index 23139943f6..e7cebfb56f 100644 --- a/packages/core/agent-loop/tests/request-error.spec.ts +++ b/packages/core/agent-loop/tests/request-error.spec.ts @@ -3,7 +3,7 @@ import { Context } from 'cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import LlmService, { LlmError } from '@deepseek-ai/dsh-llm' -import type { LlmFailure } from '@deepseek-ai/dsh-llm' +import type { LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' @@ -55,7 +55,13 @@ describe('agent/request-error', () => { ]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('request-error-retry'), { provider: 'mock', model: 'mock' }) - const seen: { turn: number; step: number; failure: LlmFailure }[] = [] + const seen: { + turn: number + step: number + failure: LlmFailure + priorFailures: readonly LlmFailure[] + retryPolicy: ResolvedRetryPolicy | undefined + }[] = [] const statuses: string[] = [] const settledTurns: number[] = [] ctx.on('agent/status', (subject, status) => { @@ -64,13 +70,15 @@ describe('agent/request-error', () => { ctx.on('agent/settled', (subject, turn) => { if (subject === agent) settledTurns.push(turn) }) - ctx.on('agent/request-error', async (subject, turn, step, _error, failure) => { + ctx.on('agent/request-error', async ( + subject, turn, step, _error, failure, priorFailures, retryPolicy, + ) => { expect(subject).toBe(agent) expect(agent.session.events.at(-1)).toMatchObject({ type: 'step/end', data: { turn, step }, }) - seen.push({ turn, step, failure }) + seen.push({ turn, step, failure, priorFailures, retryPolicy }) return { kind: 'retry' } }) @@ -99,6 +107,12 @@ describe('agent/request-error', () => { { kind: 'retry' }, { kind: 'retry' }, ]) + expect(seen.map(item => item.priorFailures.map(failure => failure.code))) + .toEqual([[], ['RATE_LIMIT']]) + expect(seen.map(item => item.retryPolicy)).toEqual([ + expect.objectContaining({ mode: 'normal' }), + expect.objectContaining({ mode: 'normal' }), + ]) expect(statuses).toEqual(['running', 'idle']) expect(settledTurns).toEqual([3]) }) diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 11522a9b47..b61ebadb86 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -8,7 +8,7 @@ import type { Context } from 'cordis' import type { Branded } from '@deepseek-ai/dsh-brand' import type { Scoped } from '@deepseek-ai/dsh-scope' -import type { ContentBlock, LlmCallConfig, LlmFailure, MessageSource } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, LlmCallConfig, LlmFailure, MessageSource, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm' import type { Session, SessionId, UserMessageData } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-system-prompt' declare module '@deepseek-ai/dsh-system-prompt' { @@ -370,11 +370,15 @@ declare module 'cordis' { * @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 + * 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, 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 /** * The turn is about to close: the model owes no response (no live tool * calls, no fresh steering). Awaited before the boundary commits — a diff --git a/packages/core/scope/tests/invariant.spec.ts b/packages/core/scope/tests/invariant.spec.ts index ffa68e0ebd..bc1224d86b 100644 --- a/packages/core/scope/tests/invariant.spec.ts +++ b/packages/core/scope/tests/invariant.spec.ts @@ -55,6 +55,8 @@ describe('scoped-dispatch invariants', () => { 1, new Error('request'), { message: 'request', code: 'UNKNOWN' }, + [], + undefined, signal, () => Promise.resolve(undefined), ], diff --git a/packages/examples/acp-demo/README.i18n.yaml b/packages/examples/acp-demo/README.i18n.yaml index eaaec10aab..7e17f48b20 100644 --- a/packages/examples/acp-demo/README.i18n.yaml +++ b/packages/examples/acp-demo/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: bbc41f1e0aa0c98a6e70ee54357675f1d7f05dbc -README.zh.md: 03e1246d5358138c633d2b19a9c186a3beec5a1e +# pnpm run verify-translation-pairing --write packages/examples/acp-demo/README.md +README.md: 395ab230146568989c4e6d1361218efb72d857e7 +README.zh.md: 1dfd2d99f4ab80953df77feba19649b934775d68 diff --git a/packages/examples/acp-demo/README.md b/packages/examples/acp-demo/README.md index bbc41f1e0a..395ab23014 100644 --- a/packages/examples/acp-demo/README.md +++ b/packages/examples/acp-demo/README.md @@ -36,7 +36,6 @@ The app does not install commands, user interaction, session navigation, configu | `toolBash` | owner defaults | Model-facing bash tool config. | | `toolTasks` | owner defaults | Generic background-task control config, or `false`. | | `goals` | owner defaults | Persisted same-session goal domain and model tools, or `false`. | -| `llmRetry` | owner defaults | Bounded transient model-request retry policy. | The shipped [`examples/acp-agent/cordis.yml`](../../../examples/acp-agent/cordis.yml) adds the DeepSeek adapter, sandboxed bash and filesystem providers, one-shot approval policy, compaction, subagents, workflows, hooks, and model-facing tools. The app supplies the derived session-query index, while the model-facing query consumer remains an explicit leaf opt-in. Snapshot overlays replace only nondeterministic providers or policy values. diff --git a/packages/examples/acp-demo/README.zh.md b/packages/examples/acp-demo/README.zh.md index 03e1246d53..1dfd2d99f4 100644 --- a/packages/examples/acp-demo/README.zh.md +++ b/packages/examples/acp-demo/README.zh.md @@ -36,7 +36,6 @@ ACP 自动化服务器应用:默认 agent 主干、客户端通过 [`@deepseek | `toolBash` | 拥有者默认值 | 面向模型的 bash 工具配置。 | | `toolTasks` | 拥有者默认值 | 通用后台任务控制配置,或 `false`。 | | `goals` | 拥有者默认值 | 持久的同会话目标领域与模型工具,或 `false`。 | -| `llmRetry` | 拥有者默认值 | 有界的瞬时模型请求重试策略。 | 已交付的 [`examples/acp-agent/cordis.yml`](../../../examples/acp-agent/cordis.yml) 添加 DeepSeek 适配器、沙箱化 bash 与文件系统提供方、一次性批准策略、压缩、subagent、工作流、钩子,以及面向模型的工具。应用提供派生会话查询索引,而面向模型的查询消费方仍由叶节点显式选用。快照 overlay 只替换非确定性提供方或策略值。 diff --git a/packages/examples/acp-demo/src/index.ts b/packages/examples/acp-demo/src/index.ts index eef866e79e..68a2909836 100644 --- a/packages/examples/acp-demo/src/index.ts +++ b/packages/examples/acp-demo/src/index.ts @@ -69,8 +69,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 } // Each front door owns a complete, directly readable config schema; extracting @@ -96,7 +94,6 @@ export const Config: z = z.object({ toolBash: agentCore.ToolBashConfigSchema, toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]), goals: z.union([z.const(false), agentCore.GoalConfigSchema]), - llmRetry: agentCore.LlmRetryConfigSchema, }) /* jscpd:ignore-end */ diff --git a/packages/examples/acp-demo/tests/built-bin.e2e.ts b/packages/examples/acp-demo/tests/built-bin.e2e.ts index 4ab05b21ab..6f67baa5a5 100644 --- a/packages/examples/acp-demo/tests/built-bin.e2e.ts +++ b/packages/examples/acp-demo/tests/built-bin.e2e.ts @@ -210,6 +210,7 @@ describe.skipIf(!existsSync(acpBin))('dsh-acp-demo BUILT bin (node lib/bin.js, n expect(code).not.toBe(0) expect(stderr).toContain('config file not found') }, 30_000) + }) /** Spawn the built acp bin against `configArg` (stdin closed at EOF) and resolve with its exit code + stderr. */ diff --git a/packages/examples/agent-spine-demo/README.i18n.yaml b/packages/examples/agent-spine-demo/README.i18n.yaml index aaf3b492cd..a7c71523b2 100644 --- a/packages/examples/agent-spine-demo/README.i18n.yaml +++ b/packages/examples/agent-spine-demo/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: 32874bf2839c194572ddde8c4ed007297f763ccc -README.zh.md: 57a06a00203b8e67f2f33c87d7450d1a0789d7e6 +# pnpm run verify-translation-pairing --write packages/examples/agent-spine-demo/README.md +README.md: 359e7153be2f480ba3fea4b06782acdc9f89ebb9 +README.zh.md: 57fec3f32f5bbc8f3d82ff8971d36d376d722753 diff --git a/packages/examples/agent-spine-demo/README.md b/packages/examples/agent-spine-demo/README.md index 32874bf283..359e7153be 100644 --- a/packages/examples/agent-spine-demo/README.md +++ b/packages/examples/agent-spine-demo/README.md @@ -23,7 +23,7 @@ Read this package for the whole plugin tree and its composition order. @deepseek-ai/dsh-goal optional persisted same-session goal domain @deepseek-ai/dsh-tool-goal optional model-facing goal controls @deepseek-ai/dsh-goal-session optional same-session goal-round driver -@deepseek-ai/dsh-llm-retry bounded transient request retry policy +@deepseek-ai/dsh-llm-retry provider-routed request retry policy @deepseek-ai/dsh-tasks-local generic background-task registry @deepseek-ai/dsh-invariants configurable invariant registry service @deepseek-ai/dsh-session/invariant @@ -55,11 +55,11 @@ This is the [interface/implementation/consumer seam](../../../.agents/notes/impl ```ts import type { Config } from '@deepseek-ai/dsh-agent-spine-demo' -// { agents?, maxParallelToolCalls?, persona?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, toolTasks?, goals?, invariants?, llmRetry? } +// { agents?, maxParallelToolCalls?, persona?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, toolTasks?, goals?, invariants? } // workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults. ``` -The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — TUI and headless apps pre-create `main`, while the ACP app creates agents on demand at `session/new`; `llmRetry` to the bounded retry policy; `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `sessionTitle` to the fallback title service; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); `invariants` to the invariant service; and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. Omitted `sessionTitle` uses the explicit example policy of 5 words, 40 fallback bytes, and 80 accepted-title bytes. A `goals` object opts into the persisted domain, model tools, and same-session driver while forwarding `goals.domain` and `goals.tool` to their owners; omission or `false` leaves the stack absent so headless callers retain one-turn settlement. Set `skills.enabled: false` to omit both the local provider and model-facing skill tool, and set `toolTasks: false` to retain the task service for foreground producers without exposing `task_output` / `task_list` / `task_kill`. It resolves `dshHome` once through [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) and forwards that absolute value to tool-bash's managed environment and enabled local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bash producer; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields. +The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — TUI and headless apps pre-create `main`, while the ACP app creates agents on demand at `session/new`; `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `sessionTitle` to the fallback title service; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); `invariants` to the invariant service; and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. It always mounts `dsh-llm-retry`, while each leaf adapter owns its nested `retryPolicy`. Omitted `sessionTitle` uses the explicit example policy of 5 words, 40 fallback bytes, and 80 accepted-title bytes. A `goals` object opts into the persisted domain, model tools, and same-session driver while forwarding `goals.domain` and `goals.tool` to their owners; omission or `false` leaves the stack absent so headless callers retain one-turn settlement. Set `skills.enabled: false` to omit both the local provider and model-facing skill tool, and set `toolTasks: false` to retain the task service for foreground producers without exposing `task_output` / `task_list` / `task_kill`. It resolves `dshHome` once through [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) and forwards that absolute value to tool-bash's managed environment and enabled local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bash producer; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields. For example, `{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/dsh-'], package_blocklist: ['agent-loop$'] } }` keeps the package-owned companions mounted but suppresses the blocked owner. Blocklist matches override allowlist matches; see [`dsh-invariants`](../../support/invariants/README.md) for regex and lifecycle rules. @@ -67,7 +67,7 @@ For example, `{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/ A YAML include can deduplicate config but cannot own a bin or provide front-door defaults. The ACP app package makes protocol-pure stdout wiring the default, though a leaf can still add an unsafe logger. Bundle children register services in the root isolate-keyed store, so injected leaf siblings see them without load-order coupling. -The bounded retry policy may repeat a transiently failed request in a new numbered step. Retry status and failed partial chunks stay outside model history, each provider attempt can still incur billing, front doors derive usage across every logged step, and the reconstructed request preserves the prior prefix for provider cache reuse. +The retry policy may repeat a failed request in a new numbered step. Retry status, provider errors, and failed partial chunks stay outside model history; each provider attempt can still incur billing, always mode has no attempt limit, front doors derive usage across every logged step, and the reconstructed request preserves the prior prefix for provider cache reuse. ## Model Experience diff --git a/packages/examples/agent-spine-demo/README.zh.md b/packages/examples/agent-spine-demo/README.zh.md index 57a06a0020..57fec3f32f 100644 --- a/packages/examples/agent-spine-demo/README.zh.md +++ b/packages/examples/agent-spine-demo/README.zh.md @@ -23,7 +23,7 @@ @deepseek-ai/dsh-goal optional persisted same-session goal domain @deepseek-ai/dsh-tool-goal optional model-facing goal controls @deepseek-ai/dsh-goal-session optional same-session goal-round driver -@deepseek-ai/dsh-llm-retry bounded transient request retry policy +@deepseek-ai/dsh-llm-retry provider-routed request retry policy @deepseek-ai/dsh-tasks-local generic background-task registry @deepseek-ai/dsh-invariants configurable invariant registry service @deepseek-ai/dsh-session/invariant @@ -55,11 +55,11 @@ ```ts import type { Config } from '@deepseek-ai/dsh-agent-spine-demo' -// { agents?, maxParallelToolCalls?, persona?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, toolTasks?, goals?, invariants?, llmRetry? } +// { agents?, maxParallelToolCalls?, persona?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, toolTasks?, goals?, invariants? } // workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults. ``` -组合包将每个字段转发给拥有它的子节点:`agents` 与 `maxParallelToolCalls` 交给 `agent-loop`(`agents` 默认为 `[]`,上限在该处默认),因此每个应用提供自己的预创建 agent;TUI 和无头应用预创建 `main`,ACP 应用则在 `session/new` 按需创建 agent;`llmRetry` 交给有界重试策略;`persona` 与 `toolOrder` 交给 `dsh-system-prompt`;`tools` 交给工具注册表以配置呈现 mode;`sessionTitle` 交给后备标题服务;`skills.registry`、`skills.local` 与 `skills.tool` 分别交给 skill 注册表、本地提供方和面向模型的消费方;必填的 `workspaceContext` 选择交给 `dsh-workspace-context`(`{ maxBytes }` 启用加载,`false` 禁用);`invariants` 交给不变式服务;`toolBash`/`toolTasks` 交给组合包拥有的两个面向模型工具插件。省略 `sessionTitle` 时采用显式示例策略:5 个词、40 个后备字节、80 个可接受标题字节。`goals` 对象会选用持久领域、模型工具和同会话驱动器,并将 `goals.domain` 与 `goals.tool` 转发给各自拥有者;省略或设为 `false` 会让整个栈缺席,使无头调用方继续以一轮结算。设置 `skills.enabled: false` 会同时省略本地提供方和面向模型的 skill 工具;设置 `toolTasks: false` 会保留供前台生产方使用的任务服务,但不公开 `task_output`/`task_list`/`task_kill`。它对 `dshHome` 只解析一次,解析通过 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 完成,并将所得绝对值转发给 tool-bash 的托管环境和已启用的本地 skill 发现。顶层 `dshHome` 缺席时采用 `skills.local.dshHome`;两者同时提供但解析后的路径不同会明确失败。`toolBash.enableRunInBackground` 只控制 bash 生产方;独立加载的生产方保留各自配置。Workspace 指令先于 skill 目录注册,因此其会话前缀消息先渲染。应用包使用 `pickSpineConfig()`,只复制这些由组合包拥有的字段。 +组合包将每个字段转发给拥有它的子节点:`agents` 与 `maxParallelToolCalls` 交给 `agent-loop`(`agents` 默认为 `[]`,上限在该处默认),因此每个应用提供自己的预创建 agent;TUI 和无头应用预创建 `main`,ACP 应用则在 `session/new` 按需创建 agent;`persona` 与 `toolOrder` 交给 `dsh-system-prompt`;`tools` 交给工具注册表以配置呈现 mode;`sessionTitle` 交给后备标题服务;`skills.registry`、`skills.local` 与 `skills.tool` 分别交给 skill 注册表、本地提供方和面向模型的消费方;必填的 `workspaceContext` 选择交给 `dsh-workspace-context`(`{ maxBytes }` 启用加载,`false` 禁用);`invariants` 交给不变式服务;`toolBash`/`toolTasks` 交给组合包拥有的两个面向模型工具插件。组合包始终挂载 `dsh-llm-retry`,而每个叶节点适配器拥有自己的嵌套 `retryPolicy`。省略 `sessionTitle` 时采用显式示例策略:5 个词、40 个后备字节、80 个可接受标题字节。`goals` 对象会选用持久领域、模型工具和同会话驱动器,并将 `goals.domain` 与 `goals.tool` 转发给各自拥有者;省略或设为 `false` 会让整个栈缺席,使无头调用方继续以一轮结算。设置 `skills.enabled: false` 会同时省略本地提供方和面向模型的 skill 工具;设置 `toolTasks: false` 会保留供前台生产方使用的任务服务,但不公开 `task_output`/`task_list`/`task_kill`。它对 `dshHome` 只解析一次,解析通过 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 完成,并将所得绝对值转发给 tool-bash 的托管环境和已启用的本地 skill 发现。顶层 `dshHome` 缺席时采用 `skills.local.dshHome`;两者同时提供但解析后的路径不同会明确失败。`toolBash.enableRunInBackground` 只控制 bash 生产方;独立加载的生产方保留各自配置。Workspace 指令先于 skill 目录注册,因此其会话前缀消息先渲染。应用包使用 `pickSpineConfig()`,只复制这些由组合包拥有的字段。 例如,`{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/dsh-'], package_blocklist: ['agent-loop$'] } }` 会让包拥有的配套插件保持挂载,但抑制被阻止的拥有者。Blocklist 匹配优先于 allowlist 匹配;正则表达式与生命周期规则见 [`dsh-invariants`](../../support/invariants/README.md)。 @@ -67,7 +67,7 @@ import type { Config } from '@deepseek-ai/dsh-agent-spine-demo' YAML include 可以去重配置,却无法拥有 bin 或提供前端入口默认值。ACP 应用包默认接出协议纯净的 stdout,但叶节点仍可添加不安全的 logger。组合包子节点把服务注册到根 isolate-keyed store,因此注入这些服务的叶节点同级插件无需依赖加载顺序即可看到它们。 -有界重试策略可能在新的编号步骤中重复瞬时失败的请求。重试状态和失败的部分 chunk 不进入模型历史;每次提供方尝试仍可能产生计费;前端入口从所有已记录步骤推导用量;重建的请求保留先前前缀,以便复用提供方 cache。 +重试策略可能在新的编号步骤中重复失败的请求。重试状态、提供方错误和失败的部分 chunk 不进入模型历史;每次提供方尝试仍可能产生计费;always mode 没有尝试次数上限;前端入口从所有已记录步骤推导用量;重建的请求保留先前前缀,以便复用提供方 cache。 ## 模型体验 diff --git a/packages/examples/agent-spine-demo/package.json b/packages/examples/agent-spine-demo/package.json index e76db97daf..930d75b0ac 100644 --- a/packages/examples/agent-spine-demo/package.json +++ b/packages/examples/agent-spine-demo/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-agent-spine-demo", - "description": "The default executor-less/UI-less agent spine with fallback session titles, bounded retry, and optional persisted goals", + "description": "The default executor-less/UI-less agent spine with fallback session titles, provider-routed retry, and optional persisted goals", "version": "0.0.1", "private": true, "type": "module", diff --git a/packages/examples/agent-spine-demo/src/index.ts b/packages/examples/agent-spine-demo/src/index.ts index 0ac96aaa85..92434f91da 100644 --- a/packages/examples/agent-spine-demo/src/index.ts +++ b/packages/examples/agent-spine-demo/src/index.ts @@ -74,8 +74,9 @@ export interface GoalConfig { * `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; @@ -111,8 +112,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 } /** The skill config schema exported for app packages that forward `skills`. */ @@ -139,9 +138,6 @@ export const GoalConfigSchema: z = z.object({ tool: toolGoal.Config, }) -/** The bounded LLM retry schema exported for app packages that forward `llmRetry`. */ -export const LlmRetryConfigSchema: z = llmRetry.Config - /** Intersect the owners' schemas so validation + defaulting stay identical. */ export const Config = z.intersect([ AgentLoop.Config, @@ -156,8 +152,7 @@ export const Config = z.intersect([ toolTasks: z.union([z.const(false), ToolTasksConfigSchema]), invariants: InvariantService.Config, goals: z.union([z.const(false), GoalConfigSchema]), - llmRetry: LlmRetryConfigSchema, - }) as unknown as z>, + }) as unknown as z>, ]) as unknown as z /** @@ -179,7 +174,6 @@ export function pickSpineConfig(config: Omit): Omit { this.requests += 1 @@ -209,15 +227,7 @@ describe('dsh-agent-spine-demo bundle', () => { it('loads and configures bounded request recovery for every bundled front door', async () => { const adapter = new TransientOnceAdapter() - const ctx = await mount({ - workspaceContext: false, - llmRetry: { - maxTransientRetries: 1, - initialDelayMs: 1, - maxDelayMs: 1, - jitterRatio: 0, - }, - }) + const ctx = await mount({ workspaceContext: false }) ctx.llm.registerAdapter(['mock'], adapter) const handle = await ctx.agents.create({ sessionId: SessionId('bundled-retry-session'), @@ -232,7 +242,7 @@ describe('dsh-agent-spine-demo bundle', () => { const retryEvents = handle.agent.session.events.filter(event => event.type === 'llm/retry') expect(retryEvents).toHaveLength(1) expect(retryEvents[0]?.data.retry).toBe(1) - expect(retryEvents[0]?.data.maxRetries).toBe(1) + expect(retryEvents[0]?.data).toMatchObject({ provider: 'mock', mode: 'normal', maxRetries: 1 }) expect(handle.agent.session.events.find(event => event.type === 'session/title')?.data.title).toBe('recover') expect(messageText(handle.agent.session.deriveMessages().at(-1))).toBe('recovered by bundled policy') await handle.dispose() @@ -513,7 +523,6 @@ describe('dsh-agent-spine-demo bundle', () => { toolBash: { enableRunInBackground: false }, toolTasks: false as const, invariants: { enabled: false }, - llmRetry: { maxTransientRetries: 1, jitterRatio: 0 }, } expect(agentCore.pickSpineConfig(appConfig)).toEqual({ @@ -527,7 +536,6 @@ describe('dsh-agent-spine-demo bundle', () => { toolBash: appConfig.toolBash, toolTasks: appConfig.toolTasks, invariants: appConfig.invariants, - llmRetry: appConfig.llmRetry, }) expect(agentCore.pickSpineConfig({ workspaceContext: false })).toEqual({ workspaceContext: false }) }) diff --git a/packages/examples/cli-demo/README.i18n.yaml b/packages/examples/cli-demo/README.i18n.yaml index 474225a1c3..80715af77d 100644 --- a/packages/examples/cli-demo/README.i18n.yaml +++ b/packages/examples/cli-demo/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: 4e8e5388e17ab2879582286adf593c72fb2cf78f -README.zh.md: 3cad72071184403a78b7906d637bba67bea1a64a +# pnpm run verify-translation-pairing --write packages/examples/cli-demo/README.md +README.md: b8f2bde962738a1a23f0e57218ab0f90e8e0b705 +README.zh.md: 322ba3fb3b253d867832534bd33f65df3a5b8d37 diff --git a/packages/examples/cli-demo/README.md b/packages/examples/cli-demo/README.md index 4e8e5388e1..b8f2bde962 100644 --- a/packages/examples/cli-demo/README.md +++ b/packages/examples/cli-demo/README.md @@ -21,7 +21,6 @@ The package mounts no console logger, interactive UI, user-interaction service, | `skills` | owner defaults | skill registry, local provider, and model-facing skill tool | | `toolBash` | owner defaults | model-facing bash config, including this producer's background opt-in | | `toolTasks` | owner defaults | generic `task_output` wait bounds | -| `llmRetry` | owner defaults | bounded transient model-request retry policy | | `persistenceRoot` | `./.sessions` | JSONL session root | | `persistenceCompression` | `'zstd'` | JSONL artifact encoding (`'zstd'` or raw `'none'`) | | `workspaceContext` | required | workspace-instruction byte budget, or `false` to disable loading | diff --git a/packages/examples/cli-demo/README.zh.md b/packages/examples/cli-demo/README.zh.md index 3cad720711..322ba3fb3b 100644 --- a/packages/examples/cli-demo/README.zh.md +++ b/packages/examples/cli-demo/README.zh.md @@ -21,7 +21,6 @@ | `skills` | 拥有者默认值 | Skill 注册表、本地提供方和面向模型的 skill 工具 | | `toolBash` | 拥有者默认值 | 面向模型的 bash 配置,包括此生产方对后台任务的选用 | | `toolTasks` | 拥有者默认值 | 通用 `task_output` 等待边界 | -| `llmRetry` | 拥有者默认值 | 有界的瞬时模型请求重试策略 | | `persistenceRoot` | `./.sessions` | JSONL 会话根目录 | | `persistenceCompression` | `'zstd'` | JSONL 工件编码(`'zstd'` 或原始 `'none'`) | | `workspaceContext` | 必填 | Workspace 指令字节预算,或以 `false` 禁用加载 | diff --git a/packages/examples/cli-demo/src/index.ts b/packages/examples/cli-demo/src/index.ts index f7d543c3fe..bfec7c9a4f 100644 --- a/packages/examples/cli-demo/src/index.ts +++ b/packages/examples/cli-demo/src/index.ts @@ -50,8 +50,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'] } @@ -74,7 +72,6 @@ export const Config: z = z.object({ tools: ToolRegistry.Config, toolBash: agentCore.ToolBashConfigSchema, toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]), - llmRetry: agentCore.LlmRetryConfigSchema, workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(), }) /* jscpd:ignore-end */ diff --git a/packages/examples/cli-demo/tests/cli.spec.ts b/packages/examples/cli-demo/tests/cli.spec.ts index c497eb59dc..50333dd5ee 100644 --- a/packages/examples/cli-demo/tests/cli.spec.ts +++ b/packages/examples/cli-demo/tests/cli.spec.ts @@ -3,7 +3,15 @@ import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { Context } from 'cordis' import type { Agent } from '@deepseek-ai/dsh-agent' -import { CallId, LlmAdapter, type GenerateOptions, type StreamChunk, type TokenUsage } from '@deepseek-ai/dsh-llm' +import { + CallId, + LlmAdapter, + resolveRetryPolicy, + type GenerateOptions, + type ResolvedRetryPolicy, + type StreamChunk, + type TokenUsage, +} from '@deepseek-ai/dsh-llm' import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' import { afterEach, describe, expect, it } from 'vitest' import * as cliDemo from '../src/index.ts' @@ -20,11 +28,19 @@ type ScriptEntry = readonly StreamChunk[] | 'hang' class ScriptedAdapter extends LlmAdapter { readonly requests: GenerateOptions[] = [] private cursor = 0 + private readonly retryPolicy = resolveRetryPolicy({ + mode: 'normal', + backoff: { initialDelayMs: 1, maxDelayMs: 1, jitterRatio: 0 }, + }, 'cli test provider retryPolicy') constructor(private readonly script: readonly ScriptEntry[]) { super() } + override providerRetryPolicy(_provider: string): ResolvedRetryPolicy { + return this.retryPolicy + } + async * stream(options: GenerateOptions): AsyncIterable { this.requests.push(options) const entry = this.script[this.cursor++] @@ -107,7 +123,6 @@ async function harness(script: readonly ScriptEntry[]): Promise { persistenceRoot: root, skills: { local: { dshHome: join(skillHome, '.dsh'), agentsHome: join(skillHome, '.agents') } }, workspaceContext: false, - llmRetry: { initialDelayMs: 1, maxDelayMs: 1, jitterRatio: 0 }, }) await new Promise(resolve => setTimeout(resolve, 80)) ctx.llm.registerAdapter(['mock'], new ScriptedAdapter(script)) diff --git a/packages/examples/tui-demo/src/index.ts b/packages/examples/tui-demo/src/index.ts index 8a88859ab3..c60ba94b3c 100644 --- a/packages/examples/tui-demo/src/index.ts +++ b/packages/examples/tui-demo/src/index.ts @@ -131,6 +131,7 @@ export function composeTuiApp(ctx: Context, config: Config): void { ctx.plugin(SessionQuerySqlite, { path: join(persistenceRoot, 'session-query.db') }) ctx.plugin(SessionReferenceService, config.sessionReferences ?? {}) ctx.plugin(UserInteractionService) + ctx.plugin(uiTui.TuiPromptService) ctx.plugin(uiTui, { ...config.ui, ...config.welcome === undefined ? {} : { welcome: config.welcome }, diff --git a/packages/examples/tui-demo/tests/tui-agent.spec.ts b/packages/examples/tui-demo/tests/tui-agent.spec.ts index cdab42289b..f647b3d9c6 100644 --- a/packages/examples/tui-demo/tests/tui-agent.spec.ts +++ b/packages/examples/tui-demo/tests/tui-agent.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { join } from 'node:path' -import type { Context } from 'cordis' +import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt' import * as tuiAgent from '../src/index.ts' @@ -40,7 +40,7 @@ describe('dsh-tui-demo app', () => { }, welcome: 'TUI ready', resumeCommand: 'dsh --resume {session}', - ui: { color: false, maxToolOutputLines: 3 }, + ui: { theme: { color: false }, maxToolOutputLines: 3 }, skills: { tool: { catalogDescriptionMaxLength: 8 } }, toolBash: { enableRunInBackground: false }, toolTasks: { waitTimeoutMs: 7, maxWaitTimeoutMs: 11 }, @@ -55,6 +55,7 @@ describe('dsh-tui-demo app', () => { 'SessionQuerySqlite', 'SessionReferenceService', 'UserInteractionService', + 'TuiPromptService', 'ui-tui', 'agent-spine-demo', 'tool-ask-user', @@ -67,15 +68,15 @@ describe('dsh-tui-demo app', () => { candidateLimit: 7, maxReferenceBytes: 1234, }) - const tuiConfig = calls[7]?.config as { sessionId: string } + const tuiConfig = calls[8]?.config as { sessionId: string } expect(tuiConfig).toMatchObject({ welcome: 'TUI ready', resumeCommand: 'dsh --resume {session}', - color: false, + theme: { color: false }, maxToolOutputLines: 3, }) expect(tuiConfig.sessionId).toMatch(/^main-session-[0-9a-f-]{36}$/) - const spineConfig = calls[8]?.config as { + const spineConfig = calls[9]?.config as { readonly agents: Array> readonly goals: Record readonly maxParallelToolCalls: number @@ -111,8 +112,8 @@ describe('dsh-tui-demo app', () => { expect(calls[2]?.config).toEqual({ root: './.sessions' }) expect(calls[5]?.config).toEqual({}) // No configured welcome forwards none: the TUI banner sweeps in without a subtitle. - expect(calls[7]?.config).toEqual({ sessionId: 'persisted-session' }) - expect((calls[8]?.config as { agents: Array> }).agents[0]).toMatchObject({ + expect(calls[8]?.config).toEqual({ sessionId: 'persisted-session' }) + expect((calls[9]?.config as { agents: Array> }).agents[0]).toMatchObject({ id: 'main', resumeSessionId: 'persisted-session', }) @@ -128,12 +129,12 @@ describe('dsh-tui-demo app', () => { workspaceContext: false, }) - const tuiConfig = calls[6]?.config as { sessionId: string } + const tuiConfig = calls[7]?.config as { sessionId: string } expect(tuiConfig.sessionId).toMatch(/^main-session-[0-9a-f-]{36}$/) - expect((calls[7]?.config as { agents: Array> }).agents[0]) + expect((calls[8]?.config as { agents: Array> }).agents[0]) .toMatchObject({ sessionId: tuiConfig.sessionId }) expect(calls.map(call => call.name)).not.toContain('command-goal') - expect(calls[7]?.config).toMatchObject({ goals: false }) + expect(calls[8]?.config).toMatchObject({ goals: false }) }) it('has the namespace-plugin export shape so the Loader keeps its schema', () => { diff --git a/packages/fs/tool-fs/src/read.ts b/packages/fs/tool-fs/src/read.ts index 4e299d9a79..05e1b41ae2 100644 --- a/packages/fs/tool-fs/src/read.ts +++ b/packages/fs/tool-fs/src/read.ts @@ -6,7 +6,7 @@ import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' -import type { GenericCallView } from '@deepseek-ai/dsh-tools' +import type { GenericCallView, GenericResultView, ToolResult } from '@deepseek-ai/dsh-tools' import { FsError } from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-system-prompt' @@ -154,6 +154,16 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void { ctx.emit('fs/observed', target, info.version, exec) return outcome }, + presentResult(_args, result: ToolResult): GenericResultView | undefined { + if (result.isError) return undefined + const only = result.content.length === 1 ? result.content[0] : undefined + const text = only?.type === 'text' ? only.text : undefined + if (text === undefined) return undefined + // Group 1 always captures (possibly empty) when the envelope matches. + const body = /^[^\n]*<\/path>\nfile<\/type>\n\n([\s\S]*)\n<\/content>$/u.exec(text)?.[1] + if (body === undefined) return undefined + return { card: 'generic', content: [{ type: 'text', text: body }] } + }, // Pure display: a generic card titled by the file with the read window appended (`Read // foo.txt (5 - 8)`), `read` kind (icon), and a follow-along location whose line is the // read's offset (defaulting to 1). The window reflects raw args, so an omitted limit keeps diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index 8f93b524a9..de844dcaf4 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -10,7 +10,7 @@ import { tmpdir } from 'node:os' import { join, resolve, sep } from 'node:path' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry from '@deepseek-ai/dsh-tools' +import ToolRegistry, { type ToolResult } from '@deepseek-ai/dsh-tools' import { FileSystem, FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' import type { FsDirEntry, @@ -432,6 +432,11 @@ describe('tool-owned presentation (pure presentCall)', () => { return ctx.tools.get(name)?.presentCall?.(args) } + const presentResult = async (name: string, args: unknown, result: ToolResult) => { + const { ctx } = await setup() + return ctx.tools.get(name)?.presentResult?.(args, result) + } + it('read: generic card titled by file with the read window, read kind, location with the offset line', async () => { expect(await presentCall('read', { file_path: 'src/a.ts', offset: 12, limit: 40 })).toEqual({ card: 'generic', title: 'Read src/a.ts (12 - 51)', kind: 'read', @@ -445,6 +450,36 @@ describe('tool-owned presentation (pure presentCall)', () => { }) }) + it('read: completed presentation removes the model-facing XML envelope', async () => { + expect(await presentResult('read', { file_path: 'a.txt' }, { + content: [{ type: 'text', text: '/tmp/a.txt\nfile\n\n1: hello\n\n(End of file - total 1 lines)\n' }], + isError: false, + })).toEqual({ + card: 'generic', + content: [{ type: 'text', text: '1: hello\n\n(End of file - total 1 lines)' }], + }) + expect(await presentResult('read', { file_path: 'a.txt' }, { + content: [{ type: 'text', text: 'malformed replay' }], + isError: false, + })).toBeUndefined() + }) + + it('read: completed presentation declines errors and non-single-text content', async () => { + const envelope = '/tmp/a.txt\nfile\n\nbody\n' + expect(await presentResult('read', { file_path: 'a.txt' }, { + content: [{ type: 'text', text: envelope }], + isError: true, + })).toBeUndefined() + expect(await presentResult('read', { file_path: 'a.txt' }, { + content: [{ type: 'text', text: envelope }, { type: 'text', text: 'second' }], + isError: false, + })).toBeUndefined() + expect(await presentResult('read', { file_path: 'a.txt' }, { + content: [{ type: 'reasoning', text: envelope }], + isError: false, + })).toBeUndefined() + }) + it('read: "from line N" window when only offset is set', async () => { expect(await presentCall('read', { file_path: 'a.txt', offset: 5 })).toEqual({ card: 'generic', title: 'Read a.txt (from line 5)', kind: 'read', locations: [{ path: 'a.txt', line: 5 }], diff --git a/packages/llm/README.i18n.yaml b/packages/llm/README.i18n.yaml index 0cd2ab4358..c5f5de580a 100644 --- a/packages/llm/README.i18n.yaml +++ b/packages/llm/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: 13a04aa9f73fec5824069644449009989d6fd924 -README.zh.md: 3e417c5f8be1f7831b99940c2a4aec815dc2c5b6 +# pnpm run verify-translation-pairing --write packages/llm/README.md +README.md: 66b7beabd73cc3fec7230f209a9da0da48a37c95 +README.zh.md: 48c54358ce3e8e21e33a6ef5b75a7e095b6581d5 diff --git a/packages/llm/README.md b/packages/llm/README.md index 13a04aa9f7..66b7beabd7 100644 --- a/packages/llm/README.md +++ b/packages/llm/README.md @@ -8,8 +8,8 @@ The LLM seam and its provider adapters. The interface package (`llm`) owns the a |---|---|---| | `llm/` | Abstract LLM service + content-block vocabulary + chunk assembler | `ctx.llm` | | `token-meter/` | Replay-aware request and surface token measurement | `ctx.tokenMeter` | -| `llm-retry/` | Bounded transient request retry policy | (listens to `agent/request-error`) | +| `llm-retry/` | Exact-provider normal or unbounded request retry policy | (listens to `agent/request-error`) | | `llm-deepseek/` | DeepSeek API adapter (direct fetch + eventsource-parser SSE) | (registers on `ctx.llm`) | | `llm-pi-ai/` | Multi-provider adapter via `@earendil-works/pi-ai` | (registers on `ctx.llm`) | -The interface lives at `llm/llm/`; adapters, retry policy, and the reusable token meter are flat siblings under the group. Requests route by `provider`, while `model` is passed through to the selected adapter. The route-owning adapter optionally resolves exact provider/model context capacity; the token meter remains model-agnostic. A new provider adapter registers one or more provider routes on `ctx.llm` without touching the interface or consumers. See [twin LLM adapters](../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md) for the two shipping implementations, the [replay token meter Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md) for measurement ownership, and the [routed model context Agent Note](../../.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.md) for capacity and compaction-policy ownership. +The interface lives at `llm/llm/`; adapters, retry policy, and the reusable token meter are flat siblings under the group. Requests route by `provider`, while `model` is passed through to the selected adapter. The route-owning adapter supplies retry policy and resolves available exact-model identity, context capacity, and reasoning metadata; the retry executor and token meter remain provider-agnostic. A new provider adapter registers one or more provider routes on `ctx.llm` without touching the consumers. See [twin LLM adapters](../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md) for the two shipping implementations, the [replay token meter Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md) for measurement ownership, and the [routed model context Agent Note](../../.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.md) for capacity and compaction-policy ownership. diff --git a/packages/llm/README.zh.md b/packages/llm/README.zh.md index 3e417c5f8b..48c54358ce 100644 --- a/packages/llm/README.zh.md +++ b/packages/llm/README.zh.md @@ -8,8 +8,8 @@ LLM seam 及其提供方适配器。接口包(`llm`)拥有抽象服务、内 |---|---|---| | `llm/` | 抽象 LLM 服务 + 内容块词汇 + 分片组装器 | `ctx.llm` | | `token-meter/` | 感知回放的请求与表层 token 测量 | `ctx.tokenMeter` | -| `llm-retry/` | 有界的暂时性请求重试策略 | (监听 `agent/request-error`) | +| `llm-retry/` | 确切提供方的 normal 或无界请求重试策略 | (监听 `agent/request-error`) | | `llm-deepseek/` | DeepSeek API 适配器(直接 fetch + eventsource-parser SSE) | (注册到 `ctx.llm`) | | `llm-pi-ai/` | 通过 `@earendil-works/pi-ai` 实现的多提供方适配器 | (注册到 `ctx.llm`) | -接口位于 `llm/llm/`;适配器、重试策略和可复用的 token 计量器都是该分组下的扁平兄弟包。请求按 `provider` 路由,而 `model` 会原样传给选中的适配器。拥有路由的适配器可以解析精确的提供方/模型上下文容量;token 计量器仍与模型无关。新的提供方适配器只需在 `ctx.llm` 上注册一个或多个提供方路由,无需改动接口或消费方。两个已交付实现见[双生 LLM 适配器](../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md),测量归属见[回放 token 计量器 Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md),容量与压缩策略归属见[路由模型上下文 Agent Note](../../.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.md)。 +接口位于 `llm/llm/`;适配器、重试策略和可复用的 token 计量器都是该分组下的扁平兄弟包。请求按 `provider` 路由,而 `model` 会原样传给选中的适配器。拥有路由的适配器提供重试策略,并解析可用的确切模型身份、上下文容量和推理元数据;重试执行器与 token 计量器仍与提供方无关。新的提供方适配器只需在 `ctx.llm` 上注册一个或多个提供方路由,无需改动消费方。两个已交付实现见[双生 LLM 适配器](../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md),测量归属见[回放 token 计量器 Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md),容量与压缩策略归属见[路由模型上下文 Agent Note](../../.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.md)。 diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index a18c71b400..32647f99fb 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/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/llm/llm-deepseek/README.md -README.md: 4358620295547248ca87c42e07022c5eab0c947b -README.zh.md: 4ecc5dd2e5980751ca6e724b5041efefc8114077 +README.md: a7f2fcb9c21d45a95fc81abd3dc1424d4336966d +README.zh.md: bca56e700c0067b644adb4d1460a47901db28209 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 4358620295..a7f2fcb9c2 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -19,6 +19,12 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire thinking: enabled # optional; provider default is enabled reasoningEffort: high # optional; off | high | max — omitted ⇒ high streamIdleTimeoutMs: 300000 # optional; positive finite Node timer delay; five-minute default + retryPolicy: # optional; omission uses bounded normal defaults + mode: always # normal | always + backoff: + initialDelayMs: 500 + maxDelayMs: 10000 + jitterRatio: 0.1 defaultContextWindow: 256000 # optional positive-integer fallback for models without an exact value models: # optional; defaults to V4 Flash and V4 Pro - id: deepseek-v4-flash @@ -28,7 +34,7 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire contextWindow: 64000 ``` -The plugin registers the single provider route `deepseek`. A request selects it with `provider: deepseek`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash` and `deepseek-v4-pro`, each with a 128,000-token context window; an explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek')` for UI selectors and deployment introspection, but remain advisory: unlisted model ids still pass through unchanged. An omitted entry name defaults to its id. +The plugin registers the single provider route `deepseek` together with its resolved `retryPolicy`. A request selects it with `provider: deepseek`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash` and `deepseek-v4-pro`, each with a 128,000-token context window; an explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek')` for UI selectors and deployment introspection, but remain advisory: unlisted model ids still pass through unchanged. An omitted entry name defaults to its id. `contextWindow` is optional per configured model and is not exposed through the advisory catalog. `ctx.llm.resolveModelInfo('deepseek', model).context` returns an exact model value first, then `defaultContextWindow` for an entry without capacity or an unlisted pass-through id. When neither value exists, `context` is absent without invalidating routing. Pressure-sensitive plugins therefore get deployment-owned capacity without treating the model selector as authoritative. Registering another adapter for `deepseek` throws `LlmError('DUPLICATE_ADAPTER')`. @@ -36,7 +42,7 @@ The same exact-model result exposes ordered `off`, `high`, and `max` efforts und `thinking: disabled` is a deployment lock that publishes only `off` with `off` as its default. Omitting `reasoningEffort` or configuring it as `off` is valid; configuring `high` or `max` fails plugin loading, and a direct per-request attempt to enable thinking fails before network I/O. A request with `GenerateOptions.purpose: 'session-title'` also forces thinking disabled and omits the already-resolved effort, reserving its bounded output for visible title text without changing conversation or compaction defaults. -`streamIdleTimeoutMs` bounds each outstanding provider read, including the initial `fetch`, without counting time the consumer spends between chunks. One stable abort signal reaches the request and body reader for the whole call; expiry stops the transport and throws `LlmError('TIMEOUT')`, while an earlier caller abort throws `LlmError('ABORTED')`. The adapter makes exactly one provider request per `stream()` call; agent-level retry is a separate plugin policy. +`streamIdleTimeoutMs` bounds each outstanding provider read, including the initial `fetch`, without counting time the consumer spends between chunks. One stable abort signal reaches the request and body reader for the whole call; expiry stops the transport and throws `LlmError('TIMEOUT')`, while an earlier caller abort throws `LlmError('ABORTED')`. The adapter makes exactly one provider request per `stream()` call; it registers the configured policy as provider metadata, and `dsh-llm-retry` separately executes it at durable agent-step boundaries. ## App attribution diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 4ecc5dd2e5..bca56e700c 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -19,6 +19,12 @@ harness LLM seam 的 DeepSeek chat-completions 适配器:直接 `fetch` + SSE thinking: enabled # optional; provider default is enabled reasoningEffort: high # optional; off | high | max — omitted ⇒ high streamIdleTimeoutMs: 300000 # optional; positive finite Node timer delay; five-minute default + retryPolicy: # optional; omission uses bounded normal defaults + mode: always # normal | always + backoff: + initialDelayMs: 500 + maxDelayMs: 10000 + jitterRatio: 0.1 defaultContextWindow: 256000 # optional positive-integer fallback for models without an exact value models: # optional; defaults to V4 Flash and V4 Pro - id: deepseek-v4-flash @@ -28,7 +34,7 @@ harness LLM seam 的 DeepSeek chat-completions 适配器:直接 `fetch` + SSE contextWindow: 64000 ``` -该插件注册唯一提供方路由 `deepseek`。请求使用 `provider: deepseek` 选择该路由;其 `model` 会作为协议 `model` 字符串原样传递,因此更改 DeepSeek 模型不需要生命周期时注册。省略 `models` 会公布 `deepseek-v4-flash` 和 `deepseek-v4-pro`,两者的上下文窗口均为 128,000 token;显式列表会替换这些默认值,`models: []` 则不公布任何模型。Catalog 配置项通过 `ctx.llm.listModels('deepseek')` 公开给 UI selector 与部署自省,但仍只提供建议:未列出模型 id 仍原样传递。省略配置项 name 默认为其 id。 +该插件注册唯一提供方路由 `deepseek`,同时注册解析后的 `retryPolicy`。请求使用 `provider: deepseek` 选择该路由;其 `model` 会作为协议 `model` 字符串原样传递,因此更改 DeepSeek 模型不需要生命周期时注册。省略 `models` 会公布 `deepseek-v4-flash` 和 `deepseek-v4-pro`,两者的上下文窗口均为 128,000 token;显式列表会替换这些默认值,`models: []` 则不公布任何模型。Catalog 配置项通过 `ctx.llm.listModels('deepseek')` 公开给 UI selector 与部署自省,但仍只提供建议:未列出模型 id 仍原样传递。省略配置项 name 默认为其 id。 `contextWindow` 对每个已配置模型都可选,不会通过建议 catalog 公开。`ctx.llm.resolveModelInfo('deepseek', model).context` 先返回精确模型值,再对不含容量的配置项或未列出原样传递 id 返回 `defaultContextWindow`。两者都不存在时,`context` 字段缺失但不会使路由失效。因此,压力敏感插件可以获得部署拥有的容量,不会将模型 selector 视为权威。为 `deepseek` 注册另一个适配器会抛出 `LlmError('DUPLICATE_ADAPTER')`。 @@ -36,7 +42,7 @@ harness LLM seam 的 DeepSeek chat-completions 适配器:直接 `fetch` + SSE `thinking: disabled` 是部署锁定:它只公布 `off`,并以 `off` 为默认值。省略 `reasoningEffort` 或将其配置为 `off` 均有效;配置 `high` 或 `max` 会使插件加载失败,直接按请求启用思考也会在网络 I/O 前失败。携带 `GenerateOptions.purpose: 'session-title'` 的请求也会强制禁用思考并省略已解析的推理强度,将有界输出保留给可见标题文本,不改变会话或压缩默认值。 -`streamIdleTimeoutMs` 会限制每次未完成提供方读取,包括初始 `fetch`,但不计入消费方在 chunk 间花费的时间。一个稳定 abort 信号会在整个调用中达到请求与 body reader;过期会停止传输并抛出 `LlmError('TIMEOUT')`,较早的调用方 abort 则抛出 `LlmError('ABORTED')`。适配器每次 `stream()` 调用精确发起一次提供方请求;agent 级重试是独立插件策略。 +`streamIdleTimeoutMs` 会限制每次未完成提供方读取,包括初始 `fetch`,但不计入消费方在 chunk 间花费的时间。一个稳定 abort 信号会在整个调用中达到请求与 body reader;过期会停止传输并抛出 `LlmError('TIMEOUT')`,较早的调用方 abort 则抛出 `LlmError('ABORTED')`。适配器每次 `stream()` 调用精确发起一次提供方请求;它把已配置策略注册为提供方元数据,再由 `dsh-llm-retry` 在持久 agent 步骤边界单独执行该策略。 ## 应用归因 diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 4cc500e29d..ff5ce9bf72 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -5,12 +5,14 @@ * @module dsh-llm-deepseek/adapter */ -import { attributionHeaders, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmAdapter, LlmError, ProviderRequestId, QUOTA_EXCEEDED_CODE, ReasoningEffortId } from '@deepseek-ai/dsh-llm' +import { attributionHeaders, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmAdapter, LlmError, ProviderRequestId, QUOTA_EXCEEDED_CODE, ReasoningEffortId, resolveRetryPolicy } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, LlmResolvedModelInfo, + ResolvedRetryPolicy, + RetryPolicyConfig, StreamChunk, } from '@deepseek-ai/dsh-llm' import { idleWatchdog, MAX_TIMER_DELAY_MS, timeoutOf } from '@deepseek-ai/dsh-timeout' @@ -46,6 +48,8 @@ export interface DeepSeekAdapterOptions { models?: readonly DeepSeekCatalogModel[] /** Maximum provider idle time while one stream read is outstanding. */ streamIdleTimeoutMs?: number + /** Provider-owned model-request retry policy; omission uses normal defaults. */ + retryPolicy?: RetryPolicyConfig } /** Default maximum idle interval while an adapter stream read is outstanding. */ @@ -115,6 +119,7 @@ export function httpErrorCode(status: number, error?: WireError['error']): strin */ export class DeepSeekAdapter extends LlmAdapter { private readonly streamIdleTimeoutMs: number + private readonly retryPolicy: ResolvedRetryPolicy constructor(private readonly options: DeepSeekAdapterOptions) { super() @@ -135,12 +140,17 @@ export class DeepSeekAdapter extends LlmAdapter { `llm-deepseek: streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`, ) } + this.retryPolicy = resolveRetryPolicy(options.retryPolicy, 'llm-deepseek: retryPolicy') } override providerInfo(provider: string): LlmProviderInfo { return { id: provider, name: 'DeepSeek' } } + override providerRetryPolicy(_provider: string): ResolvedRetryPolicy { + return this.retryPolicy + } + override listModels(provider: string): Promise { return Promise.resolve((this.options.models ?? []).map(model => modelInfo(provider, model))) } diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index b5468f4bdb..616166390e 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -7,7 +7,8 @@ import type { Context } from 'cordis' import z from 'schemastery' -import type {} from '@deepseek-ai/dsh-llm' +import { RetryPolicySchema } from '@deepseek-ai/dsh-llm' +import type { RetryPolicyConfig } from '@deepseek-ai/dsh-llm' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { DEFAULT_STREAM_IDLE_TIMEOUT_MS, DeepSeekAdapter } from './adapter.ts' import type { DeepSeekCatalogModel } from './adapter.ts' @@ -47,6 +48,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 } const catalogModel: z = z.object({ @@ -64,6 +67,7 @@ export const Config: z = z.object({ defaultContextWindow: z.number().step(1).min(1), models: z.array(catalogModel).default(DEFAULT_MODELS), streamIdleTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS), + retryPolicy: RetryPolicySchema, }) /** Public API default; the internal endpoint comes from $DEEPSEEK_BASE_URL. */ @@ -117,5 +121,6 @@ export function apply(ctx: Context, config: Config): void { : { defaultContextWindow: config.defaultContextWindow }, models: resolveModels(config.models), streamIdleTimeoutMs: config.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS, + ...config.retryPolicy === undefined ? {} : { retryPolicy: config.retryPolicy }, })) } diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 341d563b1b..d632860ae4 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -602,6 +602,26 @@ describe('plugin registration and config', () => { expect(ctx.llm.listProviders()).toEqual([]) }) + it('registers retryPolicy from the provider config', async () => { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(LlmDeepSeek, { + apiKey: 'k', + baseURL: 'http://127.0.0.1:1', + retryPolicy: { + mode: 'always', + backoff: { initialDelayMs: 25, maxDelayMs: 100, jitterRatio: 0.2 }, + }, + }) + + expect(ctx.llm.providerRetryPolicy('deepseek')).toEqual({ + mode: 'always', + initialDelayMs: 25, + maxDelayMs: 100, + jitterRatio: 0.2, + }) + }) + it('owns the deepseek provider and advertises the default models', async () => { const ctx = new Context() await ctx.plugin(LlmService) @@ -908,4 +928,16 @@ describe('plugin registration and config', () => { streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1, })).rejects.toThrow(/streamIdleTimeoutMs/) }) + + it('rejects invalid nested retryPolicy before registering the provider', async () => { + const ctx = new Context() + await ctx.plugin(LlmService) + + await expect(ctx.plugin(LlmDeepSeek, { + apiKey: 'k', + baseURL: 'http://127.0.0.1:1', + retryPolicy: { mode: 'normal', maxRetries: -1 }, + })).rejects.toThrow(/retryPolicy/) + expect(ctx.llm.listProviders()).toEqual([]) + }) }) diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 188f561c04..38b9c093f6 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/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/llm/llm-pi-ai/README.md -README.md: 24a4762342f1ce8e71d1a5b1733fe02257823cb8 -README.zh.md: 557dc892c2eac10edc4e2fe4a0a142024942b1a9 +README.md: ac47cf6a21285fc887948a5a7798a9f1cb9157b0 +README.zh.md: a3d864ed9068d8bdaff4c5b73a4b7b339802ae05 diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 24a4762342..ac47cf6a21 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -19,6 +19,13 @@ Configure credentials and deployment-specific transport settings per provider. O apiKey: !!js process.env.OPENAI_API_KEY baseURL: https://proxy.example.com:8443 reasoning: high + retryPolicy: + mode: normal + maxRetries: 3 + backoff: + initialDelayMs: 500 + maxDelayMs: 10000 + jitterRatio: 0.1 - provider: anthropic apiKey: !!js process.env.ANTHROPIC_API_KEY streamIdleTimeoutMs: 300000 @@ -34,7 +41,7 @@ The adapter exposes each configured provider's installed pi-ai models through `c The `reasoning.efforts` list is pi-ai's ordered `getSupportedThinkingLevels(model)` result without filtering or normalization, including `off` and the model-specific availability of `xhigh` or `max`. The Harness exposes each canonical pi-ai level as an opaque ID; provider/model wire spellings remain inside pi-ai's `thinkingLevelMap`. A non-reasoning model therefore exposes pi-ai's `off` choice. The profile `reasoning` value, including `off`, is the deployment default when configured; omitting it preserves the provider default. Per-request `GenerateOptions.reasoningEffort` takes precedence, and any explicit value absent from the exact model capability fails with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. pi-ai's common stream options represent `off` by omitting `reasoning`. -Supported profile fields are `provider`, `apiKey`, `baseURL`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, and `streamIdleTimeoutMs`. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Harness app attribution wins a conflicting configured header name. +Supported profile fields are `provider`, `apiKey`, `baseURL`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, and `retryPolicy`. Each profile's optional retry policy is captured with that provider route; omission uses bounded normal defaults. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Harness app attribution wins a conflicting configured header name. The adapter forces pi-ai's SDK `maxRetries` to zero so one `stream()` call makes one provider request. The removed profile fields `maxRetries` and `maxRetryDelayMs` fail load instead of silently multiplying or hiding the separately composed agent-level retry budget. Idle expiry aborts the SDK's stable request signal and surfaces `TIMEOUT`; an earlier caller abort remains `ABORTED`. @@ -102,4 +109,4 @@ Recorded response content appends to the next request and does not invalidate it - **`GenerateOptions.stop` is unsupported** — pi-ai's common stream options cannot guarantee stop-sequence behavior across providers, so the adapter rejects the field. - **In-history `system` messages use pi-ai's common context conversion** — provider-specific placement follows pi-ai rather than a harness-owned wire override. - **Provider HTTP status is unavailable** — pi-ai error events do not expose a stable HTTP status across providers; failures expose only stable harness error codes. -- **Retry policy is not an adapter option** — SDK retries are disabled so durable agent steps and `llm/retry` events own every visible attempt; direct `ctx.llm.stream()` calls remain single-attempt. +- **Retry policy is provider-owned, not an SDK retry** — each provider profile may configure nested `retryPolicy`, which `dsh-llm-retry` executes at the agent failed-step seam; pi-ai SDK retries stay disabled so durable agent steps and `llm/retry` events own every visible attempt, and direct `ctx.llm.stream()` calls remain single-attempt. diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 557dc892c2..a3d864ed90 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -19,6 +19,13 @@ apiKey: !!js process.env.OPENAI_API_KEY baseURL: https://proxy.example.com:8443 reasoning: high + retryPolicy: + mode: normal + maxRetries: 3 + backoff: + initialDelayMs: 500 + maxDelayMs: 10000 + jitterRatio: 0.1 - provider: anthropic apiKey: !!js process.env.ANTHROPIC_API_KEY streamIdleTimeoutMs: 300000 @@ -34,7 +41,7 @@ `reasoning.efforts` 列表是 pi-ai 有序的 `getSupportedThinkingLevels(model)` 结果,不经筛选或规范化,其中包括 `off`,以及模型对 `xhigh` 或 `max` 的特定支持。Harness 将每个规范 pi-ai 级别公开为不透明 ID;提供方/模型协议拼写仍保留在 pi-ai 的 `thinkingLevelMap` 中。因此,不具备推理(reasoning)能力的模型也会公开 pi-ai 的 `off` 选项。配置 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;任何未出现在确切模型能力中的显式值都会在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败,而不会被自动调整。pi-ai 的通用流选项通过省略 `reasoning` 表示 `off`。 -受支持的 profile 字段是 `provider`、`apiKey`、`baseURL`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs` 和 `streamIdleTimeoutMs`。流 idle 间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。Harness 应用归因会胜过名称冲突的已配置标头。 +受支持的 profile 字段是 `provider`、`apiKey`、`baseURL`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs` 和 `retryPolicy`。每个 profile 的可选重试策略都会与该提供方路由一同捕获;省略时使用有界的 normal 默认值。流 idle 间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。Harness 应用归因会胜过名称冲突的已配置标头。 适配器强制 pi-ai SDK `maxRetries` 为零,因此一次 `stream()` 调用只会发起一次提供方请求。已移除 profile 字段 `maxRetries` 和 `maxRetryDelayMs` 会使加载失败,而不是静默倍增或隐藏单独组合的 agent 级重试预算。Idle 过期会 abort SDK 的稳定请求信号,并以 `TIMEOUT` 呈现;较早的调用方 abort 仍为 `ABORTED`。 @@ -102,4 +109,4 @@ pi-ai 事件会变为 harness reasoning、文本、工具调用、usage 与 fini - **不支持 `GenerateOptions.stop`**:pi-ai 的通用流选项无法保证所有提供方都支持 stop sequence,因此适配器会拒绝该字段。 - **历史中的 `system` 消息使用 pi-ai 通用上下文转换**:提供方特定位置由 pi-ai 决定,而非由 harness 拥有的协议覆盖决定。 - **无法获取提供方 HTTP 状态**:pi-ai 错误事件不会在所有提供方上公开稳定 HTTP 状态;失败只公开稳定 harness 错误 code。 -- **重试策略不是适配器选项**:SDK 重试已禁用,因此持久 agent 步骤与 `llm/retry` 事件拥有每次可见尝试;直接 `ctx.llm.stream()` 调用仍只尝试一次。 +- **重试策略由提供方持有,而不是 SDK 重试**:每个提供方 profile 都可以配置嵌套的 `retryPolicy`,由 `dsh-llm-retry` 在 agent 的失败步骤 seam 上执行;pi-ai SDK 重试仍保持禁用,因此持久 agent 步骤与 `llm/retry` 事件拥有每次可见尝试,直接 `ctx.llm.stream()` 调用仍只尝试一次。 diff --git a/packages/llm/llm-pi-ai/package.json b/packages/llm/llm-pi-ai/package.json index 590e49f323..e2b639624b 100644 --- a/packages/llm/llm-pi-ai/package.json +++ b/packages/llm/llm-pi-ai/package.json @@ -33,7 +33,7 @@ "cordis": "^4.0.0-rc.7" }, "dependencies": { - "@earendil-works/pi-ai": "^0.81.1", + "@earendil-works/pi-ai": "^0.82.1", "schemastery": "^3.18.0" }, "devDependencies": { diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index 5fb980d259..0cc6dda739 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -26,6 +26,7 @@ import type { LlmModelInfo, LlmResolvedModelInfo, ReasoningEffortId as ReasoningEffortIdType, + ResolvedRetryPolicy, StreamChunk, } from '@deepseek-ai/dsh-llm' import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout' @@ -44,7 +45,10 @@ export interface PiAiAdapterOptions { * Resolve a catalog model dynamically and apply only the configured endpoint * override, preserving the catalog's API/capability/compatibility metadata. */ -function resolvePiModel(profile: PiAiProviderProfile, modelId: string): Model { +function resolvePiModel( + profile: Omit, + modelId: string, +): Model { const model = getBuiltinModels(profile.provider as BuiltinProvider).find(candidate => candidate.id === modelId) as Model | undefined if (model === undefined) { throw new LlmError(`pi-ai provider "${profile.provider}" has no catalog model "${modelId}"`, 'UNKNOWN_MODEL') @@ -54,7 +58,7 @@ function resolvePiModel(profile: PiAiProviderProfile, modelId: string): Model, reasoning: ModelThinkingLevel | undefined, ): SimpleStreamOptions { const enabledReasoning: ThinkingLevel | undefined = reasoning === 'off' ? undefined : reasoning @@ -107,6 +111,10 @@ export class PiAiAdapter extends LlmAdapter { this.profiles = new Map(resolveProfiles(options.profiles).map(profile => [profile.provider, profile])) } + override providerRetryPolicy(provider: string): ResolvedRetryPolicy | undefined { + return this.profiles.get(provider)?.retryPolicy + } + override listModels(provider: string): Promise { const profile = this.profiles.get(provider) if (profile === undefined) { diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index bf9612d9d4..8c7da2badd 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -8,6 +8,8 @@ import { getBuiltinProviders } from '@earendil-works/pi-ai/providers/all' import type { CacheRetention, ModelThinkingLevel, ThinkingBudgets, Transport } from '@earendil-works/pi-ai' import z from 'schemastery' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' +import { resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm' +import type { ResolvedRetryPolicy, RetryPolicyConfig } from '@deepseek-ai/dsh-llm' /** Default maximum idle interval while an adapter stream read is outstanding. */ export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000 @@ -36,12 +38,16 @@ 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 } /** Validated profile with every adapter-owned default resolved. */ -export interface ResolvedPiAiProviderProfile extends PiAiProviderProfile { +export interface ResolvedPiAiProviderProfile extends Omit { /** Positive finite provider-idle interval after defaulting. */ streamIdleTimeoutMs: number + /** Immutable retry policy captured with this provider route. */ + retryPolicy: ResolvedRetryPolicy } /** Plugin configuration: the non-empty provider profiles this instance owns. */ @@ -69,6 +75,7 @@ const profile = z.object({ timeoutMs: z.natural(), websocketConnectTimeoutMs: z.natural(), streamIdleTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS), + retryPolicy: RetryPolicySchema, }) /** Runtime schema for {@link Config}. */ @@ -115,6 +122,10 @@ export function resolveProfiles(profiles: readonly PiAiProviderProfile[]): Resol return { ...source, streamIdleTimeoutMs, + retryPolicy: resolveRetryPolicy( + source.retryPolicy, + `llm-pi-ai: provider "${source.provider}" retryPolicy`, + ), ...source.headers === undefined ? {} : { headers: { ...source.headers } }, ...source.thinkingBudgets === undefined ? {} : { thinkingBudgets: { ...source.thinkingBudgets } }, } diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index ab08f21b81..da104cb22d 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -10,6 +10,9 @@ * providers: * - provider: openai * apiKey: !!js process.env.OPENAI_API_KEY + * retryPolicy: + * mode: normal + * maxRetries: 2 * - provider: anthropic * apiKey: !!js process.env.ANTHROPIC_API_KEY * - provider: openrouter @@ -36,6 +39,6 @@ export const inject = ['llm'] /** Register one generic pi-ai adapter for all configured provider routes. */ export function apply(ctx: Context, config: Config): void { const profiles = resolveProfiles(config.providers) - const adapter = new PiAiAdapter({ profiles }) + const adapter = new PiAiAdapter({ profiles: config.providers }) ctx.llm.registerAdapter(profiles.map(entry => entry.provider), adapter) } diff --git a/packages/llm/llm-pi-ai/src/stream.ts b/packages/llm/llm-pi-ai/src/stream.ts index 049b10d930..22aa7cb579 100644 --- a/packages/llm/llm-pi-ai/src/stream.ts +++ b/packages/llm/llm-pi-ai/src/stream.ts @@ -28,6 +28,14 @@ export function mapUsage(usage: PiUsage): TokenUsage { } } +// XXX(pi-ai upstream): pi-ai flattens the caught error to `error.message` +// (api/anthropic-messages.js: `errorMessage = error instanceof Error ? +// error.message : JSON.stringify(error)`), discarding the original Error and its +// `cause` chain before it reaches us. undici carries the actionable transport +// detail on `cause` (e.g. `SocketError: other side closed`) but hands the fetch +// wrapper a bare `terminated`, so we are left pattern-matching terse words here. +// If pi-ai ever forwards the original Error (or a fetch/dispatcher hook that lets +// us capture the cause ourselves), classify on `code`/`cause` instead of text. function classifyPiAiError(message: string): string { if (/\b(?:401|403)\b/.test(message)) return 'AUTH' if (isQuotaExceededError(message)) return QUOTA_EXCEEDED_CODE @@ -35,8 +43,19 @@ function classifyPiAiError(message: string): string { if (/\b400\b|invalid.?request/i.test(message)) return 'INVALID_REQUEST' if (/\b5\d\d\b/.test(message)) return 'SERVER' if (/\btime(?:d)?\s*out\b|timeout/i.test(message)) return 'TIMEOUT' + // A stream truncated before the provider's terminal event: each pi-ai provider + // throws its own wording when the wire closes mid-response without a terminal + // event (`… stream ended before message_stop`, `… before a terminal response + // event`, `… ended without a terminal event`, `Stream ended without + // finish_reason`). The connection dropped mid-response, so this is a transport + // truncation, not a model-level error. + if (/stream ended (?:before|without)\b/i.test(message)) return 'TRANSPORT' if (/\b(?:network|connection|socket|fetch)\b|\bECONN[A-Z]+\b/i.test(message) - || /\b(?:other side closed|HTTP2 request did not get a response|WebSocket closed unexpectedly)\b/i.test(message)) { + || /\b(?:other side closed|HTTP2 request did not get a response|WebSocket closed unexpectedly)\b/i.test(message) + // undici renders a mid-stream socket drop as a bare `terminated` (its + // `cause` — the real SocketError — was flattened away upstream); Node's + // stream layer says `Premature close`. + || /\bterminated\b|premature close/i.test(message)) { return 'TRANSPORT' } return 'PI_AI_ERROR' diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index cb5e60c343..498db88a17 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -330,12 +330,31 @@ describe('provider profile lifecycle', () => { const ctx = new Context() await ctx.plugin(LlmService) const fiber = await ctx.plugin(LlmPiAi, { - providers: [{ provider: 'openai' }, { provider: 'anthropic' }], + providers: [ + { + provider: 'openai', + retryPolicy: { + mode: 'always', + backoff: { initialDelayMs: 25, maxDelayMs: 100, jitterRatio: 0.2 }, + }, + }, + { provider: 'anthropic' }, + ], }) expect(ctx.llm.listProviders()).toEqual([ { id: 'openai', name: 'openai' }, { id: 'anthropic', name: 'anthropic' }, ]) + expect(ctx.llm.providerRetryPolicy('openai')).toEqual({ + mode: 'always', + initialDelayMs: 25, + maxDelayMs: 100, + jitterRatio: 0.2, + }) + expect(ctx.llm.providerRetryPolicy('anthropic')).toMatchObject({ + mode: 'normal', + maxRetries: 2, + }) await fiber.dispose() expect(ctx.llm.listProviders()).toEqual([]) }) @@ -373,7 +392,6 @@ describe('provider profile lifecycle', () => { const extended = await ctx.llm.resolveModelInfo('openai', 'gpt-5.6-sol') expect(extended.reasoning?.efforts.map(effort => effort.id)).toEqual([ ReasoningEffortId('off'), - ReasoningEffortId('minimal'), ReasoningEffortId('low'), ReasoningEffortId('medium'), ReasoningEffortId('high'), @@ -460,6 +478,23 @@ describe('provider profile lifecycle', () => { } }) + it('rejects invalid nested retryPolicy at the provider-profile boundary', async () => { + expect(() => resolveProfiles([{ + provider: 'openai', + retryPolicy: { mode: 'always', backoff: { jitterRatio: -1 } }, + }])).toThrow(/retryPolicy\.backoff\.jitterRatio/) + + const ctx = new Context() + await ctx.plugin(LlmService) + await expect(ctx.plugin(LlmPiAi, { + providers: [{ + provider: 'openai', + retryPolicy: { mode: 'normal', maxRetries: -1 }, + }], + })).rejects.toThrow(/retryPolicy/) + expect(ctx.llm.listProviders()).toEqual([]) + }) + it('constructs the adapter directly and rejects routes it does not own', async () => { const adapter = new PiAiAdapter({ profiles: [{ provider: 'openai' }] }) await expect(adapter.listModels('anthropic')).rejects.toMatchObject({ code: 'NO_ADAPTER' }) diff --git a/packages/llm/llm-pi-ai/tests/convert.spec.ts b/packages/llm/llm-pi-ai/tests/convert.spec.ts index 661a930e94..ca1530b7f5 100644 --- a/packages/llm/llm-pi-ai/tests/convert.spec.ts +++ b/packages/llm/llm-pi-ai/tests/convert.spec.ts @@ -578,6 +578,15 @@ describe('mapStopReason / mapUsage', () => { 'other side closed', 'HTTP2 request did not get a response', 'WebSocket closed unexpectedly', + // undici flattens a mid-stream socket drop to this bare word (its SocketError + // cause is discarded upstream before it reaches us). + 'terminated', + 'Premature close', + // pi-ai's per-provider throws when the wire closes before the terminal event. + 'Anthropic stream ended before message_stop', + 'OpenAI Responses stream ended before a terminal response event', + 'openrouter stream ended without a terminal event', + 'Stream ended without finish_reason', ])('maps pi-ai transport wording %j', (errorMessage) => { expect(mapStopReason(assistant({ stopReason: 'error', errorMessage }))) .toMatchObject({ kind: 'error', failure: { code: 'TRANSPORT' } }) diff --git a/packages/llm/llm-retry/README.i18n.yaml b/packages/llm/llm-retry/README.i18n.yaml index be63920133..50ae377010 100644 --- a/packages/llm/llm-retry/README.i18n.yaml +++ b/packages/llm/llm-retry/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/llm/llm-retry/README.md -README.md: 596a46e5395a4b5be9d400a85ee7c54d613ec2e6 -README.zh.md: a6a48f203e4701688816d4365b03da1ae2cfad82 +README.md: 7a86652a794e70c4dfd00ab7427730387e3ec949 +README.zh.md: 6255cca8c3b669ddec401496acb1e79ad54a8b3e diff --git a/packages/llm/llm-retry/README.md b/packages/llm/llm-retry/README.md index 596a46e539..7a86652a79 100644 --- a/packages/llm/llm-retry/README.md +++ b/packages/llm/llm-retry/README.md @@ -2,42 +2,52 @@ English | [中文](README.zh.md) -Function plugin that retries selected transient model-request failures through the `agent/request-error` waterfall. It does not wrap `ctx.llm.stream()`: every adapter call remains one provider attempt, and every retry opens a fresh numbered turn. +Function plugin that applies exact-provider retry policy through the agent loop's closed-step `agent/request-error` waterfall. It does not wrap `ctx.llm.stream()`: every adapter call remains one provider attempt, and every retry opens a fresh numbered turn. -The default policy permits two retries for `EMPTY_RESPONSE`, `RATE_LIMIT`, `SERVER`, `TIMEOUT`, and `TRANSPORT`, using bounded exponential backoff from 500 ms to 10 seconds with 10 percent jitter. `EMPTY_RESPONSE` is the adapters' classification of a degenerate provider completion (a terminal stop with zero content blocks); the attempt produced nothing durable, so repeating it is safe. Delay bounds must fit Node's supported timer range. A valid `providerRetryAfterMs` replaces local backoff when it is within the configured cap; an over-cap instruction delegates to the next recovery policy instead. +Each provider adapter owns an optional nested `retryPolicy`, captured when its route registers on `ctx.llm` and carried with each call that reaches that registration's final adapter boundary. An in-flight failure retains that serving policy if the route is later disposed or replaced; a failure before any final adapter is selected has no provider policy and delegates. Omission uses normal mode: two retries for `EMPTY_RESPONSE`, `RATE_LIMIT`, `SERVER`, `TIMEOUT`, and `TRANSPORT`, with bounded exponential backoff from 500 ms to 10 seconds and 10 percent jitter. `EMPTY_RESPONSE` is the adapters' classification of a degenerate provider completion that produced no durable content, so repeating it is safe. A normal policy can change its finite budget, eligible codes, and backoff. Always mode asks downstream recovery first, then retries every model-request failure without an attempt limit; success, cancellation, or plugin disposal stops it after active delegated recovery reaches quiescence. -The recovery listener appends a non-surface `llm/retry` event after the failed step, waits for the backoff while the failed turn's signal remains live, then returns `{ kind: 'retry' }`. The loop closes that failed turn and opens a retry turn over the same durable history. The policy keeps its own retry count across that uninterrupted recovery chain and clears it at terminal `agent/settled`. Turn cancellation and plugin disposal abort the wait. +Both modes use bounded exponential backoff with symmetric jitter. A valid `providerRetryAfterMs` at or below `maxDelayMs` replaces local backoff without jitter. An over-cap provider delay makes normal mode delegate, while always mode uses its configured local backoff so it cannot terminate on that instruction. -The separately published `./invariant` companion checks that every retry record appears inside an open turn after its failed step, matches its position in the current retry chain, and carries a positive bounded retry budget and non-negative bounded timer delay. Full jitter may schedule zero milliseconds at its lower boundary. +Before waiting, the plugin appends a non-surface `llm/retry` event with the provider, mode, canonical resolved-policy key, failure, and scheduled delay. The key includes every behavior-affecting field and sorts normal-mode codes because eligibility uses set membership. Retry numbers continue only across events with the same provider and complete policy key, so a route replacement with different limits, code membership, or backoff starts its own history. Normal events include the finite maximum; always events omit it, and UIs render `∞`. After the wait, the listener returns `{ kind: 'retry' }`, and the loop closes the failed turn and opens a retry turn over the same durable history. Cancellation and plugin disposal abort active backoff, drain active delegated recovery before applying the abort, and make a callback captured before disposal fail closed. + +The separately published `./invariant` companion checks that every retry record names the current open turn and latest closed step, matches the failed request's durable provider, carries non-empty provider and policy identities, has mode-specific bounds, a unique step record, the correct provider-policy retry number, and a bounded timer delay. Full jitter may schedule zero milliseconds at its lower boundary. ```yaml -- name: '@deepseek-ai/dsh-llm-retry' +- name: '@deepseek-ai/dsh-llm-deepseek' config: - maxTransientRetries: 2 - initialDelayMs: 500 - maxDelayMs: 10000 - jitterRatio: 0.1 - retryableCodes: [EMPTY_RESPONSE, RATE_LIMIT, SERVER, TIMEOUT, TRANSPORT] + apiKey: !!js process.env.DEEPSEEK_API_KEY + retryPolicy: + mode: always + backoff: + initialDelayMs: 1000 + maxDelayMs: 30000 + jitterRatio: 0.2 + +- name: '@deepseek-ai/dsh-llm-retry' ``` +The executor has no policy config. Multi-provider adapters such as `dsh-llm-pi-ai` place `retryPolicy` inside each provider profile, avoiding a second provider-name list. + ## Model Experience -### Transient request recovery +### Model-request recovery #### What the model sees -No retry event, delay, or failure prose is model-visible. The retry turn reconstructs the same explicit provider/model request from durable session history; failed chunks never enter derived messages. +No retry event, delay, provider error, or failed partial output is model-visible. The retry turn reconstructs the same explicit provider/model request from durable surface history unless a downstream recovery policy deliberately changes that surface; failed chunks never enter derived messages. #### Token effect -Each retry is a new provider request and may repeat input-token billing. The finite budget caps attempts; `llm/retry` itself contributes no tokens. +Each retry is a new provider request and may repeat input-token billing. Normal mode has a finite budget; always mode can consume unbounded requests until success or cancellation. `llm/retry` itself contributes no tokens. #### KV Cache effect -The reconstructed request preserves the prior prefix and is eligible for provider cache reuse under that provider's rules. The non-surface status event does not change cache identity. +The reconstructed request preserves the prior prefix and is eligible for provider cache reuse under that provider's rules. The non-surface retry event does not change cache identity. ## Known Limitations and Deferred Work - **Agent turns are the only retry boundary** — direct `ctx.llm.stream()` consumers remain single-attempt because a raw stream cannot separate already-emitted chunks durably. -- **Finite plugin budgets add** — this policy counts only configured transient codes; context-overflow compaction counts only its own code. A future policy with overlapping codes must document and test registration-order behavior. -- **`llm/retry` records completed backoff, not request completion** — later step and turn events establish success, exhaustion, or cancellation. +- **Always mode retries permanent failures** — authentication, quota, invalid-request, protocol, and unrecoverable context errors continue until success, cancellation, or disposal; deployments own provider-specific cost and latency controls. +- **Finite plugin budgets add** — normal mode counts only its configured codes and exact provider policy, while context-overflow compaction owns a separate budget. A future overlapping policy must document and test registration-order behavior. +- **Recovery policies compose by waterfall order** — always mode accepts a downstream retry before applying its fallback. A later policy that ignores cancellation and never settles also prevents fallback, turn quiescence, and plugin disposal from completing. +- **`llm/retry` records scheduling, not completion** — later step and turn events establish success, exhaustion, or cancellation. diff --git a/packages/llm/llm-retry/README.zh.md b/packages/llm/llm-retry/README.zh.md index a6a48f203e..6255cca8c3 100644 --- a/packages/llm/llm-retry/README.zh.md +++ b/packages/llm/llm-retry/README.zh.md @@ -2,42 +2,52 @@ [English](README.md) | 中文 -一个函数插件,通过 `agent/request-error` waterfall 重试特定的短暂模型请求失败。它不包装 `ctx.llm.stream()`:每次适配器调用仍是一次提供方尝试,每次重试都会开启新的编号轮次。 +一个函数插件,通过 agent loop(智能体循环)在已关闭步骤上触发的 `agent/request-error` waterfall(瀑布式事件)应用确切提供方重试策略。它不包装 `ctx.llm.stream()`:每次适配器调用仍是一次提供方尝试,每次重试都会开启新的编号轮次。 -默认策略允许为 `EMPTY_RESPONSE`、`RATE_LIMIT`、`SERVER`、`TIMEOUT` 和 `TRANSPORT` 重试两次,使用从 500 ms 到 10 秒的有界指数退避与 10% jitter。`EMPTY_RESPONSE` 是适配器对退化提供方完成的分类(携带零个内容块的终止 stop);该尝试未产生持久内容,因此可安全重复。延迟边界必须适合 Node 支持的定时器范围。有效 `providerRetryAfterMs` 在已配置上限内时替换本地退避;超出上限的指令会委托给下一项恢复策略。 +每个提供方适配器都拥有可选的嵌套 `retryPolicy`;路由在 `ctx.llm` 上注册时会捕获该策略,任何到达该注册最终适配器边界的调用都会携带它。如果之后释放或替换路由,进行中的失败仍会保留为其提供服务的策略;在选中任何最终适配器前发生的失败没有提供方策略,会继续委托。省略策略时使用 normal mode:为 `EMPTY_RESPONSE`、`RATE_LIMIT`、`SERVER`、`TIMEOUT` 和 `TRANSPORT` 重试两次,并采用从 500 ms 到 10 秒的有界指数退避与 10% jitter。`EMPTY_RESPONSE` 是适配器对未产生任何持久内容的退化提供方完成所作的分类,因此可安全重复。normal 策略可以更改其有限预算、合格 code 和退避配置。always mode 会先请求下游恢复,再无次数上限地重试每个模型请求失败;成功、取消或插件 dispose(资源释放)会在活跃的委托恢复完全停稳后终止它。 -恢复 listener 会在失败步骤之后追加一个非表层 `llm/retry` 事件,在失败轮次的信号仍存活期间等待退避,然后返回 `{ kind: 'retry' }`。循环会关闭该失败轮次,并在同一持久历史上开启重试轮次。策略在这条不间断的恢复链中维护自己的重试计数,并在终态 `agent/settled` 时清零。轮次取消与插件 dispose 会中止等待。 +两种 mode 都使用带对称 jitter 的有界指数退避。有效 `providerRetryAfterMs` 不超过 `maxDelayMs` 时会替换本地退避,并且不加 jitter。超出上限的提供方延迟会使 normal mode 继续委托;always mode 则改用已配置的本地退避,避免该指令终止重试。 -单独发布的 `./invariant` 配套模块会检查每个重试记录是否出现在开启轮次内的失败步骤之后,是否与其在当前重试链中的位置匹配,以及是否携带正数有界重试预算和非负有界定时器延迟。完整 jitter 可以在下界调度为零毫秒。 +等待前,插件会追加一条不进入表层的 `llm/retry` 事件,其中包含提供方、mode、规范的解析策略 key、失败和计划延迟。该 key 包含所有影响行为的字段,并对 normal mode 的 code 排序,因为合格性采用集合成员关系判断。只有提供方与完整策略 key 都相同的事件才会延续重试编号;因此,用限制、code 成员关系或退避不同的路由替换后,会开始自己的历史。normal 事件包含有限上限;always 事件省略该上限,UI 会渲染 `∞`。等待结束后,监听器返回 `{ kind: 'retry' }`,循环关闭失败轮次,并在同一持久历史上开启重试轮次。取消与插件 dispose 会中止活跃退避,在应用中止前排空活跃的委托恢复,并使 dispose 前捕获的 callback 只能以失败结束。 + +单独发布的 `./invariant` 配套模块会检查每个重试记录是否指向当前开启轮次及其最新已关闭步骤,是否与失败请求的持久提供方匹配,是否携带非空的提供方与策略身份,是否满足 mode 特定边界,是否拥有唯一步骤记录和正确的提供方策略重试编号,以及是否携带有界定时器延迟。完整 jitter 可以在下界调度为零毫秒。 ```yaml -- name: '@deepseek-ai/dsh-llm-retry' +- name: '@deepseek-ai/dsh-llm-deepseek' config: - maxTransientRetries: 2 - initialDelayMs: 500 - maxDelayMs: 10000 - jitterRatio: 0.1 - retryableCodes: [EMPTY_RESPONSE, RATE_LIMIT, SERVER, TIMEOUT, TRANSPORT] + apiKey: !!js process.env.DEEPSEEK_API_KEY + retryPolicy: + mode: always + backoff: + initialDelayMs: 1000 + maxDelayMs: 30000 + jitterRatio: 0.2 + +- name: '@deepseek-ai/dsh-llm-retry' ``` +执行器没有策略配置。`dsh-llm-pi-ai` 等多提供方适配器会把 `retryPolicy` 放在每个提供方 profile 内,避免维护第二份提供方名称列表。 + ## 模型体验 -### 短暂请求恢复 +### 模型请求恢复 #### 模型看到的内容 -模型不会看到重试事件、延迟或失败文本。重试轮次会从持久会话历史中重建相同的显式提供方/模型请求;失败 chunk 绝不会进入派生消息。 +模型不会看到重试事件、延迟、提供方错误或失败的部分输出。重试轮次会从持久表层历史中重建相同的显式提供方/模型请求,除非下游恢复策略有意更改该表层;失败分片绝不会进入派生消息。 #### Token 影响 -每次重试都是新的提供方请求,可能重复计费输入 token。有限预算会限制尝试次数;`llm/retry` 自身不产生 token。 +每次重试都是新的提供方请求,可能重复计费输入 token。normal mode 具有有限预算;always mode 可以在成功或取消前消耗无界数量的请求。`llm/retry` 自身不产生 token。 #### KV Cache 影响 -重建请求保留之前的前缀,并可根据该提供方的规则复用 cache。非表层状态事件不会改变 cache 身份。 +重建请求保留之前的前缀,并可根据该提供方的规则复用 cache。非表层重试事件不会改变 cache 身份。 ## 已知限制与暂缓事项 - **Agent 轮次是唯一重试边界**:直接 `ctx.llm.stream()` 消费方仍只尝试一次,因为原始流无法将已发出 chunk 持久分隔为不同尝试。 -- **有限插件预算可叠加**:该策略只统计已配置短暂 code;上下文溢出压缩只统计自身 code。未来如有 code 重叠的策略,必须记录并测试注册顺序行为。 -- **`llm/retry` 记录已完成的退避,不是请求完成**:后续步骤与轮次事件用于确立成功、耗尽或取消。 +- **always mode 会重试永久性失败**:身份验证、配额、无效请求、协议和无法恢复的上下文错误都会继续重试,直至成功、取消或 dispose;部署负责提供方特定的成本与延迟控制。 +- **有限插件预算可叠加**:normal mode 只统计已配置 code 和确切提供方策略,上下文溢出压缩则拥有独立预算。未来如有重叠策略,必须记录并测试注册顺序行为。 +- **恢复策略按 waterfall 顺序组合**:always mode 会先接受下游重试,再应用自己的回退。后续策略如果忽略取消且永不结算,也会阻止回退、轮次完全停稳和插件 dispose 完成。 +- **`llm/retry` 记录调度,不是完成**:后续步骤与轮次事件用于确立成功、耗尽或取消。 diff --git a/packages/llm/llm-retry/package.json b/packages/llm/llm-retry/package.json index 64cd601b05..854e945491 100644 --- a/packages/llm/llm-retry/package.json +++ b/packages/llm/llm-retry/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-llm-retry", - "description": "Bounded transient LLM request retry policy for the DeepSeek Harness", + "description": "Provider-routed LLM request retry policy for the DeepSeek Harness", "version": "0.0.1", "private": true, "type": "module", diff --git a/packages/llm/llm-retry/src/history.ts b/packages/llm/llm-retry/src/history.ts new file mode 100644 index 0000000000..67ffe1a9d1 --- /dev/null +++ b/packages/llm/llm-retry/src/history.ts @@ -0,0 +1,32 @@ +/** Durable request-route lookup for one closed model step. @module @deepseek-ai/dsh-llm-retry/history */ + +import type { SessionEvent } from '@deepseek-ai/dsh-session' + +/** + * Find the provider in force when one step closed, excluding later recovery mutations. + * Request headers remain effective across turn boundaries until a newer full + * snapshot changes them; every provider change requires a newer full snapshot. + * @param events - session events containing the closed step. + * @param turn - turn that owns the failed step. + * @param step - failed step whose provider is required. + * @returns the provider from the request header in force at that step boundary. + */ +export function providerForClosedStep( + events: readonly SessionEvent[], + turn: number, + step: number, +): string | undefined { + const stepEndIndex = events.findLastIndex(event => + event.type === 'step/end' + && event.data.turn === turn + && event.data.step === step, + ) + if (stepEndIndex < 0) return undefined + for (let index = stepEndIndex; index >= 0; index -= 1) { + // The loop bounds prove this indexed read exists. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const event = events[index]! + if (event.type === 'request/header') return event.data.header.config.provider + } + return undefined +} diff --git a/packages/llm/llm-retry/src/index.ts b/packages/llm/llm-retry/src/index.ts index 37c72ee497..7871fa8a93 100644 --- a/packages/llm/llm-retry/src/index.ts +++ b/packages/llm/llm-retry/src/index.ts @@ -1,6 +1,6 @@ /** - * Bounded transient model-request retry policy on the agent request-recovery - * seam. Each scheduled retry is durable before its cancellable wait. + * Provider-routed model-request retry policy on the agent loop's closed-step + * recovery seam. Each scheduled retry is durable before its cancellable wait. * * @module @deepseek-ai/dsh-llm-retry */ @@ -8,20 +8,32 @@ import type { Context } from 'cordis' import z from 'schemastery' import type { Agent, RequestError, RequestErrorAction } from '@deepseek-ai/dsh-agent' -import type { LlmFailure } from '@deepseek-ai/dsh-llm' -import type {} from '@deepseek-ai/dsh-session' -import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' +import type { LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { providerForClosedStep } from './history.ts' declare module '@deepseek-ai/dsh-session' { interface SessionEventMap { - /** 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 } } } @@ -29,82 +41,19 @@ declare module '@deepseek-ai/dsh-session' { export const name = 'llm-retry' export const inject = ['agents'] -const DEFAULT_MAX_TRANSIENT_RETRIES = 2 -const DEFAULT_INITIAL_DELAY_MS = 500 -const DEFAULT_MAX_DELAY_MS = 10_000 -const DEFAULT_JITTER_RATIO = 0.1 -const DEFAULT_RETRYABLE_CODES = Object.freeze(['EMPTY_RESPONSE', 'RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT']) - -/** Deployment-owned limits and classification for transient request recovery. */ -export interface Config { - /** Maximum transient retries after the first request (default 2). */ - maxTransientRetries?: number - /** Initial local exponential-backoff delay in milliseconds (default 500). */ - initialDelayMs?: number - /** Maximum accepted or locally scheduled delay in milliseconds (default 10000). */ - maxDelayMs?: number - /** Symmetric random multiplier range around one (default 0.1). */ - jitterRatio?: number - /** Stable failure codes eligible for this policy. */ - retryableCodes?: string[] -} +/** This policy executor has no config; providers own `retryPolicy`. */ +export type Config = Readonly> /** Runtime schema for {@link Config}. */ -export const Config: z = z.object({ - maxTransientRetries: z.number().step(1).min(0).default(DEFAULT_MAX_TRANSIENT_RETRIES), - initialDelayMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_INITIAL_DELAY_MS), - maxDelayMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_MAX_DELAY_MS), - jitterRatio: z.number().min(0).max(1).default(DEFAULT_JITTER_RATIO), - retryableCodes: z.array(z.string()).default([...DEFAULT_RETRYABLE_CODES]), -}) +export const Config = z.object({}) as unknown as z -interface ResolvedConfig { - readonly maxTransientRetries: number - readonly initialDelayMs: number - readonly maxDelayMs: number - readonly jitterRatio: number - readonly retryableCodes: ReadonlySet -} - -function resolveConfig(config: Config): ResolvedConfig { - const maxTransientRetries = config.maxTransientRetries ?? DEFAULT_MAX_TRANSIENT_RETRIES - const initialDelayMs = config.initialDelayMs ?? DEFAULT_INITIAL_DELAY_MS - const maxDelayMs = config.maxDelayMs ?? DEFAULT_MAX_DELAY_MS - const jitterRatio = config.jitterRatio ?? DEFAULT_JITTER_RATIO - const codes = config.retryableCodes ?? [...DEFAULT_RETRYABLE_CODES] - - if (!Number.isInteger(maxTransientRetries) || maxTransientRetries < 0) { - throw new Error('llm-retry: maxTransientRetries must be a non-negative integer') +function validateConfig(config: Config): void { + const [key] = Object.keys(config) + if (key === undefined) return + if (key === 'retryPolicy') { + throw new Error('llm-retry: retryPolicy belongs under each provider configuration') } - if (!Number.isFinite(initialDelayMs) || initialDelayMs <= 0 || initialDelayMs > MAX_TIMER_DELAY_MS) { - throw new Error(`llm-retry: initialDelayMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`) - } - if (!Number.isFinite(maxDelayMs) || maxDelayMs <= 0 || maxDelayMs > MAX_TIMER_DELAY_MS) { - throw new Error(`llm-retry: maxDelayMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`) - } - if (initialDelayMs > maxDelayMs) { - throw new Error('llm-retry: initialDelayMs must be less than or equal to maxDelayMs') - } - if (!Number.isFinite(jitterRatio) || jitterRatio < 0 || jitterRatio > 1) { - throw new Error('llm-retry: jitterRatio must be between 0 and 1') - } - if (codes.length === 0) { - throw new Error('llm-retry: retryableCodes must not be empty') - } - if (codes.some(code => code.length === 0)) { - throw new Error('llm-retry: retryableCodes must contain only non-empty strings') - } - if (new Set(codes).size !== codes.length) { - throw new Error('llm-retry: retryableCodes must not contain duplicates') - } - - return Object.freeze({ - maxTransientRetries, - initialDelayMs, - maxDelayMs, - jitterRatio, - retryableCodes: new Set(codes), - }) + throw new Error(`llm-retry: unknown key "${key}"`) } /** Non-serializable seams used to make timing policy deterministic in tests. */ @@ -113,13 +62,40 @@ export interface RetryInternals { random?: () => number } -function localDelay(config: ResolvedConfig, retry: number, random: () => number): number { +type DownstreamOutcome = + | { readonly type: 'decision'; readonly decision: RequestErrorAction } + | { readonly type: 'error'; readonly error: unknown } + +async function settleDownstream( + next: () => Promise, +): Promise { + try { + return { type: 'decision', decision: await next() } + } catch (error: unknown) { + return { type: 'error', error } + } +} + +function localDelay(config: ResolvedRetryPolicy, retry: number, random: () => number): number { const exponent = Math.min(retry - 1, 1024) const exponential = Math.min(config.initialDelayMs * 2 ** exponent, config.maxDelayMs) const jitter = 1 - config.jitterRatio + 2 * config.jitterRatio * random() return Math.min(exponential * jitter, config.maxDelayMs) } +function retryPolicyKey(policy: ResolvedRetryPolicy): string { + return policy.mode === 'always' + ? JSON.stringify([policy.mode, policy.initialDelayMs, policy.maxDelayMs, policy.jitterRatio]) + : JSON.stringify([ + policy.mode, + policy.maxRetries, + [...policy.retryableCodes].sort(), + policy.initialDelayMs, + policy.maxDelayMs, + policy.jitterRatio, + ]) +} + function cancellableDelay(delayMs: number, signal: AbortSignal): Promise { if (signal.aborted) return Promise.resolve(false) return new Promise((resolve) => { @@ -136,60 +112,141 @@ function cancellableDelay(delayMs: number, signal: AbortSignal): Promise>() - const retries = new WeakMap() + + function track(operation: Promise): Promise { + const tracked = operation.finally(() => active.delete(tracked)) + active.add(tracked) + return tracked + } async function backoff( agent: Agent, turn: number, step: number, failure: LlmFailure, + provider: string, + policy: ResolvedRetryPolicy, + policyKey: string, retry: number, delayMs: number, signal: AbortSignal, ): Promise { const fusedSignal = AbortSignal.any([signal, lifetime.signal]) if (fusedSignal.aborted) return - agent.session.append('llm/retry', { - turn, - step, - retry, - maxRetries: resolved.maxTransientRetries, - delayMs, - failure, - }) - retries.set(agent, retry) + const eventData = policy.mode === 'normal' + ? { + turn, + step, + provider, + mode: policy.mode, + policyKey, + retry, + maxRetries: policy.maxRetries, + delayMs, + failure, + } + : { + turn, + step, + provider, + mode: policy.mode, + policyKey, + retry, + delayMs, + failure, + } + agent.session.append('llm/retry', eventData) if (!await cancellableDelay(delayMs, fusedSignal)) return return { kind: 'retry' } } - ctx.on('agent/settled', (agent) => { - retries.delete(agent) - }) - - // A completed model response ends the consecutive-failure sequence even - // when its tool calls keep the turn running into another request. - ctx.on('session/event', (session, event) => { - if (event.type !== 'assistant/message') return - const agent = ctx.agents.get(session.id) - if (agent?.session === session) retries.delete(agent) - }) - - const disposeListener = ctx.on('agent/request-error', ( + async function recover( agent: Agent, turn: number, step: number, _error: RequestError, failure: LlmFailure, + priorFailures: readonly LlmFailure[], + policy: ResolvedRetryPolicy | undefined, + signal: AbortSignal, + next: () => Promise, + ): Promise { + if (policy === undefined) return next() + // The call-local policy belongs to the registration that served this + // failure. Recover only the durable provider identity from the header; + // downstream recovery may append later state before an always fallback. + const provider = providerForClosedStep(agent.session.events, turn, step) + /* v8 ignore next 3 -- agent-loop closes only steps whose request header was recorded */ + if (provider === undefined) { + throw new Error(`llm-retry: no request provider for closed turn ${turn}/step ${step}`) + } + if (policy.mode === 'always') { + if (signal.aborted || lifetime.signal.aborted) return + const fusedSignal = AbortSignal.any([signal, lifetime.signal]) + // The loop and plugin lifetime stay open until delegated recovery settles. + // An abort then wins before the decision or fallback can mutate later state. + const downstream = await settleDownstream(next) + if (fusedSignal.aborted) return + if (downstream.type === 'error') { + ctx.logger.warn( + `llm-retry: provider "${provider}" always policy ignored a downstream recovery failure: %o`, + downstream.error, + ) + } + if (downstream.type === 'decision' && downstream.decision?.kind === 'retry') { + return downstream.decision + } + } else if (!policy.retryableCodes.includes(failure.code)) { + return next() + } + + const policyKey = retryPolicyKey(policy) + const firstPriorTurn = turn - priorFailures.length + const priorPolicyRetry = agent.session.events.findLast((event): event is SessionEvent<'llm/retry'> => + event.type === 'llm/retry' + && event.data.turn >= firstPriorTurn + && event.data.turn < turn + && event.data.provider === provider + && event.data.policyKey === policyKey, + ) + const previousRetry = priorPolicyRetry?.data.retry ?? 0 + if (policy.mode === 'normal' && previousRetry >= policy.maxRetries) return next() + const retry = previousRetry + 1 + let delayMs: number + if (failure.providerRetryAfterMs !== undefined + && Number.isFinite(failure.providerRetryAfterMs) + && failure.providerRetryAfterMs > 0) { + if (failure.providerRetryAfterMs > policy.maxDelayMs) { + if (policy.mode === 'normal') return next() + delayMs = localDelay(policy, retry, random) + } else { + delayMs = failure.providerRetryAfterMs + } + } else { + delayMs = localDelay(policy, retry, random) + } + + return backoff(agent, turn, step, failure, provider, policy, policyKey, retry, delayMs, signal) + } + + const disposeListener = ctx.on('agent/request-error', ( + agent: Agent, + turn: number, + step: number, + error: RequestError, + failure: LlmFailure, + priorFailures: readonly LlmFailure[], + policy: ResolvedRetryPolicy | undefined, signal: AbortSignal, next: () => Promise, ) => { @@ -197,30 +254,12 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna // removed. Lifetime cancellation must prevent that stale callback from // entering a downstream policy after disposal. if (lifetime.signal.aborted) return Promise.resolve(undefined) - if (!resolved.retryableCodes.has(failure.code)) return next() - const priorRetries = retries.get(agent) ?? 0 - if (priorRetries >= resolved.maxTransientRetries) return next() - - const retry = priorRetries + 1 - let delayMs: number - if (failure.providerRetryAfterMs !== undefined - && Number.isFinite(failure.providerRetryAfterMs) - && failure.providerRetryAfterMs > 0) { - if (failure.providerRetryAfterMs > resolved.maxDelayMs) return next() - delayMs = failure.providerRetryAfterMs - } else { - delayMs = localDelay(resolved, retry, random) - } - - const tracked = backoff(agent, turn, step, failure, retry, delayMs, signal) - .finally(() => active.delete(tracked)) - active.add(tracked) - return tracked + return track(recover(agent, turn, step, error, failure, priorFailures, policy, signal, next)) }) ctx.effect(() => async () => { disposeListener() lifetime.abort(new Error('llm-retry plugin disposed')) await Promise.allSettled([...active]) - }, 'llm-retry: abort and drain backoffs') + }, 'llm-retry: abort and drain active recovery') } diff --git a/packages/llm/llm-retry/src/invariant.ts b/packages/llm/llm-retry/src/invariant.ts index 4edfc8929e..03379c82d0 100644 --- a/packages/llm/llm-retry/src/invariant.ts +++ b/packages/llm/llm-retry/src/invariant.ts @@ -2,8 +2,10 @@ import type { Context } from 'cordis' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import type { LlmFailure } from '@deepseek-ai/dsh-llm' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { providerForClosedStep } from './history.ts' import type {} from './index.ts' const PACKAGE_NAME = '@deepseek-ai/dsh-llm-retry' @@ -13,6 +15,32 @@ export const name = 'llm-retry-invariant' /** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] +/** Validate the complete provider-neutral failure payload at the durable boundary. */ +function validateFailure(value: unknown, fail: InvariantFailure): asserts value is LlmFailure { + if (typeof value !== 'object' || value === null) { + fail('llm/retry failure must be an object') + } + const failure = value as Partial + if (typeof failure.message !== 'string' || failure.message.length === 0) { + fail('llm/retry failure.message must be a non-empty string') + } + if (typeof failure.code !== 'string' || failure.code.length === 0) { + fail('llm/retry failure.code must be a non-empty string') + } + if (failure.status !== undefined + && (!Number.isInteger(failure.status) || failure.status < 100 || failure.status > 599)) { + fail('llm/retry failure.status must be an integer from 100 through 599 when present') + } + if (failure.providerRetryAfterMs !== undefined + && (!Number.isFinite(failure.providerRetryAfterMs) || failure.providerRetryAfterMs <= 0)) { + fail('llm/retry failure.providerRetryAfterMs must be a positive finite number when present') + } + if (failure.requestId !== undefined + && (typeof failure.requestId !== 'string' || failure.requestId.length === 0)) { + fail('llm/retry failure.requestId must be a non-empty string when present') + } +} + /** Find the first turn in the structured-failure retry chain containing `turn`. */ function retryChainStart(history: readonly SessionEvent[], turn: number): number { let startIndex = history.findLastIndex( @@ -47,15 +75,35 @@ function validateRetry( event: SessionEvent<'llm/retry'>, fail: InvariantFailure, ): void { - const { turn, step, retry, maxRetries, delayMs } = event.data + const { turn, step, provider, mode, policyKey, retry, delayMs } = event.data + const failure: unknown = event.data.failure + validateFailure(failure, fail) if (!Number.isSafeInteger(retry) || retry < 1) { fail('llm/retry retry must be a positive safe integer') } - if (!Number.isSafeInteger(maxRetries) || maxRetries < 1 || retry > maxRetries) { - fail(`llm/retry retry ${retry} must not exceed a positive safe maxRetries ${maxRetries}`) + if (typeof provider !== 'string' || provider.length === 0) { + fail('llm/retry provider must be a non-empty string') } - if (!(delayMs >= 0 && delayMs <= MAX_TIMER_DELAY_MS)) { - fail(`llm/retry delayMs must be within 0..${MAX_TIMER_DELAY_MS}`) + if (typeof policyKey !== 'string' || policyKey.length === 0) { + fail('llm/retry policyKey must be a non-empty string') + } + switch (mode) { + case 'normal': { + const { maxRetries } = event.data + if (!Number.isSafeInteger(maxRetries) || maxRetries < 1 || retry > maxRetries) { + fail(`llm/retry retry ${retry} must not exceed a positive safe maxRetries ${maxRetries}`) + } + break + } + case 'always': + if ('maxRetries' in event.data) fail('llm/retry always mode must omit maxRetries') + break + default: + fail(`llm/retry mode must be normal or always, got ${String(mode)}`) + } + if (typeof delayMs !== 'number' || !Number.isFinite(delayMs) + || delayMs < 0 || delayMs > MAX_TIMER_DELAY_MS) { + fail(`llm/retry delayMs must be a finite number within 0..${MAX_TIMER_DELAY_MS}`) } const currentTurnEvents: SessionEvent[] = [] @@ -86,6 +134,10 @@ function validateRetry( if (closedStep === undefined || step !== closedStep) { fail(`llm/retry names step ${step}, but the latest closed step is ${String(closedStep)}`) } + const routedProvider = providerForClosedStep(history, turn, step) + if (routedProvider !== provider) { + fail(`llm/retry provider ${provider} does not match the failed request provider ${String(routedProvider)}`) + } const chainStart = retryChainStart(history, turn) const chain = history.slice(Math.max(chainStart, 0)) @@ -95,9 +147,11 @@ function validateRetry( if (chainRetries.some(prior => prior.data.turn === turn && prior.data.step === step)) { fail(`llm/retry duplicates the retry record for turn ${turn}/step ${step}`) } - const expectedRetry = chainRetries.length + 1 + const priorPolicyRetry = chainRetries.findLast(prior => + prior.data.provider === provider && prior.data.policyKey === policyKey) + const expectedRetry = (priorPolicyRetry?.data.retry ?? 0) + 1 if (retry !== expectedRetry) { - fail(`llm/retry retry ${retry} must equal retry-chain position ${expectedRetry}`) + fail(`llm/retry retry ${retry} must equal provider policy retry ${expectedRetry}`) } } diff --git a/packages/llm/llm-retry/tests/invariant.spec.ts b/packages/llm/llm-retry/tests/invariant.spec.ts index 1a9b3f7446..978a9fc9a8 100644 --- a/packages/llm/llm-retry/tests/invariant.spec.ts +++ b/packages/llm/llm-retry/tests/invariant.spec.ts @@ -1,9 +1,11 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session' +import { ProviderRequestId } from '@deepseek-ai/dsh-llm' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import InvariantService from '@deepseek-ai/dsh-invariants' import * as RetryInvariant from '@deepseek-ai/dsh-llm-retry/invariant' +import { providerForClosedStep } from '../src/history.ts' async function setup(): Promise { const ctx = new Context() @@ -13,207 +15,272 @@ async function setup(): Promise { return ctx } -const failure = { message: 'provider busy', code: 'RATE_LIMIT', status: 429 } - function closeStep(ctx: Context, id: string, turn = 1, step = 1) { const session = ctx.sessions.create(SessionId(id)) - session.append('turn/start', { - turn, - trigger: turn === 1 - ? { kind: 'message', source: { kind: 'user' } } - : { kind: 'retry' }, - }) + session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('step/start', { turn, step }) + session.append('request/header', { + header: { config: { provider: 'mock', model: 'mock' } }, + reason: 'initial', + }) session.append('step/end', { turn, step }) return session } +function appendRetryTurn(session: Session, turn: number) { + session.append('turn/start', { turn, trigger: { kind: 'retry' } }) + session.append('step/start', { turn, step: 1 }) + session.append('request/header', { + header: { config: { provider: 'mock', model: 'mock' } }, + reason: 'initial', + }) + session.append('step/end', { turn, step: 1 }) + session.append('llm/retry', { turn, step: 1, ...normal }) +} + +const failure = { message: 'provider busy', code: 'RATE_LIMIT', status: 429 } +const normal = { + provider: 'mock', + mode: 'normal' as const, + policyKey: 'normal-policy', + retry: 1, + maxRetries: 2, + delayMs: 1, + failure, +} +const always = { + provider: 'mock', + mode: 'always' as const, + policyKey: 'always-policy', + retry: 1, + delayMs: 1, + failure, +} + describe('llm-retry invariants', () => { - it('accepts increasing retry schedules for successive failed turns', async () => { + it('has no provider without the requested closed step or a route marker', () => { + expect(providerForClosedStep([], 1, 1)).toBeUndefined() + expect(providerForClosedStep([{ + type: 'step/end', + data: { turn: 1, step: 1 }, + }] as never, 1, 1)).toBeUndefined() + }) + + it('accepts bounded and unbounded records after successive closed steps', async () => { const ctx = await setup() const session = closeStep(ctx, 'retry-invariant-valid') + expect(() => { - session.append('llm/retry', { - turn: 1, step: 1, retry: 1, maxRetries: 2, delayMs: 500, failure, - }) + session.append('llm/retry', { turn: 1, step: 1, ...normal }) session.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, failure } }) session.append('turn/start', { turn: 2, trigger: { kind: 'retry' } }) session.append('step/start', { turn: 2, step: 1 }) session.append('step/end', { turn: 2, step: 1 }) session.append('llm/retry', { - turn: 2, step: 1, retry: 2, maxRetries: 2, delayMs: 0, failure, + turn: 2, step: 1, ...normal, retry: 2, delayMs: 0, }) + const unbounded = closeStep(ctx, 'retry-invariant-always') + unbounded.append('llm/retry', { turn: 1, step: 1, ...always }) }).not.toThrow() expect(() => { ctx.emit('tools/change') }).not.toThrow() }) - it.each([ - [{ retry: 0, maxRetries: 2, delayMs: 1 }, /positive safe integer/], - [{ retry: 1.5, maxRetries: 2, delayMs: 1 }, /positive safe integer/], - [{ retry: 1, maxRetries: 0, delayMs: 1 }, /positive safe maxRetries/], - [{ retry: 1, maxRetries: 1.5, delayMs: 1 }, /positive safe maxRetries/], - [{ retry: 3, maxRetries: 2, delayMs: 1 }, /must not exceed/], - [{ retry: 1, maxRetries: 2, delayMs: -1 }, /delayMs/], - [{ retry: 1, maxRetries: 2, delayMs: MAX_TIMER_DELAY_MS + 1 }, /delayMs/], - ])('rejects invalid retry bounds %#', async (data, message) => { + it('validates the complete durable failure payload', async () => { const ctx = await setup() - const session = closeStep(ctx, `retry-invariant-bounds-${data.retry}-${data.maxRetries}-${data.delayMs}`) + const complete = closeStep(ctx, 'retry-invariant-complete-failure') expect(() => { - session.append('llm/retry', { turn: 1, step: 1, ...data, failure }) + complete.append('llm/retry', { + turn: 1, + step: 1, + ...always, + failure: { + message: 'provider busy', + code: 'RATE_LIMIT', + status: 429, + providerRetryAfterMs: 25, + requestId: ProviderRequestId('request-1'), + }, + }) + }).not.toThrow() + + const invalidFailures: readonly [string, unknown, RegExp][] = [ + ['null', null, /failure must be an object/], + ['message-type', { message: 1, code: 'RATE_LIMIT' }, /failure\.message/], + ['message-empty', { message: '', code: 'RATE_LIMIT' }, /failure\.message/], + ['code-type', { message: 'failed', code: 1 }, /failure\.code/], + ['code-empty', { message: 'failed', code: '' }, /failure\.code/], + ['status-type', { message: 'failed', code: 'RATE_LIMIT', status: 429.5 }, /failure\.status/], + ['status-low', { message: 'failed', code: 'RATE_LIMIT', status: 99 }, /failure\.status/], + ['status-high', { message: 'failed', code: 'RATE_LIMIT', status: 600 }, /failure\.status/], + [ + 'retry-after-type', + { message: 'failed', code: 'RATE_LIMIT', providerRetryAfterMs: '25' }, + /failure\.providerRetryAfterMs/, + ], + [ + 'retry-after-zero', + { message: 'failed', code: 'RATE_LIMIT', providerRetryAfterMs: 0 }, + /failure\.providerRetryAfterMs/, + ], + ['request-id-type', { message: 'failed', code: 'RATE_LIMIT', requestId: 1 }, /failure\.requestId/], + ['request-id-empty', { message: 'failed', code: 'RATE_LIMIT', requestId: '' }, /failure\.requestId/], + ] + for (const [name, invalidFailure, message] of invalidFailures) { + const session = closeStep(ctx, `retry-invariant-failure-${name}`) + expect(() => { + session.append('llm/retry', { + turn: 1, step: 1, ...always, failure: invalidFailure, + } as never) + }).toThrow(message) + } + }) + + it.each([ + ['retry-zero', { ...normal, retry: 0 }, /positive safe integer/], + ['retry-fraction', { ...normal, retry: 1.5 }, /positive safe integer/], + ['max-zero', { ...normal, maxRetries: 0 }, /positive safe maxRetries/], + ['max-fraction', { ...normal, maxRetries: 1.5 }, /positive safe maxRetries/], + ['over-budget', { ...normal, retry: 3 }, /must not exceed/], + ['always-maximum', { ...always, maxRetries: 2 }, /always mode must omit maxRetries/], + ['unknown-mode', { ...always, mode: 'sometimes' }, /mode must be normal or always/], + ['empty-provider', { ...always, provider: '' }, /provider must be a non-empty string/], + ['empty-policy-key', { ...always, policyKey: '' }, /policyKey must be a non-empty string/], + ['delay-negative', { ...normal, delayMs: -1 }, /delayMs/], + ['delay-overflow', { ...normal, delayMs: MAX_TIMER_DELAY_MS + 1 }, /delayMs/], + ['delay-type', { ...normal, delayMs: '1' }, /delayMs/], + ])('rejects invalid retry data: %s', async (name, data, message) => { + const ctx = await setup() + const session = closeStep(ctx, `retry-invariant-${name}`) + expect(() => { + session.append('llm/retry', { turn: 1, step: 1, ...data } as never) }).toThrow(message) }) - it('rejects a retry record appended after its turn already closed', async () => { - const ctx = await setup() - const closed = closeStep(ctx, 'retry-invariant-closed-turn') - closed.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, failure } }) - expect(() => { - closed.append('llm/retry', { - turn: 1, step: 1, retry: 1, maxRetries: 2, delayMs: 1, failure, - }) - }).toThrow(/inside an open turn/) - }) - - it('starts a fresh chain when the turn before a retry trigger did not fail structurally', async () => { - const ctx = await setup() - const session = closeStep(ctx, 'retry-invariant-completed-predecessor') - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - session.append('turn/start', { turn: 2, trigger: { kind: 'retry' } }) - session.append('step/start', { turn: 2, step: 1 }) - session.append('step/end', { turn: 2, step: 1 }) - expect(() => { - session.append('llm/retry', { - turn: 2, step: 1, retry: 1, maxRetries: 2, delayMs: 1, failure, - }) - }).not.toThrow() - }) - - it('walks the chain across non-boundary events and stops at an unmatched turn start', async () => { - const ctx = await setup() - // The failed predecessor's turn/start is outside this log prefix (e.g. a - // truncated replay): the chain walk must stop rather than loop or throw. - const session = ctx.sessions.create(SessionId('retry-invariant-unmatched-start')) - session.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, failure } }) - // A durable non-boundary record between the turns exercises the walk over - // non-turn/end events. - session.append('todo/write', { todos: [] }) - session.append('turn/start', { turn: 2, trigger: { kind: 'retry' } }) - session.append('step/start', { turn: 2, step: 1 }) - session.append('step/end', { turn: 2, step: 1 }) - expect(() => { - session.append('llm/retry', { - turn: 2, step: 1, retry: 1, maxRetries: 2, delayMs: 1, failure, - }) - }).not.toThrow() - }) - - it('requires an open turn and its latest closed step', async () => { + it('rejects records outside the latest closed step of an open turn', async () => { const ctx = await setup() const absent = ctx.sessions.create(SessionId('retry-invariant-no-turn')) expect(() => { - absent.append('llm/retry', { - turn: 1, step: 1, retry: 1, maxRetries: 2, delayMs: 1, failure, - }) + absent.append('llm/retry', { turn: 1, step: 1, ...normal }) }).toThrow(/inside an open turn/) const wrongTurn = closeStep(ctx, 'retry-invariant-wrong-turn') expect(() => { - wrongTurn.append('llm/retry', { - turn: 2, step: 1, retry: 1, maxRetries: 2, delayMs: 1, failure, - }) + wrongTurn.append('llm/retry', { turn: 2, step: 1, ...normal }) }).toThrow(/open turn is 1/) const openStep = ctx.sessions.create(SessionId('retry-invariant-open-step')) openStep.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) openStep.append('step/start', { turn: 1, step: 1 }) expect(() => { - openStep.append('llm/retry', { - turn: 1, step: 1, retry: 1, maxRetries: 2, delayMs: 1, failure, - }) + openStep.append('llm/retry', { turn: 1, step: 1, ...normal }) }).toThrow(/step 1 is still open/) + const noStep = ctx.sessions.create(SessionId('retry-invariant-no-step')) + noStep.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + expect(() => { + noStep.append('llm/retry', { turn: 1, step: 1, ...normal }) + }).toThrow(/latest closed step is undefined/) + const wrongStep = closeStep(ctx, 'retry-invariant-wrong-step') expect(() => { - wrongStep.append('llm/retry', { - turn: 1, step: 2, retry: 1, maxRetries: 2, delayMs: 1, failure, - }) + wrongStep.append('llm/retry', { turn: 1, step: 2, ...normal }) }).toThrow(/latest closed step is 1/) + + const closedTurn = closeStep(ctx, 'retry-invariant-closed-turn') + closedTurn.append('turn/end', { turn: 1, reason: { kind: 'aborted' } }) + expect(() => { + closedTurn.append('llm/retry', { turn: 1, step: 1, ...normal }) + }).toThrow(/inside an open turn/) }) - it('rejects duplicate and out-of-sequence retry schedules', async () => { + it('rejects a second retry record for the same step', async () => { const ctx = await setup() - const duplicate = closeStep(ctx, 'retry-invariant-duplicate') - duplicate.append('llm/retry', { - turn: 1, step: 1, retry: 1, maxRetries: 3, delayMs: 1, failure, - }) - expect(() => { - duplicate.append('llm/retry', { - turn: 1, step: 1, retry: 2, maxRetries: 3, delayMs: 1, failure, - }) - }).toThrow(/duplicates/) + const session = closeStep(ctx, 'retry-invariant-duplicate') + session.append('llm/retry', { turn: 1, step: 1, ...normal }) - const nonIncreasing = closeStep(ctx, 'retry-invariant-non-increasing') - nonIncreasing.append('llm/retry', { - turn: 1, step: 1, retry: 1, maxRetries: 3, delayMs: 1, failure, - }) - nonIncreasing.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, failure } }) - nonIncreasing.append('turn/start', { turn: 2, trigger: { kind: 'retry' } }) - nonIncreasing.append('step/start', { turn: 2, step: 1 }) - nonIncreasing.append('step/end', { turn: 2, step: 1 }) expect(() => { - nonIncreasing.append('llm/retry', { - turn: 2, step: 1, retry: 1, maxRetries: 3, delayMs: 1, failure, - }) - }).toThrow(/retry-chain position 2/) + session.append('llm/retry', { turn: 1, step: 1, ...normal, retry: 2 }) + }).toThrow(/duplicates the retry record/) }) - it('resets retry numbering after a completed chain', async () => { + it('binds retry numbering to the provider policy and resets it after success', async () => { const ctx = await setup() - const session = closeStep(ctx, 'retry-invariant-reset') - session.append('llm/retry', { - turn: 1, step: 1, retry: 1, maxRetries: 2, delayMs: 1, failure, - }) - session.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, failure } }) - session.append('turn/start', { turn: 2, trigger: { kind: 'retry' } }) - session.append('step/start', { turn: 2, step: 1 }) - session.append('step/end', { turn: 2, step: 1 }) - session.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) - session.append('turn/start', { - turn: 3, - trigger: { kind: 'message', source: { kind: 'user' } }, - }) - session.append('step/start', { turn: 3, step: 1 }) - session.append('step/end', { turn: 3, step: 1 }) - + const mismatch = closeStep(ctx, 'retry-invariant-numbering') + mismatch.append('llm/retry', { turn: 1, step: 1, ...normal }) + mismatch.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, failure } }) + mismatch.append('turn/start', { turn: 2, trigger: { kind: 'retry' } }) + mismatch.append('step/start', { turn: 2, step: 1 }) + mismatch.append('step/end', { turn: 2, step: 1 }) expect(() => { - session.append('llm/retry', { - turn: 3, step: 1, retry: 1, maxRetries: 2, delayMs: 1, failure, - }) + mismatch.append('llm/retry', { turn: 2, step: 1, ...normal, retry: 1 }) + }).toThrow(/must equal provider policy retry 2/) + + const reset = closeStep(ctx, 'retry-invariant-reset') + reset.append('llm/retry', { turn: 1, step: 1, ...normal }) + reset.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, failure } }) + reset.append('turn/start', { turn: 2, trigger: { kind: 'retry' } }) + reset.append('step/start', { turn: 2, step: 1 }) + reset.append('assistant/message', { + turn: 2, + step: 1, + content: [{ type: 'text', text: 'success' }], + provenance: { provider: 'mock', model: 'mock' }, + }, { surfaceOp: 'append' }) + reset.append('step/end', { turn: 2, step: 1 }) + reset.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) + reset.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } }) + reset.append('step/start', { turn: 3, step: 1 }) + reset.append('step/end', { turn: 3, step: 1 }) + expect(() => { + reset.append('llm/retry', { turn: 3, step: 1, ...normal }) }).not.toThrow() }) + it('starts a fresh retry chain after incomplete predecessor boundaries', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + + const missingEnd = ctx.sessions.create(SessionId('retry-invariant-missing-end')) + missingEnd.append('user/message', { + content: [{ type: 'text', text: 'idle context' }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + appendRetryTurn(missingEnd, 2) + + const nonFailureEnd = ctx.sessions.create(SessionId('retry-invariant-non-failure-end')) + nonFailureEnd.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + nonFailureEnd.append('user/message', { + content: [{ type: 'text', text: 'idle context' }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + appendRetryTurn(nonFailureEnd, 2) + + const missingStart = ctx.sessions.create(SessionId('retry-invariant-missing-start')) + missingStart.append('turn/end', { + turn: 1, + reason: { kind: 'error', step: 1, failure }, + }) + appendRetryTurn(missingStart, 2) + + await ctx.plugin(InvariantService) + await expect(ctx.plugin(RetryInvariant)).resolves.toBeDefined() + }) + + it('rejects a provider that does not match the failed request route', async () => { + const ctx = await setup() + const session = closeStep(ctx, 'retry-invariant-provider') + expect(() => { + session.append('llm/retry', { turn: 1, step: 1, ...always, provider: 'other' }) + }).toThrow(/does not match the failed request provider mock/) + }) + it('validates existing histories on late registration', async () => { const ctx = new Context() await ctx.plugin(SessionStore) const session = ctx.sessions.create(SessionId('retry-invariant-late')) - session.append('llm/retry', { - turn: 1, step: 1, retry: 1, maxRetries: 2, delayMs: 1, failure, - }) + session.append('step/end', { turn: 1, step: 1 }) + session.append('llm/retry', { turn: 1, step: 1, ...normal }) await ctx.plugin(InvariantService) await expect(ctx.plugin(RetryInvariant)).rejects.toThrow(/inside an open turn/) }) - - it('accepts a valid mixed pre-existing history on late registration', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const session = ctx.sessions.create(SessionId('retry-invariant-late-valid')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('step/start', { turn: 1, step: 1 }) - session.append('step/end', { turn: 1, step: 1 }) - session.append('llm/retry', { - turn: 1, step: 1, retry: 1, maxRetries: 2, delayMs: 1, failure, - }) - await ctx.plugin(InvariantService) - await expect(ctx.plugin(RetryInvariant)).resolves.toBeDefined() - }) }) diff --git a/packages/llm/llm-retry/tests/loader-composition.spec.ts b/packages/llm/llm-retry/tests/loader-composition.spec.ts index 5c64b36097..31d01b9f35 100644 --- a/packages/llm/llm-retry/tests/loader-composition.spec.ts +++ b/packages/llm/llm-retry/tests/loader-composition.spec.ts @@ -8,8 +8,8 @@ import Loader from '@cordisjs/plugin-loader' import Include from '@cordisjs/plugin-include' import AgentRegistry from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' -import LlmService, { LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' -import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' +import LlmService, { LlmAdapter, LlmError, resolveRetryPolicy } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, ResolvedRetryPolicy, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' @@ -20,6 +20,16 @@ let context: Context | undefined class TransientOnceAdapter extends LlmAdapter { requests = 0 + private readonly retryPolicy = resolveRetryPolicy({ + mode: 'normal', + maxRetries: 1, + retryableCodes: ['RATE_LIMIT', 'SERVER'], + backoff: { initialDelayMs: 1, maxDelayMs: 1, jitterRatio: 0 }, + }, 'loader test provider retryPolicy') + + override providerRetryPolicy(_provider: string): ResolvedRetryPolicy { + return this.retryPolicy + } async * stream(_options: GenerateOptions): AsyncIterable { this.requests += 1 @@ -75,7 +85,7 @@ describe('real Loader composition', () => { // Real-Loader composition resolves workspace packages through tsx at test // time; first resolution after the host/client program split is slow enough // to trip the default 5s budget on cold caches. - it('loads the flat policy and records recovery through the shipping loop', { timeout: 60_000 }, async () => { + it('loads provider-supplied policy and records recovery through the shipping loop', { timeout: 60_000 }, async () => { const loaded = await loadYaml([ "- name: '@deepseek-ai/dsh-llm'", "- name: '@deepseek-ai/dsh-session'", @@ -83,12 +93,6 @@ describe('real Loader composition', () => { "- name: '@deepseek-ai/dsh-tools'", "- name: '@deepseek-ai/dsh-agent'", "- name: '@deepseek-ai/dsh-llm-retry'", - ' config:', - ' maxTransientRetries: 1', - ' initialDelayMs: 1', - ' maxDelayMs: 1', - ' jitterRatio: 0', - ' retryableCodes: [RATE_LIMIT, SERVER]', "- name: '@deepseek-ai/dsh-agent-loop'", ]) diff --git a/packages/llm/llm-retry/tests/persistence.spec.ts b/packages/llm/llm-retry/tests/persistence.spec.ts index 5d02192263..2b5d0234bd 100644 --- a/packages/llm/llm-retry/tests/persistence.spec.ts +++ b/packages/llm/llm-retry/tests/persistence.spec.ts @@ -34,12 +34,18 @@ describe.each(['jsonl', 'sqlite'] as const)('%s retry-event persistence', (kind) const session = ctx.sessions.create(SessionId(`retry-${kind}`)) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('step/start', { turn: 1, step: 1 }) + session.append('request/header', { + header: { config: { provider: 'mock', model: 'mock' } }, + reason: 'initial', + }) session.append('step/end', { turn: 1, step: 1 }) const event = session.append('llm/retry', { turn: 1, step: 1, + provider: 'mock', + mode: 'always', + policyKey: '["always",500,10000,0.1]', retry: 1, - maxRetries: 2, delayMs: 750, failure: { message: 'provider busy', code: 'RATE_LIMIT', status: 429 }, }) diff --git a/packages/llm/llm-retry/tests/retry.spec.ts b/packages/llm/llm-retry/tests/retry.spec.ts index a2eacb9316..9df96c040f 100644 --- a/packages/llm/llm-retry/tests/retry.spec.ts +++ b/packages/llm/llm-retry/tests/retry.spec.ts @@ -1,14 +1,22 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest' import { Context } from 'cordis' import type { Fiber } from 'cordis' -import LlmService, { CallId, EMPTY_RESPONSE_CODE, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' -import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' +import LlmService, { CallId, EMPTY_RESPONSE_CODE, LlmAdapter, LlmError, resolveRetryPolicy } from '@deepseek-ai/dsh-llm' +import type { + AlwaysRetryPolicyConfig, + BackoffConfig, + GenerateOptions, + NormalRetryPolicyConfig, + ResolvedRetryPolicy, + RetryPolicyConfig, + StreamChunk, +} from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' -import type { Agent } from '@deepseek-ai/dsh-agent' +import type { Agent, RequestErrorAction } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as retry from '../src/index.ts' @@ -16,6 +24,7 @@ type ScriptEntry = Error | Iterable | AsyncIterable class ScriptedAdapter extends LlmAdapter { readonly requests: GenerateOptions[] = [] + private retryPolicies: Readonly> = {} constructor(private readonly entries: ScriptEntry[]) { super() @@ -28,6 +37,21 @@ class ScriptedAdapter extends LlmAdapter { if (entry instanceof Error) throw entry yield* entry } + + configureRetryPolicies( + policies: Readonly>, + ): void { + this.retryPolicies = Object.fromEntries(Object.entries(policies).map(([provider, policy]) => [ + provider, + policy === undefined + ? undefined + : resolveRetryPolicy(policy, `retry test provider "${provider}" retryPolicy`), + ])) + } + + override providerRetryPolicy(provider: string): ResolvedRetryPolicy | undefined { + return this.retryPolicies[provider] + } } async function* partialToolFailure(error: Error): AsyncGenerator { @@ -50,16 +74,6 @@ function textResponse(text: string): StreamChunk[] { ] } -function toolResponse(callId: string, name: string): StreamChunk[] { - const id = CallId(callId) - return [ - { type: 'block-start', index: 0, blockType: 'tool-call' }, - { type: 'tool-call-delta', index: 0, id, name, argumentsDelta: '{}' }, - { type: 'block-end', index: 0, block: { type: 'tool-call', id, name, arguments: '{}' } }, - { type: 'finish', reason: { kind: 'tool-calls' } }, - ] -} - /** * A degenerate empty provider completion as an error finish chunk. Both * adapters emit this shape and the EMPTY_RESPONSE code (the field the policy @@ -80,11 +94,11 @@ function emptyCompletion(): StreamChunk[] { } async function harness( - adapter: LlmAdapter, - config: retry.Config = {}, + adapter: ScriptedAdapter, + policies: Readonly> = { mock: normalConfig() }, beforeRetry?: (ctx: Context) => void, internals: retry.RetryInternals = {}, -): Promise<{ ctx: Context; retryFiber: Fiber }> { +): Promise<{ ctx: Context; retryFiber: Fiber; disposeAdapter: () => void }> { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(SessionStore) @@ -92,18 +106,42 @@ async function harness( await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) beforeRetry?.(ctx) - const resolvedConfig = Object.assign({ - maxTransientRetries: 2, - initialDelayMs: 500, - maxDelayMs: 10_000, - jitterRatio: 0, - }, config) + adapter.configureRetryPolicies(policies) const retryFiber = await ctx.plugin(Object.assign((inner: Context) => { - retry.apply(inner, resolvedConfig, internals) + retry.apply(inner, {}, internals) }, { inject: retry.inject })) await ctx.plugin(AgentLoop, { agents: [] }) - ctx.llm.registerAdapter(['mock'], adapter) - return { ctx, retryFiber } + const disposeAdapter = ctx.llm.registerAdapter(['mock', 'other'], adapter) + return { ctx, retryFiber, disposeAdapter } +} + +function normalConfig( + overrides: Partial> = {}, +): NormalRetryPolicyConfig { + const { backoff, ...policy } = overrides + return { + mode: 'normal', + maxRetries: 2, + ...policy, + backoff: { + initialDelayMs: 500, + maxDelayMs: 10_000, + jitterRatio: 0, + ...backoff, + }, + } +} + +function alwaysConfig(backoff: BackoffConfig = {}): AlwaysRetryPolicyConfig { + return { + mode: 'always', + backoff: { + initialDelayMs: 500, + maxDelayMs: 10_000, + jitterRatio: 0, + ...backoff, + }, + } } function waitForIdle(ctx: Context, agent: Agent): Promise { @@ -136,43 +174,21 @@ afterEach(async () => { context = undefined }) -describe('config validation', () => { - it.each([ - [{ maxTransientRetries: 1.5 }, /maxTransientRetries must be a non-negative integer/], - [{ maxTransientRetries: -1 }, /maxTransientRetries must be a non-negative integer/], - [{ initialDelayMs: 0 }, /initialDelayMs must be a positive finite number/], - [{ initialDelayMs: Number.NaN }, /initialDelayMs must be a positive finite number/], - [{ maxDelayMs: 0 }, /maxDelayMs must be a positive finite number/], - [{ initialDelayMs: 600, maxDelayMs: 500 }, /initialDelayMs must be less than or equal to maxDelayMs/], - [{ jitterRatio: Number.NaN }, /jitterRatio must be between 0 and 1/], - [{ retryableCodes: [] }, /retryableCodes must not be empty/], - [{ retryableCodes: ['SERVER', ''] }, /retryableCodes must contain only non-empty strings/], - [{ retryableCodes: ['SERVER', 'SERVER'] }, /retryableCodes must not contain duplicates/], - ] satisfies [retry.Config, RegExp][])('rejects invalid config %j at load', (config, message) => { - expect(() => { retry.apply(new Context(), config) }).toThrow(message) - }) -}) - -describe('bounded transient retry policy', () => { +describe('provider-routed retry policy', () => { it('records the scheduled delay before opening a fresh request attempt', async () => { vi.useFakeTimers() const adapter = new ScriptedAdapter([ new LlmError('busy', 'RATE_LIMIT', { status: 429 }), textResponse('done'), ]) - ;({ ctx: context } = await harness(adapter)) + ;({ ctx: context } = await harness(adapter, { + mock: normalConfig({ retryableCodes: ['SERVER', 'RATE_LIMIT'] }), + }, undefined, { random: () => 0.5 })) const agent = context.agentLoop.create(SessionId('retry-success'), { provider: 'mock', model: 'mock', }) - const scheduled = new Promise>((resolve) => { - const dispose = context?.on('session/event', (session, event) => { - if (session === agent.session && event.type === 'llm/retry') { - dispose?.() - resolve(event) - } - }) - }) + const scheduled = waitForRetry(context, agent, 1) agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }) const event = await scheduled @@ -180,6 +196,9 @@ describe('bounded transient retry policy', () => { expect(event.data).toEqual({ turn: 1, step: 1, + provider: 'mock', + mode: 'normal', + policyKey: '["normal",2,["RATE_LIMIT","SERVER"],500,10000,0]', retry: 1, maxRetries: 2, delayMs: 500, @@ -228,9 +247,10 @@ describe('bounded transient retry policy', () => { await idle expect(adapter.requests).toHaveLength(2) - expect(agent.session.events.filter(event => event.type === 'assistant/message') - .map(event => [event.data.turn, event.data.step])) - .toEqual([[2, 1]]) + expect(agent.session.events.filter(event => event.type === 'assistant/message').map(event => ({ + turn: event.data.turn, + step: event.data.step, + }))).toEqual([{ turn: 2, step: 1 }]) expect(agent.session.deriveMessages().at(-1)).toMatchObject({ role: 'assistant', content: [{ type: 'text', text: 'recovered' }], @@ -264,7 +284,7 @@ describe('bounded transient retry policy', () => { await idle const failedChunks = agent.session.events.filter(event => - event.type === 'assistant/chunk' && event.data.turn === 1, + event.type === 'assistant/chunk' && event.data.turn === 1 && event.data.step === 1, ) expect(failedChunks).toHaveLength(6) expect(agent.session.events.filter(event => event.type === 'assistant/message').map(event => ({ @@ -288,7 +308,9 @@ describe('bounded transient retry policy', () => { new LlmError('busy two', 'SERVER'), new LlmError('busy three', 'SERVER'), ]) - ;({ ctx: context } = await harness(adapter, { jitterRatio: 0.1 }, undefined, { + ;({ ctx: context } = await harness(adapter, { mock: normalConfig({ + backoff: { jitterRatio: 0.1 }, + }) }, undefined, { random: () => samples.shift() ?? 0.5, })) const agent = context.agentLoop.create(SessionId('retry-exhausted'), { provider: 'mock', model: 'mock' }) @@ -313,84 +335,15 @@ describe('bounded transient retry policy', () => { }) }) - it('resets the retry budget for a later message', async () => { - vi.useFakeTimers() - const adapter = new ScriptedAdapter([ - new LlmError('first busy', 'SERVER'), - textResponse('first done'), - new LlmError('second busy', 'SERVER'), - textResponse('second done'), - ]) - ;({ ctx: context } = await harness(adapter, { maxTransientRetries: 1 })) - const agent = context.agentLoop.create(SessionId('retry-reset'), { provider: 'mock', model: 'mock' }) - - const firstRetry = waitForRetry(context, agent, 1) - agent.followup({ content: [{ type: 'text', text: 'first' }], source: { kind: 'user' } }) - await firstRetry - const firstIdle = waitForIdle(context, agent) - await vi.advanceTimersByTimeAsync(500) - await firstIdle - - const secondRetry = waitForRetry(context, agent, 1) - agent.followup({ content: [{ type: 'text', text: 'second' }], source: { kind: 'user' } }) - await secondRetry - const secondIdle = waitForIdle(context, agent) - await vi.advanceTimersByTimeAsync(500) - await secondIdle - - expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => event.data.retry)) - .toEqual([1, 1]) - expect(adapter.requests).toHaveLength(4) - }) - - it('resets the retry budget after a successful tool-call response within the same drain', async () => { - vi.useFakeTimers() - const adapter = new ScriptedAdapter([ - new LlmError('first busy', 'SERVER'), - toolResponse('work-1', 'work'), - new LlmError('second busy', 'SERVER'), - textResponse('done'), - ]) - ;({ ctx: context } = await harness(adapter, { maxTransientRetries: 1 })) - context.tools.register(defineContentToolFixture({ - name: 'work', - description: 'continue into another model step', - parameters: {}, - async execute() { - return [{ type: 'text', text: 'worked' }] - }, - })) - const agent = context.agentLoop.create(SessionId('retry-reset-after-success'), { - provider: 'mock', - model: 'mock', - }) - - const firstRetry = waitForRetry(context, agent, 1) - agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }) - await firstRetry - const secondRetry = waitForRetry(context, agent, 1) - await vi.advanceTimersByTimeAsync(500) - await secondRetry - const idle = waitForIdle(context, agent) - await vi.advanceTimersByTimeAsync(500) - await idle - - expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => event.data.retry)) - .toEqual([1, 1]) - expect(adapter.requests).toHaveLength(4) - }) - it('accepts the zero-delay lower jitter bound', async () => { vi.useFakeTimers() const adapter = new ScriptedAdapter([ new LlmError('busy', 'SERVER'), textResponse('done'), ]) - ;({ ctx: context } = await harness(adapter, { - initialDelayMs: 1, - maxDelayMs: 1, - jitterRatio: 1, - }, undefined, { random: () => 0 })) + ;({ ctx: context } = await harness(adapter, { mock: normalConfig({ + backoff: { initialDelayMs: 1, maxDelayMs: 1, jitterRatio: 1 }, + }) }, undefined, { random: () => 0 })) const agent = context.agentLoop.create(SessionId('retry-zero-delay'), { provider: 'mock', model: 'mock' }) const scheduled = waitForRetry(context, agent, 1) @@ -409,7 +362,9 @@ describe('bounded transient retry policy', () => { new LlmError('wait', 'RATE_LIMIT', { providerRetryAfterMs: 2_000 }), textResponse('done'), ]) - ;({ ctx: context } = await harness(accepted, { jitterRatio: 1 })) + ;({ ctx: context } = await harness(accepted, { mock: normalConfig({ + backoff: { jitterRatio: 1 }, + }) })) const acceptedAgent = context.agentLoop.create(SessionId('retry-after-accepted'), { provider: 'mock', model: 'mock' }) const scheduled = waitForRetry(context, acceptedAgent, 1) acceptedAgent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }) @@ -432,6 +387,32 @@ describe('bounded transient retry policy', () => { expect(rejectedAgent.session.events.some(event => event.type === 'llm/retry')).toBe(false) }) + it('uses local jittered backoff when always mode receives an over-cap Retry-After', async () => { + vi.useFakeTimers() + const adapter = new ScriptedAdapter([ + new LlmError('wait too long', 'AUTH', { providerRetryAfterMs: 10 }), + textResponse('done'), + ]) + ;({ ctx: context } = await harness(adapter, { mock: alwaysConfig({ + initialDelayMs: 2, + maxDelayMs: 4, + jitterRatio: 0.5, + }) }, undefined, { random: () => 1 })) + const agent = context.agentLoop.create(SessionId('retry-always-over-cap'), { + provider: 'mock', + model: 'mock', + }) + const scheduled = waitForRetry(context, agent, 1) + + agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }) + expect((await scheduled).data.delayMs).toBe(3) + const idle = waitForIdle(context, agent) + await vi.advanceTimersByTimeAsync(3) + await idle + + expect(adapter.requests).toHaveLength(2) + }) + it('delegates non-transient failures without scheduling a timer', async () => { vi.useFakeTimers() const adapter = new ScriptedAdapter([new LlmError('bad key', 'AUTH')]) @@ -445,123 +426,339 @@ describe('bounded transient retry policy', () => { expect(vi.getTimerCount()).toBe(0) }) - it('keeps the consumed budget when an unowned session logs an assistant message', async () => { + it('delegates when no final adapter served the failed request', async () => { + const adapter = new ScriptedAdapter([textResponse('must not run')]) + const mounted = await harness(adapter, { mock: alwaysConfig() }) + context = mounted.ctx + mounted.disposeAdapter() + const agent = context.agentLoop.create(SessionId('retry-no-serving-policy'), { + provider: 'mock', + model: 'mock', + }) + const idle = waitForIdle(context, agent) + + agent.followup({ content: [{ type: 'text', text: 'missing route' }], source: { kind: 'user' } }) + await idle + + expect(adapter.requests).toHaveLength(0) + expect(agent.session.events.some(event => event.type === 'llm/retry')).toBe(false) + expect(agent.session.events.at(-1)).toMatchObject({ + type: 'turn/end', + data: { reason: { kind: 'error', failure: { code: 'NO_ADAPTER' } } }, + }) + }) + + it('selects policy by the failed request provider', async () => { vi.useFakeTimers() const adapter = new ScriptedAdapter([ - new LlmError('busy one', 'SERVER'), - new LlmError('busy two', 'SERVER'), + new LlmError('mock auth failed', 'AUTH'), + new LlmError('other auth failed', 'AUTH'), + textResponse('other recovered'), ]) - ;({ ctx: context } = await harness(adapter, { maxTransientRetries: 1 })) - const agent = context.agentLoop.create(SessionId('retry-foreign-session'), { provider: 'mock', model: 'mock' }) + ;({ ctx: context } = await harness(adapter, { + other: alwaysConfig({ initialDelayMs: 1, maxDelayMs: 1, jitterRatio: 0 }), + })) + + const normalAgent = context.agentLoop.create(SessionId('retry-provider-normal'), { + provider: 'mock', + model: 'mock', + }) + const normalIdle = waitForIdle(context, normalAgent) + normalAgent.followup({ content: [{ type: 'text', text: 'normal' }], source: { kind: 'user' } }) + await normalIdle + expect(normalAgent.session.events.some(event => event.type === 'llm/retry')).toBe(false) + + const alwaysAgent = context.agentLoop.create(SessionId('retry-provider-always'), { + provider: 'other', + model: 'mock', + }) + const scheduled = waitForRetry(context, alwaysAgent, 1) + alwaysAgent.followup({ content: [{ type: 'text', text: 'always' }], source: { kind: 'user' } }) + expect((await scheduled).data).toMatchObject({ + provider: 'other', + mode: 'always', + retry: 1, + delayMs: 1, + }) + const alwaysIdle = waitForIdle(context, alwaysAgent) + await vi.advanceTimersByTimeAsync(1) + await alwaysIdle + + expect(adapter.requests.map(request => request.provider)).toEqual(['mock', 'other', 'other']) + }) + + it('selects an always policy from the provider chosen by agent/request', async () => { + vi.useFakeTimers() + const adapter = new ScriptedAdapter([ + new LlmError('rerouted auth failed', 'AUTH'), + textResponse('rerouted recovery'), + ]) + ;({ ctx: context } = await harness(adapter, { + other: alwaysConfig({ initialDelayMs: 1, maxDelayMs: 1, jitterRatio: 0 }), + }, (ctx) => { + ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => ({ + ...await next(), + provider: 'other', + })) + })) + const agent = context.agentLoop.create(SessionId('retry-provider-rerouted'), { + provider: 'mock', + model: 'mock', + }) const scheduled = waitForRetry(context, agent, 1) - agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }) - await scheduled - - // A session no agent owns completes a response; the agent's consecutive- - // failure sequence must not reset from that foreign success. - const foreign = context.sessions.create(SessionId('retry-foreign-session-other')) - foreign.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - foreign.append('step/start', { turn: 1, step: 1 }) - foreign.append('assistant/message', { - turn: 1, - step: 1, - content: [{ type: 'text', text: 'foreign' }], - provenance: { provider: 'mock', model: 'mock' }, - }, { surfaceOp: 'append' }) - + agent.followup({ content: [{ type: 'text', text: 'reroute' }], source: { kind: 'user' } }) + expect((await scheduled).data).toMatchObject({ provider: 'other', mode: 'always' }) const idle = waitForIdle(context, agent) - await vi.advanceTimersByTimeAsync(500) + await vi.advanceTimersByTimeAsync(1) + await idle + + expect(adapter.requests.map(request => request.provider)).toEqual(['other', 'other']) + }) + + it('keeps finite retry budgets scoped to the failed provider', async () => { + vi.useFakeTimers() + const adapter = new ScriptedAdapter([ + new LlmError('mock failed', 'SERVER'), + new LlmError('other failed', 'SERVER'), + textResponse('other recovered'), + ]) + ;({ ctx: context } = await harness(adapter, { + mock: normalConfig({ + maxRetries: 1, + backoff: { initialDelayMs: 1, maxDelayMs: 1 }, + }), + other: normalConfig({ + maxRetries: 1, + backoff: { initialDelayMs: 1, maxDelayMs: 1 }, + }), + }, (ctx) => { + ctx.on('agent/request', async (_agent, turn, _step, _signal, next) => ({ + ...await next(), + provider: turn === 1 ? 'mock' : 'other', + })) + })) + const agent = context.agentLoop.create(SessionId('retry-provider-budgets'), { + provider: 'mock', + model: 'mock', + }) + const idle = waitForIdle(context, agent) + + agent.followup({ + content: [{ type: 'text', text: 'switch provider after failure' }], + source: { kind: 'user' }, + }) + await vi.runAllTimersAsync() + await idle + + expect(adapter.requests.map(request => request.provider)).toEqual(['mock', 'other', 'other']) + expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => ({ + provider: event.data.provider, + retry: event.data.retry, + }))).toEqual([ + { provider: 'mock', retry: 1 }, + { provider: 'other', retry: 1 }, + ]) + }) + + it.each(['thrown', 'in-band'] as const)( + 'uses the serving registration policy and resets changed-policy history after a %s failure', + async (failureKind) => { + vi.useFakeTimers() + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + const oldAdapter = new ScriptedAdapter([(async function * (): AsyncGenerator { + entered.resolve(undefined) + await release.promise + if (failureKind === 'thrown') { + throw new LlmError('old route auth failed', 'AUTH') + } + yield { + type: 'finish', + reason: { + kind: 'error', + failure: { message: 'old route auth failed', code: 'AUTH' }, + }, + } + })()]) + const mounted = await harness(oldAdapter, { mock: alwaysConfig({ + initialDelayMs: 1, + maxDelayMs: 1, + }) }) + context = mounted.ctx + const agent = context.agentLoop.create(SessionId('retry-serving-registration'), { + provider: 'mock', + model: 'mock', + }) + const scheduled = waitForRetry(context, agent, 1) + agent.followup({ + content: [{ type: 'text', text: 'replace while in flight' }], + source: { kind: 'user' }, + }) + await entered.promise + + mounted.disposeAdapter() + const replacement = new ScriptedAdapter([ + new LlmError('replacement failed', 'AUTH'), + textResponse('replacement recovered'), + ]) + replacement.configureRetryPolicies({ mock: alwaysConfig({ + initialDelayMs: 3, + maxDelayMs: 3, + }) }) + context.llm.registerAdapter(['mock'], replacement) + release.resolve(undefined) + + const firstEvent = await scheduled + expect(firstEvent.data).toMatchObject({ + provider: 'mock', + mode: 'always', + retry: 1, + delayMs: 1, + }) + const replacementScheduled = waitForRetry(context, agent, 1) + const idle = waitForIdle(context, agent) + await vi.advanceTimersByTimeAsync(1) + const replacementEvent = await replacementScheduled + expect(replacementEvent.data).toMatchObject({ + provider: 'mock', + mode: 'always', + retry: 1, + delayMs: 3, + }) + expect(replacementEvent.data.policyKey).not.toBe(firstEvent.data.policyKey) + await vi.advanceTimersByTimeAsync(3) + await idle + + expect(oldAdapter.requests).toHaveLength(1) + expect(replacement.requests).toHaveLength(2) + expect(agent.session.deriveMessages().at(-1)).toMatchObject({ + role: 'assistant', + content: [{ type: 'text', text: 'replacement recovered' }], + }) + }, + ) + + it('keeps always mode unbounded while preserving cancellable jittered backoff', async () => { + vi.useFakeTimers() + const adapter = new ScriptedAdapter([ + new LlmError('auth one', 'AUTH'), + new LlmError('auth two', 'AUTH'), + new LlmError('auth three', 'AUTH'), + new LlmError('auth four', 'AUTH'), + textResponse('eventually recovered'), + ]) + ;({ ctx: context } = await harness(adapter, { mock: alwaysConfig({ + initialDelayMs: 1, + maxDelayMs: 4, + jitterRatio: 0.1, + }) }, undefined, { random: () => 1 })) + const agent = context.agentLoop.create(SessionId('retry-always-unbounded'), { + provider: 'mock', + model: 'mock', + }) + const idle = waitForIdle(context, agent) + + agent.followup({ content: [{ type: 'text', text: 'keep trying' }], source: { kind: 'user' } }) + await vi.runAllTimersAsync() + await idle + + const events = agent.session.events.filter(event => event.type === 'llm/retry') + expect(adapter.requests).toHaveLength(5) + expect(events.map(event => ({ + provider: event.data.provider, + mode: event.data.mode, + retry: event.data.retry, + delayMs: event.data.delayMs, + hasMax: 'maxRetries' in event.data, + }))).toEqual([ + { provider: 'mock', mode: 'always', retry: 1, delayMs: 1.1, hasMax: false }, + { provider: 'mock', mode: 'always', retry: 2, delayMs: 2.2, hasMax: false }, + { provider: 'mock', mode: 'always', retry: 3, delayMs: 4, hasMax: false }, + { provider: 'mock', mode: 'always', retry: 4, delayMs: 4, hasMax: false }, + ]) + }) + + it('keeps failed error text and partial output out of every retried model context', async () => { + vi.useFakeTimers() + const diagnostic = 'private provider diagnostic must not enter context' + const adapter = new ScriptedAdapter([ + partialToolFailure(new LlmError(diagnostic, 'AUTH')), + textResponse('recovered without leaked context'), + ]) + ;({ ctx: context } = await harness(adapter, { mock: alwaysConfig({ + initialDelayMs: 1, + maxDelayMs: 1, + }) })) + const agent = context.agentLoop.create(SessionId('retry-always-context-isolation'), { + provider: 'mock', + model: 'mock', + }) + const scheduled = waitForRetry(context, agent, 1) + + agent.followup({ content: [{ type: 'text', text: 'safe input' }], source: { kind: 'user' } }) + await scheduled + const idle = waitForIdle(context, agent) + await vi.advanceTimersByTimeAsync(1) await idle expect(adapter.requests).toHaveLength(2) - expect(agent.session.events.filter(event => event.type === 'llm/retry')).toHaveLength(1) - expect(agent.session.events.at(-1)).toMatchObject({ - type: 'turn/end', - data: { reason: { kind: 'error', failure: { message: 'busy two', code: 'SERVER' } } }, - }) + expect(adapter.requests[1]?.messages).toEqual(adapter.requests[0]?.messages) + const retriedContext = JSON.stringify(adapter.requests[1]?.messages) + expect(retriedContext).not.toContain(diagnostic) + expect(retriedContext).not.toContain('discarded partial output') + expect(agent.session.events.some(event => + event.type === 'llm/retry' && event.data.failure.message === diagnostic, + )).toBe(true) }) - it('drops a scheduled retry when cancellation lands between its durable record and its wait', async () => { - vi.useFakeTimers() + it('lets downstream specialized recovery run before always fallback', async () => { const adapter = new ScriptedAdapter([ - new LlmError('busy', 'SERVER'), - textResponse('must not run'), + new LlmError('requires specialized recovery', 'AUTH'), + textResponse('specialized recovery won'), ]) - ;({ ctx: context } = await harness(adapter)) - const agent = context.agentLoop.create(SessionId('retry-cancel-at-record'), { provider: 'mock', model: 'mock' }) - // The durable record commits synchronously before the cancellable wait; a - // user cancel observed at that exact point must skip the wait entirely. - const dispose = context.on('session/event', (session, event) => { - if (session === agent.session && event.type === 'llm/retry') { - dispose() - agent.cancel({ kind: 'user' }) - } + ;({ ctx: context } = await harness(adapter, { mock: alwaysConfig() })) + context.on('agent/request-error', async () => ({ kind: 'retry' })) + const agent = context.agentLoop.create(SessionId('retry-always-composition'), { + provider: 'mock', + model: 'mock', }) const idle = waitForIdle(context, agent) - agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }) + agent.followup({ content: [{ type: 'text', text: 'recover' }], source: { kind: 'user' } }) await idle - await vi.advanceTimersByTimeAsync(60_000) - expect(adapter.requests).toHaveLength(1) - expect(agent.session.events.filter(event => event.type === 'llm/retry')).toHaveLength(1) - expect(vi.getTimerCount()).toBe(0) - }) - - it('schedules nothing when an upstream recovery listener already cancelled the turn', async () => { - vi.useFakeTimers() - const adapter = new ScriptedAdapter([ - new LlmError('busy', 'SERVER'), - textResponse('must not run'), - ]) - ;({ ctx: context } = await harness(adapter, {}, (ctx) => { - // Registered before the retry plugin, so it wraps the policy: it cancels - // the turn, then delegates into a policy that sees an aborted signal. - ctx.on('agent/request-error', (agent, _turn, _step, _error, _failure, _signal, next) => { - agent.cancel({ kind: 'user' }) - return next() - }) - })) - const agent = context.agentLoop.create(SessionId('retry-upstream-cancel'), { provider: 'mock', model: 'mock' }) - const idle = waitForIdle(context, agent) - - agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }) - await idle - await vi.advanceTimersByTimeAsync(60_000) - - expect(adapter.requests).toHaveLength(1) + expect(adapter.requests).toHaveLength(2) expect(agent.session.events.some(event => event.type === 'llm/retry')).toBe(false) - expect(vi.getTimerCount()).toBe(0) }) - it('does nothing when its captured listener resumes after plugin disposal', async () => { + it.each([ + ['synchronously', () => { throw new Error('downstream recovery failed') }], + ['asynchronously', async () => { throw new Error('downstream recovery failed') }], + ])('falls back to always retry when downstream recovery throws %s', async (_kind, failDownstream) => { vi.useFakeTimers() const adapter = new ScriptedAdapter([ - new LlmError('busy', 'SERVER'), - textResponse('must not run'), + new LlmError('requires fallback', 'AUTH'), + textResponse('always recovered'), ]) - const holder: { dispose?: () => Promise } = {} - const mounted = await harness(adapter, {}, (ctx) => { - // An upstream listener captured in the same waterfall disposes the retry - // plugin before delegating; the stale downstream callback must bail. - ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _failure, _signal, next) => { - await holder.dispose?.() - return next() - }) + ;({ ctx: context } = await harness(adapter, { mock: alwaysConfig({ + initialDelayMs: 1, + maxDelayMs: 1, + }) })) + context.on('agent/request-error', failDownstream) + const agent = context.agentLoop.create(SessionId('retry-always-downstream-error'), { + provider: 'mock', + model: 'mock', }) - context = mounted.ctx - holder.dispose = () => mounted.retryFiber.dispose() - const agent = context.agentLoop.create(SessionId('retry-stale-listener'), { provider: 'mock', model: 'mock' }) + const scheduled = waitForRetry(context, agent, 1) + + agent.followup({ content: [{ type: 'text', text: 'recover' }], source: { kind: 'user' } }) + await scheduled const idle = waitForIdle(context, agent) - - agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }) + await vi.advanceTimersByTimeAsync(1) await idle - await vi.advanceTimersByTimeAsync(60_000) - expect(adapter.requests).toHaveLength(1) - expect(agent.session.events.some(event => event.type === 'llm/retry')).toBe(false) - expect(vi.getTimerCount()).toBe(0) + expect(adapter.requests).toHaveLength(2) }) it('aborts and drains a captured backoff before plugin disposal completes', async () => { @@ -570,13 +767,14 @@ describe('bounded transient retry policy', () => { new LlmError('temporary', 'TRANSPORT'), textResponse('must not run'), ]) - const mounted = await harness(adapter) + const mounted = await harness(adapter, { mock: alwaysConfig() }) context = mounted.ctx const agent = context.agentLoop.create(SessionId('retry-hmr'), { provider: 'mock', model: 'mock' }) const scheduled = waitForRetry(context, agent, 1) agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }) await scheduled const idle = waitForIdle(context, agent) + await mounted.retryFiber.dispose() await idle await vi.advanceTimersByTimeAsync(60_000) @@ -586,4 +784,249 @@ describe('bounded transient retry policy', () => { expect(vi.getTimerCount()).toBe(0) }) + it('drains delegated recovery before completing plugin disposal', async () => { + const adapter = new ScriptedAdapter([new LlmError('bad key', 'AUTH')]) + const mounted = await harness(adapter, { mock: alwaysConfig() }) + context = mounted.ctx + const release = Promise.withResolvers() + const entered = Promise.withResolvers() + const order: string[] = [] + context.on('agent/request-error', async () => { + entered.resolve(undefined) + await release.promise + order.push('downstream') + return { kind: 'retry' } + }) + const agent = context.agentLoop.create(SessionId('retry-delegated-disposal'), { + provider: 'mock', + model: 'mock', + }) + const idle = waitForIdle(context, agent).then(() => { order.push('idle') }) + agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }) + await entered.promise + + const disposing = mounted.retryFiber.dispose().then(() => { order.push('disposed') }) + let timer: ReturnType | undefined + const outcome = await Promise.race([ + disposing.then(() => 'disposed' as const), + new Promise<'blocked'>((resolve) => { timer = setTimeout(() => { resolve('blocked') }, 100) }), + ]) + if (timer !== undefined) clearTimeout(timer) + expect(outcome).toBe('blocked') + + release.resolve(undefined) + await disposing + await idle + + expect(order[0]).toBe('downstream') + expect(order).toEqual(expect.arrayContaining(['disposed', 'idle'])) + expect(adapter.requests).toHaveLength(1) + expect(agent.session.events.some(event => event.type === 'llm/retry')).toBe(false) + }) + + it('drains delegated recovery before turn cancellation reaches idle', async () => { + const adapter = new ScriptedAdapter([new LlmError('bad key', 'AUTH')]) + const mounted = await harness(adapter, { mock: alwaysConfig() }) + context = mounted.ctx + const downstream = Promise.withResolvers() + const entered = Promise.withResolvers() + const order: string[] = [] + context.on('agent/request-error', async () => { + entered.resolve(undefined) + const decision = await downstream.promise + order.push('downstream') + return decision + }) + const agent = context.agentLoop.create(SessionId('retry-delegated-cancel'), { + provider: 'mock', + model: 'mock', + }) + const idle = waitForIdle(context, agent).then(() => { order.push('idle') }) + agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }) + await entered.promise + + agent.cancel({ kind: 'user' }) + let timer: ReturnType | undefined + const outcome = await Promise.race([ + idle.then(() => 'idle' as const), + new Promise<'blocked'>((resolve) => { timer = setTimeout(() => { resolve('blocked') }, 100) }), + ]) + if (timer !== undefined) clearTimeout(timer) + expect(outcome).toBe('blocked') + + downstream.resolve({ kind: 'retry' }) + await idle + + expect(order).toEqual(['downstream', 'idle']) + expect(adapter.requests).toHaveLength(1) + expect(agent.session.events.at(-1)).toMatchObject({ + type: 'turn/end', + data: { reason: { kind: 'aborted' } }, + }) + }) + + it('handles synchronous cancellation while entering delegated recovery', async () => { + const adapter = new ScriptedAdapter([new LlmError('bad key', 'AUTH')]) + const mounted = await harness(adapter, { mock: alwaysConfig() }) + context = mounted.ctx + const downstream = Promise.withResolvers() + const entered = Promise.withResolvers() + context.on('agent/request-error', (agent) => { + agent.cancel({ kind: 'user' }) + entered.resolve(undefined) + return downstream.promise + }) + const agent = context.agentLoop.create(SessionId('retry-delegated-sync-cancel'), { + provider: 'mock', + model: 'mock', + }) + const idle = waitForIdle(context, agent) + + agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }) + await entered.promise + let timer: ReturnType | undefined + const outcome = await Promise.race([ + idle.then(() => 'idle' as const), + new Promise<'blocked'>((resolve) => { timer = setTimeout(() => { resolve('blocked') }, 100) }), + ]) + if (timer !== undefined) clearTimeout(timer) + expect(outcome).toBe('blocked') + + downstream.resolve({ kind: 'retry' }) + await idle + + expect(adapter.requests).toHaveLength(1) + expect(agent.session.events.at(-1)).toMatchObject({ + type: 'turn/end', + data: { reason: { kind: 'aborted' } }, + }) + }) + + it('fails a captured callback after disposal without entering downstream policy', async () => { + const adapter = new ScriptedAdapter([new LlmError('bad key', 'AUTH')]) + const captured = Promise.withResolvers() + let invokeCaptured: (() => Promise) | undefined + const mounted = await harness(adapter, {}, (ctx) => { + ctx.on('agent/request-error', ( + _agent, _turn, _step, _error, _failure, _history, _retryPolicy, _signal, next, + ) => { + return new Promise((resolve) => { + invokeCaptured = async () => { resolve(await next()) } + captured.resolve(undefined) + }) + }) + }) + context = mounted.ctx + let downstreamCalls = 0 + context.on('agent/request-error', async ( + _agent, _turn, _step, _error, _failure, _history, _retryPolicy, _signal, next, + ) => { + downstreamCalls += 1 + return next() + }) + const agent = context.agentLoop.create(SessionId('retry-captured-disposal'), { + provider: 'mock', + model: 'mock', + }) + const idle = waitForIdle(context, agent) + agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }) + await captured.promise + + await mounted.retryFiber.dispose() + if (invokeCaptured === undefined) throw new Error('request-error waterfall did not capture retry callback') + await invokeCaptured() + await idle + + expect(downstreamCalls).toBe(0) + expect(adapter.requests).toHaveLength(1) + }) + + it('lets turn cancellation win during backoff without opening another step', async () => { + vi.useFakeTimers() + const adapter = new ScriptedAdapter([ + new LlmError('permanent', 'AUTH'), + textResponse('must not run'), + ]) + ;({ ctx: context } = await harness(adapter, { mock: alwaysConfig() })) + const agent = context.agentLoop.create(SessionId('retry-cancel'), { provider: 'mock', model: 'mock' }) + const scheduled = waitForRetry(context, agent, 1) + agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }) + await scheduled + const idle = waitForIdle(context, agent) + agent.cancel({ kind: 'user' }) + await idle + + expect(adapter.requests).toHaveLength(1) + expect(agent.session.events.at(-1)).toMatchObject({ + type: 'turn/end', + data: { reason: { kind: 'aborted' } }, + }) + expect(vi.getTimerCount()).toBe(0) + }) + + it.each([ + ['normal', normalConfig()], + ['always', alwaysConfig()], + ])('lets an earlier recovery listener cancel before %s retry policy runs', async (_mode, policy) => { + vi.useFakeTimers() + const adapter = new ScriptedAdapter([ + new LlmError('temporary', 'SERVER'), + textResponse('must not run'), + ]) + ;({ ctx: context } = await harness(adapter, { mock: policy }, (ctx) => { + ctx.on('agent/request-error', async ( + agent, _turn, _step, _error, _failure, _history, _retryPolicy, _signal, next, + ) => { + agent.cancel({ kind: 'user' }) + return next() + }) + })) + const agent = context.agentLoop.create(SessionId('retry-pre-cancel'), { provider: 'mock', model: 'mock' }) + const idle = waitForIdle(context, agent) + + agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }) + await idle + + expect(adapter.requests).toHaveLength(1) + expect(agent.session.events.some(event => event.type === 'llm/retry')).toBe(false) + expect(agent.session.events.at(-1)).toMatchObject({ + type: 'turn/end', + data: { reason: { kind: 'aborted' } }, + }) + }) + + it('handles synchronous cancellation from the retry status event', async () => { + vi.useFakeTimers() + const adapter = new ScriptedAdapter([ + new LlmError('temporary', 'SERVER'), + textResponse('must not run'), + ]) + ;({ ctx: context } = await harness(adapter)) + const agent = context.agentLoop.create(SessionId('retry-event-cancel'), { provider: 'mock', model: 'mock' }) + context.on('session/event', (session, event) => { + if (session === agent.session && event.type === 'llm/retry') agent.cancel({ kind: 'user' }) + }) + const idle = waitForIdle(context, agent) + + agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }) + await idle + + expect(adapter.requests).toHaveLength(1) + expect(agent.session.events.filter(event => event.type === 'llm/retry')).toHaveLength(1) + expect(vi.getTimerCount()).toBe(0) + }) + + it('rejects retry policy configured on the executor instead of a provider', () => { + expectTypeOf<{}>().toExtend() + expectTypeOf<{ retryPolicy: { mode: 'always' } }>().not.toExtend() + expect(() => { + retry.apply(new Context(), { retryPolicy: { mode: 'always' } } as unknown as retry.Config) + }).toThrow(/retryPolicy belongs under each provider/) + }) + + it('rejects unknown executor config', () => { + expect(() => { + retry.apply(new Context(), { retryPolciy: {} } as unknown as retry.Config) + }).toThrow(/unknown key "retryPolciy"/) + }) }) diff --git a/packages/llm/llm-retry/tests/transport-recovery.spec.ts b/packages/llm/llm-retry/tests/transport-recovery.spec.ts index a8f48884b0..fcaa9c8d91 100644 --- a/packages/llm/llm-retry/tests/transport-recovery.spec.ts +++ b/packages/llm/llm-retry/tests/transport-recovery.spec.ts @@ -39,13 +39,17 @@ async function harness( apiKey: 'mock-key', baseURL, streamIdleTimeoutMs: options.streamIdleTimeoutMs ?? 1_000, + retryPolicy: { + mode: 'normal', + maxRetries: 2, + backoff: { + initialDelayMs: options.initialDelayMs ?? 10, + maxDelayMs: options.initialDelayMs ?? 10, + jitterRatio: 0, + }, + }, }) - await ctx.plugin(Retry, { - maxTransientRetries: 2, - initialDelayMs: options.initialDelayMs ?? 10, - maxDelayMs: options.initialDelayMs ?? 10, - jitterRatio: 0, - }) + await ctx.plugin(Retry) await ctx.plugin(AgentLoop, { agents: [] }) return ctx } diff --git a/packages/llm/llm-retry/tsdown.config.ts b/packages/llm/llm-retry/tsdown.config.ts new file mode 100644 index 0000000000..ab8dc26ee8 --- /dev/null +++ b/packages/llm/llm-retry/tsdown.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'tsdown' + +/** Build the package root and invariant companion as independent bundles. */ +export default defineConfig([ + { + entry: ['lib/types/index.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + }, + { + entry: ['lib/types/invariant.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + }, +]) diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index 085ade62b1..d35885f2a2 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/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/llm/llm/README.md -README.md: 3efb3ece3caadeaceaa3c504ba4b10ddb951127a -README.zh.md: 4af8b8d08cc96ff0e36b10b15e1d86afd43004c9 +README.md: 2328188e420df6de60f024982a31d37a858a303e +README.zh.md: 586767a9e790fa68d27673836700fd789ce6a180 diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index 3efb3ece3c..2328188e42 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -12,15 +12,16 @@ An adapter registry plus a single streaming call surface, interceptable via a wa - `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): () => void` Register one adapter instance for the given provider routes. Registration is all-or-nothing, and is disposed with the calling fiber. - `ctx.llm.listProviders(): LlmProviderInfo[]` Describe registered provider routes in registration order. +- `ctx.llm.providerRetryPolicy(provider: string): ResolvedRetryPolicy` Return the provider-owned retry policy captured during registration, with normal defaults resolved. - `ctx.llm.listModels(provider: string): Promise` Discover the models one registered provider currently advertises. - `ctx.llm.resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise` Resolve validated exact-model identity plus available context and reasoning metadata from the owning adapter, with optional cancellation for asynchronous adapters. - `ctx.llm.resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise` Validate an explicit effort and materialize an adapter-configured default without clamping. - `ctx.llm.prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise` Resolve a config and capture its current adapter registration as one cancellable, one-shot call. - `ctx.llm.stream(options: GenerateOptions): AsyncIterable` Stream one model call as raw chunks (token-level deltas). Consumers assemble the chunks into blocks/messages with `BlockAssembler`. -`LlmService` preserves errors from final adapter selection, synchronous dispatch, iterator construction, and iteration, and binds their provenance to the exact stream handle returned for that model call. `isLlmAdapterFailure(stream, value)` reports only errors from that call's final adapter boundary; `llmFailureOf(stream, value)` returns the adjacent immutable `LlmFailure`. Nested model calls, `llm/stream` middleware, and downstream consumer failures remain unclassified for the outer call. Classification never replaces or mutates the adapter's original coded `Error`. +`LlmService` preserves errors from final adapter selection, synchronous dispatch, iterator construction, and iteration, and binds their provenance to the exact stream handle returned for that model call. `isLlmAdapterFailure(stream, value)` reports only errors from that call's final adapter boundary; `llmFailureOf(stream, value)` returns the adjacent immutable `LlmFailure`; `llmRetryPolicyOf(stream)` returns the immutable policy of the exact registration selected at that boundary, even if the route is later disposed or replaced. A call that never reaches a final adapter has no serving policy. Nested model calls, `llm/stream` middleware, and downstream consumer failures remain unclassified for the outer call. Classification never replaces or mutates the adapter's original coded `Error`. -Provider and model metadata is a discovery surface, not a routing whitelist. `registerAdapter()` still owns provider exclusivity, while an adapter may accept model ids absent from `listModels()`; consumers must not reject a request because its model is unlisted. Returned metadata is detached and invalid or duplicate adapter entries fail with `INVALID_ADAPTER` or `INVALID_CATALOG`. +Provider and model metadata is a discovery surface, not a routing whitelist. `registerAdapter()` still owns provider exclusivity and captures the adapter's retry policy for each route, while an adapter may accept model ids absent from `listModels()`; consumers must not reject a request because its model is unlisted. Returned selector metadata is detached and invalid or duplicate adapter entries fail with `INVALID_ADAPTER` or `INVALID_CATALOG`. Exact-model metadata is a separate correctness query, not a catalog decoration or global LLM setting. `resolveModelInfo()` asks the adapter that owns the exact provider/model route once; an adapter can describe an unlisted dynamic model, and absent `context` or `reasoning` fields mean only that those capabilities are unavailable. Invalid identity, context, or reasoning metadata fails with `INVALID_MODEL_INFO`, `INVALID_MODEL_CONTEXT`, or `INVALID_MODEL_REASONING`. @@ -34,7 +35,7 @@ Reasoning identifiers are opaque adapter-owned strings rather than a core enum. ### Extension points -- Subclass `LlmAdapter` and call `ctx.llm.registerAdapter(providers, adapter)` to add one or more provider routes. `GenerateOptions.provider` selects the adapter; `GenerateOptions.model` is adapter-owned and may be resolved dynamically. Override `providerInfo()` and asynchronous `listModels()` to expose selector metadata, then implement `resolveModel()` when exact identity, capacity, or selectable reasoning efforts are available; an asynchronous resolver must honor its optional cancellation signal. The defaults use the route and model ids as names, advertise no models, and return no capacity or reasoning metadata. +- Subclass `LlmAdapter` and call `ctx.llm.registerAdapter(providers, adapter)` to add one or more provider routes. `GenerateOptions.provider` selects the adapter; `GenerateOptions.model` is adapter-owned and may be resolved dynamically. Override `providerRetryPolicy()` to supply provider-owned recovery configuration, `providerInfo()` and asynchronous `listModels()` to expose selector metadata, then implement `resolveModel()` when exact identity, capacity, or selectable reasoning efforts are available; an asynchronous resolver must honor its optional cancellation signal. The defaults use bounded normal retry policy, use the route and model ids as names, advertise no models, and return no capacity or reasoning metadata. - Wrap `llm/stream` via `ctx.on()` waterfall listeners for caching, logging, or routing. A wrapper that retries after emitting a chunk has no durable attempt boundary; shipped agent retry policy therefore uses `agent/request-error` instead. ### Content-block vocabulary (`types.ts`) @@ -76,7 +77,7 @@ Pass-through; the registry preserves the assembled request prefix, while the sel ## Known Limitations and Deferred Work -- **No default retry/caching/rate-limit policy ships in this service** — `llm/stream` remains a single-attempt call-wrapper seam; the agent loop separately offers proven model-request failures to `agent/request-error`, whose default preserves the original failure. `@deepseek-ai/dsh-llm-retry` is an optional policy plugin loaded by the shared example spine. +- **No retry execution, caching, or rate limiting ships in this service** — provider registration stores retry policy, but `llm/stream` remains a single-attempt call-wrapper seam. The agent loop separately offers proven model-request failures to `agent/request-error`, whose default preserves the original failure; `@deepseek-ai/dsh-llm-retry` is the optional executor loaded by the shared example spine. - **`GenerateOptions` sampling is `temperature`/`maxTokens`/`stop` only** — no `tool_choice`, `top_p`, or penalty fields; the vocabulary grows when a producer lands ([dropped inert knobs](../../../.agents/notes/archived/simplification/2026-07-04-drop-inert-request-knobs.md)). - **Producer-gated variants stay out until produced** — `prefill`, per-tool `strict`, block `cache` hints, and the `agent` message-source variant were pruned as producerless ([Agent Note](../../../.agents/notes/archived/simplification/2026-07-04-prune-producerless-vocabulary-variants.md)). - **`BlockAssembler` handles core block kinds only** — a plugin-added block type whose stream is never closed by `block-end` makes `blocks()` throw. diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index 4af8b8d08c..586767a9e7 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -12,15 +12,16 @@ - `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): () => void` 为给定提供方路由注册一个适配器实例。注册要么全部成功,要么全部不生效,并且会随调用 fiber dispose。 - `ctx.llm.listProviders(): LlmProviderInfo[]` 按注册顺序描述已注册提供方路由。 +- `ctx.llm.providerRetryPolicy(provider: string): ResolvedRetryPolicy` 返回注册时捕获的提供方重试策略,并解析 normal 默认值。 - `ctx.llm.listModels(provider: string): Promise` 发现某个已注册提供方当前公布的模型。 - `ctx.llm.resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise` 从拥有精确路由的适配器解析经校验的确切模型身份、可用上下文和推理(reasoning)元数据;异步适配器可选地支持取消。 - `ctx.llm.resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise` 校验显式推理强度,并填入适配器配置的默认值,但不自动调整。 - `ctx.llm.prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise` 解析配置并将其当前适配器注册捕获为一次可取消、一次性调用。 - `ctx.llm.stream(options: GenerateOptions): AsyncIterable` 将一次模型调用流式输出为原始 chunk(token 级 delta)。消费方使用 `BlockAssembler` 将 chunk 组装为块/消息。 -`LlmService` 保留来自最终适配器选择、同步 dispatch、iterator 构造与迭代的错误,并将其溯源绑定到该次模型调用返回的精确流句柄。`isLlmAdapterFailure(stream, value)` 只报告该调用最终适配器边界的错误;`llmFailureOf(stream, value)` 返回相邻的不可变 `LlmFailure`。嵌套模型调用、`llm/stream` middleware 和下游消费方失败对外层调用仍未分类。分类绝不替换或更改适配器的原始编码 `Error`。 +`LlmService` 保留来自最终适配器选择、同步 dispatch、iterator 构造与迭代的错误,并将其溯源绑定到该次模型调用返回的精确流句柄。`isLlmAdapterFailure(stream, value)` 只报告该调用最终适配器边界的错误;`llmFailureOf(stream, value)` 返回相邻的不可变 `LlmFailure`;`llmRetryPolicyOf(stream)` 返回在该边界选中的确切注册所对应的不可变策略,即使之后释放或替换路由也不变。未到达最终适配器的调用没有服务策略。嵌套模型调用、`llm/stream` middleware 和下游消费方失败对外层调用仍未分类。分类绝不替换或更改适配器的原始编码 `Error`。 -提供方与模型元数据是发现表层,不是路由白名单。`registerAdapter()` 仍拥有提供方排他性,适配器则可以接受 `listModels()` 中不存在的模型 id;消费方禁止因模型未列出而拒绝请求。返回的元数据与输入脱离,无效或重复适配器配置项会以 `INVALID_ADAPTER` 或 `INVALID_CATALOG` 失败。 +提供方与模型元数据是发现表层,不是路由白名单。`registerAdapter()` 仍拥有提供方排他性,并为每条路由捕获适配器的重试策略;适配器则可以接受 `listModels()` 中不存在的模型 id,消费方禁止因模型未列出而拒绝请求。返回的 selector 元数据与输入脱离,无效或重复适配器配置项会以 `INVALID_ADAPTER` 或 `INVALID_CATALOG` 失败。 确切模型元数据是独立的正确性查询,不是 catalog 装饰或全局 LLM 设置。`resolveModelInfo()` 会向拥有精确提供方/模型路由的适配器查询一次;适配器可以描述未列出的动态模型,缺少 `context` 或 `reasoning` 字段只表示相应能力不可用。无效的身份、上下文或推理元数据会以 `INVALID_MODEL_INFO`、`INVALID_MODEL_CONTEXT` 或 `INVALID_MODEL_REASONING` 失败。 @@ -34,7 +35,7 @@ ### 扩展点 -- 继承 `LlmAdapter` 并调用 `ctx.llm.registerAdapter(providers, adapter)`,添加一条或多条提供方路由。`GenerateOptions.provider` 选择适配器;`GenerateOptions.model` 属于适配器,可以动态解析。覆盖 `providerInfo()` 和异步 `listModels()` 以公开 selector 元数据;精确身份、容量或可选推理强度可用时,实现 `resolveModel()`;异步解析器必须响应其可选的取消 signal。默认实现将路由和模型 id 用作名称,不公布模型,也不返回容量或推理元数据。 +- 继承 `LlmAdapter` 并调用 `ctx.llm.registerAdapter(providers, adapter)`,添加一条或多条提供方路由。`GenerateOptions.provider` 选择适配器;`GenerateOptions.model` 属于适配器,可以动态解析。覆盖 `providerRetryPolicy()` 以提供由提供方持有的恢复配置,覆盖 `providerInfo()` 和异步 `listModels()` 以公开 selector 元数据;精确身份、容量或可选推理强度可用时,实现 `resolveModel()`;异步解析器必须响应其可选的取消 signal。默认实现使用有界的 normal 重试策略,将路由和模型 id 用作名称,不公布模型,也不返回容量或推理元数据。 - 包装 `llm/stream` 时,通过 `ctx.on()` waterfall listener 实现缓存、日志或路由。发出 chunk 后重试的包装层没有持久尝试边界;因此已发布 agent 重试策略改用 `agent/request-error`。 ### 内容块词汇(`types.ts`) @@ -76,7 +77,7 @@ ## 已知限制与暂缓事项 -- **本服务不内置默认重试/缓存/速率限制策略**:`llm/stream` 仍是单次尝试调用包装 seam;agent loop 会将已验证模型请求失败单独提供给 `agent/request-error`,其默认行为是保留原始失败。`@deepseek-ai/dsh-llm-retry` 是共享示例 spine 加载的可选策略插件。 +- **本服务不执行重试、缓存或速率限制**:提供方注册会存储重试策略,但 `llm/stream` 仍是单次尝试调用包装 seam。agent loop 会将已验证模型请求失败单独提供给 `agent/request-error`,其默认行为是保留原始失败;`@deepseek-ai/dsh-llm-retry` 是共享示例 spine 加载的可选执行器。 - **`GenerateOptions` 采样只包含 `temperature`/`maxTokens`/`stop`**:没有 `tool_choice`、`top_p` 或 penalty 字段;有产生方落地时词汇才会增长(见 [已删除惰性旋钮](../../../.agents/notes/archived/simplification/2026-07-04-drop-inert-request-knobs.md))。 - **由产生方调节的变体在实际产生前保持在外**:`prefill`、每工具 `strict`、块 `cache` 提示与 `agent` 消息源变体因没有产生方而被剪除(见 [Agent Note](../../../.agents/notes/archived/simplification/2026-07-04-prune-producerless-vocabulary-variants.md))。 - **`BlockAssembler` 只处理核心块 kind**:如果插件添加块类型的流从未由 `block-end` 关闭,`blocks()` 会抛出异常。 diff --git a/packages/llm/llm/package.json b/packages/llm/llm/package.json index cc13bc183e..d5b0298e50 100644 --- a/packages/llm/llm/package.json +++ b/packages/llm/llm/package.json @@ -38,11 +38,16 @@ "peerDependencies": { "@deepseek-ai/dsh-brand": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-timeout": "^0.0.1", "cordis": "^4.0.0-rc.7" }, + "dependencies": { + "schemastery": "^3.18.0" + }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/llm/llm/src/adapter-failure.ts b/packages/llm/llm/src/adapter-failure.ts index b583dc7125..e888d216a8 100644 --- a/packages/llm/llm/src/adapter-failure.ts +++ b/packages/llm/llm/src/adapter-failure.ts @@ -6,9 +6,15 @@ import { HarnessError } from './error.ts' import type { LlmFailure, StreamChunk } from './types.ts' +import type { ResolvedRetryPolicy } from './retry-policy.ts' -/** Errors and normalized facts proven to originate in one model call's final adapter boundary. */ -export type AdapterFailureScope = WeakMap +/** Call-local facts captured when one model call enters its final adapter boundary. */ +export interface AdapterFailureScope { + /** Errors and normalized facts proven to originate in this call's final adapter boundary. */ + readonly failures: WeakMap + /** Immutable policy of the exact adapter registration selected for this call. */ + retryPolicy?: ResolvedRetryPolicy +} /** Call-local failure scopes keyed by the exact stream handle returned to a consumer. */ const adapterFailureScopes = new WeakMap, AdapterFailureScope>() @@ -54,7 +60,7 @@ export function markLlmAdapterFailure( message: errorMessage(error), code: harnessErrorCode(error), }) - failures.set(error, failure) + failures.failures.set(error, failure) return error } @@ -136,7 +142,7 @@ export function isLlmAdapterFailure( value: unknown, ): value is Error & { code?: string } { const failures = adapterFailureScopes.get(stream) - return value instanceof Error && failures !== undefined && failures.has(value) + return value instanceof Error && failures !== undefined && failures.failures.has(value) } /** @@ -151,5 +157,18 @@ export function llmFailureOf( value: unknown, ): LlmFailure | undefined { const failures = adapterFailureScopes.get(stream) - return value instanceof Error ? failures?.get(value) : undefined + return value instanceof Error ? failures?.failures.get(value) : undefined +} + +/** + * Read the retry policy of the exact registration selected at this call's + * final adapter boundary. The policy remains available after that registration + * is disposed or replaced; absence means no final adapter served the call. + * @param stream - the exact stream returned by the model call. + * @returns the immutable serving-registration policy, or `undefined`. + */ +export function llmRetryPolicyOf( + stream: AsyncIterable, +): ResolvedRetryPolicy | undefined { + return adapterFailureScopes.get(stream)?.retryPolicy } diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 05c4ee87ac..103bab747f 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -16,6 +16,8 @@ import type { Message, StreamChunk, } from './types.ts' +import { resolveRetryPolicy } from './retry-policy.ts' +import type { ResolvedRetryPolicy } from './retry-policy.ts' import type { ProviderRequestId } from './brand.ts' import { callConfigEquals, deepFreeze } from './call-config.ts' import type { LlmCallConfig } from './call-config.ts' @@ -28,10 +30,11 @@ export * from './brand.ts' export * from './never.ts' export * from './error.ts' export * from './types.ts' +export * from './retry-policy.ts' export { BlockAssembler } from './assembler.ts' export { callConfigEquals, deepFreeze, isAgentLoopRequest, markAgentLoopRequest } from './call-config.ts' export type { LlmCallConfig } from './call-config.ts' -export { isLlmAdapterFailure, llmFailureOf } from './adapter-failure.ts' +export { isLlmAdapterFailure, llmFailureOf, llmRetryPolicyOf } from './adapter-failure.ts' declare module 'cordis' { interface Context { @@ -134,6 +137,15 @@ export abstract class LlmAdapter { return { id: provider, name: provider } } + /** + * 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 { + return undefined + } + /** * List models this adapter can currently advertise for one owned provider. * The result is advisory: an adapter may accept unlisted model ids, and @@ -204,7 +216,13 @@ export class LlmService extends Service { throw new LlmError(`adapter metadata for provider "${provider}" must preserve its id and have a non-empty name`, 'INVALID_ADAPTER') } unique.add(provider) - registrations.push({ adapter, provider: { id: info.id, name: info.name } }) + const retryPolicy = adapter.providerRetryPolicy(provider) + ?? resolveRetryPolicy(undefined, `llm: provider "${provider}" retryPolicy`) + registrations.push({ + adapter, + provider: { id: info.id, name: info.name }, + retryPolicy, + }) } for (const registration of registrations) this.adapters.set(registration.provider.id, registration) yield () => { @@ -224,6 +242,15 @@ export class LlmService extends Service { return [...this.adapters.values()].map(({ provider }) => ({ ...provider })) } + /** + * 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 { + return this.registration(provider).retryPolicy + } + /** * Discover models advertised by one registered provider. Catalog membership * is advisory and never changes routing or request validation. @@ -459,6 +486,7 @@ export class LlmService extends Service { let iterator: AsyncIterator try { const registration = prepared?.registration ?? this.registration(options.provider) + failures.retryPolicy = registration.retryPolicy const resolvedConfig = prepared === undefined ? await this.resolveCallConfigFor(registration, options, options.signal) : prepared.config @@ -530,7 +558,7 @@ export class LlmService extends Service { options: GenerateOptions, prepared?: { registration: AdapterRegistration; config: LlmCallConfig }, ): AsyncIterable { - const failures: AdapterFailureScope = new WeakMap() + const failures: AdapterFailureScope = { failures: new WeakMap() } const stream = this.ctx.waterfall( this, 'llm/stream', @@ -544,6 +572,7 @@ export class LlmService extends Service { interface AdapterRegistration { readonly adapter: LlmAdapter readonly provider: LlmProviderInfo + readonly retryPolicy: ResolvedRetryPolicy } export default LlmService diff --git a/packages/llm/llm/src/retry-policy.ts b/packages/llm/llm/src/retry-policy.ts new file mode 100644 index 0000000000..7d4c4601eb --- /dev/null +++ b/packages/llm/llm/src/retry-policy.ts @@ -0,0 +1,191 @@ +/** + * Provider-owned request-retry policy configuration and resolution. + * + * Adapters expose one resolved policy per registered provider route; the + * optional dsh-llm-retry plugin executes it on the agent's failed-step seam. + * + * @module @deepseek-ai/dsh-llm/retry-policy + */ + +import z from 'schemastery' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' +import { EMPTY_RESPONSE_CODE } from './error.ts' + +const DEFAULT_MAX_RETRIES = 2 +const DEFAULT_INITIAL_DELAY_MS = 500 +const DEFAULT_MAX_DELAY_MS = 10_000 +const DEFAULT_JITTER_RATIO = 0.1 +const DEFAULT_RETRYABLE_CODES = Object.freeze([ + EMPTY_RESPONSE_CODE, + 'RATE_LIMIT', + 'SERVER', + 'TIMEOUT', + 'TRANSPORT', +]) + +/** Bounded exponential backoff with symmetric jitter around each local delay. */ +export interface BackoffConfig { + /** Initial local exponential-backoff delay in milliseconds (default 500). */ + initialDelayMs?: number + /** Maximum locally scheduled or accepted provider delay in milliseconds (default 10000). */ + maxDelayMs?: number + /** Symmetric random multiplier range around one (default 0.1). */ + jitterRatio?: number +} + +/** Current bounded transient retry behavior for one provider route. */ +export interface NormalRetryPolicyConfig { + /** Retry only configured transient failure codes. */ + mode: 'normal' + /** Maximum eligible retries after the first request (default 2). */ + maxRetries?: number + /** Stable failure codes eligible for this policy. */ + retryableCodes?: string[] + /** Local exponential-backoff and jitter configuration. */ + backoff?: BackoffConfig +} + +/** Unbounded retry behavior for every model-request failure on one provider route. */ +export interface AlwaysRetryPolicyConfig { + /** Retry every model-request failure until success, cancellation, or disposal. */ + mode: 'always' + /** Local exponential-backoff and jitter configuration. */ + backoff?: BackoffConfig +} + +/** Provider-owned model-request retry policy configuration. */ +export type RetryPolicyConfig = NormalRetryPolicyConfig | AlwaysRetryPolicyConfig + +/** Fully resolved backoff shared by both retry modes. */ +export interface ResolvedRetryBackoff { + readonly initialDelayMs: number + readonly maxDelayMs: number + readonly jitterRatio: number +} + +/** Fully resolved bounded transient retry policy. */ +export interface ResolvedNormalRetryPolicy extends ResolvedRetryBackoff { + readonly mode: 'normal' + readonly maxRetries: number + readonly retryableCodes: readonly string[] +} + +/** Fully resolved unbounded retry policy. */ +export interface ResolvedAlwaysRetryPolicy extends ResolvedRetryBackoff { + readonly mode: 'always' +} + +/** Immutable provider policy captured when its adapter route is registered. */ +export type ResolvedRetryPolicy = ResolvedNormalRetryPolicy | ResolvedAlwaysRetryPolicy + +const backoffSchema: z = z.object({ + initialDelayMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_INITIAL_DELAY_MS), + maxDelayMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_MAX_DELAY_MS), + jitterRatio: z.number().min(0).max(1).default(DEFAULT_JITTER_RATIO), +}) + +const normalPolicySchema: z = z.object({ + mode: z.const('normal').required(), + maxRetries: z.number().step(1).min(0).max(Number.MAX_SAFE_INTEGER).default(DEFAULT_MAX_RETRIES), + retryableCodes: z.array(z.string()).default([...DEFAULT_RETRYABLE_CODES]), + backoff: backoffSchema, +}) + +const alwaysPolicySchema: z = z.object({ + mode: z.const('always').required(), + backoff: backoffSchema, +}) + +/** Cordis schema embedded by each concrete provider configuration. */ +export const RetryPolicySchema: z = z.union([ + normalPolicySchema, + alwaysPolicySchema, +]) + +const NORMAL_POLICY_KEYS: ReadonlySet = new Set([ + 'mode', 'maxRetries', 'retryableCodes', 'backoff', +]) +const ALWAYS_POLICY_KEYS: ReadonlySet = new Set(['mode', 'backoff']) +const BACKOFF_KEYS: ReadonlySet = new Set(['initialDelayMs', 'maxDelayMs', 'jitterRatio']) + +function validateKeys(value: object, allowed: ReadonlySet, path: string): void { + for (const key of Object.keys(value)) { + if (!allowed.has(key)) throw new Error(`${path}: unknown key "${key}"`) + } +} + +function resolveBackoff(config: BackoffConfig | undefined, path: string): ResolvedRetryBackoff { + if (config !== undefined) validateKeys(config, BACKOFF_KEYS, path) + const initialDelayMs = config?.initialDelayMs ?? DEFAULT_INITIAL_DELAY_MS + const maxDelayMs = config?.maxDelayMs ?? DEFAULT_MAX_DELAY_MS + const jitterRatio = config?.jitterRatio ?? DEFAULT_JITTER_RATIO + + if (!Number.isFinite(initialDelayMs) || initialDelayMs <= 0 || initialDelayMs > MAX_TIMER_DELAY_MS) { + throw new Error(`${path}.initialDelayMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`) + } + if (!Number.isFinite(maxDelayMs) || maxDelayMs <= 0 || maxDelayMs > MAX_TIMER_DELAY_MS) { + throw new Error(`${path}.maxDelayMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`) + } + if (initialDelayMs > maxDelayMs) { + throw new Error(`${path}.initialDelayMs must be less than or equal to maxDelayMs`) + } + if (!Number.isFinite(jitterRatio) || jitterRatio < 0 || jitterRatio > 1) { + throw new Error(`${path}.jitterRatio must be between 0 and 1`) + } + + return Object.freeze({ initialDelayMs, maxDelayMs, jitterRatio }) +} + +/** + * Validate, default, and detach one provider-owned retry policy. + * @param config - optional provider configuration; omission selects normal defaults. + * @param path - diagnostic path naming the provider config that owns the value. + * @returns an immutable policy safe to capture in provider registration state. + */ +export function resolveRetryPolicy( + config: RetryPolicyConfig | undefined, + path: string, +): ResolvedRetryPolicy { + if (config === undefined) { + return Object.freeze({ + mode: 'normal', + maxRetries: DEFAULT_MAX_RETRIES, + retryableCodes: DEFAULT_RETRYABLE_CODES, + ...resolveBackoff(undefined, `${path}.backoff`), + }) + } + + switch (config.mode) { + case 'normal': { + validateKeys(config, NORMAL_POLICY_KEYS, path) + const maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES + const retryableCodes = config.retryableCodes ?? [...DEFAULT_RETRYABLE_CODES] + if (!Number.isSafeInteger(maxRetries) || maxRetries < 0) { + throw new Error(`${path}.maxRetries must be a non-negative safe integer`) + } + if (retryableCodes.length === 0) { + throw new Error(`${path}.retryableCodes must not be empty`) + } + if (retryableCodes.some(code => typeof code !== 'string' || code.length === 0)) { + throw new Error(`${path}.retryableCodes must contain only non-empty strings`) + } + if (new Set(retryableCodes).size !== retryableCodes.length) { + throw new Error(`${path}.retryableCodes must not contain duplicates`) + } + return Object.freeze({ + mode: 'normal', + maxRetries, + retryableCodes: Object.freeze([...retryableCodes]), + ...resolveBackoff(config.backoff, `${path}.backoff`), + }) + } + case 'always': + validateKeys(config, ALWAYS_POLICY_KEYS, path) + return Object.freeze({ + mode: 'always', + ...resolveBackoff(config.backoff, `${path}.backoff`), + }) + default: + throw new Error(`${path}.mode must be "normal" or "always"`) + } +} diff --git a/packages/llm/llm/tests/retry-policy.spec.ts b/packages/llm/llm/tests/retry-policy.spec.ts new file mode 100644 index 0000000000..1860625147 --- /dev/null +++ b/packages/llm/llm/tests/retry-policy.spec.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest' +import { + resolveRetryPolicy, + RetryPolicySchema, +} from '@deepseek-ai/dsh-llm' +import type { RetryPolicyConfig } from '@deepseek-ai/dsh-llm' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' + +describe('provider retry policy', () => { + it('resolves immutable normal defaults', () => { + const policy = resolveRetryPolicy(undefined, 'provider.retryPolicy') + + expect(policy).toEqual({ + mode: 'normal', + maxRetries: 2, + retryableCodes: ['EMPTY_RESPONSE', 'RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT'], + initialDelayMs: 500, + maxDelayMs: 10_000, + jitterRatio: 0.1, + }) + expect(Object.isFrozen(policy)).toBe(true) + if (policy.mode !== 'normal') throw new Error('expected normal policy') + expect(Object.isFrozen(policy.retryableCodes)).toBe(true) + }) + + it('resolves and detaches a configured normal policy', () => { + const retryableCodes = ['BUSY'] + const config: RetryPolicyConfig = { + mode: 'normal', + maxRetries: 4, + retryableCodes, + backoff: { + initialDelayMs: 25, + maxDelayMs: 100, + jitterRatio: 0, + }, + } + + const policy = resolveRetryPolicy(config, 'provider.retryPolicy') + retryableCodes.push('LATE') + + expect(policy).toEqual({ + mode: 'normal', + maxRetries: 4, + retryableCodes: ['BUSY'], + initialDelayMs: 25, + maxDelayMs: 100, + jitterRatio: 0, + }) + }) + + it('resolves always mode with default backoff', () => { + expect(resolveRetryPolicy({ mode: 'always' }, 'provider.retryPolicy')).toEqual({ + mode: 'always', + initialDelayMs: 500, + maxDelayMs: 10_000, + jitterRatio: 0.1, + }) + expect(RetryPolicySchema).toBeDefined() + }) + + it.each([ + [{ mode: 'normal', maxRetries: -1 }, /maxRetries/], + [{ mode: 'normal', maxRetries: 1.5 }, /maxRetries/], + [{ mode: 'normal', maxRetries: Number.MAX_SAFE_INTEGER + 1 }, /maxRetries/], + [{ mode: 'always', backoff: { initialDelayMs: 0 } }, /initialDelayMs/], + [{ mode: 'normal', backoff: { maxDelayMs: Number.POSITIVE_INFINITY } }, /maxDelayMs/], + [{ mode: 'normal', backoff: { initialDelayMs: MAX_TIMER_DELAY_MS + 1 } }, /initialDelayMs/], + [{ mode: 'always', backoff: { maxDelayMs: MAX_TIMER_DELAY_MS + 1 } }, /maxDelayMs/], + [{ mode: 'normal', backoff: { initialDelayMs: 20, maxDelayMs: 10 } }, /less than or equal/], + [{ mode: 'always', backoff: { jitterRatio: 1.1 } }, /jitterRatio/], + [{ mode: 'normal', retryableCodes: [] }, /must not be empty/], + [{ mode: 'normal', retryableCodes: ['SERVER', 'SERVER'] }, /duplicates/], + [{ mode: 'normal', retryableCodes: [''] }, /non-empty strings/], + [{ mode: 'normal', retryableCodes: [429] }, /non-empty strings/], + [{ mode: 'normal', maxRetires: 1 }, /unknown key "maxRetires"/], + [{ mode: 'always', maxRetries: 1 }, /unknown key "maxRetries"/], + [{ mode: 'always', backoff: { initialDelay: 1 } }, /unknown key "initialDelay"/], + [{ mode: 'sometimes' }, /mode must be "normal" or "always"/], + ] as const)('rejects invalid policy %#', (config, message) => { + expect(() => { + resolveRetryPolicy(config as unknown as RetryPolicyConfig, 'provider.retryPolicy') + }).toThrow(message) + }) +}) diff --git a/packages/llm/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts index 686bab1c17..dc4cf1d9c0 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -10,8 +10,10 @@ import LlmService, { LlmAdapter, LlmError, llmFailureOf, + llmRetryPolicyOf, ProviderRequestId, ReasoningEffortId, + resolveRetryPolicy, StreamChunk, } from '@deepseek-ai/dsh-llm' import type { @@ -173,6 +175,72 @@ describe('LlmService', () => { expect(chunks).toEqual(SCRIPT) }) + it('captures provider-owned retry policy at registration and defaults omission', async () => { + const configured = resolveRetryPolicy({ mode: 'always' }, 'test retryPolicy') + const adapter = new class extends ScriptedAdapter { + override providerRetryPolicy(provider: string) { + return provider === 'configured' ? configured : undefined + } + }(SCRIPT) + const ctx = new Context() + await ctx.plugin(LlmService) + ctx.llm.registerAdapter(['configured', 'defaulted'], adapter) + + expect(ctx.llm.providerRetryPolicy('configured')).toBe(configured) + expect(ctx.llm.providerRetryPolicy('defaulted')).toMatchObject({ + mode: 'normal', + maxRetries: 2, + }) + expect(() => ctx.llm.providerRetryPolicy('missing')).toThrow( + expect.objectContaining({ code: 'NO_ADAPTER' }), + ) + }) + + it('keeps the serving registration policy on an in-flight call after route replacement', async () => { + const oldPolicy = resolveRetryPolicy({ mode: 'always' }, 'old retryPolicy') + const newPolicy = resolveRetryPolicy({ mode: 'normal', maxRetries: 0 }, 'new retryPolicy') + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + const failure = new LlmError('old route failed', 'AUTH') + const oldAdapter = new class extends LlmAdapter { + override providerRetryPolicy(): typeof oldPolicy { + return oldPolicy + } + + async * stream(_options: GenerateOptions): AsyncIterable { + entered.resolve(undefined) + await release.promise + throw failure + } + }() + const newAdapter = new class extends ScriptedAdapter { + override providerRetryPolicy(): typeof newPolicy { + return newPolicy + } + }(SCRIPT) + const ctx = new Context() + await ctx.plugin(LlmService) + const disposeOld = ctx.llm.registerAdapter(['route'], oldAdapter) + const stream = ctx.llm.stream({ provider: 'route', model: 'model', messages: [] }) + const outcome = (async (): Promise => { + try { + for await (const _chunk of stream) { /* drain */ } + } catch (error: unknown) { + return error + } + return undefined + })() + await entered.promise + + disposeOld() + ctx.llm.registerAdapter(['route'], newAdapter) + release.resolve(undefined) + + expect(await outcome).toBe(failure) + expect(llmRetryPolicyOf(stream)).toBe(oldPolicy) + expect(ctx.llm.providerRetryPolicy('route')).toBe(newPolicy) + }) + it('throws NO_ADAPTER for unregistered providers', async () => { const ctx = new Context() await ctx.plugin(LlmService) @@ -187,6 +255,7 @@ describe('LlmService', () => { expect((caught as LlmError).code).toBe('NO_ADAPTER') expect((caught as LlmError).message).toContain('no adapter registered') expect(isLlmAdapterFailure(stream, caught)).toBe(true) + expect(llmRetryPolicyOf(stream)).toBeUndefined() }) it.each(['done', 'value'] as const)('tags a throwing IteratorResult.%s getter without replacing its Error', async (field) => { diff --git a/packages/llm/llm/tsconfig.json b/packages/llm/llm/tsconfig.json index 5bc7a9fcf5..fa4adda095 100644 --- a/packages/llm/llm/tsconfig.json +++ b/packages/llm/llm/tsconfig.json @@ -19,6 +19,9 @@ }, { "path": "../../support/invariants" + }, + { + "path": "../../util/timeout" } ] } diff --git a/packages/plan/plan-mode/src/index.ts b/packages/plan/plan-mode/src/index.ts index 405b93b5e0..d851e74e0a 100644 --- a/packages/plan/plan-mode/src/index.ts +++ b/packages/plan/plan-mode/src/index.ts @@ -8,9 +8,9 @@ * * The state in force is folded from the session log (`plan/mode`, last one * wins), so resume and fork restore it without a live mirror. User selections - * are held as pending intent until a turn boundary because every session event - * is turn-enclosed. The service flushes before the affected request assembly - * on prompt submission and each request step (including retry turns). + * are held as pending intent until an in-turn request boundary because every + * session event is turn-enclosed. The service flushes at `agent/step` before + * the affected request assembly, including retry turns. * * The exit tool remains registered while plan mode is inactive so crossing a * boundary changes only the prompt section, not the request tool catalog. @@ -145,9 +145,9 @@ export class PlanModeService extends Service { private readonly section: string /** - * Latest selection per session awaiting a turn-boundary flush. `narrate` is - * true for user selections and false for the exit tool, whose result already - * narrates the transition. + * Latest selection per session awaiting an in-turn request-boundary flush. + * `narrate` is true for user selections and false for the exit tool, whose + * result already narrates the transition. */ private readonly pendingIntents = new WeakMap() @@ -298,7 +298,7 @@ export class PlanModeService extends Service { } /** - * 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. diff --git a/packages/plan/plan-mode/tests/integration.spec.ts b/packages/plan/plan-mode/tests/integration.spec.ts index 79f8710795..5e63d7df6e 100644 --- a/packages/plan/plan-mode/tests/integration.spec.ts +++ b/packages/plan/plan-mode/tests/integration.spec.ts @@ -13,7 +13,7 @@ const PLAN_CONFIG = { section: 'Test plan mode instructions.' } /** * Full-loop integration: a scripted mock model drives the REAL plan-mode plugin - * through the agent loop — the pending-intent flush at the turn boundary, the + * through the agent loop — the pending-intent flush at the request boundary, the * assembly the soft layer shapes (the exit tool + mode section), and the * `request/header` snapshots every transition leaves. * Only the model is mocked; the loop, the session log, and the plugin are @@ -72,7 +72,7 @@ describe('plan mode through the agent loop', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('it-plan-seed'), { provider: 'mock', model: 'mock' }) // Selected while idle: the pending intent flushes at the first - // prompt-submit, BEFORE the first assembly. + // in-turn agent/step seam, before the first assembly. ctx.planMode.set(agent, true) agent.followup({ content: [{ type: 'text', text: 'explore the repo' }], source: { kind: 'user' } }) @@ -136,7 +136,9 @@ describe('plan mode through the agent loop', () => { const adapter = new MockAdapter([failedRequest, textResponse('Recovered in plan mode.')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('it-plan-retry-flip'), { provider: 'mock', model: 'mock' }) - ctx.on('agent/request-error', async (subject, _turn, _step, _error, _failure, _signal, next) => { + ctx.on('agent/request-error', async ( + subject, _turn, _step, _error, _failure, _priorFailures, _retryPolicy, _signal, next, + ) => { if (subject !== agent) return next() ctx.planMode.set(agent, true) return { kind: 'retry' } diff --git a/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts b/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts index 9fcfe23de8..a032e1add7 100644 --- a/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts +++ b/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts @@ -26,6 +26,7 @@ const WORKSPACE_CLOSURE = [ 'packages/sandbox/sandbox', 'packages/llm/llm', 'packages/util/brand', + 'packages/util/timeout', 'packages/support/invariants', ] diff --git a/packages/sdk/helper/src/features/builtin/app.ts b/packages/sdk/helper/src/features/builtin/app.ts index ed4050bf11..88c07a0853 100644 --- a/packages/sdk/helper/src/features/builtin/app.ts +++ b/packages/sdk/helper/src/features/builtin/app.ts @@ -18,6 +18,7 @@ import { } from '../feature.ts' import { ProjectContribution, type ProjectResource } from '../resources.ts' import { + cordisConfigEntry, npmCordisConfigEntry, optionalString, ownedTextFile, @@ -86,6 +87,10 @@ class AppOption extends FeatureOption { id: 'user-interaction', name: '@deepseek-ai/dsh-user-interaction', }), + cordisConfigEntry(ID, { + id: 'tui-prompt', + name: '@deepseek-ai/dsh-tui/prompt', + }), ...npmCordisConfigEntry(ID, { id: 'tui', name: '@deepseek-ai/dsh-tui', diff --git a/packages/sdk/helper/tests/project.spec.ts b/packages/sdk/helper/tests/project.spec.ts index f1216d2768..3a8c3b3158 100644 --- a/packages/sdk/helper/tests/project.spec.ts +++ b/packages/sdk/helper/tests/project.spec.ts @@ -187,6 +187,7 @@ describe('SdkProject and ProjectEditSession', () => { expect(await readFile(join(project.root, 'cordis.yml'), 'utf8')) .toContain('sessionId: !!js process.env.DSH_SDK_SESSION_ID') expect(project.cordis.entry('tui')?.config).not.toHaveProperty('model') + expect(project.cordis.entry('tui-prompt')?.name).toBe('@deepseek-ai/dsh-tui/prompt') expect(project.cordis.entry('agent-loop')?.config).toEqual({ agents: [] }) expect(project.cordis.entry('session-invariant')?.name).toBe('@deepseek-ai/dsh-session/invariant') expect(project.cordis.entry('agent-invariant')?.name).toBe('@deepseek-ai/dsh-agent/invariant') diff --git a/packages/skill/skill-local/src/index.ts b/packages/skill/skill-local/src/index.ts index 2c5e480e2a..02478a7244 100644 --- a/packages/skill/skill-local/src/index.ts +++ b/packages/skill/skill-local/src/index.ts @@ -32,6 +32,7 @@ const PROJECT_AGENTS_RANK = 200 const CUSTOM_RANK = 300 const USER_DSH_RANK = 400 const USER_AGENTS_RANK = 500 +const BUNDLED_RANK = 600 export const name = 'skill-local' export const inject = ['skills'] @@ -44,12 +45,15 @@ 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 } export const Config: Schema = z.object({ dshHome: z.string(), agentsHome: z.string(), customSkillDirs: z.array(z.string()).default([]), + bundledSkillDir: z.string(), }) interface SkillRoot { @@ -57,6 +61,7 @@ interface SkillRoot { source: SkillSource rank: number skipSystem?: boolean + trustedHost?: boolean } interface SkillRootEntry { @@ -91,11 +96,14 @@ export class LocalSkillProvider implements SkillProvider { private readonly dshHome: string private readonly agentsHome: string private readonly customSkillDirs: string[] + private readonly bundledSkillDir: string | undefined constructor(private readonly ctx: Context, config: Config = {}) { this.dshHome = resolveDshHome(config.dshHome) this.agentsHome = resolve(config.agentsHome ?? process.env.DSH_AGENTS_HOME ?? join(homedir(), '.agents')) this.customSkillDirs = (config.customSkillDirs ?? []).map(root => resolve(root)) + const bundledSkillDir = config.bundledSkillDir ?? process.env.DSH_BUNDLED_SKILL_DIR + this.bundledSkillDir = bundledSkillDir === undefined ? undefined : resolve(bundledSkillDir) } /** @@ -122,7 +130,7 @@ export class LocalSkillProvider implements SkillProvider { */ async get(candidate: SkillCandidate, options: SkillLookupOptions): Promise { const locator = candidate.locator as LocalLocator - const parsed = await parseSkillFile(locator.path, this.ctx, options.signal) + const parsed = await parseSkillFile(locator.path, this.ctx, options.signal, candidate.source === 'bundled') if (parsed === undefined) return undefined return { name: parsed.name, @@ -151,6 +159,9 @@ export class LocalSkillProvider implements SkillProvider { ...this.customSkillDirs.map(path => ({ path, source: 'custom' as const, rank: CUSTOM_RANK })), { path: join(this.dshHome, 'skills'), source: 'user-dsh', rank: USER_DSH_RANK, skipSystem: true }, { path: join(this.agentsHome, 'skills'), source: 'user-agents', rank: USER_AGENTS_RANK }, + ...this.bundledSkillDir === undefined + ? [] + : [{ path: this.bundledSkillDir, source: 'bundled' as const, rank: BUNDLED_RANK, trustedHost: true }], ) return roots } @@ -167,7 +178,7 @@ async function discoverRoot(root: SkillRoot, ctx: Context): Promise { const fs = optionalFileSystem(ctx) - if (fs !== undefined) return await listSkillRootEntriesFromFileSystem(root, fs) + if (fs !== undefined && root.trustedHost !== true) return await listSkillRootEntriesFromFileSystem(root, fs) return await listSkillRootEntriesFromNode(root, ctx) } @@ -225,8 +236,8 @@ async function listSkillRootEntriesFromNode(root: SkillRoot, ctx: Context): Prom return result } -async function parseSkillFile(path: string, ctx: Context, signal?: AbortSignal): Promise { - const raw = await readSkillText(ctx, path, signal) +async function parseSkillFile(path: string, ctx: Context, signal?: AbortSignal, trustedHost = false): Promise { + const raw = await readSkillText(ctx, path, signal, trustedHost) signal?.throwIfAborted() if (raw === undefined) { return undefined @@ -266,10 +277,10 @@ function optionalFileSystem(ctx: Context): FileSystem | undefined { return ctx.get('fs') } -async function readSkillText(ctx: Context, path: string, signal?: AbortSignal): Promise { +async function readSkillText(ctx: Context, path: string, signal?: AbortSignal, trustedHost = false): Promise { signal?.throwIfAborted() const fs = optionalFileSystem(ctx) - if (fs !== undefined) { + if (fs !== undefined && !trustedHost) { return await readSkillTextFromFileSystem(ctx, fs, path, signal) } try { diff --git a/packages/skill/skill-local/tests/skill-local.spec.ts b/packages/skill/skill-local/tests/skill-local.spec.ts index 0c7d473b13..fc3f83f955 100644 --- a/packages/skill/skill-local/tests/skill-local.spec.ts +++ b/packages/skill/skill-local/tests/skill-local.spec.ts @@ -149,15 +149,23 @@ describe('LocalSkillProvider', () => { await writeSkill(custom, 'custom-only', 'custom only') await writeSkill(join(home, '.dsh/skills/.system'), 'hidden-system', 'hidden system') - const ctx = await setupLocal(home, { customSkillDirs: [custom] }) + const bundled = await tempDir('skill-bundled') + await writeSkill(bundled, 'bundled-only', 'bundled skill') + await writeSkill(bundled, 'same', 'bundled skill') + const ctx = await setupLocal(home, { customSkillDirs: [custom], bundledSkillDir: bundled }) const skills = await ctx.skills.list({ cwd: join(project, 'src') }) - expect(skills.map(skill => [skill.name, skill.description])).toEqual([ - ['custom-only', 'custom only'], - ['same', 'project dsh skill'], + expect(skills.map(skill => skill.name)).toEqual([ + 'bundled-only', + 'custom-only', + 'same', ]) + expect(skills.find(skill => skill.name === 'custom-only')?.description).toBe('custom only') + expect(skills.find(skill => skill.name === 'same')?.description).toBe('project dsh skill') expect(skills.find(skill => skill.name === 'same')?.source).toBe('project-dsh') expect(skills.find(skill => skill.name === 'hidden-system')).toBeUndefined() + expect(skills.find(skill => skill.name === 'bundled-only')).toMatchObject({ source: 'bundled' }) + expect((await ctx.skills.get('bundled-only'))?.content).toBe('Use the skill.') const noGit = await tempDir('skill-no-git') await writeSkill(join(noGit, '.dsh/skills'), 'fallback-root', 'Fallback root') @@ -335,6 +343,20 @@ describe('LocalSkillProvider', () => { ]) expect(fs.listDirCalls).toBeGreaterThan(0) expect(await ctx.skills.get('binary-skill')).toBeUndefined() + + const bundled = await tempDir('skill-backend-bundled') + await writeSkill(bundled, 'bundled-host', 'Bundled host skill') + const bundledCtx = new Context() + await bundledCtx.plugin(TestFileSystem) + const bundledFs = bundledCtx.fs as TestFileSystem + bundledFs.failResolvePaths.add(bundled) + await bundledCtx.plugin(SkillService) + await bundledCtx.plugin(SkillLocal, { + dshHome: join(home, '.dsh'), + agentsHome: join(home, '.agents'), + bundledSkillDir: bundled, + }) + expect((await bundledCtx.skills.get('bundled-host'))?.source).toBe('bundled') }) it('forwards cancellation to filesystem reads while loading a skill', async () => { @@ -375,17 +397,22 @@ describe('LocalSkillProvider', () => { it('uses default home root resolution without exposing builtin skills', async () => { const previousDshHome = process.env.DSH_HOME const previousAgentsHome = process.env.DSH_AGENTS_HOME + const previousBundledSkillDir = process.env.DSH_BUNDLED_SKILL_DIR const envHome = await tempDir('skill-env-home') try { process.env.DSH_HOME = join(envHome, '.dsh') process.env.DSH_AGENTS_HOME = join(envHome, '.agents') + const bundled = join(envHome, 'bundled-skills') + process.env.DSH_BUNDLED_SKILL_DIR = bundled await writeSkill(join(envHome, '.dsh/skills'), 'env-skill', 'Env skill') + await writeSkill(bundled, 'env-bundled-skill', 'Env bundled skill') const ctx = new Context() await ctx.plugin(SkillService) await ctx.plugin(SkillLocal) - expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['env-skill']) + expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['env-bundled-skill', 'env-skill']) process.env.DSH_HOME = join(envHome, 'empty-dsh') + delete process.env.DSH_BUNDLED_SKILL_DIR process.env.DSH_AGENTS_HOME = join(envHome, 'empty-agents') const empty = new Context() await empty.plugin(SkillService) @@ -405,6 +432,11 @@ describe('LocalSkillProvider', () => { } else { process.env.DSH_AGENTS_HOME = previousAgentsHome } + if (previousBundledSkillDir === undefined) { + delete process.env.DSH_BUNDLED_SKILL_DIR + } else { + process.env.DSH_BUNDLED_SKILL_DIR = previousBundledSkillDir + } } }) }) diff --git a/packages/skill/skill/src/index.ts b/packages/skill/skill/src/index.ts index f736fc8d0f..d0b547ed8b 100644 --- a/packages/skill/skill/src/index.ts +++ b/packages/skill/skill/src/index.ts @@ -28,7 +28,7 @@ export function isSkillName(name: string): boolean { } /** Origin bucket for a skill contribution. The value is prompt-visible metadata, not precedence by itself. */ -export type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | 'user-agents' | 'custom' | (string & {}) +export type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | 'user-agents' | 'custom' | 'bundled' | (string & {}) /** Optional provider-specific base used by loaded skill bodies to resolve relative resources. */ export type SkillResourceBase = diff --git a/packages/support/llm-replay/README.i18n.yaml b/packages/support/llm-replay/README.i18n.yaml index 7ce4a5ee56..d0c335ee24 100644 --- a/packages/support/llm-replay/README.i18n.yaml +++ b/packages/support/llm-replay/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: ce0758641f3d49a54b29415ed449e43043840f9a -README.zh.md: 47a2b9aa211b44c4e476a1adf5a9a72d927cd0ed +# pnpm run verify-translation-pairing --write packages/support/llm-replay/README.md +README.md: 6c934e01a5f13b94724d0e5435524d9d463adb2f +README.zh.md: 03309931d01957ef60aa65d5d9ba3c2a844b0a16 diff --git a/packages/support/llm-replay/README.md b/packages/support/llm-replay/README.md index ce0758641f..6c934e01a5 100644 --- a/packages/support/llm-replay/README.md +++ b/packages/support/llm-replay/README.md @@ -25,7 +25,7 @@ Replay keys every call by its calling session id (`GenerateOptions.sessionId`, s | `file` | string | `$DSH_SNAPSHOT_FILE` | Path to the primary (parent) `session.jsonl` fixture. Required (config or env). | | `overrideFile` | string | `$DSH_SNAPSHOT_OVERRIDE` | Optional `ReplayOverrideDoc` sidecar for the primary session: a bare `ReplayEntry[]` replaces its derived script, while `{ patches }` augments it by call index. | | `childFiles` | string[] | `$DSH_SNAPSHOT_CHILD_FILES` (path-delimited) | Recorded subagent child-session logs for a nested scenario; empty for a single-session scenario. | -| `providers` | `ReplayProviderConfig[]` | — | Optional replay-only provider and model catalog. Each model may publish `contextWindow`; configured routes dispatch through the replay adapter and never perform provider I/O. | +| `providers` | `ReplayProviderConfig[]` | — | Optional replay-only provider and model catalog. Each provider may set `retryPolicy`, and each model may publish `contextWindow`; configured routes dispatch through the replay adapter and never perform provider I/O. | | `paceMs` | number | — (burst) | Optional per-chunk delay in ms so downstream transports (e.g. the web SSE mux observed by a real browser) see genuinely incremental delivery. A realism knob only — tests must not depend on it for correctness. Non-negative integer; abort during a pace wait cancels the stream promptly. | ```yaml @@ -35,6 +35,12 @@ Replay keys every call by its calling session id (`GenerateOptions.sessionId`, s providers: - id: deepseek name: DeepSeek + retryPolicy: + mode: normal + backoff: + initialDelayMs: 1 + maxDelayMs: 1 + jitterRatio: 0 models: - id: deepseek-v4-flash contextWindow: 128000 diff --git a/packages/support/llm-replay/README.zh.md b/packages/support/llm-replay/README.zh.md index 47a2b9aa21..03309931d0 100644 --- a/packages/support/llm-replay/README.zh.md +++ b/packages/support/llm-replay/README.zh.md @@ -25,7 +25,7 @@ Fixture 就是持久化会话日志(`/session.jsonl`)。其 `assis | `file` | string | `$DSH_SNAPSHOT_FILE` | 主(父)`session.jsonl` fixture 的路径。必需(配置或 env)。 | | `overrideFile` | string | `$DSH_SNAPSHOT_OVERRIDE` | 主会话的可选 `ReplayOverrideDoc` sidecar:裸 `ReplayEntry[]` 替换其派生脚本,`{ patches }` 则按调用索引增补该脚本。 | | `childFiles` | string[] | `$DSH_SNAPSHOT_CHILD_FILES` (path-delimited) | 嵌套场景中已记录的 subagent 子会话日志;单会话场景为空。 | -| `providers` | `ReplayProviderConfig[]` | 无 | 可选的仅回放提供方和模型目录。每个模型可以发布 `contextWindow`;已配置路由通过回放适配器分派,绝不执行提供方 I/O。 | +| `providers` | `ReplayProviderConfig[]` | 无 | 可选的仅回放提供方和模型目录。每个提供方可以设置 `retryPolicy`,每个模型可以发布 `contextWindow`;已配置路由通过回放适配器分派,绝不执行提供方 I/O。 | | `paceMs` | number | 无(突发) | 可选的每分片毫秒延迟,使下游传输(例如真实浏览器观察的 web SSE mux)看到真正的增量传递。它只是仿真开关,测试不得依赖它保证正确性。值必须是非负整数;pace 等待期间中止会迅速取消流。 | ```yaml @@ -35,6 +35,12 @@ Fixture 就是持久化会话日志(`/session.jsonl`)。其 `assis providers: - id: deepseek name: DeepSeek + retryPolicy: + mode: normal + backoff: + initialDelayMs: 1 + maxDelayMs: 1 + jitterRatio: 0 models: - id: deepseek-v4-flash contextWindow: 128000 diff --git a/packages/support/llm-replay/src/index.ts b/packages/support/llm-replay/src/index.ts index 904379994d..bfb9859415 100644 --- a/packages/support/llm-replay/src/index.ts +++ b/packages/support/llm-replay/src/index.ts @@ -11,8 +11,16 @@ import { delimiter as pathDelimiter } from 'node:path' import type { Context } from 'cordis' import { decodeStorageRecord } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' -import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' -import { LlmAdapter, LlmError, assertNever } from '@deepseek-ai/dsh-llm' +import type { + GenerateOptions, + LlmModelInfo, + LlmProviderInfo, + LlmResolvedModelInfo, + ResolvedRetryPolicy, + RetryPolicyConfig, + StreamChunk, +} from '@deepseek-ai/dsh-llm' +import { LlmAdapter, LlmError, assertNever, resolveRetryPolicy } from '@deepseek-ai/dsh-llm' /** * One recorded model call. `throw` may replay prefix chunks before failing; @@ -48,6 +56,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 } /** Resolved plugin configuration. */ @@ -414,6 +424,15 @@ class ReplayAdapter extends LlmAdapter { return { id: provider, name: configured.name ?? provider } } + override providerRetryPolicy(provider: string): ResolvedRetryPolicy | undefined { + const configured = this.providers.get(provider) + /* v8 ignore next -- LlmService only asks about routes registered from this same map. */ + if (configured === undefined) return super.providerRetryPolicy(provider) + return configured.retryPolicy === undefined + ? undefined + : resolveRetryPolicy(configured.retryPolicy, `llm-replay: provider "${provider}" retryPolicy`) + } + override listModels(provider: string): Promise { const configured = this.providers.get(provider) /* v8 ignore next -- LlmService only asks about routes registered from this same map. */ diff --git a/packages/support/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts index 85a0e2d719..d693c0571b 100644 --- a/packages/support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/support/llm-replay/tests/llm-replay.spec.ts @@ -320,6 +320,11 @@ describe('installLlmReplay (through the real LlmService)', () => { { id: 'deepseek', name: 'DeepSeek', + retryPolicy: { + mode: 'normal', + maxRetries: 2, + backoff: { initialDelayMs: 1, maxDelayMs: 1, jitterRatio: 0 }, + }, models: [ { id: 'flash', contextWindow: 128_000 }, { id: 'pro', name: 'Pro', description: 'Larger model' }, @@ -344,12 +349,39 @@ describe('installLlmReplay (through the real LlmService)', () => { await expect(ctx.llm.resolveModelInfo('deepseek', 'pro')).resolves.not.toHaveProperty('context') await expect(ctx.llm.resolveModelInfo('deepseek', 'unlisted')).resolves.not.toHaveProperty('context') await expect(ctx.llm.resolveModelInfo('empty', 'unlisted')).resolves.not.toHaveProperty('context') + expect(ctx.llm.providerRetryPolicy('deepseek')).toMatchObject({ + mode: 'normal', + maxRetries: 2, + initialDelayMs: 1, + maxDelayMs: 1, + jitterRatio: 0, + }) + expect(ctx.llm.providerRetryPolicy('empty')).toMatchObject({ + mode: 'normal', + maxRetries: 2, + initialDelayMs: 500, + maxDelayMs: 10_000, + jitterRatio: 0.1, + }) expect(await drain(ctx.llm.stream({ provider: 'deepseek', model: 'pro', messages: [] }))).toEqual(TEXT_CHUNKS) dispose() expect(ctx.llm.listProviders()).toEqual([]) }) + it('rejects an invalid replay-provider retry policy during registration', async () => { + writeLog(TEXT_CHUNKS) + const ctx = new Context() + await ctx.plugin(LlmService) + + expect(() => { + installLlmReplay(ctx, { + file, + providers: [{ id: 'deepseek', retryPolicy: { mode: 'normal', maxRetries: -1 } }], + }) + }).toThrow(/llm-replay: provider "deepseek" retryPolicy\.maxRetries/) + }) + it('serves the Nth call the Nth derived entry (positional)', async () => { const second: StreamChunk[] = [ { type: 'block-start', index: 0, blockType: 'text' }, diff --git a/packages/ui/tui/README.i18n.yaml b/packages/ui/tui/README.i18n.yaml index a11958da85..7bf2eead46 100644 --- a/packages/ui/tui/README.i18n.yaml +++ b/packages/ui/tui/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/ui/tui/README.md -README.md: 84ed3de78469ab778118ee5599acfd8476b0ecd1 -README.zh.md: 771b89a91077db7543713b4ce1b5fce0c30c2a16 +README.md: 528daef773635451ceb198ab3231c2dd87cb9413 +README.zh.md: ed19023334389e3f64b8a2f3821307f1540876f2 diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 84ed3de784..528daef773 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -74,7 +74,7 @@ Startup fails before mounting when either process stream is not a TTY. The compo ## Color -The palette uses the standard 16-color ANSI foregrounds and SGR attributes, which every terminal remaps to its active color scheme, so it stays readable on light and dark backgrounds alike. Body text keeps the terminal's default foreground rather than a fixed shade. Grouped regions (user prompts, tool cards) use a colored left-gutter bar instead of a filled background block; the question panel emphasizes its active row with bold accent text, while selectors use reverse video. These treatments are foreground-only, so they never collide with the terminal background. Set `color: false` to strip all styling. +The palette uses the standard 16-color ANSI foregrounds and SGR attributes, which every terminal remaps to its active color scheme, so it stays readable on light and dark backgrounds alike. Body text keeps the terminal's default foreground rather than a fixed shade. Grouped regions (user prompts, assistant replies, tool cards) are separated by a bold, underlined role header in the role color and blank-line spacing rather than a filled block or a per-line prefix, so a mouse drag-select copies the message text without any leading bar or indent; a tool card's status (pending, error, success) shows in its colored, underlined title glyph and title. The question panel emphasizes its active row with bold accent text, while selectors use reverse video. These treatments are foreground-only, so they never collide with the terminal background. Set `color: false` to strip all styling. ## Model Experience diff --git a/packages/ui/tui/README.zh.md b/packages/ui/tui/README.zh.md index 771b89a910..ed19023334 100644 --- a/packages/ui/tui/README.zh.md +++ b/packages/ui/tui/README.zh.md @@ -74,7 +74,7 @@ Footer 将会话报告的用量汇总为 `↑`;任 ## 颜色 -Palette 使用标准 16 色 ANSI 前景色和 SGR 属性,每个终端都会将其重新映射到当前配色方案,因此浅色与深色背景下都保持可读。正文使用终端默认前景色,而非固定色调。成组区域(用户提示词、工具卡片)使用彩色左侧 gutter bar,而非填充背景块;问题面板使用粗体强调色文本突出活跃行,选择器则使用反色。所有效果都只作用于前景色,因此不会与终端背景冲突。设置 `color: false` 可移除所有样式。 +Palette 使用标准 16 色 ANSI 前景色和 SGR 属性,每个终端都会将其重新映射到当前配色方案,因此浅色与深色背景下都保持可读。正文使用终端默认前景色,而非固定色调。成组区域(用户提示词、assistant 回复、工具卡片)通过以角色色渲染的粗体带下划线角色标题和空行分隔,而非填充背景块或逐行前缀,因此用鼠标框选复制时不会带上任何左侧竖条或缩进;工具卡片的状态(进行中、错误、成功)由其彩色带下划线的标题字形与标题体现。问题面板使用粗体强调色文本突出活跃行,选择器则使用反色。所有效果都只作用于前景色,因此不会与终端背景冲突。设置 `color: false` 可移除所有样式。 ## 模型体验 diff --git a/packages/ui/tui/package.json b/packages/ui/tui/package.json index 3242f5a2a3..f9260e3231 100644 --- a/packages/ui/tui/package.json +++ b/packages/ui/tui/package.json @@ -15,12 +15,17 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, + "./prompt": { + "types": "./lib/types/prompt.d.ts", + "default": "./lib/prompt.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", "lib/invariant.js", + "lib/prompt.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -35,9 +40,9 @@ "@deepseek-ai/dsh-llm-retry": "^0.0.1", "@deepseek-ai/dsh-goal": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-session-reference": "^0.0.1", "@deepseek-ai/dsh-session-persistence": "^0.0.1", "@deepseek-ai/dsh-session-query": "^0.0.1", + "@deepseek-ai/dsh-session-reference": "^0.0.1", "@deepseek-ai/dsh-session-title": "^0.0.1", "@deepseek-ai/dsh-skill": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", @@ -59,6 +64,7 @@ }, "dependencies": { "@earendil-works/pi-tui": "0.80.7", + "saxes": "6.0.0", "schemastery": "^3.18.0" }, "devDependencies": { @@ -71,9 +77,9 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-reference": "workspace:^", - "@deepseek-ai/dsh-session-query": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-session-query": "workspace:^", + "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", diff --git a/packages/ui/tui/src/chat/autocomplete.ts b/packages/ui/tui/src/chat/autocomplete.ts new file mode 100644 index 0000000000..d63ed3614d --- /dev/null +++ b/packages/ui/tui/src/chat/autocomplete.ts @@ -0,0 +1,95 @@ +/** + * Editor autocomplete provider merging path-only file candidates and optional + * session-reference snapshots with the base slash-command completions. + * @module @deepseek-ai/dsh-tui/chat/autocomplete + */ + +import { + CombinedAutocompleteProvider, + type AutocompleteItem, + type AutocompleteProvider, + type AutocompleteSuggestions, +} from '@earendil-works/pi-tui' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { + formatSessionReferenceMention, + type SessionReferenceService, +} from '@deepseek-ai/dsh-session-reference' +import { displayInlineText } from '../components/text.ts' +import { activeAtToken, formatFileMention, WorkspaceFileSearch } from './file-autocomplete.ts' + +/** Merge path-only file candidates and optional session snapshots with commands. */ +export class ReferenceAutocompleteProvider implements AutocompleteProvider { + constructor( + private readonly base: CombinedAutocompleteProvider, + private readonly files: WorkspaceFileSearch, + private readonly sessions: SessionReferenceService | undefined, + private readonly agent: Agent, + ) {} + + async getSuggestions( + lines: string[], + cursorLine: number, + cursorCol: number, + options: { signal: AbortSignal; force?: boolean }, + ): Promise { + const basePromise = this.base.getSuggestions(lines, cursorLine, cursorCol, options) + const currentLine = lines[cursorLine] + /* v8 ignore next -- Editor always supplies its current state line. */ + if (currentLine === undefined) return basePromise + const token = activeAtToken(currentLine, cursorCol) + if (token === undefined) { + this.files.invalidate() + return basePromise + } + const filePromise = this.files.list(token.query, options.signal).catch(() => []) + const sessionPromise = this.sessions === undefined || token.quoted + ? Promise.resolve([]) + : this.sessions.listCandidates(this.agent, token.query, undefined, options.signal).catch(() => []) + const [base, fileCandidates, sessionCandidates] = await Promise.all([ + basePromise, + filePromise, + sessionPromise, + ]) + if (options.signal.aborted) return base + const fileItems: AutocompleteItem[] = fileCandidates.flatMap((candidate) => { + const value = formatFileMention(candidate, token.quoted) + if (value === undefined) return [] + const name = candidate.path.slice(candidate.path.lastIndexOf('/') + 1) + const directory = candidate.kind === 'directory' + return [{ + value, + label: `${directory ? 'Folder' : 'File'} · ${displayInlineText(name)}${directory ? '/' : ''}`, + description: displayInlineText(candidate.path), + }] + }) + const sessionItems: AutocompleteItem[] = sessionCandidates.map((candidate) => { + const mentionLabel = displayInlineText(candidate.label) + const sessionId = displayInlineText(candidate.sessionId) + const location = candidate.cwd === undefined ? '(no cwd)' : displayInlineText(candidate.cwd) + const description = `${candidate.label === candidate.sessionId ? '' : `${sessionId} · `}${location} · ${new Date(candidate.createdAt).toISOString()}` + return { + value: formatSessionReferenceMention({ sessionId: candidate.sessionId, label: mentionLabel }), + label: `Session · ${mentionLabel}`, + description, + } + }) + const items = [...fileItems, ...sessionItems] + if (items.length === 0) return base + return { items: [...items, ...(base?.items ?? [])], prefix: token.prefix } + } + + applyCompletion( + lines: string[], + cursorLine: number, + cursorCol: number, + item: AutocompleteItem, + prefix: string, + ): { lines: string[]; cursorLine: number; cursorCol: number } { + return this.base.applyCompletion(lines, cursorLine, cursorCol, item, prefix) + } + + shouldTriggerFileCompletion(lines: string[], cursorLine: number, cursorCol: number): boolean { + return this.base.shouldTriggerFileCompletion(lines, cursorLine, cursorCol) + } +} diff --git a/packages/ui/tui/src/chat/channel.ts b/packages/ui/tui/src/chat/channel.ts new file mode 100644 index 0000000000..bb46aad780 --- /dev/null +++ b/packages/ui/tui/src/chat/channel.ts @@ -0,0 +1,31 @@ +/** + * Shared collaborator surface every chat-channel sub-controller receives from + * `createTuiChat`. Each controller's own `*Deps` extends {@link ChatChannelDeps} + * (and {@link ChannelNotice} when it reports outcomes) with the extra services + * it needs. Value collaborators (`ctx`, `resolved`, `palette`, `overlayManager`) + * are stable for the channel's life; the callbacks stay on the object so a + * controller always calls the channel's current implementation. + * @module @deepseek-ai/dsh-tui/chat/channel + */ + +import type { Context } from 'cordis' +import type { TuiOverlayManager } from '../extension/overlay-manager.ts' +import type { Palette } from '../components/theme.ts' +import type { ResolvedTuiConfig } from '../config.ts' + +/** Collaborators shared by every chat-channel sub-controller. */ +export interface ChatChannelDeps { + readonly ctx: Context + readonly resolved: ResolvedTuiConfig + readonly palette: Palette + readonly overlayManager: TuiOverlayManager + /** Redraw the channel. */ + requestRender(): void + /** Whether the channel has begun shutting down. */ + isDisposed(): boolean +} + +/** Append a channel notice line; controllers that report outcomes mix this in. */ +export interface ChannelNotice { + appendNotice(message: string, kind?: 'info' | 'warning' | 'error'): void +} diff --git a/packages/ui/tui/src/file-autocomplete.ts b/packages/ui/tui/src/chat/file-autocomplete.ts similarity index 99% rename from packages/ui/tui/src/file-autocomplete.ts rename to packages/ui/tui/src/chat/file-autocomplete.ts index 23a3a7c1fa..5bd4282d5d 100644 --- a/packages/ui/tui/src/file-autocomplete.ts +++ b/packages/ui/tui/src/chat/file-autocomplete.ts @@ -3,7 +3,7 @@ * paths only: selected values remain ordinary prompt text and file contents * stay behind the model-facing `read` tool. * - * @module @deepseek-ai/dsh-tui/file-autocomplete + * @module @deepseek-ai/dsh-tui/chat/file-autocomplete */ import { lstat, readdir } from 'node:fs/promises' diff --git a/packages/ui/tui/src/chat/helpers.ts b/packages/ui/tui/src/chat/helpers.ts new file mode 100644 index 0000000000..5721774451 --- /dev/null +++ b/packages/ui/tui/src/chat/helpers.ts @@ -0,0 +1,137 @@ +/** + * Zero-state helpers for the interactive chat channel: prompt-directory and + * Git-branch formatting, surface/tool-call derivations over the session log, + * session-reference context cards, the placeholder editor, and banner-reveal + * timing constants. None of these close over channel state. + * @module @deepseek-ai/dsh-tui/chat/helpers + */ + +import { execFileSync } from 'node:child_process' +import { homedir } from 'node:os' +import { isAbsolute, relative, resolve, sep } from 'node:path' +import { + CURSOR_MARKER, + Editor, + truncateToWidth, + visibleWidth, +} from '@earendil-works/pi-tui' +import type { Session } from '@deepseek-ai/dsh-session' + +/** Editor that shows a placeholder without making it editable content. */ +export class HintEditor extends Editor { + /** Placeholder shown in the empty input row; `undefined` hides it. */ + hint: string | undefined + /** Prompt text rendered before the placeholder, matching the live prompt width. */ + hintPrefix = '' + + override render(width: number): string[] { + const lines = super.render(width) + if (this.hint === undefined || this.getText() !== '') return lines + const content = lines[0] + /* v8 ignore next -- Editor always renders one content row. */ + if (content === undefined) return lines + const padding = ' '.repeat(this.getPaddingX()) + /* v8 ignore next -- the mounted editor is focused whenever its empty-input hint is rendered. */ + const marker = this.focused ? CURSOR_MARKER : '' + const available = Math.max(0, width - visibleWidth(padding) - visibleWidth(this.hintPrefix)) + const placeholder = truncateToWidth(this.hint, available, '') + const used = visibleWidth(padding) + visibleWidth(this.hintPrefix) + visibleWidth(placeholder) + lines[0] = `${padding}${this.hintPrefix}${marker}${placeholder}${' '.repeat(Math.max(0, width - used))}` + return lines + } +} + +/** + * Format the session working directory as a prompt label: `~` for home, + * `~/rel` for a home-relative path, the raw path otherwise. + * @param cwd - operational working directory from the session header. + * @returns unescaped prompt label. + */ +export function formatCwd(cwd: string | undefined): string { + if (cwd === undefined) return 'cwd unset' + const home = homedir() + const rel = relative(resolve(home), resolve(cwd)) + if (rel === '') return '~' + /* v8 ignore next -- Windows cross-drive coverage; POSIX relative() cannot return an absolute path. */ + if (isAbsolute(rel)) return cwd + if (rel !== '..' && !rel.startsWith(`..${sep}`)) return `~${sep}${rel}` + return cwd +} + +/** + * Resolve the current Git branch for the prompt context line. + * @param cwd - operational working directory to query. + * @returns branch name, or `undefined` outside a worktree or on any failure. + */ +export function gitBranch(cwd: string): string | undefined { + try { + const env = Object.fromEntries( + Object.entries(process.env).filter(([name]) => !/(?:KEY|SECRET|TOKEN)/iu.test(name)), + ) + const branch = execFileSync('git', ['branch', '--show-current'], { + cwd, + encoding: 'utf8', + env, + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 1_000, + }).trim() + /* v8 ignore next -- detached-HEAD behavior is exercised by the runtime smoke, not the unit checkout. */ + return branch === '' ? undefined : branch + } catch (_gitUnavailableOrOutsideWorktree) { + return undefined + } +} + +/** + * Sequence numbers currently visible on the session surface. + * @param session - session whose surface nodes to read. + * @returns the set of visible event sequence numbers. + */ +export function activeSurfaceSeqs(session: Session): Set { + return new Set(session.surface.nodes) +} + +/** + * Tool-call ids whose owning assistant message is on the active surface. + * @param session - session whose events to scan. + * @param active - sequence numbers currently on the surface. + * @returns the set of active tool-call ids. + */ +export function activeToolCallIds(session: Session, active: ReadonlySet): Set { + const ids = new Set() + for (const event of session.events) { + if (event.type !== 'assistant/message' || !active.has(event.seq)) continue + for (const block of event.data.content) { + if (block.type === 'tool-call') ids.add(block.id) + } + } + return ids +} + +/** + * Read a session-reference context card's display labels from an event source. + * @param source - event source to inspect. + * @returns per-reference labels, or `undefined` when the source is not a reference card. + */ +export function sessionReferenceCard(source: unknown): string[] | undefined { + if (typeof source !== 'object' || source === null) return undefined + const record = source as Record + if (record['kind'] !== 'session-reference' || !Array.isArray(record['references'])) return undefined + const references = record['references'] as unknown[] + const labels: string[] = [] + for (const reference of references) { + if (typeof reference !== 'object' || reference === null) return undefined + const entry = reference as Record + const sessionId = entry['sessionId'] + const label = entry['label'] + if (typeof sessionId !== 'string' || typeof label !== 'string') return undefined + labels.push(label === sessionId ? sessionId : `${label} (${sessionId})`) + } + return labels +} + +/** Milliseconds between banner sweep-reveal frames (~60 fps). */ +export const BANNER_REVEAL_INTERVAL_MS = 15 + +/** Number of sweep frames the banner reveal spreads the terminal width over. */ +export const BANNER_REVEAL_STEPS = 24 diff --git a/packages/ui/tui/src/chat/model-command.ts b/packages/ui/tui/src/chat/model-command.ts new file mode 100644 index 0000000000..133a3d0d9f --- /dev/null +++ b/packages/ui/tui/src/chat/model-command.ts @@ -0,0 +1,191 @@ +/** + * Model-selection sub-controller for the interactive chat channel: the queued + * `/model` command, the keyboard model selector overlay with reasoning-effort + * selection, and resolution of the selected model's context window. Owns the + * context-window cache the prompt and status views read; the caller owns the + * shared {@link AgentLlmTargetRef}. + * @module @deepseek-ai/dsh-tui/chat/model-command + */ + +import type { AgentLlmTarget, AgentLlmTargetRef } from '@deepseek-ai/dsh-agent' +import { errorChain, type ReasoningEffortId } from '@deepseek-ai/dsh-llm' +import type { TuiOverlaySession } from '../extension/types.ts' +import { displayText } from '../components/text.ts' +import { + ModelDialog, + readModelChoices, + targetLabel, + targetReasoningLabel, + type ModelChoice, + type ModelDialogSelection, +} from '../components/dialogs.ts' +import type { ChannelNotice, ChatChannelDeps } from './channel.ts' + +/** Collaborators the model controller needs from the chat channel. */ +export interface ModelControllerDeps extends ChatChannelDeps, ChannelNotice { + /** Shared selected-target handle owned by the channel. */ + readonly target: AgentLlmTargetRef +} + +/** Model-selection controller for one chat channel. */ +export interface ModelController { + /** Resolved context window of the selected model, or `undefined` if unknown. */ + contextWindow(): number | undefined + /** Queue a `/model` command; empty argument opens the selector. */ + queueModelCommand(raw: string): void + /** Drop the pending context-window resolution (shutdown). */ + resetContextResolution(): void + /** Forget the tracked selector overlay (shutdown). */ + clearOverlay(): void +} + +type ContextResolution = + | { readonly kind: 'resolved'; readonly contextWindow: number | undefined } + | { readonly kind: 'error'; readonly error: unknown } + +/** + * Build the model-selection controller for one chat channel. + * @param deps - channel collaborators and shared target handle. + * @returns the controller wired to the channel's overlay and prompt views. + */ +export function createModelController(deps: ModelControllerDeps): ModelController { + const { ctx, resolved, palette, overlayManager, target } = deps + let contextWindow: number | undefined + let contextResolution: Promise | undefined + let modelOverlay: TuiOverlaySession | undefined + let modelCommands = Promise.resolve() + + const resolveContextWindow = (selected: AgentLlmTarget | undefined): void => { + contextWindow = undefined + const resolution: Promise = selected === undefined + ? Promise.resolve({ kind: 'resolved', contextWindow: undefined } as const) + : ctx.llm.resolveModelInfo(selected.provider, selected.model).then( + info => ({ kind: 'resolved', contextWindow: info.context?.contextWindow } as const), + (error: unknown) => ({ kind: 'error', error } as const), + ) + contextResolution = resolution + void resolution.then((result) => { + if (contextResolution !== resolution) return + if (result.kind === 'error') { + deps.appendNotice(`Could not resolve model context: ${errorChain(result.error)}`, 'error') + return + } + contextWindow = result.contextWindow + deps.requestRender() + }) + } + resolveContextWindow(target.current) + + const selectModel = ( + selected: ModelChoice, + explicitReasoning?: { effort: ReasoningEffortId | undefined }, + ): void => { + const sameRoute = target.current?.provider === selected.provider && target.current.model === selected.model + const reasoningEffort = explicitReasoning === undefined + ? (sameRoute ? target.current?.reasoningEffort ?? selected.reasoning?.defaultEffort : selected.reasoning?.defaultEffort) + : explicitReasoning.effort + if (sameRoute && target.current?.reasoningEffort === reasoningEffort) { + const reasoning = targetReasoningLabel(selected, reasoningEffort) + deps.appendNotice(`Model is already ${targetLabel(selected)}${reasoning === undefined ? '' : ` with reasoning effort ${displayText(reasoning)}`}.`) + return + } + target.current = { + provider: selected.provider, + model: selected.model, + ...reasoningEffort === undefined ? {} : { reasoningEffort }, + } + resolveContextWindow(target.current) + const reasoning = targetReasoningLabel(selected, reasoningEffort) + deps.appendNotice([ + `Model selected: ${targetLabel(selected)}.`, + ...reasoning === undefined ? [] : [`Reasoning effort: ${displayText(reasoning)}.`], + 'New steps will use it.', + ].join(' ')) + } + + const showModelSelector = (choices: readonly ModelChoice[]): void => { + const current = target.current === undefined ? 'unset' : targetLabel(target.current) + if (choices.length === 0) { + deps.appendNotice(`Current model: ${current}\nNo models are advertised by registered providers.`, 'warning') + return + } + void modelOverlay?.close() + const session = overlayManager.open({ + create: () => new ModelDialog( + choices, + target.current, + resolved.maxModelOptions, + palette, + (selection: ModelDialogSelection) => { + void session.close() + selectModel(selection.choice, { effort: selection.reasoningEffort }) + }, + () => { void session.close() }, + ), + options: { + width: resolved.modelDialogWidth, + maxHeight: resolved.modelDialogMaxHeight, + anchor: 'center', + margin: 1, + }, + }) + modelOverlay = session + void session.closed.then(() => { + if (modelOverlay === session) modelOverlay = undefined + }) + deps.requestRender() + } + + const handleModelCommand = async (raw: string): Promise => { + const choices = await readModelChoices(ctx, target.current) + if (deps.isDisposed()) return + const argument = raw.trim() + if (argument === '') { + showModelSelector(choices) + return + } + const parts = argument.split(/\s+/u) + if (parts.length > 2) { + deps.appendNotice('Usage: /model [provider/]model', 'warning') + return + } + + let matches: ModelChoice[] + if (parts.length === 2) { + matches = choices.filter(choice => choice.provider === parts[0] && choice.model === parts[1]) + } else { + const value = argument + const qualified = choices.filter(choice => targetLabel(choice) === value) + matches = qualified.length > 0 ? qualified : choices.filter(choice => choice.model === value) + } + if (matches.length === 0) { + deps.appendNotice(`Unknown model: ${argument}. Run /model to list available models.`, 'warning') + return + } + if (matches.length > 1) { + deps.appendNotice(`Model "${argument}" is advertised by multiple providers; use /model /.`, 'warning') + return + } + const selected = matches[0] + /* v8 ignore next -- a non-empty matches array always has index zero. */ + if (selected === undefined) return + selectModel(selected) + } + + return { + contextWindow: () => contextWindow, + queueModelCommand(raw: string): void { + modelCommands = modelCommands.then(async () => { + await handleModelCommand(raw) + }).catch((error: unknown) => { + if (!deps.isDisposed()) deps.appendNotice(`Could not read the model catalog: ${errorChain(error)}`, 'error') + }) + }, + resetContextResolution(): void { + contextResolution = undefined + }, + clearOverlay(): void { + modelOverlay = undefined + }, + } +} diff --git a/packages/ui/tui/src/chat/questions.ts b/packages/ui/tui/src/chat/questions.ts new file mode 100644 index 0000000000..e5f2c8b806 --- /dev/null +++ b/packages/ui/tui/src/chat/questions.ts @@ -0,0 +1,168 @@ +/** + * Ask-user-question sub-machine for the interactive chat channel. Registers the + * user-interaction provider, presents one question overlay at a time in FIFO + * order, and settles each request on answer, abort, overlay error, or channel + * shutdown. + * @module @deepseek-ai/dsh-tui/chat/questions + */ + +import { errorChain } from '@deepseek-ai/dsh-llm' +import { + UserInteractionError, + type AskUserQuestionAnswer, + type AskUserQuestionAnswerItem, + type AskUserQuestionRequest, +} from '@deepseek-ai/dsh-user-interaction' +import type { TuiOverlaySession } from '../extension/types.ts' +import { QuestionDialog } from '../components/dialogs.ts' +import type { ChatChannelDeps } from './channel.ts' + +/** One queued or active ask-user-question request and its running answers. */ +interface PendingQuestion { + request: AskUserQuestionRequest + index: number + answers: AskUserQuestionAnswerItem[] + resolve(answer: AskUserQuestionAnswer): void + reject(error: unknown): void + onAbort: () => void + overlay: TuiOverlaySession | undefined +} + +/** Collaborators the question queue needs from the chat channel. */ +export type QuestionQueueDeps = ChatChannelDeps + +/** Ask-user-question controller for one chat channel. */ +export interface QuestionQueue { + /** Reject the active and all queued questions (shutdown). */ + rejectAll(): void + /** Remove the user-interaction provider registration. */ + unregister(): void +} + +/** + * Build the ask-user-question queue for one chat channel. + * @param deps - channel collaborators and overlay host. + * @returns the controller used at shutdown to drain and unregister. + */ +export function createQuestionQueue(deps: QuestionQueueDeps): QuestionQueue { + const { ctx, resolved, palette, overlayManager } = deps + const questionQueue: PendingQuestion[] = [] + let activeQuestion: PendingQuestion | undefined + + const removeAbortListener = (pending: PendingQuestion): void => { + pending.request.signal?.removeEventListener('abort', pending.onAbort) + } + + const rejectQuestion = (pending: PendingQuestion): void => { + void pending.overlay?.close() + pending.overlay = undefined + removeAbortListener(pending) + pending.reject(new UserInteractionError( + 'ask_user_question was interrupted before the user answered', + 'ASK_ABORTED', + )) + } + + const startNextQuestion = (): void => { + if (activeQuestion !== undefined || deps.isDisposed()) return + const pending = questionQueue.shift() + if (pending === undefined) return + activeQuestion = pending + const show = (): void => { + const question = pending.request.questions[pending.index] + if (question === undefined) { + activeQuestion = undefined + removeAbortListener(pending) + pending.resolve({ answers: pending.answers }) + startNextQuestion() + return + } + const session = overlayManager.open({ + ...pending.request.signal === undefined ? {} : { signal: pending.request.signal }, + create: () => new QuestionDialog( + question, + pending.index + 1, + pending.request.questions.length, + pending.request.questions.length - pending.answers.length, + resolved.maxQuestionOptions, + palette, + (selection) => { + pending.overlay = undefined + void session.close() + pending.answers.push({ id: question.id, ...selection }) + pending.index += 1 + show() + }, + () => { + activeQuestion = undefined + rejectQuestion(pending) + startNextQuestion() + }, + ), + options: { + width: resolved.questionDialogWidth, + maxHeight: resolved.questionDialogMaxHeight, + anchor: 'bottom-left', + margin: { bottom: 1 }, + }, + }) + pending.overlay = session + void session.closed.then((result) => { + if (pending.overlay !== session) return + pending.overlay = undefined + /* v8 ignore next 2 -- close, abort, and shutdown settle the owner before this callback */ + if (result.reason !== 'error') return + activeQuestion = undefined + removeAbortListener(pending) + pending.reject(new UserInteractionError( + `ask_user_question TUI failed: ${errorChain(result.error)}`, + 'ASK_ABORTED', + )) + startNextQuestion() + }) + deps.requestRender() + } + show() + } + + const unregister = ctx.userInteraction.registerProvider({ + ask(request) { + return new Promise((resolveAnswer, reject) => { + const pending: PendingQuestion = { + request, + index: 0, + answers: [], + resolve: resolveAnswer, + reject, + overlay: undefined, + onAbort: () => { + if (activeQuestion === pending) { + activeQuestion = undefined + rejectQuestion(pending) + startNextQuestion() + return + } + // A non-active pending ask remains in the queue until this listener settles it. + questionQueue.splice(questionQueue.indexOf(pending), 1) + rejectQuestion(pending) + }, + } + request.signal?.addEventListener('abort', pending.onAbort, { once: true }) + questionQueue.push(pending) + startNextQuestion() + }) + }, + }) + + return { + rejectAll(): void { + if (activeQuestion !== undefined) { + const pending = activeQuestion + activeQuestion = undefined + rejectQuestion(pending) + } + for (const pending of questionQueue.splice(0)) rejectQuestion(pending) + }, + unregister, + } +} diff --git a/packages/ui/tui/src/chat/resume.ts b/packages/ui/tui/src/chat/resume.ts new file mode 100644 index 0000000000..2f0fdd423c --- /dev/null +++ b/packages/ui/tui/src/chat/resume.ts @@ -0,0 +1,245 @@ +/** + * Session-resume sub-controller for the interactive chat channel: the + * `/resume` selector, per-candidate summary reads that tolerate a corrupt + * neighbor, the pre-handoff preflight, the terminal handoff itself, and the + * durable resume-hint command printed on exit. + * @module @deepseek-ai/dsh-tui/chat/resume + */ + +import type { TUI } from '@earendil-works/pi-tui' +import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' +import { errorChain } from '@deepseek-ai/dsh-llm' +import { SessionId, type SessionHeader } from '@deepseek-ai/dsh-session' +import type { + SessionLogSnapshot, + SessionQueryService, + SessionRecord, +} from '@deepseek-ai/dsh-session-query' +import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' +import type { HintEditor } from './helpers.ts' +import { formatCwd } from './helpers.ts' +import type { TuiOverlaySession } from '../extension/types.ts' +import type { TuiRuntime } from '../runtime.ts' +import type { Config } from '../config.ts' +import { + ResumePicker, + summarizeResumeCandidate, + type ResumeCandidate, +} from '../components/dialogs.ts' +import type { ChannelNotice, ChatChannelDeps } from './channel.ts' + +/** Collaborators the resume controller needs from the chat channel. */ +export interface ResumeControllerDeps extends ChatChannelDeps, ChannelNotice { + readonly agent: Agent + readonly config: Config + readonly runtime: TuiRuntime + readonly persistence: SessionPersistence | undefined + readonly sessionQuery: SessionQueryService | undefined + readonly ui: TUI + readonly editor: HintEditor + /** Current agent status, re-read at each resume precondition point. */ + agentStatus(): AgentStatus +} + +/** Session-resume controller for one chat channel. */ +export interface ResumeController { + /** Open the current-workspace searchable session selector. */ + showResume(): void + /** + * The resume command for the current session — the configured template with + * every `{session}` filled — but only once the session is durably persisted; + * `undefined` otherwise. + */ + currentResumeCommand(): Promise +} + +/** + * Build the session-resume controller for one chat channel. + * @param deps - channel collaborators, terminal handles, and optional services. + * @returns the controller wired to the `/resume` command and exit hint. + */ +export function createResumeController(deps: ResumeControllerDeps): ResumeController { + const { + ctx, agent, config, runtime, resolved, palette, overlayManager, + persistence, sessionQuery, ui, editor, + } = deps + let resumeOverlay: TuiOverlaySession | undefined + let resumeInFlight = false + let resumeScan = 0 + + /** + * Persisted sessions for this workspace, newest first. Empty when no + * persistence backend is mounted or a listing failure would otherwise block + * exit or crash `/resume`; the resume hint is best-effort convenience. + */ + const listWorkspaceSessions = async (): Promise => { + if (persistence === undefined) return [] + let all: readonly SessionHeader[] + try { + all = await persistence.list() + } catch { + // A listing failure must never block terminal exit or crash `/resume`. + return [] + } + return all + .filter(header => header.cwd === agent.session.header.cwd) + } + + /** Build one display candidate without letting a corrupt neighbor abort the selector. */ + const readResumeCandidate = async ( + record: SessionRecord, + providers: ReadonlySet, + ): Promise => { + try { + let snapshot: SessionLogSnapshot + const live = ctx.sessions.get(record.header.id) + if (live !== undefined) { + snapshot = { + session: structuredClone(live.header), + events: live.events.map(event => structuredClone(event)), + } + } else { + /* v8 ignore next -- caller checks the optional service before mapping records */ + if (sessionQuery === undefined) throw new Error('session query is unavailable') + snapshot = await sessionQuery.readSession(record.header.id) + } + return summarizeResumeCandidate( + record, + snapshot, + agent.session.id, + agent.session.header.cwd, + providers, + ) + } catch (error: unknown) { + return { + record, + title: 'Unreadable session', + lastActivityAt: record.header.createdAt, + lastTurn: 'log unavailable', + disabledReason: `session cannot be loaded: ${errorChain(error)}`, + } + } + } + + /** Re-read every mutable precondition immediately before terminal handoff. */ + const preflightResume = async (sessionId: SessionId): Promise => { + /* v8 ignore next -- only showResume can call this closure, after proving the optional service exists */ + if (sessionQuery === undefined) throw new Error('Resume is unavailable: session query is not mounted.') + const initialStatus = deps.agentStatus() + if (initialStatus !== 'idle') throw new Error(`Resume requires an idle agent (status: ${initialStatus}).`) + const record = (await sessionQuery.listSessions()).find(candidate => candidate.header.id === sessionId) + if (record === undefined) throw new Error(`Session "${sessionId}" is no longer available.`) + const candidate = await readResumeCandidate( + record, + new Set(ctx.llm.listProviders().map(provider => provider.id)), + ) + if (candidate.disabledReason !== undefined) throw new Error(candidate.disabledReason) + const finalStatus = deps.agentStatus() + if (finalStatus !== 'idle') throw new Error(`Resume requires an idle agent (status: ${finalStatus}).`) + return candidate + } + + const handoffResume = async (candidate: ResumeCandidate, overlay: TuiOverlaySession): Promise => { + if (resumeInFlight) return + resumeInFlight = true + let terminalReleased = false + try { + const checked = await preflightResume(candidate.record.header.id) + const hostHandoff = runtime.handoffResume + if (hostHandoff === undefined) { + const template = config.resumeCommand + const fallback = template?.replaceAll('{session}', checked.record.header.id) + await overlay.close() + resumeOverlay = undefined + deps.appendNotice(fallback === undefined + ? 'Session is resumable, but this host cannot hand it off in place.' + : `This host cannot hand off in place. Exit and run: ${fallback}`, 'warning') + return + } + /* v8 ignore next -- shutdown during preflight invalidates an awaited service read or reaches this guard */ + if (deps.isDisposed()) return + await ctx.sessions.flush(agent.session) + // Disposal can run while the flush promise is pending. + if (deps.isDisposed()) return + if (agent.status !== 'idle') throw new Error(`Resume requires an idle agent (status: ${agent.status}).`) + await overlay.close() + resumeOverlay = undefined + await runtime.terminal.drainInput(100, 20) + // Disposal can run while terminal draining is pending. + if (deps.isDisposed()) return + ui.stop() + terminalReleased = true + await hostHandoff(checked.record.header.id) + throw new Error('resume host returned without replacing the process') + } catch (error: unknown) { + if (!deps.isDisposed()) { + if (terminalReleased) { + ui.start() + ui.setFocus(editor) + deps.appendNotice(`Resume handoff failed: ${errorChain(error)}`, 'error') + } else { + await overlay.close() + resumeOverlay = undefined + deps.appendNotice(`Resume failed: ${errorChain(error)}`, 'error') + } + } + } finally { + resumeInFlight = false + } + } + + return { + currentResumeCommand: async (): Promise => { + if (config.resumeCommand === undefined) return undefined + const sessions = await listWorkspaceSessions() + if (!sessions.some(header => header.id === agent.session.id)) return undefined + return config.resumeCommand.replaceAll('{session}', agent.session.id) + }, + showResume(): void { + if (agent.status !== 'idle') { + deps.appendNotice('Resume requires the current turn to finish or be cancelled first.', 'warning') + return + } + if (sessionQuery === undefined) { + deps.appendNotice('Resume is not available: session query is not mounted.', 'warning') + return + } + const scan = ++resumeScan + void resumeOverlay?.close() + void sessionQuery.listSessions().then(async (records) => { + if (deps.isDisposed() || scan !== resumeScan) return + const workspace = records.filter(record => record.header.cwd === agent.session.header.cwd) + const providers = new Set(ctx.llm.listProviders().map(provider => provider.id)) + const candidates = await Promise.all(workspace.map(record => readResumeCandidate(record, providers))) + candidates.sort((a, b) => b.lastActivityAt - a.lastActivityAt + || a.record.header.id.localeCompare(b.record.header.id)) + if (deps.isDisposed() || scan !== resumeScan) return + const session = overlayManager.open({ + create: host => new ResumePicker( + candidates, + resolved.maxResumeOptions, + runtime.formatCwd?.(agent.session.header.cwd) ?? formatCwd(agent.session.header.cwd), + () => host.viewport.rows, + palette, + (candidate) => { void handoffResume(candidate, session) }, + () => { void session.close() }, + ), + options: { + width: '100%', + maxHeight: '100%', + anchor: 'top-left', + margin: 0, + }, + }) + resumeOverlay = session + void session.closed.then(() => { + /* v8 ignore next -- overlay FIFO closes this session before a replacement can become the tracked resume overlay */ + if (resumeOverlay === session) resumeOverlay = undefined + }) + deps.requestRender() + }, (error: unknown) => { + if (!deps.isDisposed() && scan === resumeScan) deps.appendNotice(`Resume session scan failed: ${errorChain(error)}`, 'error') + }) + }, + } +} diff --git a/packages/ui/tui/src/chat/skill-invocation.ts b/packages/ui/tui/src/chat/skill-invocation.ts new file mode 100644 index 0000000000..7eb7a555ae --- /dev/null +++ b/packages/ui/tui/src/chat/skill-invocation.ts @@ -0,0 +1,67 @@ +/** + * Manual `/skill: [instructions]` parsing and model-visible rendering for + * the terminal front door. + * @module @deepseek-ai/dsh-tui/chat/skill-invocation + */ + +import { assertNever } from '@deepseek-ai/dsh-llm' +import type { SkillDefinition, SkillResourceBase } from '@deepseek-ai/dsh-skill' + +/** Prefix that marks an editor submission as a manual skill invocation. */ +export const SKILL_COMMAND_PREFIX = '/skill:' + +/** Parsed `/skill: [instructions]` submission; `name` is empty when the prefix carries no name. */ +export interface ParsedSkillCommand { + /** Skill name typed after `/skill:`, up to the first space. */ + name: string + /** Trimmed text after the name; empty when none was typed. */ + instructions: string +} + +/** + * Split a `/skill: [instructions]` submission into its name and trailing instructions. + * @param text - trimmed submission that starts with {@link SKILL_COMMAND_PREFIX}. + * @returns the skill name and any trailing instructions. + */ +export function parseSkillCommand(text: string): ParsedSkillCommand { + const rest = text.slice(SKILL_COMMAND_PREFIX.length) + const spaceIndex = rest.indexOf(' ') + if (spaceIndex === -1) return { name: rest, instructions: '' } + return { name: rest.slice(0, spaceIndex), instructions: rest.slice(spaceIndex + 1).trim() } +} + +/** Model-visible line locating a manually invoked skill's relative resources, or `undefined` when the provider has no base. */ +function skillResourceReference(base: SkillResourceBase | undefined): string | undefined { + if (base === undefined) return undefined + switch (base.kind) { + case 'directory': + return `References in this skill are relative to ${base.path}.` + case 'url': + return `References in this skill are relative to ${base.url}.` + case 'opaque': + return base.description + default: + return assertNever(base, 'SkillResourceBase.kind') + } +} + +/** + * Render a manually invoked skill into the model-visible user-message text. The + * `` block carries the body and, when the provider supplies one, its + * resource base; the trimmed `instructions` follow the block as the user's + * request for this turn. The name is registry-validated kebab-case + * (the skill registry rejects any other) and the resource base is trusted + * same-process provider prose, so — unlike the model-facing `dsh-tool-skill` + * result, which escapes for a tool channel — this user turn is assembled raw. + * @param skill - the loaded skill definition. + * @param instructions - trimmed text typed after `/skill:`; empty when absent. + * @returns the user-message text delivered to the agent. + */ +export function renderSkillInvocation(skill: SkillDefinition, instructions: string): string { + const lines = [``] + const reference = skillResourceReference(skill.resourceBase) + if (reference !== undefined) lines.push(reference, '') + lines.push(skill.content, '') + const block = lines.join('\n') + return instructions === '' ? block : `${block}\n\n${instructions}` +} diff --git a/packages/ui/tui/src/chat/timing.ts b/packages/ui/tui/src/chat/timing.ts new file mode 100644 index 0000000000..13477adfa0 --- /dev/null +++ b/packages/ui/tui/src/chat/timing.ts @@ -0,0 +1,347 @@ +/** + * Per-step timing model and running-status glyph animation for the terminal + * front door. Timing buckets are replayed from the session event stream; the + * running glyph fades in on turn start, throbs while the turn runs, and fades + * out on turn end. + * @module @deepseek-ai/dsh-tui/chat/timing + */ + +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import type { Palette } from '../components/theme.ts' + +/** + * Render cadence of the running prompt while active, and while the glyph fades + * out after a turn ends. ~20 fps so the truecolor glyph fade reads smoothly; + * the same tick keeps the elapsed-time text (0.1 s resolution) current. Only + * changed terminal cells are re-emitted, so the faster tick stays cheap. + */ +export const STATUS_ANIMATION_INTERVAL_MS = 50 + +/** + * Milliseconds over which the running glyph fades in when a turn starts and + * fades out after it ends. The fade is an envelope over the running pulse: + * inside it the glyph throbs (see {@link STATUS_PULSE_PERIOD_MS}). + */ +export const STATUS_FADE_MS = 300 + +/** Milliseconds for one full brightness throb of the running glyph. */ +export const STATUS_PULSE_PERIOD_MS = 1400 + +/** + * Brightness floor of the running throb, as a fraction of the settled gray. At + * 0 the pulse swells from the near-background trough up to full and back. The + * trough is still rendered as the dimmest gray, not clipped to a blank, so the + * cosine breathes symmetrically bold→dim→bold. + */ +export const STATUS_PULSE_FLOOR = 0 + +/** + * Muted-gray foreground the truecolor running glyph fades through, from the + * near-background trough (opacity 0) to the settled dim gray (opacity 1). Same + * hue-free gray as the idle caret, so the glyph reads as the caret dimly + * appearing rather than a colored indicator. Foreground-only, matching the + * brand gradient, so it stays legible on any terminal background. + */ +const STATUS_FADE_GRAY = { + trough: [43, 43, 43], + settled: [136, 136, 136], +} as const + +/** The active phase of a running step, one bucket of accumulated wall time. */ +export type TimingBucket = 'ttft' | 'thinking' | 'responding' | 'tools' + +/** Turn/step coordinates of one assistant step. */ +export type StepPosition = { turn: number; step: number } + +/** Accumulated wall time per phase for one step or session slice. */ +export interface TimingTotals { + ttft: number + thinking: number + responding: number + tools: number +} + +interface TimingState { + totals: TimingTotals + active: { bucket: TimingBucket; since: number } | undefined +} + +const TIMING_BUCKET_LABELS: Record = { + ttft: 'Model wait', + thinking: 'Thinking', + responding: 'Response', + tools: 'Tools', +} + +const TIMING_BUCKETS: readonly TimingBucket[] = ['ttft', 'thinking', 'responding', 'tools'] + +function emptyTimingTotals(): TimingTotals { + return { ttft: 0, thinking: 0, responding: 0, tools: 0 } +} + +function timingState(startedAt?: number): TimingState { + return { + totals: emptyTimingTotals(), + /* v8 ignore next -- production timing state always begins at a logged step timestamp. */ + active: startedAt === undefined ? undefined : { bucket: 'ttft', since: startedAt }, + } +} + +function sameStep(event: SessionEvent, position: StepPosition): boolean { + return typeof event.data === 'object' + && 'turn' in event.data && 'step' in event.data + && event.data.turn === position.turn && event.data.step === position.step +} + +function closeTimingBucket(state: TimingState, at: number): void { + if (state.active === undefined) return + state.totals[state.active.bucket] += Math.max(0, at - state.active.since) + state.active = undefined +} + +function enterTimingBucket(state: TimingState, bucket: TimingBucket | undefined, at: number): void { + if (state.active?.bucket === bucket) return + closeTimingBucket(state, at) + if (bucket !== undefined) state.active = { bucket, since: at } +} + +function advanceStepTiming( + state: TimingState, + event: Extract, +): void { + if (event.type === 'assistant/chunk') { + const chunk = event.data.chunk + if (state.active?.bucket === 'ttft') enterTimingBucket(state, undefined, event.time) + if (chunk.type === 'reasoning-delta' || (chunk.type === 'block-start' && chunk.blockType === 'reasoning')) { + enterTimingBucket(state, 'thinking', event.time) + } else if (chunk.type === 'text-delta' || (chunk.type === 'block-start' && chunk.blockType === 'text')) { + enterTimingBucket(state, 'responding', event.time) + } + } else if (event.type === 'tool/call') { + enterTimingBucket(state, 'tools', event.time) + } else { + closeTimingBucket(state, event.time) + } +} + +function timingTotalsAt(state: TimingState, at?: number): TimingTotals { + const totals = { ...state.totals } + if (state.active !== undefined && at !== undefined) { + totals[state.active.bucket] += Math.max(0, at - state.active.since) + } + return totals +} + +/** + * Replay one step's accumulated per-phase timing up to clock `at`. + * @param events - Session events to replay. + * @param position - Turn/step coordinates of the step. + * @param at - Render clock to accumulate the open bucket up to. + * @returns The step's per-phase totals. + */ +export function stepTimingAt( + events: readonly SessionEvent[], + position: StepPosition, + at: number, +): TimingTotals { + const startIndex = events.findIndex(event => event.type === 'step/start' && sameStep(event, position)) + if (startIndex < 0) return emptyTimingTotals() + const start = events[startIndex] as Extract + const state = timingState(start.time) + for (let index = startIndex + 1; index < events.length; index += 1) { + const event = events[index] as SessionEvent + if (event.time > at) break + if ((event.type === 'assistant/chunk' || event.type === 'tool/call' || event.type === 'step/end') + && sameStep(event, position)) { + advanceStepTiming(state, event) + if (event.type === 'step/end') break + } + } + return timingTotalsAt(state, at) +} + +/** + * The turn index of the currently open turn, or `undefined` when none is open. + * @param events - Session events to scan from the tail. + * @returns The open turn index, or `undefined`. + */ +export function openTurn(events: readonly SessionEvent[]): number | undefined { + for (let index = events.length - 1; index >= 0; index -= 1) { + const event = events[index] as SessionEvent + if (event.type === 'turn/end') return undefined + if (event.type === 'turn/start') return event.data.turn + } + return undefined +} + +/** + * Phase-specific status glyph, keyed by the running step's active timing bucket. + * `ttft` is the pre-first-token wait a running turn falls back to between steps. + */ +export const TIMING_BUCKET_GLYPHS: Record = { + ttft: '◍', + thinking: '✻', + responding: '●', + tools: '⚙', +} + +/** + * Derive the currently open step's active timing bucket, or `undefined` when no + * step is open. The open step is the last `step/start` with no later matching + * `step/end`; its bucket is replayed with the same rules as {@link stepTimingAt}. + * @param events - Session events to scan. + * @returns The open step's active bucket, or `undefined`. + */ +export function openStepPhase(events: readonly SessionEvent[]): TimingBucket | undefined { + let startIndex = -1 + let start: Extract | undefined + for (let index = events.length - 1; index >= 0; index -= 1) { + const event = events[index] as SessionEvent + if (event.type === 'step/end') return undefined + if (event.type === 'step/start') { + startIndex = index + start = event + break + } + if (event.type === 'turn/end') return undefined + } + if (start === undefined) return undefined + const position = start.data + const state = timingState(start.time) + for (let index = startIndex + 1; index < events.length; index += 1) { + const event = events[index] as SessionEvent + if ((event.type === 'assistant/chunk' || event.type === 'tool/call' || event.type === 'step/end') + && sameStep(event, position)) { + advanceStepTiming(state, event) + } + } + return state.active?.bucket +} + +/** + * The running agent's phase glyph, or `undefined` when idle. A running turn + * with no open step falls back to the pre-first-token wait so a glyph is always + * available while the agent works; it fades in on turn start, throbs while the + * turn runs, and fades out on turn end (see {@link fadeGlyph}). + * @param events - Session events to derive the phase from. + * @param running - Whether the agent is currently running. + * @returns The phase glyph, or `undefined` when idle. + */ +export function runningPhaseGlyph(events: readonly SessionEvent[], running: boolean): string | undefined { + if (!running) return undefined + const bucket = openStepPhase(events) ?? 'ttft' + return TIMING_BUCKET_GLYPHS[bucket] +} + +/** + * The running throb's brightness at continuous clock `nowMs`: a cosine between + * {@link STATUS_PULSE_FLOOR} and 1 over {@link STATUS_PULSE_PERIOD_MS}, so the + * dim glyph breathes bold→dim→bold without ever blinking off. Multiplied by the + * fade envelope, which alone drives appear/disappear at turn boundaries. + * + * @param nowMs - Monotonic render clock in milliseconds. + * @returns Brightness fraction in [{@link STATUS_PULSE_FLOOR}, 1]. + */ +export function pulseLevel(nowMs: number): number { + const phase = (nowMs % STATUS_PULSE_PERIOD_MS) / STATUS_PULSE_PERIOD_MS + const wave = 0.5 - 0.5 * Math.cos(2 * Math.PI * phase) + return STATUS_PULSE_FLOOR + (1 - STATUS_PULSE_FLOOR) * wave +} + +/** + * One frame of the running glyph at fade `opacity` (0 = near-background trough + * gray, 1 = settled dim gray). The character and its width never change — only + * the gray fades — so the prompt caret column stays fixed and the glyph reads as + * the caret dimly breathing, never a colored indicator. + * + * With truecolor the glyph's 24-bit gray foreground interpolates continuously + * between {@link STATUS_FADE_GRAY}'s trough and settled stops, so both the fade + * and the running throb render as a smooth, symmetric brightness swing with no + * hard cutoff to clip the trough into a blank. Without truecolor there is no + * per-frame gray, so `visible` (driven by the fade envelope, not the opacity) + * shows the glyph in the palette's muted role or leaves a blank column — a + * single dim appear/disappear at fixed width, still dim rather than accent, and + * no throb-driven blink. With color off entirely a visible glyph is bare, + * holding the caret column on a monochrome terminal. + * + * @param glyph - The phase glyph to paint. + * @param palette - Active palette supplying the muted (dim gray) role. + * @param colorEnabled - Whether ANSI is emitted at all. + * @param truecolor - Whether the terminal accepts 24-bit foreground codes. + * @param opacity - Brightness fraction in [0, 1] for the truecolor gray. + * @param visible - Whether the non-truecolor fallback shows the glyph at all. + * @returns The gray glyph at this opacity, or a single space when hidden. + */ +export function fadeGlyph( + glyph: string, + palette: Palette, + colorEnabled: boolean, + truecolor: boolean, + opacity: number, + visible: boolean, +): string { + if (truecolor && colorEnabled) { + const o = Math.min(Math.max(opacity, 0), 1) + const [tr, tg, tb] = STATUS_FADE_GRAY.trough + const [sr, sg, sb] = STATUS_FADE_GRAY.settled + const r = Math.round(tr + (sr - tr) * o) + const g = Math.round(tg + (sg - tg) * o) + const b = Math.round(tb + (sb - tb) * o) + return `\x1b[38;2;${r};${g};${b}m${glyph}\x1b[39m` + } + if (!visible) return ' ' + return colorEnabled ? palette.muted(glyph) : glyph +} + +/** + * Format a non-negative elapsed span at 100 ms resolution. + * @param elapsedMs - Elapsed milliseconds. + * @returns The formatted duration (e.g. `1.5s`, `2m03.4s`). + */ +export function formatStatusDuration(elapsedMs: number): string { + const tenths = Math.floor(Math.max(0, elapsedMs) / 100) + const seconds = tenths / 10 + if (seconds < 60) return `${seconds.toFixed(1)}s` + const minutes = Math.floor(seconds / 60) + return `${minutes}m${(seconds - minutes * 60).toFixed(1).padStart(4, '0')}s` +} + +/** + * Format the non-zero timing buckets of one step as a middot-joined summary. + * @param totals - Per-phase totals to format. + * @param includeModelWait - Whether to always include the model-wait bucket. + * @returns The formatted timing summary. + */ +export function formatTimingTotals(totals: TimingTotals, includeModelWait = false): string { + return TIMING_BUCKETS + .filter(bucket => totals[bucket] > 0 || (includeModelWait && bucket === 'ttft')) + .map(bucket => `${TIMING_BUCKET_LABELS[bucket]} ${formatStatusDuration(totals[bucket])}`) + .join(' · ') +} + +/** + * Format the queued-steering badge shown on the running status line. + * @param queued - Number of queued steering messages. + * @returns The badge text, or `undefined` when nothing is queued. + */ +export function formatQueuedStatus(queued: number): string | undefined { + return queued > 0 ? `${queued} queued` : undefined +} + +/** + * Format a completion timestamp as `YYYY-MM-DD HH:MM:SS` in local time. + * @param time - Epoch milliseconds. + * @returns The formatted local timestamp. + */ +export function formatCompletionTime(time: number): string { + const date = new Date(time) + const parts = [ + date.getFullYear().toString().padStart(4, '0'), + (date.getMonth() + 1).toString().padStart(2, '0'), + date.getDate().toString().padStart(2, '0'), + ] + const clock = [date.getHours(), date.getMinutes(), date.getSeconds()] + .map(value => value.toString().padStart(2, '0')) + .join(':') + return `${parts.join('-')} ${clock}` +} diff --git a/packages/ui/tui/src/chat/tokens.ts b/packages/ui/tui/src/chat/tokens.ts new file mode 100644 index 0000000000..ab54292a1d --- /dev/null +++ b/packages/ui/tui/src/chat/tokens.ts @@ -0,0 +1,96 @@ +/** + * Running token accounting for the terminal footer. Usage is keyed per + * turn/step so replayed or re-emitted usage replaces rather than double-counts. + * @module @deepseek-ai/dsh-tui/chat/tokens + */ + +import type { TokenUsage } from '@deepseek-ai/dsh-llm' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' + +/** + * Running token totals for the footer, keyed per turn/step so replayed or + * re-emitted usage replaces rather than double-counts; `input` is uncached + * input, cache buckets are disjoint. + */ +export interface SessionTokenTotals { + input: number + output: number + cacheRead: number + cacheWrite: number + readonly byStep: Map +} + +/** + * Fold one step's usage into the running totals, replacing any prior usage + * logged for the same turn/step. + * @param totals - Running totals mutated in place. + * @param turn - Turn index of the usage. + * @param step - Step index of the usage. + * @param usage - The step's token usage. + */ +export function recordTokenUsage(totals: SessionTokenTotals, turn: number, step: number, usage: TokenUsage): void { + const key = `${turn}:${step}` + const previous = totals.byStep.get(key) + if (previous !== undefined) { + totals.input -= previous.inputTokens + totals.output -= previous.outputTokens + totals.cacheRead -= previous.cacheReadTokens ?? 0 + totals.cacheWrite -= previous.cacheWriteTokens ?? 0 + } + totals.byStep.set(key, usage) + totals.input += usage.inputTokens + totals.output += usage.outputTokens + totals.cacheRead += usage.cacheReadTokens ?? 0 + totals.cacheWrite += usage.cacheWriteTokens ?? 0 +} + +/** + * Fold a usage-bearing session event into the running totals. + * @param totals - Running totals mutated in place. + * @param event - Session event; ignored when it carries no usage. + */ +export function recordEventUsage(totals: SessionTokenTotals, event: SessionEvent): void { + if (event.type === 'assistant/chunk' && event.data.chunk.type === 'usage') { + recordTokenUsage(totals, event.data.turn, event.data.step, event.data.chunk.usage) + } else if (event.type === 'assistant/message' && event.data.usage !== undefined) { + recordTokenUsage(totals, event.data.turn, event.data.step, event.data.usage) + } +} + +/** + * Share of billed input (prompt) tokens served from the provider cache, as an + * integer percent, or `undefined` before any input is billed (avoids 0/0 and a + * meaningless rate on an empty session). + * @param totals - Running totals to measure. + * @returns The cache hit rate percent, or `undefined` when no input is billed. + */ +export function cacheHitRate(totals: SessionTokenTotals): number | undefined { + const billedInput = totals.input + totals.cacheRead + totals.cacheWrite + if (billedInput === 0) return undefined + return Math.round((totals.cacheRead / billedInput) * 100) +} + +/** + * Fold every usage-bearing event in a session into fresh totals. + * @param session - Session whose events supply usage. + * @returns The accumulated token totals. + */ +export function sessionTokens(session: Session): SessionTokenTotals { + const totals: SessionTokenTotals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, byStep: new Map() } + for (const event of session.events) { + recordEventUsage(totals, event) + } + return totals +} + +/** + * Format a token count with a compact k/m suffix for the footer. + * @param value - Token count. + * @returns The compact display string. + */ +export function formatTokens(value: number): string { + if (value < 1_000) return String(value) + if (value < 10_000) return `${(value / 1_000).toFixed(1)}k` + if (value < 1_000_000) return `${Math.round(value / 1_000)}k` + return `${(value / 1_000_000).toFixed(1)}m` +} diff --git a/packages/ui/tui/src/components/content.ts b/packages/ui/tui/src/components/content.ts new file mode 100644 index 0000000000..a4536a6afd --- /dev/null +++ b/packages/ui/tui/src/components/content.ts @@ -0,0 +1,56 @@ +/** + * Content-block primitives shared across the terminal front door: flattening + * session content to display text and parsing tool-call arguments. + * @module @deepseek-ai/dsh-tui/components/content + */ + +import type { ContentBlock } from '@deepseek-ai/dsh-llm' + +/** + * Flatten content blocks into a single display string, recursing into + * tool-result content and naming unknown block types. + * @param content - Content blocks to flatten. + * @returns The concatenated display text. + */ +export function contentText(content: readonly ContentBlock[]): string { + const parts: string[] = [] + for (const block of content) { + switch (block.type) { + case 'text': + case 'reasoning': + parts.push(block.text) + break + case 'tool-call': + parts.push(`${block.name}(${block.arguments})`) + break + case 'tool-result': + parts.push(contentText(block.content)) + break + default: { + const rawType = (block as { type?: unknown }).type + parts.push(`[${typeof rawType === 'string' ? rawType : 'content'}]`) + break + } + } + } + return parts.join('') +} + +/** A tool call's arguments parsed from their JSON source, with a validity flag. */ +export interface ParsedArguments { + value: unknown + valid: boolean +} + +/** + * Parse tool-call arguments from their JSON source. + * @param raw - Raw JSON arguments text. + * @returns The parsed value, or the raw text with `valid: false` on parse failure. + */ +export function parseArguments(raw: string): ParsedArguments { + try { + return { value: JSON.parse(raw), valid: true } + } catch { + return { value: raw, valid: false } + } +} diff --git a/packages/ui/tui/src/components/dialogs.ts b/packages/ui/tui/src/components/dialogs.ts new file mode 100644 index 0000000000..1ebfb00fd7 --- /dev/null +++ b/packages/ui/tui/src/components/dialogs.ts @@ -0,0 +1,789 @@ +/** + * pi-tui dialog and selector components for the terminal front door: the status + * card, prompt-context line, model selector, resume picker, and user-question + * dialog, plus the model-choice and resume-candidate data they present. + * @module @deepseek-ai/dsh-tui/components/dialogs + */ + +import { + Input, + Key, + SelectList, + matchesKey, + truncateToWidth, + visibleWidth, + wrapTextWithAnsi, + type Component, + type Focusable, + type SelectItem, +} from '@earendil-works/pi-tui' +import type { Context } from 'cordis' +import { + type Agent, + type AgentLlmTarget, +} from '@deepseek-ai/dsh-agent' +import type { LlmModelInfo, LlmModelReasoningInfo, ReasoningEffortId } from '@deepseek-ai/dsh-llm' +import type { SessionId } from '@deepseek-ai/dsh-session' +import { foldGoal, type GoalPhase } from '@deepseek-ai/dsh-goal' +import { foldSessionTitle } from '@deepseek-ai/dsh-session-title' +import type { + SessionLogSnapshot, + SessionRecord, +} from '@deepseek-ai/dsh-session-query' +import type { AskUserQuestionItem } from '@deepseek-ai/dsh-user-interaction' +import { BRACKETED_PASTE_END, BRACKETED_PASTE_START, displayText, sanitizePastedText } from './text.ts' +import { dialogSelectTheme, type Palette } from './theme.ts' +import { + renderTuiPromptTemplate, + type TuiPromptTemplateToken, +} from '../prompt.ts' + +/** A selectable model advertised by a provider, with its display name, description, and reasoning metadata. */ +export interface ModelChoice extends AgentLlmTarget { + modelName: string + description?: string + reasoning?: LlmModelReasoningInfo +} + +/** + * The provider/model route and selected reasoning effort resolved from a model dialog. + */ +export interface ModelDialogSelection { + choice: ModelChoice + reasoningEffort: ReasoningEffortId | undefined +} + +/** + * Format a provider/model target as its `provider/model` label. + * @param target - The LLM target. + * @returns The `provider/model` label. + */ +export function targetLabel(target: AgentLlmTarget): string { + return `${target.provider}/${target.model}` +} + +/** + * Format a target compactly as its model name with any selected reasoning effort appended. + * @param target - The LLM target. + * @returns The compact `model [effort]` label. + */ +export function compactTargetLabel(target: AgentLlmTarget): string { + return `${target.model}${target.reasoningEffort === undefined ? '' : ` ${target.reasoningEffort}`}` +} + +/** + * Resolve the display label for a choice's reasoning effort. + * @param choice - The model choice carrying advertised reasoning metadata. + * @param effort - The selected effort, or `undefined` for provider default. + * @returns The effort's display name, `provider default`, or `undefined` when the model has no reasoning metadata. + */ +export function targetReasoningLabel(choice: ModelChoice, effort: ReasoningEffortId | undefined): string | undefined { + if (effort === undefined) return choice.reasoning === undefined ? undefined : 'provider default' + return choice.reasoning?.efforts.find(candidate => candidate.id === effort)?.name ?? effort +} + +/** + * Derive the agent's initial LLM target from its logged request header or options. + * @param agent - The driven agent. + * @returns The initial target, or `undefined` when unset. + */ +export function initialTarget(agent: Agent): AgentLlmTarget | undefined { + const logged = agent.session.requestHeader()?.config + if (logged !== undefined) { + if (logged.reasoningEffort === undefined) { + return { provider: logged.provider, model: logged.model } + } + return { provider: logged.provider, model: logged.model, reasoningEffort: logged.reasoningEffort } + } + if (agent.options.provider === undefined || agent.options.model === undefined) return undefined + return { provider: agent.options.provider, model: agent.options.model } +} + +/** + * List every advertised model across registered providers, appending the current + * target when a provider does not advertise it. + * @param ctx - Context supplying the LLM service. + * @param current - The current target, appended when unadvertised. + * @returns The model choices, flattened across providers. + */ +export async function readModelChoices( + ctx: Context, + current: AgentLlmTarget | undefined, +): Promise { + const providers = ctx.llm.listProviders() + const groups = await Promise.all(providers.map(async (provider) => { + const advertised = await ctx.llm.listModels(provider.id) + const models: LlmModelInfo[] = [...advertised] + if ( + current?.provider === provider.id + && !models.some(model => model.id === current.model) + ) { + models.push({ provider: provider.id, id: current.model, name: current.model }) + } + return Promise.all(models.map(async (model): Promise => { + const reasoning = (await ctx.llm.resolveModelInfo(provider.id, model.id)).reasoning + return { + provider: provider.id, + model: model.id, + modelName: model.name, + ...model.description === undefined ? {} : { description: model.description }, + ...reasoning === undefined ? {} : { reasoning }, + } + })) + })) + return groups.flat() +} + +/** + * Format a diagnostic integer with grouping separators. + * @param value - Integer to format. + * @returns The grouped decimal string. + */ +export function formatDiagnosticNumber(value: number): string { + return value.toLocaleString('en-US') +} + +/** + * Format a diagnostic timestamp as an ISO date-time in UTC. + * @param value - Epoch milliseconds. + * @returns The formatted UTC timestamp. + */ +export function formatDiagnosticTime(value: number): string { + return new Date(value).toISOString().replace('T', ' ').replace(/\.\d{3}Z$/u, ' UTC') +} + +/** + * Format a pluralized count for a diagnostic row. + * @param value - Count. + * @param singular - Singular noun; an `s` is appended for other counts. + * @returns The formatted count. + */ +export function formatDiagnosticCount(value: number, singular: string): string { + return `${String(value)} ${singular}${value === 1 ? '' : 's'}` +} + +/** + * Render a fixed-width filled meter bar for a percentage. + * @param percent - Percentage in [0, 100]. + * @param palette - Active role palette. + * @returns The rendered meter. + */ +export function diagnosticMeter(percent: number, palette: Palette): string { + const width = 16 + const filled = Math.round(Math.min(100, Math.max(0, percent)) / 100 * width) + return `${palette.dim('[')}${palette.accent('█'.repeat(filled))}${palette.dim(`${'░'.repeat(width - filled)}]`)}` +} + +/** One `label: value` row of a status card group. */ +export type StatusCardRow = readonly [label: string, value: string] + +/** Bordered, grouped field card for one point-in-time status snapshot. */ +export class StatusCardComponent implements Component { + constructor( + private readonly groups: readonly (readonly StatusCardRow[])[], + private readonly palette: Palette, + ) {} + + invalidate(): void {} + + render(width: number): string[] { + const labels = this.groups.flatMap(group => group.map(([label]) => `${label}:`)) + const naturalLabelWidth = Math.max(...labels.map(label => label.length)) + const naturalBodyWidth = Math.max(...this.groups.flatMap(group => group.map(([, value]) => + 1 + naturalLabelWidth + 2 + visibleWidth(value)))) + const cardWidth = Math.min( + Math.max(8, width), + Math.max('Session status'.length + 5, naturalBodyWidth + 4), + ) + const innerWidth = Math.max(1, cardWidth - 4) + const labelWidth = Math.min( + naturalLabelWidth, + Math.max(1, Math.floor(innerWidth / 3)), + ) + const body: string[] = [] + for (const [groupIndex, group] of this.groups.entries()) { + if (groupIndex > 0) body.push('') + for (const [label, value] of group) { + const plainLabel = truncateToWidth(`${label}:`, labelWidth, '') + const prefix = ` ${this.palette.muted(plainLabel.padEnd(labelWidth))} ` + const continuation = ' '.repeat(1 + labelWidth + 2) + const valueWidth = Math.max(1, innerWidth - visibleWidth(prefix)) + const wrapped = wrapTextWithAnsi(value, valueWidth) + for (const [lineIndex, line] of wrapped.entries()) { + body.push(`${lineIndex === 0 ? prefix : continuation}${line}`) + } + } + } + + const title = truncateToWidth('Session status', Math.max(1, cardWidth - 5), '') + const topTail = '─'.repeat(Math.max(0, cardWidth - visibleWidth(title) - 5)) + const top = `${this.palette.dim('╭─ ')}${this.palette.bold(this.palette.accent(title))}${this.palette.dim(` ${topTail}╮`)}` + const lines = [top] + for (const line of body) { + const clipped = truncateToWidth(line, innerWidth, '') + lines.push(`${this.palette.dim('│')} ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ${this.palette.dim('│')}`) + } + lines.push(this.palette.dim(`╰${'─'.repeat(Math.max(0, cardWidth - 2))}╯`)) + return lines + } +} + +/** The left/right template line rendered above the editor. */ +export class PromptContextComponent implements Component { + constructor( + private readonly leftTemplate: readonly TuiPromptTemplateToken[], + private readonly rightTemplate: readonly TuiPromptTemplateToken[], + private readonly resolve: (name: string) => string | undefined, + ) {} + + invalidate(): void {} + + render(width: number): string[] { + const right = truncateToWidth(renderTuiPromptTemplate(this.rightTemplate, this.resolve), width, '') + const rightWidth = visibleWidth(right) + const leftCapacity = Math.max(0, width - rightWidth - (rightWidth === 0 ? 0 : 2)) + const left = truncateToWidth(renderTuiPromptTemplate(this.leftTemplate, this.resolve), leftCapacity, '') + if (rightWidth === 0) return [left] + const gap = ' '.repeat(Math.max(0, width - visibleWidth(left) - rightWidth)) + return [`${left}${gap}${right}`] + } +} + +/** A user's answer to one question: chosen option labels and an optional custom answer. */ +export interface QuestionSelection { + selected: string[] + custom?: string +} + +/** + * Render a bordered dialog frame around body lines with a titled top edge. + * @param title - Dialog title shown in the top border. + * @param body - Body lines. + * @param width - Dialog width in columns. + * @param palette - Active role palette. + * @returns The framed dialog lines. + */ +export function renderDialog( + title: string, + body: readonly string[], + width: number, + palette: Palette, +): string[] { + const innerWidth = Math.max(1, width - 4) + const topLabel = ` ${displayText(title)} ` + const top = `╭${topLabel}${'─'.repeat(Math.max(0, width - visibleWidth(topLabel) - 2))}╮` + const lines: string[] = [palette.accent(top)] + for (const line of body) { + const clipped = truncateToWidth(line, innerWidth, '') + lines.push(`${palette.accent('│')} ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ${palette.accent('│')}`) + } + lines.push(palette.accent(`╰${'─'.repeat(Math.max(0, width - 2))}╯`)) + return lines +} + +/** Keyboard model selector rendered as a bordered overlay, with per-model reasoning-effort cycling. */ +export class ModelDialog implements Component { + private readonly list: SelectList + private readonly items: Map + private readonly choices: Map + private readonly efforts: Map + private readonly currentValue: string | undefined + + constructor( + choices: readonly ModelChoice[], + current: AgentLlmTarget | undefined, + maxVisible: number, + private readonly palette: Palette, + done: (selection: ModelDialogSelection) => void, + cancel: () => void, + ) { + this.items = new Map() + this.choices = new Map() + this.efforts = new Map() + this.currentValue = current === undefined ? undefined : targetLabel(current) + for (const choice of choices) { + const value = targetLabel(choice) + const isCurrent = current?.provider === choice.provider && current.model === choice.model + this.choices.set(value, choice) + this.efforts.set( + value, + isCurrent + ? current.reasoningEffort ?? choice.reasoning?.defaultEffort + : choice.reasoning?.defaultEffort, + ) + this.items.set(value, { + value, + label: displayText(value), + description: this.describeChoice(choice, isCurrent), + }) + } + this.list = new SelectList([...this.items.values()], maxVisible, dialogSelectTheme(palette)) + const currentIndex = current === undefined + ? 0 + : choices.findIndex(choice => choice.provider === current.provider && choice.model === current.model) + this.list.setSelectedIndex(currentIndex) + this.list.onSelect = (item) => { + const selected = choices.find(choice => targetLabel(choice) === item.value) + /* v8 ignore next -- SelectList only returns values built from `choices`. */ + if (selected === undefined) return + done({ choice: selected, reasoningEffort: this.efforts.get(item.value) }) + } + this.list.onCancel = cancel + } + + private describeChoice(choice: ModelChoice, isCurrent: boolean): string { + const effortLabel = targetReasoningLabel(choice, this.efforts.get(targetLabel(choice))) + return [ + displayText(choice.modelName), + ...choice.description === undefined ? [] : [displayText(choice.description)], + ...effortLabel === undefined ? [] : [displayText(effortLabel)], + ...isCurrent ? ['current'] : [], + ].join(' — ') + } + + private cycleReasoningEffort(): void { + const selectedItem = this.list.getSelectedItem() + /* v8 ignore next -- the dialog is opened only for a non-empty catalog. */ + if (selectedItem === null) return + const choice = this.choices.get(selectedItem.value) + if (choice?.reasoning === undefined) return + const current = this.efforts.get(selectedItem.value) + const efforts: Array = [ + ...choice.reasoning.defaultEffort === undefined ? [undefined] : [], + ...choice.reasoning.efforts.map(effort => effort.id), + ] + const currentIndex = efforts.indexOf(current) + const next = efforts[(currentIndex + 1) % efforts.length] + this.efforts.set(selectedItem.value, next) + const item = this.items.get(selectedItem.value) + /* v8 ignore next -- items and choices are constructed from the same values. */ + if (item === undefined) return + item.description = this.describeChoice(choice, selectedItem.value === this.currentValue) + } + + invalidate(): void { + this.list.invalidate() + } + + handleInput(data: string): void { + if (matchesKey(data, Key.shift(Key.tab))) { + this.cycleReasoningEffort() + } else { + this.list.handleInput(data) + } + this.invalidate() + } + + render(width: number): string[] { + const innerWidth = Math.max(1, width - 4) + return renderDialog('Select model', [ + ...this.list.render(innerWidth), + '', + this.palette.dim('↑/↓ navigate • Shift+Tab reasoning • Enter select • Esc cancel'), + ], width, this.palette) + } +} + +/** The provider/model route recovered from a resume candidate's log. */ +export interface ResumeRoute { + provider: string + model: string +} + +/** A preflighted resume selector row summarizing one persisted session. */ +export interface ResumeCandidate { + record: SessionRecord + title: string + lastActivityAt: number + lastTurn: string + route?: ResumeRoute + goalPhase?: GoalPhase + disabledReason?: string +} + +function resumeTurnLabel(snapshot: SessionLogSnapshot): string { + const event = snapshot.events.findLast(item => item.type === 'turn/end') + if (event === undefined) return 'no completed turn' + const reason = event.data.reason + switch (reason.kind) { + case 'completed': return `turn ${event.data.turn}: completed` + case 'aborted': return `turn ${event.data.turn}: cancelled` + case 'error': return `turn ${event.data.turn}: error` + case 'disposed': return `turn ${event.data.turn}: disposed` + case 'max-tokens': return `turn ${event.data.turn}: max tokens` + case 'interrupted': return `turn ${event.data.turn}: interrupted` + default: return `turn ${event.data.turn}: unknown result` + } +} + +function resumeRoute(snapshot: SessionLogSnapshot): ResumeRoute | undefined { + const header = snapshot.events.findLast(item => item.type === 'request/header') + if (header?.type === 'request/header') { + return { provider: header.data.header.config.provider, model: header.data.header.config.model } + } + const assistant = snapshot.events.findLast(item => item.type === 'assistant/message') + return assistant?.type === 'assistant/message' + ? { provider: assistant.data.provenance.provider, model: assistant.data.provenance.model } + : undefined +} + +/** + * Build one resume selector row from a record and its log snapshot, deriving the + * title, route, goal phase, and any reason the session cannot be resumed here. + * @param record - The session record. + * @param snapshot - The session's log snapshot. + * @param currentId - The current session id. + * @param cwd - The current workspace directory. + * @param availableProviders - Providers registered in this runtime. + * @returns The summarized resume candidate. + */ +export function summarizeResumeCandidate( + record: SessionRecord, + snapshot: SessionLogSnapshot, + currentId: SessionId, + cwd: string | undefined, + availableProviders: ReadonlySet, +): ResumeCandidate { + const title = foldSessionTitle(snapshot.events)?.title ?? 'Untitled session' + const route = resumeRoute(snapshot) + const foldedGoal = foldGoal(snapshot.events).goal + let disabledReason: string | undefined + if (record.header.id === currentId) disabledReason = 'current session' + else if (record.live) disabledReason = 'session is already live in this runtime' + else if (record.header.cwd !== cwd) disabledReason = 'different workspace' + else if (route !== undefined && !availableProviders.has(route.provider)) { + disabledReason = `session is complete, but route is currently unavailable (${route.provider}/${route.model})` + } + return { + record, + title, + lastActivityAt: snapshot.events.at(-1)?.time ?? snapshot.session.createdAt, + lastTurn: resumeTurnLabel(snapshot), + ...route === undefined ? {} : { route }, + /* v8 ignore next -- goal-bearing resume records are covered by the goal/session integration surface. */ + ...foldedGoal === undefined ? {} : { goalPhase: foldedGoal.phase }, + ...disabledReason === undefined ? {} : { disabledReason }, + } +} + +/** Full-viewport keyboard selector over detached, preflighted resume summaries. */ +export class ResumePicker implements Component, Focusable { + private readonly search = new Input() + private pasteBuffer: string | undefined + private selectedIndex = 0 + private error = '' + focused = false + + constructor( + private readonly candidates: readonly ResumeCandidate[], + private readonly maxVisible: number, + private readonly workspaceLabel: string, + private readonly viewportRows: () => number, + private readonly palette: Palette, + private readonly done: (candidate: ResumeCandidate) => void, + private readonly cancel: () => void, + ) {} + + invalidate(): void { + this.search.invalidate() + } + + private filtered(): ResumeCandidate[] { + const query = this.search.getValue().trim().toLocaleLowerCase() + if (query === '') return [...this.candidates] + return this.candidates.filter(candidate => candidate.title.toLocaleLowerCase().includes(query) + || candidate.record.header.id.toLocaleLowerCase().includes(query)) + } + + private visibleCandidateCount(): number { + const candidateBudget = Math.max(1, Math.floor((Math.max(1, this.viewportRows()) - 13) / 4)) + return Math.min(this.maxVisible, candidateBudget) + } + + private handleBracketedPaste(data: string): boolean { + const start = data.indexOf(BRACKETED_PASTE_START) + if (this.pasteBuffer === undefined && start < 0) return false + if (this.pasteBuffer === undefined) { + const prefix = data.slice(0, start) + if (prefix !== '') this.handleInput(prefix) + this.pasteBuffer = data.slice(start + BRACKETED_PASTE_START.length) + } else { + this.pasteBuffer += data + } + const end = this.pasteBuffer.indexOf(BRACKETED_PASTE_END) + if (end < 0) return true + const pasted = sanitizePastedText(this.pasteBuffer.slice(0, end)) + const remaining = this.pasteBuffer.slice(end + BRACKETED_PASTE_END.length) + this.pasteBuffer = undefined + const previous = this.search.getValue() + this.search.handleInput(`${BRACKETED_PASTE_START}${pasted}${BRACKETED_PASTE_END}`) + if (this.search.getValue() !== previous) { + this.selectedIndex = 0 + this.error = '' + } + if (remaining !== '') this.handleInput(remaining) + this.invalidate() + return true + } + + handleInput(data: string): void { + if (this.handleBracketedPaste(data)) return + const filtered = this.filtered() + if (matchesKey(data, Key.ctrl('c'))) { + this.cancel() + return + } + if (matchesKey(data, Key.escape)) { + if (this.search.getValue() === '') this.cancel() + else { + this.search.setValue('') + this.selectedIndex = 0 + this.error = '' + } + } else if (matchesKey(data, Key.up)) { + this.selectedIndex = filtered.length === 0 + ? 0 + : (this.selectedIndex + filtered.length - 1) % filtered.length + } else if (matchesKey(data, Key.down)) { + this.selectedIndex = filtered.length === 0 ? 0 : (this.selectedIndex + 1) % filtered.length + } else if (matchesKey(data, Key.pageUp)) { + this.selectedIndex = Math.max(0, this.selectedIndex - this.visibleCandidateCount()) + } else if (matchesKey(data, Key.pageDown)) { + this.selectedIndex = Math.min( + Math.max(0, filtered.length - 1), + this.selectedIndex + this.visibleCandidateCount(), + ) + } else if (matchesKey(data, Key.enter)) { + const selected = filtered[this.selectedIndex] + if (selected === undefined) this.error = 'No session matches this search.' + else if (selected.disabledReason !== undefined) this.error = selected.disabledReason + else this.done(selected) + } else { + const previous = this.search.getValue() + this.search.focused = this.focused + this.search.handleInput(data) + if (this.search.getValue() !== previous) { + this.selectedIndex = 0 + this.error = '' + } + } + this.invalidate() + } + + render(width: number): string[] { + this.search.focused = this.focused + const height = Math.max(1, this.viewportRows()) + const horizontalPadding = width >= 12 ? 2 : 0 + const contentWidth = Math.max(1, width - horizontalPadding * 2) + const indent = ' '.repeat(horizontalPadding) + const filtered = this.filtered() + if (this.selectedIndex >= filtered.length) this.selectedIndex = Math.max(0, filtered.length - 1) + const selected = filtered[this.selectedIndex] + const position = selected === undefined ? 0 : this.selectedIndex + 1 + const lines: string[] = [ + '', + `${indent}${this.palette.bold(this.palette.accent(`Resume session (${position} of ${filtered.length})`))}`, + '', + ] + + const searchInnerWidth = Math.max(1, contentWidth - 4) + lines.push(`${indent}${this.palette.dim(`╭${'─'.repeat(Math.max(0, contentWidth - 2))}╮`)}`) + const searchContent = this.search.render(searchInnerWidth).join('').replace(/^> /u, '⌕ ') + const clippedSearch = truncateToWidth(searchContent, searchInnerWidth, '') + lines.push( + `${indent}${this.palette.dim('│')} ${clippedSearch}${' '.repeat(Math.max(0, searchInnerWidth - visibleWidth(clippedSearch)))} ${this.palette.dim('│')}`, + `${indent}${this.palette.dim(`╰${'─'.repeat(Math.max(0, contentWidth - 2))}╯`)}`, + '', + `${indent}${this.palette.muted(displayText(this.workspaceLabel))}`, + '', + ) + + const visibleCount = this.visibleCandidateCount() + const start = Math.max(0, Math.min( + this.selectedIndex - Math.floor(visibleCount / 2), + filtered.length - visibleCount, + )) + const end = Math.min(filtered.length, start + visibleCount) + const push = (line: string): void => { + lines.push(`${indent}${truncateToWidth(line, contentWidth, '…')}`) + } + for (let index = start; index < end; index += 1) { + const candidate = filtered[index] as ResumeCandidate + const active = index === this.selectedIndex + const status = [ + candidate.disabledReason === 'current session' ? 'current' : undefined, + candidate.record.live ? 'live' : undefined, + candidate.record.persisted ? 'persisted' : undefined, + ].filter((value): value is string => value !== undefined).join(' · ') + const lead = `${active ? '❯' : ' '} ${displayText(candidate.title)}` + push(active ? this.palette.bold(this.palette.accent(lead)) : lead) + const route = candidate.route === undefined ? 'route unavailable' : `${candidate.route.provider}/${candidate.route.model}` + /* v8 ignore next -- only goal-bearing resume records add this integration-owned suffix. */ + const goal = candidate.goalPhase === undefined ? '' : ` · goal ${candidate.goalPhase}` + push(this.palette.muted(` ${new Date(candidate.lastActivityAt).toISOString()} · ${candidate.lastTurn} · ${route}${goal}`)) + push(this.palette.dim(` ${status} · ${displayText(candidate.record.header.id)}`)) + if (candidate.disabledReason !== undefined) { + push(this.palette.warning(` unavailable: ${displayText(candidate.disabledReason)}`)) + } + } + if (filtered.length === 0) push(this.palette.warning('No matching sessions.')) + if (this.error !== '') { + lines.push('') + push(this.palette.error(displayText(this.error))) + } + + const footer = `${indent}${this.palette.dim('Type to search • ↑/↓ navigate • Enter resume • Esc clear/cancel')}` + while (lines.length < height - 2) lines.push('') + lines.push(footer, '') + return lines.slice(0, height) + } +} + +/** Bottom-anchored dialog for one user question with option or custom-answer modes. */ +export class QuestionDialog implements Component, Focusable { + private selectedIndex = 0 + private selected = new Set() + private mode: 'options' | 'custom' + private error = '' + private readonly input = new Input() + private readonly options: NonNullable + focused = false + + constructor( + private readonly question: AskUserQuestionItem, + private readonly position: number, + private readonly total: number, + private readonly unanswered: number, + private readonly maxVisible: number, + private readonly palette: Palette, + private readonly done: (selection: QuestionSelection) => void, + private readonly cancel: () => void, + ) { + this.options = question.options ?? [] + this.mode = this.options.length > 0 ? 'options' : 'custom' + this.input.onSubmit = (value) => { this.submitCustom(value) } + this.input.onEscape = () => { + if (this.options.length > 0) { + this.mode = 'options' + this.error = '' + } else { + this.cancel() + } + } + } + + invalidate(): void { + this.input.invalidate() + } + + handleInput(data: string): void { + this.invalidate() + if (this.mode === 'custom') { + this.input.focused = this.focused + this.input.handleInput(data) + return + } + const options = this.options + if (matchesKey(data, Key.up)) { + this.selectedIndex = this.selectedIndex === 0 ? options.length - 1 : this.selectedIndex - 1 + } else if (matchesKey(data, Key.down)) { + this.selectedIndex = this.selectedIndex === options.length - 1 ? 0 : this.selectedIndex + 1 + } else if (matchesKey(data, Key.space) && this.question.multiSelect) { + if (this.selected.has(this.selectedIndex)) this.selected.delete(this.selectedIndex) + else this.selected.add(this.selectedIndex) + } else if (matchesKey(data, Key.enter)) { + const indices = this.question.multiSelect ? [...this.selected].sort((a, b) => a - b) : [this.selectedIndex] + if (indices.length === 0) { + this.error = 'Select at least one option, or press Tab for a custom answer.' + return + } + this.done({ selected: indices.map(index => options[index]?.label).filter((label): label is string => label !== undefined) }) + } else if (matchesKey(data, Key.tab) || data.toLowerCase() === 'c') { + this.mode = 'custom' + this.error = '' + } else if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) { + this.cancel() + } + } + + private submitCustom(value: string): void { + const custom = value.trim() + if (custom === '') { + this.error = 'Enter an answer before submitting.' + return + } + this.done({ selected: [], custom }) + } + + render(width: number): string[] { + this.input.focused = this.focused + const innerWidth = Math.max(1, width - 4) + const header = `Question ${this.position}/${this.total} (${this.unanswered} unanswered)${this.question.header === undefined ? '' : ` · ${displayText(this.question.header)}`}` + const lines = [ + this.palette.muted(header), + ...wrapTextWithAnsi(this.palette.text(displayText(this.question.question)), innerWidth), + ] + const push = (line: string): void => { lines.push(line) } + // Supporting detail (e.g. the full plan under review) renders between the + // question and the answer surface, kept out of option labels. + if (this.question.detail !== undefined) { + push('') + for (const line of wrapTextWithAnsi(displayText(this.question.detail), innerWidth)) push(line) + } + push('') + if (this.mode === 'custom') { + for (const line of this.input.render(innerWidth)) push(line) + push(this.palette.dim(this.options.length > 0 ? 'Enter submit • Esc options' : 'Enter submit • Esc cancel')) + } else { + const options = this.options + const start = Math.max(0, Math.min( + this.selectedIndex - Math.floor(this.maxVisible / 2), + options.length - this.maxVisible, + )) + const end = Math.min(options.length, start + this.maxVisible) + const optionRows = options.slice(start, end).map((option, offset) => { + const index = start + offset + const mark = this.question.multiSelect + ? this.selected.has(index) ? '[x] ' : '[ ] ' + : '' + return `${index === this.selectedIndex ? '›' : ' '} ${index + 1}. ${mark}${displayText(option.label)}` + }) + const descriptionColumn = Math.min( + Math.max(...optionRows.map(row => visibleWidth(row))) + 2, + Math.max(1, Math.floor(innerWidth * 0.55)), + ) + for (let index = start; index < end; index += 1) { + // `index < end <= options.length`; the options array is borrowed immutably for this dialog. + const option = options[index] as NonNullable[number] + const mark = this.question.multiSelect + ? this.selected.has(index) ? '[x] ' : '[ ] ' + : '' + const left = `${index === this.selectedIndex ? '›' : ' '} ${index + 1}. ${mark}${displayText(option.label)}` + const leftStyled = index === this.selectedIndex + ? this.palette.bold(this.palette.accent(left)) + : left + const description = option.description === undefined + ? '' + : `${' '.repeat(Math.max(1, descriptionColumn - visibleWidth(left)))}${this.palette.muted(displayText(option.description))}` + push(`${leftStyled}${description}`) + } + if (options.length > this.maxVisible) push(this.palette.dim(`${this.selectedIndex + 1}/${options.length}`)) + const controls = [ + 'Tab custom answer', + ...(options.length > 1 ? ['↑/↓ navigate'] : []), + ...(this.question.multiSelect ? ['Space toggle'] : []), + 'Enter submit', + 'Esc interrupt', + ] + const hint = this.palette.dim(controls.join(' • ')) + for (const line of wrapTextWithAnsi(hint, innerWidth)) push(line) + } + if (this.error) { + for (const line of wrapTextWithAnsi(this.palette.error(this.error), innerWidth)) push(line) + } + return ['', ...lines, ''].map((line) => { + const clipped = truncateToWidth(line, innerWidth, '') + return ` ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ` + }) + } +} diff --git a/packages/ui/tui/src/components/text.ts b/packages/ui/tui/src/components/text.ts new file mode 100644 index 0000000000..876893adfd --- /dev/null +++ b/packages/ui/tui/src/components/text.ts @@ -0,0 +1,49 @@ +/** + * Terminal text sanitization shared across the pi-tui front door. External text + * (model output, tool results, clipboard) is escaped or stripped of C0/C1 + * controls before the TUI adds its own application-owned ANSI. + * @module @deepseek-ai/dsh-tui/components/text + */ + +const TERMINAL_CONTROL_PATTERN = /[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/gu +const TERMINAL_OSC_PATTERN = /(?:\u001B\]|\u009D)(?:(?!\u0007|\u001B\\)[\s\S])*(?:\u0007|\u001B\\|$)/gu +const TERMINAL_CSI_PATTERN = /(?:\u001B\[|\u009B)[0-?]*[ -/]*[@-~]/gu +const TERMINAL_ESCAPE_PATTERN = /\u001B[@-_]/gu + +/** Bracketed-paste start marker emitted by terminals around pasted content. */ +export const BRACKETED_PASTE_START = '\u001B[200~' +/** Bracketed-paste end marker emitted by terminals around pasted content. */ +export const BRACKETED_PASTE_END = '\u001B[201~' + +/** + * Escape external C0/C1 controls before pi-tui adds application-owned ANSI. + * Line feeds remain structural so transcript and tool output retain their layout. + * @param text - Untrusted text to render. + * @returns The text with control characters escaped as `\xNN`. + */ +export function displayText(text: string): string { + return text.replace(TERMINAL_CONTROL_PATTERN, control => + `\\x${control.charCodeAt(0).toString(16).padStart(2, '0')}`) +} + +/** + * Escape external controls for terminal fields that must remain on one line. + * @param text - Untrusted text to render inline. + * @returns The escaped text with newlines rendered as `\x0a`. + */ +export function displayInlineText(text: string): string { + return displayText(text).replaceAll('\n', '\\x0a') +} + +/** + * Remove terminal controls from clipboard text before an editable field stores it. + * @param text - Raw pasted clipboard text. + * @returns The text stripped of OSC, CSI, escape, and control sequences. + */ +export function sanitizePastedText(text: string): string { + return text + .replace(TERMINAL_OSC_PATTERN, '') + .replace(TERMINAL_CSI_PATTERN, '') + .replace(TERMINAL_ESCAPE_PATTERN, '') + .replace(TERMINAL_CONTROL_PATTERN, '') +} diff --git a/packages/ui/tui/src/components/theme.ts b/packages/ui/tui/src/components/theme.ts new file mode 100644 index 0000000000..1ea75b437f --- /dev/null +++ b/packages/ui/tui/src/components/theme.ts @@ -0,0 +1,184 @@ +/** + * Theme-agnostic ANSI palette and derived pi-tui themes for the terminal front + * door. The palette is built from the standard 16-color ANSI set plus SGR + * attributes so every terminal remaps it to its active color scheme. + * @module @deepseek-ai/dsh-tui/components/theme + */ + +import type { + MarkdownTheme, + SelectListTheme, + TerminalColorScheme, +} from '@earendil-works/pi-tui' + +/** Theme-agnostic role colors and SGR attribute wrappers. */ +export interface Palette { + accent: (text: string) => string + accent2: (text: string) => string + text: (text: string) => string + muted: (text: string) => string + dim: (text: string) => string + success: (text: string) => string + warning: (text: string) => string + error: (text: string) => string + code: (text: string) => string + added: (text: string) => string + removed: (text: string) => string + bold: (text: string) => string + italic: (text: string) => string + underline: (text: string) => string + strike: (text: string) => string + /** Reverse video for the active selection; swaps the theme's own fg/bg so it reads on any scheme. */ + selected: (text: string) => string +} + +function ansi(open: string, close: string, enabled: boolean): (text: string) => string { + return enabled ? text => `\x1b[${open}m${text}\x1b[${close}m` : text => text +} + +/** + * Theme-agnostic palette built from the standard 16-color ANSI set plus SGR + * attributes, which every terminal remaps to its active color scheme. Body + * `text` stays the terminal's default foreground so it reads on light and dark + * backgrounds alike; grouping uses foreground-only bold, underlined role + * headers and reverse video rather than fixed background fills or per-line + * prefixes, so a transcript drag-select copies message text without stray + * glyphs. + * + * @param enabled - Whether ANSI is emitted at all. + * @param scheme - Active terminal color scheme; adjusts dim and code roles. + * @returns The role palette for the given scheme. + */ +export function createPalette(enabled: boolean, scheme: TerminalColorScheme = 'dark'): Palette { + return { + accent: ansi('94', '39', enabled), + accent2: ansi('95', '39', enabled), + text: text => text, + muted: ansi('90', '39', enabled), + // SGR 2 (dim) lightens text on a light background — substitute ANSI 90 + // (bright black / gray) which renders as a readable muted tone on any scheme. + dim: scheme === 'light' ? ansi('90', '39', enabled) : ansi('2', '22', enabled), + success: ansi('32', '39', enabled), + warning: ansi('33', '39', enabled), + error: ansi('31', '39', enabled), + // ANSI 36 (cyan) is difficult to read on a light background — use + // ANSI 34 (blue) which is legible on both light and dark schemes. + code: scheme === 'light' ? ansi('34', '39', enabled) : ansi('36', '39', enabled), + added: ansi('32', '39', enabled), + removed: ansi('31', '39', enabled), + bold: ansi('1', '22', enabled), + italic: ansi('3', '23', enabled), + underline: ansi('4', '24', enabled), + strike: ansi('9', '29', enabled), + selected: ansi('7', '27', enabled), + } +} + +/** + * DeepSeek brand gradient stops (indigo → light blue) taken from the + * deepseek.com logo, painted across the startup banner's product name on + * truecolor terminals. Fixed brand identity, deliberately outside the + * theme-adaptive {@link Palette}. + */ +const BRAND_GRADIENT = [ + [77, 107, 254], // #4D6BFE + [57, 130, 255], // #3982FF + [36, 152, 255], // #2498FF +] as const + +/** + * Sample {@link BRAND_GRADIENT} at fraction `t` via piecewise-linear + * interpolation across its stops. + * + * @param t - Position along the gradient; clamped to [0, 1]. + * @returns The interpolated `[r, g, b]` channels, each rounded to 0–255. + */ +function brandColorAt(t: number): readonly [number, number, number] { + const span = Math.min(Math.max(t, 0), 1) * (BRAND_GRADIENT.length - 1) + const index = Math.min(Math.floor(span), BRAND_GRADIENT.length - 2) + const local = span - index + // `index` is clamped to a valid adjacent pair, so both lookups are in-bounds. + const from = BRAND_GRADIENT[index] as readonly [number, number, number] + const to = BRAND_GRADIENT[index + 1] as readonly [number, number, number] + return [ + Math.round(from[0] + (to[0] - from[0]) * local), + Math.round(from[1] + (to[1] - from[1]) * local), + Math.round(from[2] + (to[2] - from[2]) * local), + ] +} + +/** + * Paint `text` left-to-right in the DeepSeek brand gradient with per-character + * 24-bit foreground codes, resetting to the default foreground at the end. + * Foreground-only, so it stays legible on any terminal background; the caller + * gates it on truecolor support and wraps it in bold. + * + * @param text - Text to colorize; sampled once per character. + * @returns `text` wrapped in truecolor SGR foreground codes. + */ +export function gradientText(text: string): string { + // The sole caller passes the ASCII product name, so UTF-16 unit iteration + // samples exactly one color per visible letter. + const last = Math.max(1, text.length - 1) + let painted = '' + for (let index = 0; index < text.length; index += 1) { + const [r, g, b] = brandColorAt(index / last) + painted += `\x1b[38;2;${r};${g};${b}m${text.charAt(index)}` + } + return `${painted}\x1b[39m` +} + +/** + * Derive the pi-tui Markdown theme from a role palette. + * @param palette - Active role palette. + * @returns The Markdown theme wired to palette roles. + */ +export function markdownTheme(palette: Palette): MarkdownTheme { + return { + heading: text => palette.accent(text), + link: text => palette.accent(text), + // pi-tui requires this URL slot but its current Markdown renderer does not invoke it. + /* v8 ignore next */ + linkUrl: text => palette.dim(text), + code: text => palette.code(text), + codeBlock: text => palette.code(text), + // pi-tui presents both fence rows through this callback. Keep the opening + // language label, but hide Markdown syntax and the otherwise-empty close. + codeBlockBorder: text => palette.dim(text.slice(3)), + quote: text => palette.muted(text), + quoteBorder: text => palette.accent2(text), + hr: text => palette.dim(text), + listBullet: text => palette.accent(text), + bold: text => palette.bold(text), + italic: text => palette.italic(text), + strikethrough: text => palette.strike(text), + underline: text => palette.underline(text), + } +} + +/** + * Derive the pi-tui select-list theme from a role palette. + * @param palette - Active role palette. + * @returns The select-list theme wired to palette roles. + */ +export function selectTheme(palette: Palette): SelectListTheme { + return { + selectedPrefix: palette.accent, + selectedText: palette.accent, + description: palette.muted, + scrollInfo: palette.dim, + noMatch: palette.warning, + } +} + +/** + * Derive the reverse-video dialog select-list theme from a role palette. + * @param palette - Active role palette. + * @returns The dialog select-list theme with a reverse-video selection. + */ +export function dialogSelectTheme(palette: Palette): SelectListTheme { + return { + ...selectTheme(palette), + selectedText: text => palette.selected(palette.accent(text)), + } +} diff --git a/packages/ui/tui/src/components/transcript.ts b/packages/ui/tui/src/components/transcript.ts new file mode 100644 index 0000000000..b01ffc5e48 --- /dev/null +++ b/packages/ui/tui/src/components/transcript.ts @@ -0,0 +1,529 @@ +/** + * pi-tui transcript components: the startup banner, user/assistant messages, + * per-step timing footer, streaming assistant buffer, tool cards, and the todo + * panel. Each is a pure function of its inputs and the active palette. + * @module @deepseek-ai/dsh-tui/components/transcript + */ + +import { + Container, + Markdown, + Spacer, + Text, + truncateToWidth, + wrapTextWithAnsi, + type Component, + type MarkdownTheme, +} from '@earendil-works/pi-tui' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm' +import type { JsonValue, SessionEvent, TodoItem } from '@deepseek-ai/dsh-session' +import type { + TerminalCallView, + ToolCallView, + ToolDefinition, + ToolResultView, +} from '@deepseek-ai/dsh-tools' +import type { FileDiff } from '@deepseek-ai/dsh-tools' +import { renderUnknownXml } from './xml-tool-output.ts' +import { displayInlineText, displayText } from './text.ts' +import { gradientText, type Palette } from './theme.ts' +import { contentText, type ParsedArguments } from './content.ts' +import { + formatCompletionTime, + formatTimingTotals, + stepTimingAt, + type StepPosition, +} from '../chat/timing.ts' + +/** Concatenate the text of every block of one type, separated by blank lines. */ +function textBlocks(content: readonly ContentBlock[], type: 'text' | 'reasoning'): string { + return content + .filter((block): block is Extract => block.type === type) + .map(block => block.text) + .join('\n\n') +} + +/** Render a value as terminal-safe text: strings escaped, other values as pretty JSON. */ +function pretty(value: unknown): string { + if (typeof value === 'string') return displayText(value) + // JSON.stringify is typed to return string but yields undefined for e.g. symbols. + const serialized = JSON.stringify(value, null, 2) as string | undefined + return displayText(serialized ?? String(value)) +} + +/** A file diff as colored `+`/`-` lines, optionally prefixed with its path. */ +function diffLines(diff: FileDiff, palette: Palette): string[] { + // The card header is a fixed `Tool / ` frame that never names a file, so + // each hunk always carries its own path header (no redundancy to suppress). + const lines = [palette.bold(displayText(diff.path))] + if (diff.oldText !== null) { + for (const line of displayText(diff.oldText).split('\n')) lines.push(palette.removed(`- ${line}`)) + } + for (const line of displayText(diff.newText).split('\n')) lines.push(palette.added(`+ ${line}`)) + return lines +} + +/** + * A message's bold, underlined role header in the role color. The underline + * bands each role without a background fill or per-line prefix, so it reads on + * any theme and a body drag-select copies the message text verbatim. + */ +function messageHeader(label: string, color: (text: string) => string, palette: Palette): string { + return palette.bold(palette.underline(color(displayText(label)))) +} + +/** + * Borderless startup banner: product title, an optional configured subtitle, + * and the session id. No box frame — each line renders as plain left-padded + * text (matching transcript notices) so it reads on any theme. + */ +export class HeaderComponent implements Component { + /** Columns of the banner currently revealed; `undefined` renders it whole. */ + private revealWidth: number | undefined + + constructor( + private readonly agent: Agent, + private readonly subtitle: () => string | undefined, + private readonly palette: Palette, + private readonly gradient: boolean, + ) {} + + /** + * Clip the banner to `width` columns (the sweep reveal); `undefined` restores it. + * @param width - Revealed banner width in columns, or `undefined` for the whole banner. + */ + setRevealWidth(width: number | undefined): void { + this.revealWidth = width + } + + invalidate(): void {} + + render(width: number): string[] { + const usable = Math.max(1, width - 2) + const name = this.gradient + ? this.palette.bold(gradientText('DEEPSEEK')) + : this.palette.bold(this.palette.accent('DEEPSEEK')) + const title = `${name} ${this.palette.bold('HARNESS')}` + const detail = displayText(this.agent.session.id) + const subtitle = this.subtitle() + const lines = [ + title, + ...subtitle === undefined ? [] : [this.palette.muted(displayText(subtitle))], + this.palette.dim(detail), + ] + .flatMap(line => wrapTextWithAnsi(line, usable)) + .map(line => ` ${truncateToWidth(line, usable, '')}`) + if (this.revealWidth === undefined) return lines + const revealed = this.revealWidth + return lines.map(line => truncateToWidth(line, revealed, '')) + } +} + +/** + * A user or steering prompt in the transcript. An underlined accent role header + * plus blank-line spacing separate it from surrounding blocks; body lines carry + * no prefix or indent, so a terminal drag-select copies the prompt verbatim. + */ +export class UserMessageComponent extends Container { + constructor(text: string, palette: Palette, mdTheme: MarkdownTheme, label = 'You') { + super() + this.addChild(new Text(messageHeader(label, palette.accent, palette), 0, 0)) + this.addChild(new Markdown(displayText(text), 0, 0, mdTheme, { color: value => palette.text(value) }, { + preserveOrderedListMarkers: true, + preserveBackslashEscapes: true, + })) + } +} + +/** Children of a settled assistant message: optional reasoning block then the response text. */ +function assistantMessageChildren( + content: readonly ContentBlock[], + showReasoning: boolean, + palette: Palette, + mdTheme: MarkdownTheme, +): Component[] { + const reasoning = displayText(textBlocks(content, 'reasoning').trim()) + const text = displayText(textBlocks(content, 'text').trim()) + const children: Component[] = [ + new Spacer(1), + new Text(messageHeader('Assistant', palette.accent2, palette), 0, 0), + ] + if (reasoning && showReasoning) { + children.push( + new Text(palette.italic(palette.muted('Reasoning')), 0, 0), + new Markdown(reasoning, 0, 0, mdTheme, { color: value => palette.muted(value), italic: true }), + ) + } + if (text) children.push(new Markdown(text, 0, 0, mdTheme, { color: value => palette.text(value) })) + return children +} + +/** + * A step's timing summary, rendered as a self-refreshing footer that stays at + * the tail of the step's output. Kept separate from the assistant message so + * the timing line trails any tool cards the step appends after its message. + */ +class StepTimingComponent extends Container { + private completionTime: number | undefined + + constructor( + private readonly position: StepPosition, + private readonly events: () => readonly SessionEvent[], + private readonly now: () => number, + private readonly palette: Palette, + ) { + super() + this.rebuild() + } + + complete(time: number): void { + this.completionTime = time + this.rebuild() + } + + override invalidate(): void { + this.rebuild() + super.invalidate() + } + + private rebuild(): void { + this.clear() + const totals = stepTimingAt(this.events(), this.position, this.completionTime ?? this.now()) + const timing = formatTimingTotals(totals, true) + const header = this.completionTime === undefined + ? timing + : `${timing} · Completed ${formatCompletionTime(this.completionTime)}` + this.addChild(new Text(this.palette.dim(header), 0, 0)) + } +} + +interface StreamingBlock { + type: string + text: string +} + +/** A live assistant step: streamed reasoning/text blocks until the message settles. */ +export class StreamingAssistantComponent extends Container { + private readonly blocks = new Map() + private settledContent: readonly ContentBlock[] | undefined + /** + * The step's timing footer. The renderer keeps it at the tail of the chat so + * it trails any tool cards the step appends after this assistant message; it + * is not a child of this component. + */ + readonly timing: StepTimingComponent + + constructor( + position: StepPosition, + events: () => readonly SessionEvent[], + now: () => number, + private showReasoning: boolean, + private readonly palette: Palette, + private readonly mdTheme: MarkdownTheme, + ) { + super() + this.timing = new StepTimingComponent(position, events, now, palette) + this.rebuild() + } + + /** + * Replace the streamed blocks with the step's settled content. + * @param content - The settled assistant content blocks. + */ + settle(content: readonly ContentBlock[]): void { + this.settledContent = content + this.rebuild() + } + + /** + * Whether this step's assistant message has settled. + * @returns `true` once {@link settle} has run. + */ + isSettled(): boolean { + return this.settledContent !== undefined + } + + /** + * Pin the step's timing footer to its completion time. + * @param time - Step completion time in epoch milliseconds. + */ + complete(time: number): void { + this.timing.complete(time) + } + + override invalidate(): void { + this.rebuild() + this.timing.invalidate() + super.invalidate() + } + + /** + * Fold one streamed chunk into the live block buffer and re-render. + * @param chunk - The streamed assistant chunk. + */ + update(chunk: StreamChunk): void { + if (chunk.type === 'block-start') { + this.blocks.set(chunk.index, { type: chunk.blockType, text: '' }) + } else if (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta') { + const type = chunk.type === 'text-delta' ? 'text' : 'reasoning' + const block = this.blocks.get(chunk.index) ?? { type, text: '' } + block.text += chunk.text + this.blocks.set(chunk.index, block) + } else if (chunk.type === 'block-end' && (chunk.block.type === 'text' || chunk.block.type === 'reasoning')) { + this.blocks.set(chunk.index, { type: chunk.block.type, text: chunk.block.text }) + } + this.rebuild() + this.timing.invalidate() + } + + /** + * Toggle whether reasoning blocks render, then re-render. + * @param show - Whether to show reasoning blocks. + */ + setShowReasoning(show: boolean): void { + this.showReasoning = show + this.rebuild() + } + + private rebuild(): void { + this.clear() + const content: readonly ContentBlock[] = this.settledContent ?? [...this.blocks.entries()] + .sort(([left], [right]) => left - right) + .flatMap(([, block]) => { + if (block.type === 'text') return [{ type: 'text', text: block.text }] + if (block.type === 'reasoning') return [{ type: 'reasoning', text: block.text }] + return [] + }) + for (const child of assistantMessageChildren(content, this.showReasoning, this.palette, this.mdTheme)) { + this.addChild(child) + } + } +} + +/** A tool call and its result, rendered as a collapsible status card. */ +export class ToolCardComponent implements Component { + private result: { content: ContentBlock[]; isError: boolean; meta?: JsonValue } | undefined + private expanded = false + private callView: ToolCallView + private resultView: ToolResultView | undefined + + constructor( + private readonly name: string, + private readonly parsed: ParsedArguments, + private readonly definition: ToolDefinition | undefined, + private readonly maxOutputLines: number, + private readonly palette: Palette, + private readonly mdTheme: MarkdownTheme, + ) { + this.callView = this.presentCall() + } + + private presentCall(): ToolCallView { + if (this.parsed.valid && this.definition?.presentCall) { + try { + const view = this.definition.presentCall(this.parsed.value) + if (view !== undefined) return view + } catch (error: unknown) { + return { card: 'generic', title: displayText(this.name), rawInput: `Presenter failed: ${String(error)}` } + } + } + return { card: 'generic', title: displayText(this.name), rawInput: this.parsed.value } + } + + /** + * Record the tool result and derive its result view. + * @param event - The `tool/result` event payload. + */ + updateResult(event: Extract['data']): void { + this.result = { + content: [...event.content], + isError: event.isError, + ...event.meta !== undefined ? { meta: event.meta } : {}, + } + if (this.parsed.valid && this.definition?.presentResult) { + try { + const view = this.definition.presentResult(this.parsed.value, this.result) + if (view !== undefined) this.resultView = view + } catch (error: unknown) { + this.resultView = { card: 'generic', content: [{ type: 'text', text: `Presenter failed: ${String(error)}` }] } + } + } + } + + /** + * Expand or collapse the card's body preview. + * @param expanded - Whether the full body is shown. + */ + setExpanded(expanded: boolean): void { + this.expanded = expanded + } + + invalidate(): void {} + + render(width: number): string[] { + const isError = this.result?.isError ?? false + // A ring marker: hollow while the call is pending, filled once it settles; + // the header color (warning/success/error) tells pending from ok from error. + const glyph = this.result === undefined ? '○' : '●' + const rawBody = this.renderBody() + const view = this.resultView ?? this.callView + const genericContent = view.card === 'generic' ? view.content ?? this.result?.content : undefined + const unknownXml = this.definition === undefined && genericContent !== undefined + ? renderUnknownXml( + displayText(contentText(genericContent)), + this.maxOutputLines, + this.expanded, + displayText, + text => this.palette.muted(text), + /* v8 ignore next -- renderUnknownXml calls the collapsed summary only when hidden XML children exceed this card's limit. */ + count => this.palette.dim(` … +${count} lines (Ctrl+O to expand)`), + ) + : undefined + const body = unknownXml ?? (genericContent !== undefined && rawBody.length > 0 + ? new Markdown(rawBody.join('\n'), 0, 0, this.mdTheme, { color: value => this.palette.text(value) }).render(width) + : rawBody) + const headLines = Math.ceil(this.maxOutputLines / 2) + const tailLines = this.maxOutputLines - headLines + const visibleBody = unknownXml !== undefined || this.expanded || body.length <= this.maxOutputLines + ? body + : [ + ...body.slice(0, headLines), + this.palette.dim(`… +${body.length - this.maxOutputLines} lines (Ctrl+O to expand)`), + ...body.slice(body.length - tailLines), + ] + // The header is a fixed `Tool / ` frame in the status color (warning + // pending / success ok / error), flat — no bold or underline, so one color + // reads consistently across the whole row. Every tool-specific detail (a + // read's path, a diff, command output) lives in the body below; the sole + // header extra is a bash card's model-authored description, appended as a + // `/ ` segment. The body stays unprefixed so a drag-select copies only + // the tool text; body lines pass through Text so overlong output wraps. + const statusColor = this.result === undefined + ? this.palette.warning + : isError ? this.palette.error : this.palette.success + // The header is a single card row: collapse an embedded newline in the + // description to an inline escape so it cannot break onto extra rows and + // collide with the body lines that follow. + const desc = this.headerDescription() + const headerText = `${glyph} Tool / ${displayText(this.name)}${desc === undefined ? '' : ` / ${displayInlineText(desc)}`}` + const header = truncateToWidth(headerText, Math.max(1, width - 2), '') + const lines = [statusColor(header)] + if (visibleBody.length > 0) lines.push(...new Text(visibleBody.join('\n'), 0, 0).render(width)) + return lines + } + + /** The pending terminal call view, when this row is a terminal card. */ + private terminalPending(): TerminalCallView | undefined { + return this.callView.card === 'terminal' ? this.callView : undefined + } + + /** + * The optional header `/ ` segment: a bash (terminal) card's + * model-authored description. Non-terminal tools contribute no header detail — + * their presenter title moves into the body instead. + */ + private headerDescription(): string | undefined { + const description = this.terminalPending()?.description + return description !== undefined && description !== '' ? description : undefined + } + + /** + * The presenter's title for a non-terminal card, shown as the first body line + * (a read's `Read src/foo.ts`, a diff's `Edit files`) now that the header is a + * fixed `Tool / ` frame. The result-state title replaces the pending one. + */ + private bodyTitle(): string { + return this.resultView?.title ?? this.callView.title + } + + private renderBody(): string[] { + const view = this.resultView ?? this.callView + if (view.card === 'terminal') { + const pending = this.terminalPending() + const lines: string[] = [] + // The command shows as a $-line here whenever it is not the header: either a + // description headlines the row (the command still belongs somewhere) or the row + // is a pending undescribed call (the classic running-command echo). A completed + // undescribed row keeps the command only in the header. + // The command and cwd are each a single card row, so escape a multi-line + // command inline (displayInlineText) — a real newline would break onto extra + // rows and collide with the output below. + const headlined = pending?.description !== undefined && pending.description !== '' + const commandInBody = pending !== undefined && (headlined || this.result === undefined) + if (commandInBody) lines.push(this.palette.code(`$ ${displayInlineText(pending.title)}`)) + if (pending?.cwd) lines.push(this.palette.dim(displayInlineText(pending.cwd))) + if (this.resultView?.card === 'terminal') { + if (this.resultView.output) lines.push(...displayText(this.resultView.output).split('\n')) + if (this.resultView.exitCode !== undefined) lines.push(this.palette.dim(`[exit ${this.resultView.exitCode}]`)) + if (this.resultView.signal !== undefined) { + lines.push(this.palette.error(`[signal ${displayText(this.resultView.signal)}]`)) + } + } else if (this.result !== undefined) { + lines.push(...displayText(contentText(this.result.content)).split('\n')) + } + return lines.filter(Boolean) + } + if (view.card === 'diff') { + // The header no longer names the file, so each diff keeps its own path + // header. A trailing footer summarizes the change (`+A -R · N file(s)`). + let added = 0 + let removed = 0 + const hunks = view.diffs.flatMap((diff, index) => { + if (diff.oldText !== null) removed += displayText(diff.oldText).split('\n').length + added += displayText(diff.newText).split('\n').length + return [...index > 0 ? [''] : [], ...diffLines(diff, this.palette)] + }) + const files = view.diffs.length + const footer = this.palette.dim(`└ +${added} -${removed} · ${files} file${files === 1 ? '' : 's'}`) + return [...hunks, footer] + } + const content = view.content ?? this.result?.content + const lines: string[] = [] + // The presenter title headlines the body now that the header is a fixed + // `Tool / ` frame (a terminal card keeps its command $-line instead). + // Skip it when it only repeats the tool name (the fallback presenter for a + // tool with no presentCall, or an unknown tool), which the header already shows. + const bodyTitle = this.bodyTitle() + if (bodyTitle !== displayText(this.name)) lines.push(displayInlineText(bodyTitle)) + if (content !== undefined) lines.push(...displayText(contentText(content)).split('\n')) + const rawInput = this.result === undefined && this.callView.card === 'generic' + ? this.callView.rawInput + : undefined + if (rawInput !== undefined) lines.push(...pretty(rawInput).split('\n')) + return lines.filter((line, index, all) => line.length > 0 || (index > 0 && index < all.length - 1)) + } +} + +/** The plan/todo panel rendered above the prompt. */ +export class TodoComponent implements Component { + private todos: readonly TodoItem[] = [] + + constructor(private readonly palette: Palette) {} + + /** + * Replace the rendered plan items. + * @param todos - The current todo items. + */ + update(todos: readonly TodoItem[]): void { + this.todos = todos + } + + invalidate(): void {} + + render(width: number): string[] { + if (this.todos.length === 0) return [] + const lines = [this.palette.bold(this.palette.accent('Plan'))] + for (const todo of this.todos) { + const prefix = todo.status === 'completed' + ? this.palette.success('✓') + : todo.status === 'in_progress' + ? this.palette.warning('●') + : this.palette.dim('○') + const content = displayText(todo.content) + const text = todo.status === 'completed' ? this.palette.muted(content) : content + lines.push(truncateToWidth(` ${prefix} ${text}`, width, '')) + } + return ['', ...lines] + } +} diff --git a/packages/ui/tui/src/components/xml-tool-output.ts b/packages/ui/tui/src/components/xml-tool-output.ts new file mode 100644 index 0000000000..24beb58c31 --- /dev/null +++ b/packages/ui/tui/src/components/xml-tool-output.ts @@ -0,0 +1,142 @@ +/** + * Conservative readable-tree rendering for model-facing text containing one XML + * document, used by the transcript's tool and context cards. + * @module @deepseek-ai/dsh-tui/components/xml-tool-output + */ + +import { SaxesParser } from 'saxes' + +interface XmlElement { + readonly name: string + readonly attributes: readonly XmlAttribute[] + readonly children: XmlNode[] +} + +interface XmlAttribute { + readonly name: string + readonly value: string +} + +type XmlNode = XmlElement | string + +function parseXml(source: string, display: (text: string) => string): XmlElement | undefined { + const parser = new SaxesParser({ xmlns: false }) + const stack: XmlElement[] = [] + let root: XmlElement | undefined + const state = { invalid: false } + const reject = (): void => { state.invalid = true } + parser.on('opentag', (tag) => { + const element: XmlElement = { + name: tag.name, + // Attribute values and text pass through `display` because character references can + // expand to valid-XML control characters (tab, CR, DEL, C1) that pre-parse escaping + // of the raw source never saw. Element names cannot carry them: control characters + // are not XML name characters and character references do not apply inside names. + attributes: Object.entries(tag.attributes).map(([name, value]) => ({ name, value: display(value) })), + children: [], + } + const parent = stack.at(-1) + if (parent === undefined) { + if (root !== undefined) reject() + root = element + } else { + parent.children.push(element) + } + stack.push(element) + }) + parser.on('text', (text) => { + const parent = stack.at(-1) + if (parent === undefined) { + if (text.trim() !== '') reject() + } else { + parent.children.push(display(text)) + } + }) + parser.on('cdata', (text) => { + const parent = stack.at(-1) + if (parent === undefined) reject() + else parent.children.push(display(text)) + }) + parser.on('closetag', () => { stack.pop() }) + parser.on('xmldecl', reject) + parser.on('processinginstruction', reject) + parser.on('doctype', reject) + parser.on('comment', reject) + parser.on('error', reject) + parser.write(source).close() + return state.invalid ? undefined : root +} + +function elementLabel(element: XmlElement): string { + const attributes = element.attributes.map(attribute => `${attribute.name}=${JSON.stringify(attribute.value)}`).join(' ') + return attributes === '' ? element.name : `${element.name} (${attributes})` +} + +function meaningfulChildren(element: XmlElement): readonly XmlNode[] { + return element.children.filter(child => typeof child !== 'string' || child.trim() !== '') +} + +function textBlock(text: string, depth: number): string[] { + return text.replace(/^\n|\n$/gu, '').split('\n').map(line => `${' '.repeat(depth)}${line}`) +} + +function treeLines(element: XmlElement, depth: number, label: (text: string) => string): string[] { + const indent = ' '.repeat(depth) + const children = meaningfulChildren(element) + if (children.length === 0) return [`${indent}${label(elementLabel(element))}`] + if (children.length === 1 && typeof children[0] === 'string' && !children[0].includes('\n')) { + return [`${indent}${label(`${elementLabel(element)}:`)} ${children[0].trim()}`] + } + const lines = [`${indent}${label(elementLabel(element))}`] + for (const child of children) { + if (typeof child === 'string') lines.push(...textBlock(child, depth + 1)) + else lines.push(...treeLines(child, depth + 1, label)) + } + return lines +} + +function preview(lines: readonly string[], limit: number, omitted: (count: number) => string): string[] { + if (lines.length <= limit) return [...lines] + const head = Math.ceil(limit / 2) + const tail = limit - head + return [...lines.slice(0, head), omitted(lines.length - limit), ...lines.slice(lines.length - tail)] +} + +/** + * Render a complete XML document as an indented tree, or decline without changing partial/mixed text. + * @param source - Raw model-facing text from a context message or unknown tool result. + * @param maxChildLines - Collapsed budget independently applied to each top-level child's lines and + * to the number of top-level children, so many siblings cannot grow the collapsed card without bound. + * @param expanded - Whether to retain every rendered child line. + * @param display - Escapes parsed text and attribute values for terminal output; character references + * can expand to control characters that pre-parse escaping never saw. + * @param label - Styles element names and attributes. + * @param omitted - Renders the omitted-line marker for a collapsed child or child range. + * @returns Tree rows, or `undefined` when `source` is not one supported complete XML document. + */ +export function renderUnknownXml( + source: string, + maxChildLines: number, + expanded: boolean, + display: (text: string) => string, + label: (text: string) => string, + omitted: (count: number) => string, +): string[] | undefined { + const root = parseXml(source, display) + if (root === undefined) return undefined + const blocks = meaningfulChildren(root).map(child => + typeof child === 'string' ? textBlock(child, 1) : treeLines(child, 1, label)) + const rootLine = label(elementLabel(root)) + if (expanded) return [rootLine, ...blocks.flat()] + const previewed = blocks.map(block => preview(block, maxChildLines, omitted)) + if (previewed.length <= maxChildLines) return [rootLine, ...previewed.flat()] + const head = Math.ceil(maxChildLines / 2) + const tail = maxChildLines - head + const hidden = blocks.slice(head, blocks.length - tail).reduce((total, block) => total + block.length, 0) + return [ + rootLine, + ...previewed.slice(0, head).flat(), + omitted(hidden), + ...previewed.slice(previewed.length - tail).flat(), + ] +} diff --git a/packages/ui/tui/src/config.ts b/packages/ui/tui/src/config.ts new file mode 100644 index 0000000000..a48a1d704c --- /dev/null +++ b/packages/ui/tui/src/config.ts @@ -0,0 +1,213 @@ +/** + * Serializable configuration and defaults for the pi-tui terminal mode. Loader + * schema validation normally fills defaults; {@link resolveTuiConfig} applies + * the same defaults for direct callers that bypass the Loader. + * @module @deepseek-ai/dsh-tui/config + */ + +import z from 'schemastery' +import { + DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES, + DEFAULT_FILE_SEARCH_MAX_ENTRIES, + DEFAULT_FILE_SEARCH_MAX_RESULTS, +} from './chat/file-autocomplete.ts' + +/** 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 +} + +/** Interaction and presentation settings for the pi-tui terminal mode. */ +export interface TuiConfig { + /** Render model reasoning blocks. */ + showReasoning?: boolean + /** Maximum tool-card body lines retained in its collapsed head/tail preview. */ + maxToolOutputLines?: number + /** Maximum options visible at once in a user-question panel. */ + maxQuestionOptions?: number + /** Maximum models visible at once in the model selector. */ + maxModelOptions?: number + /** Maximum sessions visible at once in the resume selector. */ + maxResumeOptions?: number + /** User-question panel width in terminal columns, clamped to the terminal. */ + questionDialogWidth?: number + /** User-question panel maximum height in terminal rows. */ + questionDialogMaxHeight?: number + /** Model-selector width in terminal columns. */ + modelDialogWidth?: number + /** Model-selector maximum height in terminal rows. */ + modelDialogMaxHeight?: number + /** Maximum fuzzy file candidates displayed for one `@` query. */ + fileSearchMaxResults?: number + /** Maximum paths retained in one `@` workspace index. */ + fileSearchMaxEntries?: number + /** Directory basenames excluded from `@` traversal and completion. */ + fileSearchExcludedDirectories?: string[] + /** Show the terminal's hardware cursor at the pi editor's IME marker. */ + showHardwareCursor?: boolean + /** Color and prompt-template settings. */ + theme?: TuiThemeConfig + /** Terminal window title while the UI is mounted; a logged session title prefixes it. */ + title?: string +} + +const showReasoningSchema = z.boolean().default(true) +const maxToolOutputLinesSchema = z.number().step(1).min(1).default(6) +const maxQuestionOptionsSchema = z.number().step(1).min(1).default(8) +const maxModelOptionsSchema = z.number().step(1).min(1).default(8) +const maxResumeOptionsSchema = z.number().step(1).min(1).default(8) +const questionDialogWidthSchema = z.number().step(1).min(20).default(200) +const questionDialogMaxHeightSchema = z.number().step(1).min(6).default(20) +const modelDialogWidthSchema = z.number().step(1).min(20).default(76) +const modelDialogMaxHeightSchema = z.number().step(1).min(6).default(20) +const fileSearchMaxResultsSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_RESULTS) +const fileSearchMaxEntriesSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_ENTRIES) +const fileSearchExcludedDirectoriesSchema = z.array(z.string()).default([...DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES]) +const showHardwareCursorSchema = z.boolean().default(false) +const colorSchema = z.boolean().default(true) +// No default: an unset value auto-detects truecolor from COLORTERM in `apply`. +const truecolorSchema = z.boolean() +const DEFAULT_LEFT_PROMPT = '${cwd}${git/worktree}${model}${token_meter/cache_hit_rate}${context}' +const DEFAULT_RIGHT_PROMPT = '${timing}' +const DEFAULT_INPUT_PROMPT = '${symbol} ${indicator}' +const DEFAULT_INPUT_PLACEHOLDER = 'press enter to steer and esc to cancel' +const TuiThemeConfigSchema: z = z.object({ + color: colorSchema, + truecolor: truecolorSchema, + leftPrompt: z.string().default(DEFAULT_LEFT_PROMPT), + rightPrompt: z.string().default(DEFAULT_RIGHT_PROMPT), + inputPrompt: z.string().default(DEFAULT_INPUT_PROMPT), + inputPlaceholder: z.string().default(DEFAULT_INPUT_PLACEHOLDER), +}) +const titleSchema = z.string().default('DeepSeek Harness') + +const tuiConfigSchemaFields = { + showReasoning: showReasoningSchema, + maxToolOutputLines: maxToolOutputLinesSchema, + maxQuestionOptions: maxQuestionOptionsSchema, + maxModelOptions: maxModelOptionsSchema, + maxResumeOptions: maxResumeOptionsSchema, + questionDialogWidth: questionDialogWidthSchema, + questionDialogMaxHeight: questionDialogMaxHeightSchema, + modelDialogWidth: modelDialogWidthSchema, + modelDialogMaxHeight: modelDialogMaxHeightSchema, + fileSearchMaxResults: fileSearchMaxResultsSchema, + fileSearchMaxEntries: fileSearchMaxEntriesSchema, + fileSearchExcludedDirectories: fileSearchExcludedDirectoriesSchema, + showHardwareCursor: showHardwareCursorSchema, + theme: TuiThemeConfigSchema, + title: titleSchema, +} + +/** Schemastery schema for presentation settings embedded by app bundles. */ +export const TuiConfigSchema: z = z.object(tuiConfigSchemaFields) + +/** Serializable plugin configuration. */ +export interface Config extends TuiConfig { + /** Banner subtitle line. When absent, the banner has no subtitle and sweeps in on start. */ + welcome?: string + /** Exact shared agent/session identity driven by this terminal. Defaults to `main`. */ + sessionId?: string + /** + * Shell command fallback printed on exit or after selecting a session when + * the host cannot hand off in place. Every `{session}` becomes the selected + * id; the TUI never executes this text. Absent disables only the fallback, + * not the interactive selector. + */ + resumeCommand?: string +} + +/** Schemastery schema for the full plugin configuration. */ +export const Config: z = z.object({ + welcome: z.string(), + sessionId: z.string().default('main'), + resumeCommand: z.string(), + showReasoning: tuiConfigSchemaFields.showReasoning, + maxToolOutputLines: tuiConfigSchemaFields.maxToolOutputLines, + maxQuestionOptions: tuiConfigSchemaFields.maxQuestionOptions, + maxModelOptions: tuiConfigSchemaFields.maxModelOptions, + maxResumeOptions: tuiConfigSchemaFields.maxResumeOptions, + questionDialogWidth: tuiConfigSchemaFields.questionDialogWidth, + questionDialogMaxHeight: tuiConfigSchemaFields.questionDialogMaxHeight, + modelDialogWidth: tuiConfigSchemaFields.modelDialogWidth, + modelDialogMaxHeight: tuiConfigSchemaFields.modelDialogMaxHeight, + fileSearchMaxResults: tuiConfigSchemaFields.fileSearchMaxResults, + fileSearchMaxEntries: tuiConfigSchemaFields.fileSearchMaxEntries, + fileSearchExcludedDirectories: tuiConfigSchemaFields.fileSearchExcludedDirectories, + showHardwareCursor: tuiConfigSchemaFields.showHardwareCursor, + theme: tuiConfigSchemaFields.theme, + title: tuiConfigSchemaFields.title, +}) + +/** Fully defaulted TUI theme settings. */ +export interface ResolvedTuiThemeConfig { + color: boolean + truecolor: boolean + leftPrompt: string + rightPrompt: string + inputPrompt: string + inputPlaceholder: string +} + +/** Fully defaulted TUI presentation settings. */ +export interface ResolvedTuiConfig { + showReasoning: boolean + maxToolOutputLines: number + maxQuestionOptions: number + maxModelOptions: number + maxResumeOptions: number + questionDialogWidth: number + questionDialogMaxHeight: number + modelDialogWidth: number + modelDialogMaxHeight: number + fileSearchMaxResults: number + fileSearchMaxEntries: number + fileSearchExcludedDirectories: string[] + showHardwareCursor: boolean + theme: ResolvedTuiThemeConfig + title: string +} + +/** + * Apply direct-call defaults after Loader schema validation has normally run. + * + * @param config - Deployment-provided terminal presentation settings. + * @returns Complete settings consumed by the TUI renderer. + */ +export function resolveTuiConfig(config: TuiConfig | undefined): ResolvedTuiConfig { + return { + showReasoning: config?.showReasoning ?? true, + maxToolOutputLines: config?.maxToolOutputLines ?? 6, + maxQuestionOptions: config?.maxQuestionOptions ?? 8, + maxModelOptions: config?.maxModelOptions ?? 8, + maxResumeOptions: config?.maxResumeOptions ?? 8, + questionDialogWidth: config?.questionDialogWidth ?? 200, + questionDialogMaxHeight: config?.questionDialogMaxHeight ?? 20, + modelDialogWidth: config?.modelDialogWidth ?? 76, + modelDialogMaxHeight: config?.modelDialogMaxHeight ?? 20, + fileSearchMaxResults: config?.fileSearchMaxResults ?? DEFAULT_FILE_SEARCH_MAX_RESULTS, + fileSearchMaxEntries: config?.fileSearchMaxEntries ?? DEFAULT_FILE_SEARCH_MAX_ENTRIES, + fileSearchExcludedDirectories: [...(config?.fileSearchExcludedDirectories ?? DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES)], + showHardwareCursor: config?.showHardwareCursor ?? false, + theme: { + color: config?.theme?.color ?? true, + truecolor: config?.theme?.truecolor ?? false, + leftPrompt: config?.theme?.leftPrompt ?? DEFAULT_LEFT_PROMPT, + rightPrompt: config?.theme?.rightPrompt ?? DEFAULT_RIGHT_PROMPT, + inputPrompt: config?.theme?.inputPrompt ?? DEFAULT_INPUT_PROMPT, + inputPlaceholder: config?.theme?.inputPlaceholder ?? DEFAULT_INPUT_PLACEHOLDER, + }, + title: config?.title ?? 'DeepSeek Harness', + } +} diff --git a/packages/ui/tui/src/overlay-manager.ts b/packages/ui/tui/src/extension/overlay-manager.ts similarity index 98% rename from packages/ui/tui/src/overlay-manager.ts rename to packages/ui/tui/src/extension/overlay-manager.ts index efe8716777..59d84ff3d5 100644 --- a/packages/ui/tui/src/overlay-manager.ts +++ b/packages/ui/tui/src/extension/overlay-manager.ts @@ -3,12 +3,12 @@ * * The manager serializes modal ownership, guards extension callbacks, and * settles every queued or active operation before terminal teardown. - * @module @deepseek-ai/dsh-tui/overlay-manager + * @module @deepseek-ai/dsh-tui/extension/overlay-manager */ import { Service, type Context } from 'cordis' import type { Agent } from '@deepseek-ai/dsh-agent' -import type { TuiExtensionService } from './index.ts' +import type { TuiExtensionService } from '../index.ts' import type { Component, Focusable, @@ -26,7 +26,7 @@ import type { TuiOverlayState, TuiTheme, TuiViewport, -} from './extension.ts' +} from './types.ts' /** pi-tui operations retained by the front door instead of exposed to plugins. */ export interface TuiOverlayDriver { diff --git a/packages/ui/tui/src/extension.ts b/packages/ui/tui/src/extension/types.ts similarity index 99% rename from packages/ui/tui/src/extension.ts rename to packages/ui/tui/src/extension/types.ts index d6cb7bc4e4..adb45d5430 100644 --- a/packages/ui/tui/src/extension.ts +++ b/packages/ui/tui/src/extension/types.ts @@ -5,7 +5,7 @@ * the live pi-tui tree, focus controller, overlay handles, or terminal * lifecycle. Registrations and open overlays remain owned by the calling * Cordis fiber. - * @module @deepseek-ai/dsh-tui/extension + * @module @deepseek-ai/dsh-tui/extension/types */ /** Terminal component shape accepted from a trusted TUI extension. */ diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 3f13fe89e4..0222183ab8 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -5,108 +5,154 @@ * @module @deepseek-ai/dsh-tui */ -import { homedir } from 'node:os' -import { isAbsolute, relative, resolve, sep } from 'node:path' import { CombinedAutocompleteProvider, Container, - Editor, - Input, Key, - Loader, - Markdown, Spacer, Text, TUI, ProcessTerminal, - SelectList, matchesKey, - truncateToWidth, visibleWidth, - wrapTextWithAnsi, - type Component, - type AutocompleteItem, - type AutocompleteProvider, - type AutocompleteSuggestions, type EditorTheme, - type Focusable, - type MarkdownTheme, - type SelectItem, - type SelectListTheme, type SlashCommand, - type Terminal, type TerminalColorScheme, } from '@earendil-works/pi-tui' import { Service, type Context, type Fiber } from 'cordis' -import z from 'schemastery' import { + assembleContextFor, installAgentLlmTarget, type Agent, type AgentMessageId, - type AgentLlmTarget, type AgentLlmTargetRef, type AgentStatus, } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-agent-loop' import type {} from '@deepseek-ai/dsh-token-meter' -import type {} from '@deepseek-ai/dsh-commands' -import { assertNever, errorChain } from '@deepseek-ai/dsh-llm' -import type { - ContentBlock, - LlmModelInfo, - LlmModelReasoningInfo, - ReasoningEffortId, - StreamChunk, - TokenUsage, -} from '@deepseek-ai/dsh-llm' +import type { CommandResult } from '@deepseek-ai/dsh-commands' +import { errorChain } from '@deepseek-ai/dsh-llm' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import { renderUnknownXml } from './components/xml-tool-output.ts' import type {} from '@deepseek-ai/dsh-llm-retry' +import { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import { SessionId, - type JsonValue, - type Session, type SessionEvent, - type SessionHeader, - type TodoItem, type UserMessageData, } from '@deepseek-ai/dsh-session' -import { foldGoal, type GoalPhase } from '@deepseek-ai/dsh-goal' +import { foldGoal } from '@deepseek-ai/dsh-goal' import { - formatSessionReferenceMention, parseSessionReferenceText, - type SessionReferenceService, } from '@deepseek-ai/dsh-session-reference' import { foldSessionTitle } from '@deepseek-ai/dsh-session-title' -import type { - SessionLogSnapshot, - SessionRecord, -} from '@deepseek-ai/dsh-session-query' // Type import also declaration-merges the optional `sessionPersistence` // service onto `Context` so `ctx.get('sessionPersistence')` is typed. import type {} from '@deepseek-ai/dsh-session-persistence' -import type { SkillDefinition, SkillResourceBase, SkillService } from '@deepseek-ai/dsh-skill' -import type { - FileDiff, - TerminalCallView, - ToolCallView, - ToolDefinition, - ToolResultView, -} from '@deepseek-ai/dsh-tools' -import { - UserInteractionError, - type AskUserQuestionAnswer, - type AskUserQuestionAnswerItem, - type AskUserQuestionItem, - type AskUserQuestionRequest, -} from '@deepseek-ai/dsh-user-interaction' +import type { SkillService } from '@deepseek-ai/dsh-skill' +// Type import declaration-merges the `userInteraction` service onto `Context`; +// the ask-user-question queue is registered by ./chat/questions. +import type {} from '@deepseek-ai/dsh-user-interaction' import { TuiExtensionServiceImpl, TuiOverlayManager, -} from './overlay-manager.ts' +} from './extension/overlay-manager.ts' +import { + parseTuiPromptTemplate, + renderTuiPromptTemplate, + type TuiPromptValueHandle, +} from './prompt.ts' import type { TuiOverlayRequest, TuiOverlaySession, TuiTheme, -} from './extension.ts' +} from './extension/types.ts' +import { displayInlineText, displayText } from './components/text.ts' +import { createPalette, markdownTheme, selectTheme } from './components/theme.ts' +import { contentText, parseArguments } from './components/content.ts' +import { + cacheHitRate, + formatTokens, + recordEventUsage, + sessionTokens, +} from './chat/tokens.ts' +import { + fadeGlyph, + formatQueuedStatus, + openStepPhase, + openTurn, + pulseLevel, + runningPhaseGlyph, + STATUS_ANIMATION_INTERVAL_MS, + STATUS_FADE_MS, + TIMING_BUCKET_GLYPHS, + type StepPosition, +} from './chat/timing.ts' +import { + resolveTuiConfig, + type Config, +} from './config.ts' +import { + HeaderComponent, + StreamingAssistantComponent, + ToolCardComponent, + TodoComponent, + UserMessageComponent, +} from './components/transcript.ts' +import { + compactTargetLabel, + diagnosticMeter, + formatDiagnosticCount, + formatDiagnosticNumber, + formatDiagnosticTime, + initialTarget, + StatusCardComponent, + PromptContextComponent, + targetLabel, + type StatusCardRow, +} from './components/dialogs.ts' +import { + parseSkillCommand, + renderSkillInvocation, + SKILL_COMMAND_PREFIX, +} from './chat/skill-invocation.ts' +import { ReferenceAutocompleteProvider } from './chat/autocomplete.ts' +import { + activeSurfaceSeqs, + activeToolCallIds, + BANNER_REVEAL_INTERVAL_MS, + BANNER_REVEAL_STEPS, + formatCwd, + gitBranch, + HintEditor, + sessionReferenceCard, +} from './chat/helpers.ts' +import { + createModelController, + type ModelController, +} from './chat/model-command.ts' +import { createQuestionQueue } from './chat/questions.ts' +import { createResumeController } from './chat/resume.ts' +import type { TuiResumeHost, TuiRuntime } from './runtime.ts' +import { WorkspaceFileSearch } from './chat/file-autocomplete.ts' + +export { TuiPromptService } from './prompt.ts' +export { renderSkillInvocation } from './chat/skill-invocation.ts' +export type { TuiResumeHost, TuiRuntime } from './runtime.ts' +export { + resolveTuiConfig, + TuiConfigSchema, + Config, + type ResolvedTuiConfig, + type ResolvedTuiThemeConfig, + type TuiConfig, + type TuiThemeConfig, +} from './config.ts' +export { + DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES, + DEFAULT_FILE_SEARCH_MAX_ENTRIES, + DEFAULT_FILE_SEARCH_MAX_RESULTS, +} from './chat/file-autocomplete.ts' export type { TuiComponent, @@ -122,7 +168,7 @@ export type { TuiOverlayState, TuiTheme, TuiViewport, -} from './extension.ts' +} from './extension/types.ts' declare module 'cordis' { interface Context { @@ -133,17 +179,6 @@ declare module 'cordis' { } } -/** Process-lifecycle owner used by the shipped CLI for an atomic resume handoff. */ -export interface TuiResumeHost { - /** - * Dispose the current app and replace it with a runtime for `sessionId`. - * Success does not return. A host may reject before it commits teardown; - * after commit it owns fatal reporting and process exit. - * @param sessionId - validated persisted session selected by the user. - */ - handoff(sessionId: SessionId): Promise -} - /** * Optional terminal-local interaction service provided by one mounted TUI. * @@ -167,1671 +202,28 @@ export abstract class TuiExtensionService extends Service { */ abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession } -import { - activeAtToken, - DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES, - DEFAULT_FILE_SEARCH_MAX_ENTRIES, - DEFAULT_FILE_SEARCH_MAX_RESULTS, - formatFileMention, - WorkspaceFileSearch, -} from './file-autocomplete.ts' - -export { - DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES, - DEFAULT_FILE_SEARCH_MAX_ENTRIES, - DEFAULT_FILE_SEARCH_MAX_RESULTS, -} from './file-autocomplete.ts' export const name = 'ui-tui' -export const inject = ['agents', 'sessions', 'commands', 'userInteraction', 'tools', 'llm', 'systemPrompt', 'tokenMeter'] +export const inject = ['agents', 'sessions', 'commands', 'userInteraction', 'tools', 'llm', 'systemPrompt', 'tokenMeter', 'tuiPrompt'] /** Model guidance for path-only file references selected through the TUI. */ export const FILE_REFERENCE_PROMPT = 'Paths prefixed with @ are files explicitly referenced by the user. Use the read tool when their contents are needed; do not claim to have inspected a file before reading it.' -/** Interaction and presentation settings for the pi-tui terminal mode. */ -export interface TuiConfig { - /** Render model reasoning blocks. */ - showReasoning?: boolean - /** Maximum tool-card body lines retained in its collapsed head/tail preview. */ - maxToolOutputLines?: number - /** Maximum options visible at once in a user-question panel. */ - maxQuestionOptions?: number - /** Maximum models visible at once in the model selector. */ - maxModelOptions?: number - /** Maximum sessions visible at once in the resume selector. */ - maxResumeOptions?: number - /** User-question panel width in terminal columns, clamped to the terminal. */ - questionDialogWidth?: number - /** User-question panel maximum height in terminal rows. */ - questionDialogMaxHeight?: number - /** Model-selector width in terminal columns. */ - modelDialogWidth?: number - /** Model-selector maximum height in terminal rows. */ - modelDialogMaxHeight?: number - /** Maximum fuzzy file candidates displayed for one `@` query. */ - fileSearchMaxResults?: number - /** Maximum paths retained in one `@` workspace index. */ - fileSearchMaxEntries?: number - /** Directory basenames excluded from `@` traversal and completion. */ - 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 - /** Terminal window title while the UI is mounted; a logged session title prefixes it. */ - title?: string -} - -const showReasoningSchema = z.boolean().default(true) -const maxToolOutputLinesSchema = z.number().step(1).min(1).default(6) -const maxQuestionOptionsSchema = z.number().step(1).min(1).default(8) -const maxModelOptionsSchema = z.number().step(1).min(1).default(8) -const maxResumeOptionsSchema = z.number().step(1).min(1).default(8) -const questionDialogWidthSchema = z.number().step(1).min(20).default(200) -const questionDialogMaxHeightSchema = z.number().step(1).min(6).default(20) -const modelDialogWidthSchema = z.number().step(1).min(20).default(76) -const modelDialogMaxHeightSchema = z.number().step(1).min(6).default(20) -const fileSearchMaxResultsSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_RESULTS) -const fileSearchMaxEntriesSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_ENTRIES) -const fileSearchExcludedDirectoriesSchema = z.array(z.string()).default([...DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES]) -const showHardwareCursorSchema = z.boolean().default(false) -const colorSchema = z.boolean().default(true) -// No default: an unset value auto-detects truecolor from COLORTERM in `apply`. -const truecolorSchema = z.boolean() -const titleSchema = z.string().default('DeepSeek Harness') - -const tuiConfigSchemaFields = { - showReasoning: showReasoningSchema, - maxToolOutputLines: maxToolOutputLinesSchema, - maxQuestionOptions: maxQuestionOptionsSchema, - maxModelOptions: maxModelOptionsSchema, - maxResumeOptions: maxResumeOptionsSchema, - questionDialogWidth: questionDialogWidthSchema, - questionDialogMaxHeight: questionDialogMaxHeightSchema, - modelDialogWidth: modelDialogWidthSchema, - modelDialogMaxHeight: modelDialogMaxHeightSchema, - fileSearchMaxResults: fileSearchMaxResultsSchema, - fileSearchMaxEntries: fileSearchMaxEntriesSchema, - fileSearchExcludedDirectories: fileSearchExcludedDirectoriesSchema, - showHardwareCursor: showHardwareCursorSchema, - color: colorSchema, - truecolor: truecolorSchema, - title: titleSchema, -} - -/** Schemastery schema for presentation settings embedded by app bundles. */ -export const TuiConfigSchema: z = z.object(tuiConfigSchemaFields) - -/** Serializable plugin configuration. */ -export interface Config extends TuiConfig { - /** Banner subtitle line. When absent, the banner has no subtitle and sweeps in on start. */ - welcome?: string - /** Exact shared agent/session identity driven by this terminal. Defaults to `main`. */ - sessionId?: string - /** - * Shell command fallback printed on exit or after selecting a session when - * the host cannot hand off in place. Every `{session}` becomes the selected - * id; the TUI never executes this text. Absent disables only the fallback, - * not the interactive selector. - */ - resumeCommand?: string -} - -export const Config: z = z.object({ - welcome: z.string(), - sessionId: z.string().default('main'), - resumeCommand: z.string(), - showReasoning: tuiConfigSchemaFields.showReasoning, - maxToolOutputLines: tuiConfigSchemaFields.maxToolOutputLines, - maxQuestionOptions: tuiConfigSchemaFields.maxQuestionOptions, - maxModelOptions: tuiConfigSchemaFields.maxModelOptions, - maxResumeOptions: tuiConfigSchemaFields.maxResumeOptions, - questionDialogWidth: tuiConfigSchemaFields.questionDialogWidth, - questionDialogMaxHeight: tuiConfigSchemaFields.questionDialogMaxHeight, - modelDialogWidth: tuiConfigSchemaFields.modelDialogWidth, - modelDialogMaxHeight: tuiConfigSchemaFields.modelDialogMaxHeight, - fileSearchMaxResults: tuiConfigSchemaFields.fileSearchMaxResults, - fileSearchMaxEntries: tuiConfigSchemaFields.fileSearchMaxEntries, - fileSearchExcludedDirectories: tuiConfigSchemaFields.fileSearchExcludedDirectories, - showHardwareCursor: tuiConfigSchemaFields.showHardwareCursor, - color: tuiConfigSchemaFields.color, - truecolor: tuiConfigSchemaFields.truecolor, - title: tuiConfigSchemaFields.title, -}) - -/** Fully defaulted TUI presentation settings. */ -export interface ResolvedTuiConfig { - showReasoning: boolean - maxToolOutputLines: number - maxQuestionOptions: number - maxModelOptions: number - maxResumeOptions: number - questionDialogWidth: number - questionDialogMaxHeight: number - modelDialogWidth: number - modelDialogMaxHeight: number - fileSearchMaxResults: number - fileSearchMaxEntries: number - fileSearchExcludedDirectories: string[] - showHardwareCursor: boolean - color: boolean - truecolor: boolean - title: string -} - -/** Runtime boundary used by the interactive TUI. */ -export interface TuiRuntime { - /** Terminal implementation; production uses pi-tui's `ProcessTerminal`. */ - terminal: Terminal - /** Exit hook used by terminal shutdown or a target-agent startup failure. */ - exit(code: number): void - /** - * Override the footer's logical working-directory label without changing the session directory used by tools. - * @param cwd - Operational working directory from the session header. - * @returns Unescaped label; the TUI makes terminal controls visible. - */ - formatCwd?: (cwd: string | undefined) => string - /** Monotonic-enough wall clock for elapsed status rendering. Defaults to `Date.now`. */ - now?(): number - /** Host-owned process handoff; absent leaves `resumeCommand` as the fallback. */ - handoffResume?: TuiResumeHost['handoff'] -} - -/** - * Apply direct-call defaults after Loader schema validation has normally run. - * - * @param config - Deployment-provided terminal presentation settings. - * @returns Complete settings consumed by the TUI renderer. - */ -export function resolveTuiConfig(config: TuiConfig | undefined): ResolvedTuiConfig { - return { - showReasoning: config?.showReasoning ?? true, - maxToolOutputLines: config?.maxToolOutputLines ?? 6, - maxQuestionOptions: config?.maxQuestionOptions ?? 8, - maxModelOptions: config?.maxModelOptions ?? 8, - maxResumeOptions: config?.maxResumeOptions ?? 8, - questionDialogWidth: config?.questionDialogWidth ?? 200, - questionDialogMaxHeight: config?.questionDialogMaxHeight ?? 20, - modelDialogWidth: config?.modelDialogWidth ?? 76, - modelDialogMaxHeight: config?.modelDialogMaxHeight ?? 20, - fileSearchMaxResults: config?.fileSearchMaxResults ?? DEFAULT_FILE_SEARCH_MAX_RESULTS, - fileSearchMaxEntries: config?.fileSearchMaxEntries ?? DEFAULT_FILE_SEARCH_MAX_ENTRIES, - fileSearchExcludedDirectories: [...(config?.fileSearchExcludedDirectories ?? DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES)], - showHardwareCursor: config?.showHardwareCursor ?? false, - color: config?.color ?? true, - truecolor: config?.truecolor ?? false, - title: config?.title ?? 'DeepSeek Harness', - } -} - -interface Palette { - accent: (text: string) => string - accent2: (text: string) => string - text: (text: string) => string - muted: (text: string) => string - dim: (text: string) => string - success: (text: string) => string - warning: (text: string) => string - error: (text: string) => string - code: (text: string) => string - added: (text: string) => string - removed: (text: string) => string - bold: (text: string) => string - italic: (text: string) => string - underline: (text: string) => string - strike: (text: string) => string - /** Reverse video for the active selection; swaps the theme's own fg/bg so it reads on any scheme. */ - selected: (text: string) => string -} - -function ansi(open: string, close: string, enabled: boolean): (text: string) => string { - return enabled ? text => `\x1b[${open}m${text}\x1b[${close}m` : text => text -} - -const TERMINAL_CONTROL_PATTERN = /[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/gu -const TERMINAL_OSC_PATTERN = /(?:\u001B\]|\u009D)(?:(?!\u0007|\u001B\\)[\s\S])*(?:\u0007|\u001B\\|$)/gu -const TERMINAL_CSI_PATTERN = /(?:\u001B\[|\u009B)[0-?]*[ -/]*[@-~]/gu -const TERMINAL_ESCAPE_PATTERN = /\u001B[@-_]/gu -const BRACKETED_PASTE_START = '\u001B[200~' -const BRACKETED_PASTE_END = '\u001B[201~' - -/** - * Escape external C0/C1 controls before pi-tui adds application-owned ANSI. - * Line feeds remain structural so transcript and tool output retain their layout. - */ -function displayText(text: string): string { - return text.replace(TERMINAL_CONTROL_PATTERN, control => - `\\x${control.charCodeAt(0).toString(16).padStart(2, '0')}`) -} - -/** Escape external controls for terminal fields that must remain on one line. */ -function displayInlineText(text: string): string { - return displayText(text).replaceAll('\n', '\\x0a') -} - -/** Remove terminal controls from clipboard text before an editable field stores it. */ -function sanitizePastedText(text: string): string { - return text - .replace(TERMINAL_OSC_PATTERN, '') - .replace(TERMINAL_CSI_PATTERN, '') - .replace(TERMINAL_ESCAPE_PATTERN, '') - .replace(TERMINAL_CONTROL_PATTERN, '') -} - -/** - * Theme-agnostic palette built from the standard 16-color ANSI set plus SGR - * attributes, which every terminal remaps to its active color scheme. Body - * `text` stays the terminal's default foreground so it reads on light and dark - * backgrounds alike; grouping uses foreground-only gutter bars and reverse - * video rather than fixed background fills. - */ -function createPalette(enabled: boolean, scheme: TerminalColorScheme = 'dark'): Palette { - return { - accent: ansi('94', '39', enabled), - accent2: ansi('95', '39', enabled), - text: text => text, - muted: ansi('90', '39', enabled), - // SGR 2 (dim) lightens text on a light background — substitute ANSI 90 - // (bright black / gray) which renders as a readable muted tone on any scheme. - dim: scheme === 'light' ? ansi('90', '39', enabled) : ansi('2', '22', enabled), - success: ansi('32', '39', enabled), - warning: ansi('33', '39', enabled), - error: ansi('31', '39', enabled), - // ANSI 36 (cyan) is difficult to read on a light background — use - // ANSI 34 (blue) which is legible on both light and dark schemes. - code: scheme === 'light' ? ansi('34', '39', enabled) : ansi('36', '39', enabled), - added: ansi('32', '39', enabled), - removed: ansi('31', '39', enabled), - bold: ansi('1', '22', enabled), - italic: ansi('3', '23', enabled), - underline: ansi('4', '24', enabled), - strike: ansi('9', '29', enabled), - selected: ansi('7', '27', enabled), - } -} - -/** - * DeepSeek brand gradient stops (indigo → light blue) taken from the - * deepseek.com logo, painted across the startup banner's product name on - * truecolor terminals. Fixed brand identity, deliberately outside the - * theme-adaptive {@link Palette}. - */ -const BRAND_GRADIENT = [ - [77, 107, 254], // #4D6BFE - [57, 130, 255], // #3982FF - [36, 152, 255], // #2498FF -] as const - -/** - * Sample {@link BRAND_GRADIENT} at fraction `t` via piecewise-linear - * interpolation across its stops. - * - * @param t - Position along the gradient; clamped to [0, 1]. - * @returns The interpolated `[r, g, b]` channels, each rounded to 0–255. - */ -function brandColorAt(t: number): readonly [number, number, number] { - const span = Math.min(Math.max(t, 0), 1) * (BRAND_GRADIENT.length - 1) - const index = Math.min(Math.floor(span), BRAND_GRADIENT.length - 2) - const local = span - index - // `index` is clamped to a valid adjacent pair, so both lookups are in-bounds. - const from = BRAND_GRADIENT[index] as readonly [number, number, number] - const to = BRAND_GRADIENT[index + 1] as readonly [number, number, number] - return [ - Math.round(from[0] + (to[0] - from[0]) * local), - Math.round(from[1] + (to[1] - from[1]) * local), - Math.round(from[2] + (to[2] - from[2]) * local), - ] -} - -/** - * Paint `text` left-to-right in the DeepSeek brand gradient with per-character - * 24-bit foreground codes, resetting to the default foreground at the end. - * Foreground-only, so it stays legible on any terminal background; the caller - * gates it on truecolor support and wraps it in bold. - * - * @param text - Text to colorize; sampled once per character. - * @returns `text` wrapped in truecolor SGR foreground codes. - */ -function gradientText(text: string): string { - // The sole caller passes the ASCII product name, so UTF-16 unit iteration - // samples exactly one color per visible letter. - const last = Math.max(1, text.length - 1) - let painted = '' - for (let index = 0; index < text.length; index += 1) { - const [r, g, b] = brandColorAt(index / last) - painted += `\x1b[38;2;${r};${g};${b}m${text.charAt(index)}` - } - return `${painted}\x1b[39m` -} - -function markdownTheme(palette: Palette): MarkdownTheme { - return { - heading: text => palette.accent(text), - link: text => palette.accent(text), - // pi-tui requires this URL slot but its current Markdown renderer does not invoke it. - /* v8 ignore next */ - linkUrl: text => palette.dim(text), - code: text => palette.code(text), - codeBlock: text => palette.text(text), - codeBlockBorder: text => palette.dim(text), - quote: text => palette.muted(text), - quoteBorder: text => palette.accent2(text), - hr: text => palette.dim(text), - listBullet: text => palette.accent(text), - bold: text => palette.bold(text), - italic: text => palette.italic(text), - strikethrough: text => palette.strike(text), - underline: text => palette.underline(text), - } -} - -function selectTheme(palette: Palette): SelectListTheme { - return { - selectedPrefix: palette.accent, - selectedText: palette.accent, - description: palette.muted, - scrollInfo: palette.dim, - noMatch: palette.warning, - } -} - -function dialogSelectTheme(palette: Palette): SelectListTheme { - return { - ...selectTheme(palette), - selectedText: text => palette.selected(palette.accent(text)), - } -} - -function contentText(content: readonly ContentBlock[]): string { - const parts: string[] = [] - for (const block of content) { - switch (block.type) { - case 'text': - case 'reasoning': - parts.push(block.text) - break - case 'tool-call': - parts.push(`${block.name}(${block.arguments})`) - break - case 'tool-result': - parts.push(contentText(block.content)) - break - default: { - const rawType = (block as { type?: unknown }).type - parts.push(`[${typeof rawType === 'string' ? rawType : 'content'}]`) - break - } - } - } - return parts.join('') -} - -function textBlocks(content: readonly ContentBlock[], type: 'text' | 'reasoning'): string { - return content - .filter((block): block is Extract => block.type === type) - .map(block => block.text) - .join('\n\n') -} - -interface ModelChoice extends AgentLlmTarget { - modelName: string - description?: string - reasoning?: LlmModelReasoningInfo -} - -interface ModelDialogSelection { - choice: ModelChoice - reasoningEffort: ReasoningEffortId | undefined -} - -function targetLabel(target: AgentLlmTarget): string { - return `${target.provider}/${target.model}` -} - -function compactTargetLabel(target: AgentLlmTarget): string { - return `${target.model}${target.reasoningEffort === undefined ? '' : ` ${target.reasoningEffort}`}` -} - -function targetReasoningLabel(choice: ModelChoice, effort: ReasoningEffortId | undefined): string | undefined { - if (effort === undefined) return choice.reasoning === undefined ? undefined : 'provider default' - return choice.reasoning?.efforts.find(candidate => candidate.id === effort)?.name ?? effort -} - -function initialTarget(agent: Agent): AgentLlmTarget | undefined { - const logged = agent.session.requestHeader()?.config - if (logged !== undefined) { - if (logged.reasoningEffort === undefined) { - return { provider: logged.provider, model: logged.model } - } - return { - provider: logged.provider, - model: logged.model, - reasoningEffort: logged.reasoningEffort, - } - } - if (agent.options.provider === undefined || agent.options.model === undefined) return undefined - return { provider: agent.options.provider, model: agent.options.model } -} - -async function readModelChoices( - ctx: Context, - current: AgentLlmTarget | undefined, -): Promise { - const providers = ctx.llm.listProviders() - const groups = await Promise.all(providers.map(async (provider) => { - const advertised = await ctx.llm.listModels(provider.id) - const models: LlmModelInfo[] = [...advertised] - if ( - current?.provider === provider.id - && !models.some(model => model.id === current.model) - ) { - models.push({ provider: provider.id, id: current.model, name: current.model }) - } - return Promise.all(models.map(async (model): Promise => { - const reasoning = (await ctx.llm.resolveModelInfo(provider.id, model.id)).reasoning - return { - provider: provider.id, - model: model.id, - modelName: model.name, - ...model.description === undefined ? {} : { description: model.description }, - ...reasoning === undefined ? {} : { reasoning }, - } - })) - })) - return groups.flat() -} - -/** Milliseconds between banner sweep-reveal frames (~60 fps). */ -const BANNER_REVEAL_INTERVAL_MS = 15 - -/** Number of sweep frames the banner reveal spreads the terminal width over. */ -const BANNER_REVEAL_STEPS = 24 - -/** - * Borderless startup banner: product title, an optional configured subtitle, - * and the model/session detail line. No box frame — each line renders as plain - * left-padded text (matching transcript notices) so it reads on any theme. - */ -class HeaderComponent implements Component { - /** Columns of the banner currently revealed; `undefined` renders it whole. */ - private revealWidth: number | undefined - - constructor( - private readonly agent: Agent, - private readonly subtitle: () => string | undefined, - private readonly palette: Palette, - private readonly gradient: boolean, - private readonly currentModel: () => string | undefined, - ) {} - - /** Clip the banner to `width` columns (the sweep reveal); `undefined` restores it. */ - setRevealWidth(width: number | undefined): void { - this.revealWidth = width - } - - invalidate(): void {} - - render(width: number): string[] { - const usable = Math.max(1, width - 2) - const name = this.gradient - ? this.palette.bold(gradientText('DEEPSEEK')) - : this.palette.bold(this.palette.accent('DEEPSEEK')) - const title = `${name} ${this.palette.bold('HARNESS')}` - const model = displayText(this.currentModel() ?? 'model unset') - const detail = `${model} • ${displayText(this.agent.session.id)}` - const subtitle = this.subtitle() - const lines = [ - title, - ...subtitle === undefined ? [] : [this.palette.muted(displayText(subtitle))], - this.palette.dim(detail), - ] - .flatMap(line => wrapTextWithAnsi(line, usable)) - .map(line => ` ${truncateToWidth(line, usable, '')}`) - if (this.revealWidth === undefined) return lines - const revealed = this.revealWidth - return lines.map(line => truncateToWidth(line, revealed, '')) - } -} - -/** Milliseconds between elapsed-time refreshes of the running status line. */ -const STATUS_ELAPSED_INTERVAL_MS = 1000 - -/** Steering/cancel affordance shown on every running status line. */ -const STATUS_HINT = 'Enter sends steering, Esc cancels' - -/** - * Fine-grained activity of a running turn, derived in the TUI from session - * lifecycle events for the status line. It is presentation-only, not a durable - * agent state: `waiting` spans a step from its `step/start` until the first - * reasoning or text chunk, `thinking`/`responding` track reasoning/text deltas, - * and `executing` covers tool calls until the next step begins. - */ -type TurnPhase = 'waiting' | 'thinking' | 'responding' | 'executing' - -/** - * Live controller for the running status line: its {@link Loader}, the derived - * {@link TurnPhase}, the elapsed-time baselines the label reads, and the timer - * that refreshes it. Present only while the turn runs; `undefined` when idle. - */ interface RunningStatus { - loader: Loader - phase: TurnPhase - phaseStartedAt: number - stepStartedAt: number + turn: number | undefined timer: ReturnType + /** Render clock when the turn began; origin of the glyph fade-in. */ + startedAt: number + /** The most recently rendered phase glyph, handed to the fade-out. */ + lastGlyph: string } -/** Status-line label for each {@link TurnPhase}. */ -const TURN_PHASE_LABELS: Record = { - waiting: 'Waiting for the first token', - thinking: 'Thinking', - responding: 'Responding', - executing: 'Executing tools', -} - -/** - * Format a non-negative elapsed span as a compact status duration: whole - * seconds under a minute (`8s`), else minutes and zero-padded seconds - * (`1m05s`). - * @param elapsedMs - Elapsed time in milliseconds; negatives clamp to zero. - * @returns The compact duration string. - */ -function formatStatusDuration(elapsedMs: number): string { - const total = Math.floor(Math.max(0, elapsedMs) / 1000) - if (total < 60) return `${total}s` - return `${Math.floor(total / 60)}m${(total % 60).toString().padStart(2, '0')}s` -} - -/** - * Compose the running status-line text from the current phase, its timers, and - * the queued-steering badge. The waiting phase spans the whole step so it shows - * one duration; later phases show time in the phase plus the running step - * total, and a non-zero `queued` count surfaces as a badge before the hint. - * @param phase - The current turn phase. - * @param phaseMs - Elapsed time in the current phase, in milliseconds. - * @param stepMs - Elapsed time in the current step, in milliseconds. - * @param queued - Count of pending steering messages; zero hides the badge. - * @returns The status-line text, including the steering/cancel hint. - */ -function formatTurnStatus(phase: TurnPhase, phaseMs: number, stepMs: number, queued: number): string { - const timing = phase === 'waiting' - ? formatStatusDuration(stepMs) - : `${formatStatusDuration(phaseMs)} · total ${formatStatusDuration(stepMs)}` - const badge = queued > 0 ? `${queued} queued · ` : '' - return `${TURN_PHASE_LABELS[phase]} ${timing} — ${badge}${STATUS_HINT}` -} - -/** - * Groups children behind a colored left-gutter bar (`▌`). Foreground-only, so - * it renders legibly on any terminal background — unlike a filled block whose - * body text would collide with the theme's default foreground. - */ -class GutterBox implements Component { - protected readonly children: Component[] = [] - - constructor(private readonly barFn: (text: string) => string, private readonly paddingY = 1) {} - - addChild(child: Component): void { - this.children.push(child) - } - - invalidate(): void { - for (const child of this.children) child.invalidate() - } - - render(width: number): string[] { - const inner = Math.max(1, width - 2) - const body: string[] = [] - for (const child of this.children) for (const line of child.render(inner)) body.push(line) - // Every caller adds a non-empty title/label child, so an all-empty box is unreachable; - // the guard preserves Box semantics (render nothing) rather than emitting stray gutter bars. - /* v8 ignore next */ - if (body.length === 0) return [] - const bar = this.barFn('▌') - const pad = Array.from({ length: this.paddingY }, () => '') - return [...pad, ...body, ...pad].map(line => `${bar} ${line}`) - } -} - -class UserMessageComponent extends GutterBox { - constructor(text: string, palette: Palette, mdTheme: MarkdownTheme, label = 'You') { - super(value => palette.accent(value)) - this.addChild(new Text(palette.bold(palette.accent(displayText(label))), 0, 0)) - this.addChild(new Markdown(displayText(text), 0, 0, mdTheme, { color: value => palette.text(value) }, { - preserveOrderedListMarkers: true, - preserveBackslashEscapes: true, - })) - } -} - -class AssistantMessageComponent extends Container { - constructor(content: readonly ContentBlock[], showReasoning: boolean, palette: Palette, mdTheme: MarkdownTheme) { - super() - const reasoning = displayText(textBlocks(content, 'reasoning').trim()) - const text = displayText(textBlocks(content, 'text').trim()) - if (reasoning && showReasoning) { - this.addChild(new Spacer(1)) - this.addChild(new Text(palette.italic(palette.muted('Reasoning')), 1, 0)) - this.addChild(new Markdown(reasoning, 1, 0, mdTheme, { - color: value => palette.muted(value), - italic: true, - })) - } - if (text) { - this.addChild(new Spacer(1)) - this.addChild(new Text(palette.bold(palette.accent2('Assistant')), 1, 0)) - this.addChild(new Markdown(text, 1, 0, mdTheme, { color: value => palette.text(value) })) - } - } -} - -interface StreamingBlock { - type: string - text: string -} - -class StreamingAssistantComponent extends Container { - private readonly blocks = new Map() - - constructor( - private showReasoning: boolean, - private readonly palette: Palette, - private readonly mdTheme: MarkdownTheme, - ) { - super() - } - - update(chunk: StreamChunk): void { - if (chunk.type === 'block-start') { - this.blocks.set(chunk.index, { type: chunk.blockType, text: '' }) - } else if (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta') { - const type = chunk.type === 'text-delta' ? 'text' : 'reasoning' - const block = this.blocks.get(chunk.index) ?? { type, text: '' } - block.text += chunk.text - this.blocks.set(chunk.index, block) - } else if (chunk.type === 'block-end' && (chunk.block.type === 'text' || chunk.block.type === 'reasoning')) { - this.blocks.set(chunk.index, { type: chunk.block.type, text: chunk.block.text }) - } - this.rebuild() - } - - setShowReasoning(show: boolean): void { - this.showReasoning = show - this.rebuild() - } - - private rebuild(): void { - this.clear() - const content: ContentBlock[] = [...this.blocks.entries()] - .sort(([left], [right]) => left - right) - .flatMap(([, block]) => { - if (block.type === 'text') return [{ type: 'text', text: block.text }] - if (block.type === 'reasoning') return [{ type: 'reasoning', text: block.text }] - return [] - }) - const component = new AssistantMessageComponent(content, this.showReasoning, this.palette, this.mdTheme) - for (const child of component.children) this.addChild(child) - } -} - -interface ParsedArguments { - value: unknown - valid: boolean -} - -function parseArguments(raw: string): ParsedArguments { - try { - return { value: JSON.parse(raw), valid: true } - } catch { - return { value: raw, valid: false } - } -} - -function pretty(value: unknown): string { - if (typeof value === 'string') return displayText(value) - // The lib declaration narrows `unknown` to a string-returning overload, but - // JSON.stringify returns undefined for runtime values such as symbols. - const serialized = JSON.stringify(value, null, 2) as string | undefined - return displayText(serialized ?? String(value)) -} - -function diffLines(diff: FileDiff, palette: Palette): string[] { - const lines = [palette.bold(displayText(diff.path))] - if (diff.oldText !== null) { - for (const line of displayText(diff.oldText).split('\n')) lines.push(palette.removed(`- ${line}`)) - } - for (const line of displayText(diff.newText).split('\n')) lines.push(palette.added(`+ ${line}`)) - return lines -} - -class ToolCardComponent implements Component { - private result: { content: ContentBlock[]; isError: boolean; meta?: JsonValue } | undefined - private expanded = false - private callView: ToolCallView - private resultView: ToolResultView | undefined - - constructor( - private readonly name: string, - private readonly parsed: ParsedArguments, - private readonly definition: ToolDefinition | undefined, - private readonly maxOutputLines: number, - private readonly palette: Palette, - ) { - this.callView = this.presentCall() - } - - private presentCall(): ToolCallView { - if (this.parsed.valid && this.definition?.presentCall) { - try { - const view = this.definition.presentCall(this.parsed.value) - if (view !== undefined) return view - } catch (error: unknown) { - return { card: 'generic', title: displayText(this.name), rawInput: `Presenter failed: ${String(error)}` } - } - } - return { card: 'generic', title: displayText(this.name), rawInput: this.parsed.value } - } - - updateResult(event: Extract['data']): void { - this.result = { - content: [...event.content], - isError: event.isError, - ...event.meta !== undefined ? { meta: event.meta } : {}, - } - if (this.parsed.valid && this.definition?.presentResult) { - try { - const view = this.definition.presentResult(this.parsed.value, this.result) - if (view !== undefined) this.resultView = view - } catch (error: unknown) { - this.resultView = { card: 'generic', content: [{ type: 'text', text: `Presenter failed: ${String(error)}` }] } - } - } - } - - setExpanded(expanded: boolean): void { - this.expanded = expanded - } - - invalidate(): void {} - - render(width: number): string[] { - const isError = this.result?.isError ?? false - const glyph = this.result === undefined ? this.palette.warning('◌') : isError ? this.palette.error('✕') : this.palette.success('✓') - const body = this.renderBody() - const title = truncateToWidth(`${glyph} ${displayText(this.title())}`, Math.max(1, width - 4), '') - const headLines = Math.ceil(this.maxOutputLines / 2) - const tailLines = this.maxOutputLines - headLines - const visibleBody = this.expanded || body.length <= this.maxOutputLines - ? body - : [ - ...body.slice(0, headLines), - this.palette.dim(`… +${body.length - this.maxOutputLines} lines (Ctrl+O to expand)`), - ...body.slice(body.length - tailLines), - ] - const barFn = this.result === undefined - ? this.palette.warning - : isError ? this.palette.error : this.palette.success - const box = new GutterBox(barFn, visibleBody.length > 0 ? 1 : 0) - box.addChild(new Text(this.palette.bold(title), 0, 0)) - if (visibleBody.length > 0) box.addChild(new Text(visibleBody.join('\n'), 0, 0)) - return box.render(width) - } - - private title(): string { - return this.resultView?.title ?? this.callView.title - } - - private renderBody(): string[] { - const view = this.resultView ?? this.callView - if (view.card === 'terminal') { - const pending = this.callView.card === 'terminal' ? this.callView : undefined - const lines: string[] = [] - if (pending?.description) lines.push(this.palette.muted(displayText(pending.description))) - if (pending?.cwd) lines.push(this.palette.dim(displayText(pending.cwd))) - if (this.resultView?.card === 'terminal') { - if (this.resultView.output) lines.push(...displayText(this.resultView.output).split('\n')) - if (this.resultView.exitCode !== undefined) lines.push(this.palette.dim(`[exit ${this.resultView.exitCode}]`)) - if (this.resultView.signal !== undefined) { - lines.push(this.palette.error(`[signal ${displayText(this.resultView.signal)}]`)) - } - } else if (this.result === undefined) { - // A pending terminal view is the call view itself; TerminalCallView requires a title. - lines.push(this.palette.code(`$ ${displayText((pending as TerminalCallView).title)}`)) - } else { - lines.push(...displayText(contentText(this.result.content)).split('\n')) - } - return lines.filter(Boolean) - } - if (view.card === 'diff') { - return view.diffs.flatMap((diff, index) => [ - ...index > 0 ? [''] : [], - ...diffLines(diff, this.palette), - ]) - } - const content = view.content ?? this.result?.content - const lines: string[] = [] - if (content !== undefined) lines.push(...displayText(contentText(content)).split('\n')) - const rawInput = this.result === undefined && this.callView.card === 'generic' - ? this.callView.rawInput - : undefined - if (rawInput !== undefined) lines.push(...pretty(rawInput).split('\n')) - return lines.filter((line, index, all) => line.length > 0 || (index > 0 && index < all.length - 1)) - } -} - -class TodoComponent implements Component { - private todos: readonly TodoItem[] = [] - - constructor(private readonly palette: Palette) {} - - update(todos: readonly TodoItem[]): void { - this.todos = todos - } - - invalidate(): void {} - - render(width: number): string[] { - if (this.todos.length === 0) return [] - const lines = [this.palette.bold(this.palette.accent('Plan'))] - for (const todo of this.todos) { - const prefix = todo.status === 'completed' - ? this.palette.success('✓') - : todo.status === 'in_progress' - ? this.palette.warning('●') - : this.palette.dim('○') - const content = displayText(todo.content) - const text = todo.status === 'completed' ? this.palette.muted(content) : content - lines.push(truncateToWidth(` ${prefix} ${text}`, width, '')) - } - return ['', ...lines] - } -} - -function formatTokens(value: number): string { - if (value < 1_000) return String(value) - if (value < 10_000) return `${(value / 1_000).toFixed(1)}k` - if (value < 1_000_000) return `${Math.round(value / 1_000)}k` - return `${(value / 1_000_000).toFixed(1)}m` -} - -function formatCwd(cwd: string | undefined): string { - if (cwd === undefined) return 'cwd unset' - const home = homedir() - const rel = relative(resolve(home), resolve(cwd)) - if (rel === '') return '~' - /* v8 ignore next -- Windows cross-drive coverage; POSIX relative() cannot return an absolute path. */ - if (isAbsolute(rel)) return cwd - if (rel !== '..' && !rel.startsWith(`..${sep}`)) return `~${sep}${rel}` - return cwd -} - -/** - * Running token totals for the footer, keyed per turn/step so replayed or - * re-emitted usage replaces rather than double-counts; `input` is uncached - * input, cache buckets are disjoint. - */ -interface SessionTokenTotals { - input: number - output: number - cacheRead: number - cacheWrite: number - readonly byStep: Map -} - -function recordTokenUsage(totals: SessionTokenTotals, turn: number, step: number, usage: TokenUsage): void { - const key = `${turn}:${step}` - const previous = totals.byStep.get(key) - if (previous !== undefined) { - totals.input -= previous.inputTokens - totals.output -= previous.outputTokens - totals.cacheRead -= previous.cacheReadTokens ?? 0 - totals.cacheWrite -= previous.cacheWriteTokens ?? 0 - } - totals.byStep.set(key, usage) - totals.input += usage.inputTokens - totals.output += usage.outputTokens - totals.cacheRead += usage.cacheReadTokens ?? 0 - totals.cacheWrite += usage.cacheWriteTokens ?? 0 -} - -function recordEventUsage(totals: SessionTokenTotals, event: SessionEvent): void { - if (event.type === 'assistant/chunk' && event.data.chunk.type === 'usage') { - recordTokenUsage(totals, event.data.turn, event.data.step, event.data.chunk.usage) - } else if (event.type === 'assistant/message' && event.data.usage !== undefined) { - recordTokenUsage(totals, event.data.turn, event.data.step, event.data.usage) - } -} - -/** - * Share of billed input (prompt) tokens served from the provider cache, as an - * integer percent, or `undefined` before any input is billed (avoids 0/0 and a - * meaningless rate on an empty session). - */ -function cacheHitRate(totals: SessionTokenTotals): number | undefined { - const billedInput = totals.input + totals.cacheRead + totals.cacheWrite - if (billedInput === 0) return undefined - return Math.round((totals.cacheRead / billedInput) * 100) -} - -function sessionTokens(session: Session): SessionTokenTotals { - const totals: SessionTokenTotals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, byStep: new Map() } - for (const event of session.events) { - recordEventUsage(totals, event) - } - return totals -} - -function formatDiagnosticNumber(value: number): string { - return value.toLocaleString('en-US') -} - -function formatDiagnosticTime(value: number): string { - return new Date(value).toISOString().replace('T', ' ').replace(/\.\d{3}Z$/u, ' UTC') -} - -function formatDiagnosticCount(value: number, singular: string): string { - return `${String(value)} ${singular}${value === 1 ? '' : 's'}` -} - -function diagnosticMeter(percent: number, palette: Palette): string { - const width = 16 - const filled = Math.round(Math.min(100, Math.max(0, percent)) / 100 * width) - return `${palette.dim('[')}${palette.accent('█'.repeat(filled))}${palette.dim(`${'░'.repeat(width - filled)}]`)}` -} - -type StatusCardRow = readonly [label: string, value: string] - -/** Bordered, grouped field card for one point-in-time status snapshot. */ -class StatusCardComponent implements Component { - constructor( - private readonly groups: readonly (readonly StatusCardRow[])[], - private readonly palette: Palette, - ) {} - - invalidate(): void {} - - render(width: number): string[] { - const labels = this.groups.flatMap(group => group.map(([label]) => `${label}:`)) - const naturalLabelWidth = Math.max(...labels.map(label => label.length)) - const naturalBodyWidth = Math.max(...this.groups.flatMap(group => group.map(([, value]) => - 1 + naturalLabelWidth + 2 + visibleWidth(value)))) - const cardWidth = Math.min( - Math.max(8, width), - Math.max('Session status'.length + 5, naturalBodyWidth + 4), - ) - const innerWidth = Math.max(1, cardWidth - 4) - const labelWidth = Math.min( - naturalLabelWidth, - Math.max(1, Math.floor(innerWidth / 3)), - ) - const body: string[] = [] - for (const [groupIndex, group] of this.groups.entries()) { - if (groupIndex > 0) body.push('') - for (const [label, value] of group) { - const plainLabel = truncateToWidth(`${label}:`, labelWidth, '') - const prefix = ` ${this.palette.muted(plainLabel.padEnd(labelWidth))} ` - const continuation = ' '.repeat(1 + labelWidth + 2) - const valueWidth = Math.max(1, innerWidth - visibleWidth(prefix)) - const wrapped = wrapTextWithAnsi(value, valueWidth) - for (const [lineIndex, line] of wrapped.entries()) { - body.push(`${lineIndex === 0 ? prefix : continuation}${line}`) - } - } - } - - const title = truncateToWidth('Session status', Math.max(1, cardWidth - 5), '') - const topTail = '─'.repeat(Math.max(0, cardWidth - visibleWidth(title) - 5)) - const top = `${this.palette.dim('╭─ ')}${this.palette.bold(this.palette.accent(title))}${this.palette.dim(` ${topTail}╮`)}` - const lines = [top] - for (const line of body) { - const clipped = truncateToWidth(line, innerWidth, '') - lines.push(`${this.palette.dim('│')} ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ${this.palette.dim('│')}`) - } - lines.push(this.palette.dim(`╰${'─'.repeat(Math.max(0, cardWidth - 2))}╯`)) - return lines - } -} - -class FooterComponent implements Component { - constructor( - private readonly agent: Agent, - private readonly palette: Palette, - private readonly toolsExpanded: () => boolean, - private readonly tokens: () => SessionTokenTotals, - private readonly cwdFormatter: TuiRuntime['formatCwd'], - private readonly currentModel: () => string | undefined, - private readonly contextPercent: () => number | undefined, - ) {} - - invalidate(): void {} - - render(width: number): string[] { - const totals = this.tokens() - const model = displayText(this.currentModel() ?? 'model unset') - const rate = cacheHitRate(totals) - const cache = rate === undefined ? '' : ` cache ${rate}%` - const formattedCwd = displayText( - this.cwdFormatter?.(this.agent.session.header.cwd) ?? formatCwd(this.agent.session.header.cwd), - ) - const left = `${model} ${formattedCwd} ↑${formatTokens(totals.input)} ↓${formatTokens(totals.output)}${cache}` - const contextPercent = this.contextPercent() - const context = contextPercent === undefined ? '' : `${contextPercent}% context ` - const right = `${context}tools:${this.toolsExpanded() ? 'expanded' : 'collapsed'}` - const leftStyled = this.palette.dim(left) - const available = Math.max(0, width - visibleWidth(left) - 2) - const rightClipped = truncateToWidth(right, available, '') - const gap = ' '.repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(rightClipped))) - return [truncateToWidth(`${leftStyled}${gap}${this.palette.dim(rightClipped)}`, width, '')] - } -} - -interface QuestionSelection { - selected: string[] - custom?: string -} - -function renderDialog( - title: string, - body: readonly string[], - width: number, - palette: Palette, -): string[] { - const innerWidth = Math.max(1, width - 4) - const topLabel = ` ${displayText(title)} ` - const top = `╭${topLabel}${'─'.repeat(Math.max(0, width - visibleWidth(topLabel) - 2))}╮` - const lines: string[] = [palette.accent(top)] - for (const line of body) { - const clipped = truncateToWidth(line, innerWidth, '') - lines.push(`${palette.accent('│')} ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ${palette.accent('│')}`) - } - lines.push(palette.accent(`╰${'─'.repeat(Math.max(0, width - 2))}╯`)) - return lines -} - -class ModelDialog implements Component { - private readonly list: SelectList - private readonly items: Map - private readonly choices: Map - private readonly efforts: Map - private readonly currentValue: string | undefined - - constructor( - choices: readonly ModelChoice[], - current: AgentLlmTarget | undefined, - maxVisible: number, - private readonly palette: Palette, - done: (selection: ModelDialogSelection) => void, - cancel: () => void, - ) { - this.items = new Map() - this.choices = new Map() - this.efforts = new Map() - this.currentValue = current === undefined ? undefined : targetLabel(current) - for (const choice of choices) { - const value = targetLabel(choice) - const isCurrent = current?.provider === choice.provider && current.model === choice.model - this.choices.set(value, choice) - this.efforts.set( - value, - isCurrent - ? current.reasoningEffort ?? choice.reasoning?.defaultEffort - : choice.reasoning?.defaultEffort, - ) - this.items.set(value, { - value, - label: displayText(value), - description: this.describeChoice(choice, isCurrent), - }) - } - this.list = new SelectList([...this.items.values()], maxVisible, dialogSelectTheme(palette)) - const currentIndex = current === undefined - ? 0 - : choices.findIndex(choice => choice.provider === current.provider && choice.model === current.model) - this.list.setSelectedIndex(currentIndex) - this.list.onSelect = (item) => { - const selected = choices.find(choice => targetLabel(choice) === item.value) - /* v8 ignore next -- SelectList only returns values built from `choices`. */ - if (selected === undefined) return - done({ - choice: selected, - reasoningEffort: this.efforts.get(item.value), - }) - } - this.list.onCancel = cancel - } - - private describeChoice(choice: ModelChoice, isCurrent: boolean): string { - const selectedEffort = this.efforts.get(targetLabel(choice)) - const effort = choice.reasoning?.efforts.find(candidate => candidate.id === selectedEffort) - const effortLabel = selectedEffort === undefined - ? choice.reasoning === undefined ? undefined : 'provider default' - : effort?.name ?? selectedEffort - return [ - displayText(choice.modelName), - ...choice.description === undefined ? [] : [displayText(choice.description)], - ...effortLabel === undefined ? [] : [displayText(effortLabel)], - ...isCurrent ? ['current'] : [], - ].join(' — ') - } - - private cycleReasoningEffort(): void { - const selectedItem = this.list.getSelectedItem() - /* v8 ignore next -- the dialog is opened only for a non-empty catalog. */ - if (selectedItem === null) return - const choice = this.choices.get(selectedItem.value) - if (choice?.reasoning === undefined) return - const current = this.efforts.get(selectedItem.value) - const efforts: Array = [ - ...choice.reasoning.defaultEffort === undefined ? [undefined] : [], - ...choice.reasoning.efforts.map(effort => effort.id), - ] - const currentIndex = efforts.indexOf(current) - const next = efforts[(currentIndex + 1) % efforts.length] - this.efforts.set(selectedItem.value, next) - const item = this.items.get(selectedItem.value) - /* v8 ignore next -- items and choices are constructed from the same values. */ - if (item === undefined) return - item.description = this.describeChoice(choice, selectedItem.value === this.currentValue) - } - - invalidate(): void { - this.list.invalidate() - } - - handleInput(data: string): void { - if (matchesKey(data, Key.shift(Key.tab))) { - this.cycleReasoningEffort() - } else { - this.list.handleInput(data) - } - this.invalidate() - } - - render(width: number): string[] { - const innerWidth = Math.max(1, width - 4) - return renderDialog('Select model', [ - ...this.list.render(innerWidth), - '', - this.palette.dim('↑/↓ navigate • Shift+Tab reasoning • Enter select • Esc cancel'), - ], width, this.palette) - } -} - -interface ResumeRoute { - provider: string - model: string -} - -interface ResumeCandidate { - record: SessionRecord - title: string - lastActivityAt: number - lastTurn: string - route?: ResumeRoute - goalPhase?: GoalPhase - disabledReason?: string -} - -function resumeTurnLabel(snapshot: SessionLogSnapshot): string { - const event = snapshot.events.findLast(item => item.type === 'turn/end') - if (event === undefined) return 'no completed turn' - const reason = event.data.reason - switch (reason.kind) { - case 'completed': return `turn ${event.data.turn}: completed` - case 'aborted': return `turn ${event.data.turn}: cancelled` - case 'error': return `turn ${event.data.turn}: error` - case 'disposed': return `turn ${event.data.turn}: disposed` - case 'max-tokens': return `turn ${event.data.turn}: max tokens` - case 'interrupted': return `turn ${event.data.turn}: interrupted` - default: return `turn ${event.data.turn}: unknown result` - } -} - -function resumeRoute(snapshot: SessionLogSnapshot): ResumeRoute | undefined { - const header = snapshot.events.findLast(item => item.type === 'request/header') - if (header?.type === 'request/header') { - return { provider: header.data.header.config.provider, model: header.data.header.config.model } - } - const assistant = snapshot.events.findLast(item => item.type === 'assistant/message') - return assistant?.type === 'assistant/message' - ? { provider: assistant.data.provenance.provider, model: assistant.data.provenance.model } - : undefined -} - -function summarizeResumeCandidate( - record: SessionRecord, - snapshot: SessionLogSnapshot, - currentId: SessionId, - cwd: string | undefined, - availableProviders: ReadonlySet, -): ResumeCandidate { - const title = foldSessionTitle(snapshot.events)?.title ?? 'Untitled session' - const route = resumeRoute(snapshot) - const foldedGoal = foldGoal(snapshot.events).goal - let disabledReason: string | undefined - if (record.header.id === currentId) disabledReason = 'current session' - else if (record.live) disabledReason = 'session is already live in this runtime' - else if (record.header.cwd !== cwd) disabledReason = 'different workspace' - else if (route !== undefined && !availableProviders.has(route.provider)) { - disabledReason = `session is complete, but route is currently unavailable (${route.provider}/${route.model})` - } - return { - record, - title, - lastActivityAt: snapshot.events.at(-1)?.time ?? snapshot.session.createdAt, - lastTurn: resumeTurnLabel(snapshot), - ...route === undefined ? {} : { route }, - ...foldedGoal === undefined ? {} : { goalPhase: foldedGoal.phase }, - ...disabledReason === undefined ? {} : { disabledReason }, - } -} - -/** Full-viewport keyboard selector over detached, preflighted resume summaries. */ -class ResumePicker implements Component, Focusable { - private readonly search = new Input() - private pasteBuffer: string | undefined - private selectedIndex = 0 - private error = '' - focused = false - - constructor( - private readonly candidates: readonly ResumeCandidate[], - private readonly maxVisible: number, - private readonly workspaceLabel: string, - private readonly viewportRows: () => number, - private readonly palette: Palette, - private readonly done: (candidate: ResumeCandidate) => void, - private readonly cancel: () => void, - ) {} - - invalidate(): void { - this.search.invalidate() - } - - private filtered(): ResumeCandidate[] { - const query = this.search.getValue().trim().toLocaleLowerCase() - if (query === '') return [...this.candidates] - return this.candidates.filter(candidate => candidate.title.toLocaleLowerCase().includes(query) - || candidate.record.header.id.toLocaleLowerCase().includes(query)) - } - - private visibleCandidateCount(): number { - const candidateBudget = Math.max(1, Math.floor((Math.max(1, this.viewportRows()) - 13) / 4)) - return Math.min(this.maxVisible, candidateBudget) - } - - private handleBracketedPaste(data: string): boolean { - const start = data.indexOf(BRACKETED_PASTE_START) - if (this.pasteBuffer === undefined && start < 0) return false - if (this.pasteBuffer === undefined) { - const prefix = data.slice(0, start) - if (prefix !== '') this.handleInput(prefix) - this.pasteBuffer = data.slice(start + BRACKETED_PASTE_START.length) - } else { - this.pasteBuffer += data - } - const end = this.pasteBuffer.indexOf(BRACKETED_PASTE_END) - if (end < 0) return true - const pasted = sanitizePastedText(this.pasteBuffer.slice(0, end)) - const remaining = this.pasteBuffer.slice(end + BRACKETED_PASTE_END.length) - this.pasteBuffer = undefined - const previous = this.search.getValue() - this.search.handleInput(`${BRACKETED_PASTE_START}${pasted}${BRACKETED_PASTE_END}`) - if (this.search.getValue() !== previous) { - this.selectedIndex = 0 - this.error = '' - } - if (remaining !== '') this.handleInput(remaining) - this.invalidate() - return true - } - - handleInput(data: string): void { - if (this.handleBracketedPaste(data)) return - const filtered = this.filtered() - if (matchesKey(data, Key.ctrl('c'))) { - this.cancel() - return - } - if (matchesKey(data, Key.escape)) { - if (this.search.getValue() === '') this.cancel() - else { - this.search.setValue('') - this.selectedIndex = 0 - this.error = '' - } - } else if (matchesKey(data, Key.up)) { - this.selectedIndex = filtered.length === 0 - ? 0 - : (this.selectedIndex + filtered.length - 1) % filtered.length - } else if (matchesKey(data, Key.down)) { - this.selectedIndex = filtered.length === 0 ? 0 : (this.selectedIndex + 1) % filtered.length - } else if (matchesKey(data, Key.pageUp)) { - this.selectedIndex = Math.max(0, this.selectedIndex - this.visibleCandidateCount()) - } else if (matchesKey(data, Key.pageDown)) { - this.selectedIndex = Math.min( - Math.max(0, filtered.length - 1), - this.selectedIndex + this.visibleCandidateCount(), - ) - } else if (matchesKey(data, Key.enter)) { - const selected = filtered[this.selectedIndex] - if (selected === undefined) this.error = 'No session matches this search.' - else if (selected.disabledReason !== undefined) this.error = selected.disabledReason - else this.done(selected) - } else { - const previous = this.search.getValue() - this.search.focused = this.focused - this.search.handleInput(data) - if (this.search.getValue() !== previous) { - this.selectedIndex = 0 - this.error = '' - } - } - this.invalidate() - } - - render(width: number): string[] { - this.search.focused = this.focused - const height = Math.max(1, this.viewportRows()) - const horizontalPadding = width >= 12 ? 2 : 0 - const contentWidth = Math.max(1, width - horizontalPadding * 2) - const indent = ' '.repeat(horizontalPadding) - const filtered = this.filtered() - if (this.selectedIndex >= filtered.length) this.selectedIndex = Math.max(0, filtered.length - 1) - const selected = filtered[this.selectedIndex] - const position = selected === undefined ? 0 : this.selectedIndex + 1 - const lines: string[] = [ - '', - `${indent}${this.palette.bold(this.palette.accent(`Resume session (${position} of ${filtered.length})`))}`, - '', - ] - - const searchInnerWidth = Math.max(1, contentWidth - 4) - lines.push(`${indent}${this.palette.dim(`╭${'─'.repeat(Math.max(0, contentWidth - 2))}╮`)}`) - const searchContent = this.search.render(searchInnerWidth).join('').replace(/^> /u, '⌕ ') - const clippedSearch = truncateToWidth(searchContent, searchInnerWidth, '') - lines.push( - `${indent}${this.palette.dim('│')} ${clippedSearch}${' '.repeat(Math.max(0, searchInnerWidth - visibleWidth(clippedSearch)))} ${this.palette.dim('│')}`, - `${indent}${this.palette.dim(`╰${'─'.repeat(Math.max(0, contentWidth - 2))}╯`)}`, - '', - `${indent}${this.palette.muted(displayText(this.workspaceLabel))}`, - '', - ) - - const visibleCount = this.visibleCandidateCount() - const start = Math.max(0, Math.min( - this.selectedIndex - Math.floor(visibleCount / 2), - filtered.length - visibleCount, - )) - const end = Math.min(filtered.length, start + visibleCount) - const push = (line: string): void => { - lines.push(`${indent}${truncateToWidth(line, contentWidth, '…')}`) - } - for (let index = start; index < end; index += 1) { - const candidate = filtered[index] as ResumeCandidate - const active = index === this.selectedIndex - const status = [ - candidate.disabledReason === 'current session' ? 'current' : undefined, - candidate.record.live ? 'live' : undefined, - candidate.record.persisted ? 'persisted' : undefined, - ].filter((value): value is string => value !== undefined).join(' · ') - const lead = `${active ? '❯' : ' '} ${displayText(candidate.title)}` - push(active ? this.palette.bold(this.palette.accent(lead)) : lead) - const route = candidate.route === undefined ? 'route unavailable' : `${candidate.route.provider}/${candidate.route.model}` - const goal = candidate.goalPhase === undefined ? '' : ` · goal ${candidate.goalPhase}` - push(this.palette.muted(` ${new Date(candidate.lastActivityAt).toISOString()} · ${candidate.lastTurn} · ${route}${goal}`)) - push(this.palette.dim(` ${status} · ${displayText(candidate.record.header.id)}`)) - if (candidate.disabledReason !== undefined) { - push(this.palette.warning(` unavailable: ${displayText(candidate.disabledReason)}`)) - } - } - if (filtered.length === 0) push(this.palette.warning('No matching sessions.')) - if (this.error !== '') { - lines.push('') - push(this.palette.error(displayText(this.error))) - } - - const footer = `${indent}${this.palette.dim('Type to search • ↑/↓ navigate • Enter resume • Esc clear/cancel')}` - while (lines.length < height - 2) lines.push('') - lines.push(footer, '') - return lines.slice(0, height) - } -} - -class QuestionDialog implements Component, Focusable { - private selectedIndex = 0 - private selected = new Set() - private mode: 'options' | 'custom' - private error = '' - private readonly input = new Input() - private readonly options: NonNullable - focused = false - - constructor( - private readonly question: AskUserQuestionItem, - private readonly position: number, - private readonly total: number, - private readonly unanswered: number, - private readonly maxVisible: number, - private readonly palette: Palette, - private readonly done: (selection: QuestionSelection) => void, - private readonly cancel: () => void, - ) { - this.options = question.options ?? [] - this.mode = this.options.length > 0 ? 'options' : 'custom' - this.input.onSubmit = (value) => { this.submitCustom(value) } - this.input.onEscape = () => { - if (this.options.length > 0) { - this.mode = 'options' - this.error = '' - } else { - this.cancel() - } - } - } - - invalidate(): void { - this.input.invalidate() - } - - handleInput(data: string): void { - this.invalidate() - if (this.mode === 'custom') { - this.input.focused = this.focused - this.input.handleInput(data) - return - } - const options = this.options - if (matchesKey(data, Key.up)) { - this.selectedIndex = this.selectedIndex === 0 ? options.length - 1 : this.selectedIndex - 1 - } else if (matchesKey(data, Key.down)) { - this.selectedIndex = this.selectedIndex === options.length - 1 ? 0 : this.selectedIndex + 1 - } else if (matchesKey(data, Key.space) && this.question.multiSelect) { - if (this.selected.has(this.selectedIndex)) this.selected.delete(this.selectedIndex) - else this.selected.add(this.selectedIndex) - } else if (matchesKey(data, Key.enter)) { - const indices = this.question.multiSelect ? [...this.selected].sort((a, b) => a - b) : [this.selectedIndex] - if (indices.length === 0) { - this.error = 'Select at least one option, or press Tab for a custom answer.' - return - } - this.done({ selected: indices.map(index => options[index]?.label).filter((label): label is string => label !== undefined) }) - } else if (matchesKey(data, Key.tab) || data.toLowerCase() === 'c') { - this.mode = 'custom' - this.error = '' - } else if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) { - this.cancel() - } - } - - private submitCustom(value: string): void { - const custom = value.trim() - if (custom === '') { - this.error = 'Enter an answer before submitting.' - return - } - this.done({ selected: [], custom }) - } - - render(width: number): string[] { - this.input.focused = this.focused - const innerWidth = Math.max(1, width - 4) - const header = `Question ${this.position}/${this.total} (${this.unanswered} unanswered)${this.question.header === undefined ? '' : ` · ${displayText(this.question.header)}`}` - const lines = [ - this.palette.muted(header), - ...wrapTextWithAnsi(this.palette.text(displayText(this.question.question)), innerWidth), - ] - const push = (line: string): void => { lines.push(line) } - // Supporting detail (e.g. the full plan under review) renders between the - // question and the answer surface, kept out of option labels. - if (this.question.detail !== undefined) { - push('') - for (const line of wrapTextWithAnsi(displayText(this.question.detail), innerWidth)) push(line) - } - push('') - if (this.mode === 'custom') { - for (const line of this.input.render(innerWidth)) push(line) - push(this.palette.dim(this.options.length > 0 ? 'Enter submit • Esc options' : 'Enter submit • Esc cancel')) - } else { - const options = this.options - const start = Math.max(0, Math.min( - this.selectedIndex - Math.floor(this.maxVisible / 2), - options.length - this.maxVisible, - )) - const end = Math.min(options.length, start + this.maxVisible) - const optionRows = options.slice(start, end).map((option, offset) => { - const index = start + offset - const mark = this.question.multiSelect - ? this.selected.has(index) ? '[x] ' : '[ ] ' - : '' - return `${index === this.selectedIndex ? '›' : ' '} ${index + 1}. ${mark}${displayText(option.label)}` - }) - const descriptionColumn = Math.min( - Math.max(...optionRows.map(row => visibleWidth(row))) + 2, - Math.max(1, Math.floor(innerWidth * 0.55)), - ) - for (let index = start; index < end; index += 1) { - // `index < end <= options.length`; the options array is borrowed immutably for this dialog. - const option = options[index] as NonNullable[number] - const mark = this.question.multiSelect - ? this.selected.has(index) ? '[x] ' : '[ ] ' - : '' - const left = `${index === this.selectedIndex ? '›' : ' '} ${index + 1}. ${mark}${displayText(option.label)}` - const leftStyled = index === this.selectedIndex - ? this.palette.bold(this.palette.accent(left)) - : left - const description = option.description === undefined - ? '' - : `${' '.repeat(Math.max(1, descriptionColumn - visibleWidth(left)))}${this.palette.muted(displayText(option.description))}` - push(`${leftStyled}${description}`) - } - if (options.length > this.maxVisible) push(this.palette.dim(`${this.selectedIndex + 1}/${options.length}`)) - const hint = this.palette.dim(this.question.multiSelect - ? 'Tab custom answer • ↑/↓ navigate • Space toggle • Enter submit • Esc interrupt' - : 'Tab custom answer • ↑/↓ navigate • Enter submit • Esc interrupt') - for (const line of wrapTextWithAnsi(hint, innerWidth)) push(line) - } - if (this.error) { - for (const line of wrapTextWithAnsi(this.palette.error(this.error), innerWidth)) push(line) - } - return ['', ...lines, ''].map((line) => { - const clipped = truncateToWidth(line, innerWidth, '') - return ` ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ` - }) - } -} - -interface PendingQuestion { - request: AskUserQuestionRequest - index: number - answers: AskUserQuestionAnswerItem[] - resolve(answer: AskUserQuestionAnswer): void - reject(error: unknown): void - onAbort: () => void - overlay: TuiOverlaySession | undefined -} - -/** Merge path-only file candidates and optional session snapshots with commands. */ -class ReferenceAutocompleteProvider implements AutocompleteProvider { - constructor( - private readonly base: CombinedAutocompleteProvider, - private readonly files: WorkspaceFileSearch, - private readonly sessions: SessionReferenceService | undefined, - private readonly agent: Agent, - ) {} - - async getSuggestions( - lines: string[], - cursorLine: number, - cursorCol: number, - options: { signal: AbortSignal; force?: boolean }, - ): Promise { - const basePromise = this.base.getSuggestions(lines, cursorLine, cursorCol, options) - const currentLine = lines[cursorLine] - /* v8 ignore next -- Editor always supplies its current state line. */ - if (currentLine === undefined) return basePromise - const token = activeAtToken(currentLine, cursorCol) - if (token === undefined) { - this.files.invalidate() - return basePromise - } - const filePromise = this.files.list(token.query, options.signal).catch(() => []) - const sessionPromise = this.sessions === undefined || token.quoted - ? Promise.resolve([]) - : this.sessions.listCandidates(this.agent, token.query, undefined, options.signal).catch(() => []) - const [base, fileCandidates, sessionCandidates] = await Promise.all([ - basePromise, - filePromise, - sessionPromise, - ]) - if (options.signal.aborted) return base - const fileItems: AutocompleteItem[] = fileCandidates.flatMap((candidate) => { - const value = formatFileMention(candidate, token.quoted) - if (value === undefined) return [] - const name = candidate.path.slice(candidate.path.lastIndexOf('/') + 1) - const directory = candidate.kind === 'directory' - return [{ - value, - label: `${directory ? 'Folder' : 'File'} · ${displayInlineText(name)}${directory ? '/' : ''}`, - description: displayInlineText(candidate.path), - }] - }) - const sessionItems: AutocompleteItem[] = sessionCandidates.map((candidate) => { - const mentionLabel = displayInlineText(candidate.label) - const sessionId = displayInlineText(candidate.sessionId) - const location = candidate.cwd === undefined ? '(no cwd)' : displayInlineText(candidate.cwd) - const description = `${candidate.label === candidate.sessionId ? '' : `${sessionId} · `}${location} · ${new Date(candidate.createdAt).toISOString()}` - return { - value: formatSessionReferenceMention({ sessionId: candidate.sessionId, label: mentionLabel }), - label: `Session · ${mentionLabel}`, - description, - } - }) - const items = [...fileItems, ...sessionItems] - if (items.length === 0) return base - return { items: [...items, ...(base?.items ?? [])], prefix: token.prefix } - } - - applyCompletion( - lines: string[], - cursorLine: number, - cursorCol: number, - item: AutocompleteItem, - prefix: string, - ): { lines: string[]; cursorLine: number; cursorCol: number } { - return this.base.applyCompletion(lines, cursorLine, cursorCol, item, prefix) - } - - shouldTriggerFileCompletion(lines: string[], cursorLine: number, cursorCol: number): boolean { - return this.base.shouldTriggerFileCompletion(lines, cursorLine, cursorCol) - } +/** A running glyph fading out after its turn ended, before the caret returns. */ +interface FadingStatus { + glyph: string + /** Render clock when the turn ended; origin of the glyph fade-out. */ + endedAt: number + timer: ReturnType } /** Lifecycle handle for a mounted interactive terminal channel. */ @@ -1840,97 +232,6 @@ export interface TuiController { dispose(): Promise } -/** Prefix that marks an editor submission as a manual skill invocation. */ -const SKILL_COMMAND_PREFIX = '/skill:' - -/** Parsed `/skill: [instructions]` submission; `name` is empty when the prefix carries no name. */ -interface ParsedSkillCommand { - /** Skill name typed after `/skill:`, up to the first space. */ - name: string - /** Trimmed text after the name; empty when none was typed. */ - instructions: string -} - -/** - * Split a `/skill: [instructions]` submission into its name and trailing instructions. - * @param text - trimmed submission that starts with {@link SKILL_COMMAND_PREFIX}. - * @returns the skill name and any trailing instructions. - */ -function parseSkillCommand(text: string): ParsedSkillCommand { - const rest = text.slice(SKILL_COMMAND_PREFIX.length) - const spaceIndex = rest.indexOf(' ') - if (spaceIndex === -1) return { name: rest, instructions: '' } - return { name: rest.slice(0, spaceIndex), instructions: rest.slice(spaceIndex + 1).trim() } -} - -/** Model-visible line locating a manually invoked skill's relative resources, or `undefined` when the provider has no base. */ -function skillResourceReference(base: SkillResourceBase | undefined): string | undefined { - if (base === undefined) return undefined - switch (base.kind) { - case 'directory': - return `References in this skill are relative to ${base.path}.` - case 'url': - return `References in this skill are relative to ${base.url}.` - case 'opaque': - return base.description - default: - return assertNever(base, 'SkillResourceBase.kind') - } -} - -/** - * Render a manually invoked skill into the model-visible user-message text. The - * `` block carries the body and, when the provider supplies one, its - * resource base; the trimmed `instructions` follow the block as the user's - * request for this turn. The name is registry-validated kebab-case - * ({@link SkillService} rejects any other) and the resource base is trusted - * same-process provider prose, so — unlike the model-facing `dsh-tool-skill` - * result, which escapes for a tool channel — this user turn is assembled raw. - * @param skill - the loaded skill definition. - * @param instructions - trimmed text typed after `/skill:`; empty when absent. - * @returns the user-message text delivered to the agent. - */ -export function renderSkillInvocation(skill: SkillDefinition, instructions: string): string { - const lines = [``] - const reference = skillResourceReference(skill.resourceBase) - if (reference !== undefined) lines.push(reference, '') - lines.push(skill.content, '') - const block = lines.join('\n') - return instructions === '' ? block : `${block}\n\n${instructions}` -} - -function activeSurfaceSeqs(session: Session): Set { - return new Set(session.surface.nodes) -} - -function sessionReferenceCard(source: unknown): string[] | undefined { - if (typeof source !== 'object' || source === null) return undefined - const record = source as Record - if (record['kind'] !== 'session-reference' || !Array.isArray(record['references'])) return undefined - const references = record['references'] as unknown[] - const labels: string[] = [] - for (const reference of references) { - if (typeof reference !== 'object' || reference === null) return undefined - const entry = reference as Record - const sessionId = entry['sessionId'] - const label = entry['label'] - if (typeof sessionId !== 'string' || typeof label !== 'string') return undefined - labels.push(label === sessionId ? sessionId : `${label} (${sessionId})`) - } - return labels -} - -function activeToolCallIds(session: Session, active: ReadonlySet): Set { - const ids = new Set() - for (const event of session.events) { - if (event.type !== 'assistant/message' || !active.has(event.seq)) continue - for (const block of event.data.content) { - if (block.type === 'tool-call') ids.add(block.id) - } - } - return ids -} - /** * Start the interactive pi-tui channel for an already-created target agent. * @param ctx - agent, tools, session-event, and user-interaction context. @@ -1949,21 +250,33 @@ export function createTuiChat( const persistence = ctx.get('sessionPersistence') const sessionQuery = ctx.get('sessionQuery') const resolved = resolveTuiConfig(config) - const palette = createPalette(resolved.color) + const palette = createPalette(resolved.theme.color) const mdTheme = markdownTheme(palette) const ui = new TUI(runtime.terminal, resolved.showHardwareCursor) const chat = new Container() const todoContainer = new Container() - const statusContainer = new Container() - const editor = new Editor(ui, { + const inputTemplate = parseTuiPromptTemplate(displayInlineText(resolved.theme.inputPrompt)) + const renderInputPrompt = (): string => renderTuiPromptTemplate(inputTemplate, valueName => ctx.tuiPrompt.get(valueName)) + const initialInputPrompt = renderInputPrompt() + const editor = new HintEditor(ui, { borderColor: palette.dim, selectList: selectTheme(palette), - } satisfies EditorTheme, { paddingX: 1 }) + } satisfies EditorTheme, { + paddingX: 1, + frame: 'none', + prompt: { + first: initialInputPrompt, + continuation: ' '.repeat(visibleWidth(initialInputPrompt)), + }, + }) + editor.hintPrefix = initialInputPrompt const todo = new TodoComponent(palette) let showReasoning = resolved.showReasoning let toolsExpanded = false let streaming: StreamingAssistantComponent | undefined + let completedStreaming: StreamingAssistantComponent | undefined let runningStatus: RunningStatus | undefined + let fadingStatus: FadingStatus | undefined // TUI steering submissions that the inbox has not yet claimed or discarded. // Correlation ids avoid guessing whether a running-state submission actually // joined steering or fell back to the queued-turn FIFO during turn close. @@ -1984,22 +297,16 @@ export function createTuiChat( const toolCards = new Map() const allToolCards = new Set() const liveErrors = new Set() - const questionQueue: PendingQuestion[] = [] const commandControllers = new Set() const referenceControllers = new Set() - let activeQuestion: PendingQuestion | undefined - let modelOverlay: TuiOverlaySession | undefined - let resumeOverlay: TuiOverlaySession | undefined - let resumeInFlight = false - let resumeScan = 0 let tuiServiceFiber: Fiber | undefined const target: AgentLlmTargetRef = { current: initialTarget(agent), assembled: undefined } - let contextWindow: number | undefined - let contextResolution: Promise< - | { readonly kind: 'resolved'; readonly contextWindow: number | undefined } - | { readonly kind: 'error'; readonly error: unknown } - > | undefined - let modelCommands = Promise.resolve() + // `updatePromptValues` (defined below) closes over the model controller, but + // the controller needs `appendNotice`/`overlayManager`, defined after that + // closure. Declare here, assign once after those exist, and defer the first + // `updatePromptValues()` call until after the assignment so no read precedes it. + // eslint-disable-next-line prefer-const -- single assignment is a forward-reference, not a const. + let modelController!: ModelController const now = (): number => runtime.now?.() ?? Date.now() const agentStatus = (): AgentStatus => agent.status const isDisposed = (): boolean => disposed @@ -2011,27 +318,82 @@ export function createTuiChat( agent, () => sessionTitle ?? config.welcome, palette, - resolved.color && resolved.truecolor, - () => target.current === undefined ? undefined : compactTargetLabel(target.current), + resolved.theme.color && resolved.theme.truecolor, ) - const footer = new FooterComponent( - agent, - palette, - () => toolsExpanded, - () => tokens, - runtime.formatCwd, - () => target.current === undefined ? undefined : compactTargetLabel(target.current), - () => contextWindow === undefined - ? undefined - : Math.min(100, Math.round(ctx.tokenMeter.measure(agent.session).totalTokens / contextWindow * 100)), + const formattedCwd = displayText(runtime.formatCwd?.(agent.session.header.cwd) ?? formatCwd(agent.session.header.cwd)) + const branch = runtime.gitBranch?.(cwd) ?? gitBranch(cwd) + const promptValues: TuiPromptValueHandle[] = [ + ctx.tuiPrompt.register('cwd', palette.bold(palette.accent(formattedCwd))), + ctx.tuiPrompt.register('git/worktree', branch === undefined ? undefined : palette.muted(` (${displayText(branch)})`)), + ctx.tuiPrompt.register('token_meter/cache_hit_rate'), + ctx.tuiPrompt.register('model'), + ctx.tuiPrompt.register('context'), + ctx.tuiPrompt.register('timing'), + ctx.tuiPrompt.register('symbol', palette.bold(palette.accent('dsh'))), + ctx.tuiPrompt.register('indicator', palette.muted('> ')), + ] + const [cwdValue, gitValue, tokenValue, modelValue, contextValue, timingValue, symbolValue, indicatorValue] = promptValues + /* v8 ignore next -- the fixed built-in registration list always supplies each handle. */ + if (cwdValue === undefined || gitValue === undefined || tokenValue === undefined || modelValue === undefined + || contextValue === undefined || timingValue === undefined || symbolValue === undefined || indicatorValue === undefined) { + throw new Error('TUI prompt built-ins failed to initialize') + } + const updatePromptValues = (): void => { + cwdValue.set(palette.bold(palette.accent(formattedCwd))) + gitValue.set(branch === undefined ? undefined : palette.muted(` (${displayText(branch)})`)) + const rate = cacheHitRate(tokens) + const usage = `↑${formatTokens(tokens.input)} ↓${formatTokens(tokens.output)}` + modelValue.set(` ${palette.muted(displayText(target.current === undefined ? 'model unset' : compactTargetLabel(target.current)))}`) + tokenValue.set(` ${palette.muted(rate === undefined ? usage : `${usage} cache ${rate}%`)}`) + const contextWindow = modelController.contextWindow() + contextValue.set(contextWindow === undefined ? undefined : ` ${palette.muted( + `${Math.min(100, Math.round(ctx.tokenMeter.measure(agent.session).totalTokens / contextWindow * 100))}% context`, + )}`) + const queued = runningStatus === undefined ? undefined : formatQueuedStatus(pendingSteering.size) + timingValue.set(queued === undefined ? undefined : palette.dim(queued)) + symbolValue.set(palette.bold(palette.accent('dsh'))) + // `${indicator}` owns the caret column and its trailing gap before the + // cursor. The phase glyph replaces the `>` caret in place — same width + // every frame — fading in as a turn starts, throbbing while it runs, and + // fading out after it ends before the plain `>` returns. Only the gray + // brightness changes, so the cursor never shifts. + const runningGlyph = runningPhaseGlyph(agent.session.events, runningStatus !== undefined) + // Remember the live phase glyph so the fade-out shows it, not the ttft + // fallback the derivation returns once the closing turn's step has ended. + if (runningStatus !== undefined && runningGlyph !== undefined) runningStatus.lastGlyph = runningGlyph + // The fade envelope gates appear/disappear; the running throb breathes the + // glyph the whole turn. Truecolor opacity is envelope × throb; the + // non-truecolor fallback keys visibility off the envelope alone, so the + // throb never blinks it. `envelope` clamps to [0, 1]. + const envelope = runningStatus !== undefined && runningGlyph !== undefined + ? { glyph: runningGlyph, level: Math.min(1, (now() - runningStatus.startedAt) / STATUS_FADE_MS) } + : fadingStatus !== undefined + ? { glyph: fadingStatus.glyph, level: Math.max(0, 1 - (now() - fadingStatus.endedAt) / STATUS_FADE_MS) } + : undefined + const caret = envelope === undefined + ? palette.muted('>') + : fadeGlyph( + envelope.glyph, + palette, + resolved.theme.color, + resolved.theme.color && resolved.theme.truecolor, + envelope.level * pulseLevel(now()), + envelope.level >= 0.5, + ) + indicatorValue.set(`${caret}${palette.muted(' ')}`) + } + const promptContext = new PromptContextComponent( + parseTuiPromptTemplate(displayInlineText(resolved.theme.leftPrompt)), + parseTuiPromptTemplate(displayInlineText(resolved.theme.rightPrompt)), + valueName => ctx.tuiPrompt.get(valueName), ) ui.addChild(header) ui.addChild(chat) - ui.addChild(statusContainer) + ui.addChild(new Spacer(1)) todoContainer.addChild(todo) ui.addChild(todoContainer) + ui.addChild(promptContext) ui.addChild(editor) - ui.addChild(footer) ui.setFocus(editor) const updateTerminalTitle = (): void => { runtime.terminal.setTitle(displayText( @@ -2041,14 +403,23 @@ export function createTuiChat( updateTerminalTitle() const requestRender = (): void => { - footer.invalidate() + if (disposed) return + updatePromptValues() + const inputPrompt = renderInputPrompt() + editor.setPrompt({ first: inputPrompt, continuation: ' '.repeat(visibleWidth(inputPrompt)) }) + editor.hintPrefix = inputPrompt + promptContext.invalidate() ui.requestRender() } + // A prompt value that changes on its own schedule (e.g. a plugin-owned + // `${custom}` fragment) redraws through the registry's coalesced notification; + // built-ins are already covered by the state-change callers of requestRender. + const disposePromptChanges = ctx.tuiPrompt.subscribe(requestRender) const appendNotice = (message: string, kind: 'info' | 'warning' | 'error' = 'info'): void => { const color = kind === 'error' ? palette.error : kind === 'warning' ? palette.warning : palette.muted chat.addChild(new Spacer(1)) - chat.addChild(new Text(color(displayText(message)), 1, 0)) + chat.addChild(new Text(color(displayText(message)), 0, 0)) requestRender() } @@ -2089,183 +460,73 @@ export function createTuiChat( const disposeTargetListeners = installAgentLlmTarget(agent.ctx, target) - const resolveContextWindow = (selected: AgentLlmTarget | undefined): void => { - contextWindow = undefined - const resolution = selected === undefined - ? Promise.resolve({ kind: 'resolved', contextWindow: undefined } as const) - : ctx.llm.resolveModelInfo(selected.provider, selected.model).then( - info => ({ kind: 'resolved', contextWindow: info.context?.contextWindow } as const), - (error: unknown) => ({ kind: 'error', error } as const), - ) - contextResolution = resolution - void resolution.then((result) => { - if (contextResolution !== resolution) return - if (result.kind === 'error') { - appendNotice(`Could not resolve model context: ${errorChain(result.error)}`, 'error') - return - } - contextWindow = result.contextWindow - requestRender() - }) - } - resolveContextWindow(target.current) + modelController = createModelController({ + ctx, + resolved, + palette, + overlayManager, + target, + appendNotice, + requestRender, + isDisposed, + }) + updatePromptValues() - const selectModel = ( - selected: ModelChoice, - explicitReasoning?: { effort: ReasoningEffortId | undefined }, - ): void => { - const sameRoute = target.current?.provider === selected.provider && target.current.model === selected.model - const reasoningEffort = explicitReasoning === undefined - ? (sameRoute ? target.current?.reasoningEffort ?? selected.reasoning?.defaultEffort : selected.reasoning?.defaultEffort) - : explicitReasoning.effort - if (sameRoute && target.current?.reasoningEffort === reasoningEffort) { - const reasoning = targetReasoningLabel(selected, reasoningEffort) - appendNotice(`Model is already ${targetLabel(selected)}${reasoning === undefined ? '' : ` with reasoning effort ${displayText(reasoning)}`}.`) - return - } - target.current = { - provider: selected.provider, - model: selected.model, - ...reasoningEffort === undefined ? {} : { reasoningEffort }, - } - resolveContextWindow(target.current) - const reasoning = targetReasoningLabel(selected, reasoningEffort) - appendNotice([ - `Model selected: ${targetLabel(selected)}.`, - ...reasoning === undefined ? [] : [`Reasoning effort: ${displayText(reasoning)}.`], - 'New steps will use it.', - ].join(' ')) - } - - const showModelSelector = (choices: readonly ModelChoice[]): void => { - const current = target.current === undefined ? 'unset' : targetLabel(target.current) - if (choices.length === 0) { - appendNotice(`Current model: ${current}\nNo models are advertised by registered providers.`, 'warning') - return - } - void modelOverlay?.close() - const session = overlayManager.open({ - create: () => new ModelDialog( - choices, - target.current, - resolved.maxModelOptions, - palette, - (selection) => { - void session.close() - selectModel(selection.choice, { effort: selection.reasoningEffort }) - }, - () => { void session.close() }, - ), - options: { - width: resolved.modelDialogWidth, - maxHeight: resolved.modelDialogMaxHeight, - anchor: 'center', - margin: 1, - }, - }) - modelOverlay = session - void session.closed.then(() => { - if (modelOverlay === session) modelOverlay = undefined - }) + const renderStatus = (): void => { + streaming?.invalidate() requestRender() } - const handleModelCommand = async (raw: string): Promise => { - const choices = await readModelChoices(ctx, target.current) - if (disposed) return - const argument = raw.trim() - if (argument === '') { - showModelSelector(choices) - return - } - const parts = argument.split(/\s+/u) - if (parts.length > 2) { - appendNotice('Usage: /model [provider/]model', 'warning') - return - } - - let matches: ModelChoice[] - if (parts.length === 2) { - matches = choices.filter(choice => choice.provider === parts[0] && choice.model === parts[1]) - } else { - const value = argument - const qualified = choices.filter(choice => targetLabel(choice) === value) - matches = qualified.length > 0 ? qualified : choices.filter(choice => choice.model === value) - } - if (matches.length === 0) { - appendNotice(`Unknown model: ${argument}. Run /model to list available models.`, 'warning') - return - } - if (matches.length > 1) { - appendNotice(`Model "${argument}" is advertised by multiple providers; use /model /.`, 'warning') - return - } - const selected = matches[0] - /* v8 ignore next -- a non-empty matches array always has index zero. */ - if (selected === undefined) return - selectModel(selected) - } - - const queueModelCommand = (raw: string): void => { - modelCommands = modelCommands.then(async () => { - await handleModelCommand(raw) - }).catch((error: unknown) => { - if (!disposed) appendNotice(`Could not read the model catalog: ${errorChain(error)}`, 'error') - }) - } - + /** Stop the running and fade-out timers and drop both states at once. */ const clearStatus = (): void => { if (runningStatus !== undefined) { clearInterval(runningStatus.timer) - runningStatus.loader.stop() runningStatus = undefined } - statusContainer.clear() + if (fadingStatus !== undefined) { + clearInterval(fadingStatus.timer) + fadingStatus = undefined + } runtime.terminal.setProgress(false) } - // Refresh the status line's elapsed timers and queued badge from the - // controller's phase and the current steering count. - const renderStatus = (running: RunningStatus): void => { - const at = now() - running.loader.setMessage( - formatTurnStatus(running.phase, at - running.phaseStartedAt, at - running.stepStartedAt, pendingSteering.size), - ) - } - - // Move to a derived phase, resetting the phase timer on a genuine change and - // the step timer when a new step begins; ignored unless a turn is running. - const enterPhase = (phase: TurnPhase, resetStep: boolean): void => { - const running = runningStatus - if (running === undefined) return - const at = now() - if (resetStep) running.stepStartedAt = at - if (phase !== running.phase || resetStep) running.phaseStartedAt = at - running.phase = phase - renderStatus(running) + /** + * On the running → non-running edge, hand the last rendered glyph to a + * fade-out that re-renders until it settles on the `>` caret, then stops its + * own timer. A hard clear (teardown) skips this via {@link clearStatus}. + */ + const beginFadeOut = (glyph: string): void => { + clearStatus() + const fading: FadingStatus = { + glyph, + endedAt: now(), + timer: setInterval(() => { + if (now() - fading.endedAt >= STATUS_FADE_MS) clearStatus() + renderStatus() + }, STATUS_ANIMATION_INTERVAL_MS), + } + fadingStatus = fading } const setStatus = (status: AgentStatus): void => { - // A running→running rebuild (a mid-turn palette swap re-derives the border) - // carries the derived phase and both elapsed baselines across; only a fresh - // idle→running turn starts at `waiting`. - const prior = runningStatus - clearStatus() + const priorTurn = runningStatus?.turn + const fadeOutGlyph = status !== 'running' ? runningStatus?.lastGlyph : undefined + if (status === 'running') clearStatus() + else if (fadeOutGlyph !== undefined) beginFadeOut(fadeOutGlyph) + else clearStatus() editor.borderColor = status === 'running' ? text => palette.accent(text) : text => palette.dim(text) + editor.hint = status === 'running' ? palette.dim(displayInlineText(resolved.theme.inputPlaceholder)) : undefined if (status === 'running') { - const at = now() - const phase = prior?.phase ?? 'waiting' - const phaseStartedAt = prior?.phaseStartedAt ?? at - const stepStartedAt = prior?.stepStartedAt ?? at - const message = formatTurnStatus(phase, at - phaseStartedAt, at - stepStartedAt, pendingSteering.size) - const loader = new Loader(ui, text => palette.accent(text), text => palette.muted(text), message) - statusContainer.addChild(loader) + const turn = priorTurn ?? openTurn(agent.session.events) const running: RunningStatus = { - loader, - phase, - phaseStartedAt, - stepStartedAt, - timer: setInterval(() => { renderStatus(running) }, STATUS_ELAPSED_INTERVAL_MS), + turn, + startedAt: now(), + // Seed with the current phase (ttft before the first step opens) so the + // fade-out always has a glyph, even for a turn that ends before a render. + lastGlyph: TIMING_BUCKET_GLYPHS[openStepPhase(agent.session.events) ?? 'ttft'], + // Refresh every tick so the fading prompt phase glyph animates even + // before the first token, when no streaming component exists yet. + timer: setInterval(renderStatus, STATUS_ANIMATION_INTERVAL_MS), } runningStatus = running runtime.terminal.setProgress(true) @@ -2273,35 +534,8 @@ export function createTuiChat( requestRender() } - // Refresh the running status line's queued-steering badge from the current - // count; a no-op when idle because the controller only exists while running. const refreshStatus = (): void => { - if (runningStatus !== undefined) renderStatus(runningStatus) - requestRender() - } - - // Derive the status-line phase from live session lifecycle events. The event - // map is merge-extensible, so unhandled types fall through the default. - const advanceTurnPhase = (event: SessionEvent): void => { - switch (event.type) { - case 'step/start': - enterPhase('waiting', true) - break - case 'assistant/chunk': { - const chunk = event.data.chunk - if (chunk.type === 'reasoning-delta' || (chunk.type === 'block-start' && chunk.blockType === 'reasoning')) { - enterPhase('thinking', false) - } else if (chunk.type === 'text-delta' || (chunk.type === 'block-start' && chunk.blockType === 'text')) { - enterPhase('responding', false) - } - break - } - case 'tool/call': - enterPhase('executing', false) - break - default: - break - } + renderStatus() } const parsedTool = (event: Extract): ToolCardComponent => { @@ -2312,6 +546,7 @@ export function createTuiChat( ctx.tools.get(event.data.name, agent), resolved.maxToolOutputLines, palette, + mdTheme, ) card.setExpanded(toolsExpanded) toolCards.set(event.data.callId, card) @@ -2319,15 +554,62 @@ export function createTuiChat( return card } - const clearStreaming = (): void => { + const removeStreaming = (current: StreamingAssistantComponent | undefined): void => { + if (current === undefined) return + for (const child of [current, current.timing]) { + const index = chat.children.indexOf(child) + /* v8 ignore next -- streaming components and their timing footers are retained only while attached to the chat. */ + if (index >= 0) chat.children.splice(index, 1) + } + } + + /** + * Move the running step's timing footer to the tail of the chat so it trails + * the tool cards the step just appended. A completed footer (its step ended, + * so `streaming` is cleared) stays pinned where it is. + */ + const trailStreamingTiming = (): void => { + /* v8 ignore next -- every replayed tool event follows its step/start, so an open step always owns an attached footer here. */ if (streaming === undefined) return - const index = chat.children.indexOf(streaming) - /* v8 ignore next -- streaming is assigned only after the same component is added, and every removal clears it. */ - if (index >= 0) chat.children.splice(index, 1) + const footer = streaming.timing + const index = chat.children.indexOf(footer) + /* v8 ignore next -- the open step's footer is attached to the chat whenever a tool event of that step renders. */ + if (index < 0) return + chat.children.splice(index, 1) + chat.addChild(footer) + } + + const clearStreaming = (): void => { + removeStreaming(streaming) streaming = undefined } - const renderEvent = (event: SessionEvent, options: { addHistory: boolean; renderChunks: boolean }): void => { + const retractFailedStreaming = (): void => { + removeStreaming(streaming ?? completedStreaming) + streaming = undefined + completedStreaming = undefined + } + + const startAssistantStep = (position: StepPosition): void => { + streaming = new StreamingAssistantComponent( + position, + () => agent.session.events, + now, + showReasoning, + palette, + mdTheme, + ) + chat.addChild(streaming) + chat.addChild(streaming.timing) + } + + const renderEvent = ( + event: SessionEvent, + options: { + addHistory: boolean + renderChunks: boolean + }, + ): void => { switch (event.type) { case 'user/message': { // Injected context (plugin/goal source) renders as a dim context card, @@ -2338,18 +620,29 @@ export function createTuiChat( const references = sessionReferenceCard(event.data.source) if (references !== undefined) { chat.addChild(new Spacer(1)) - chat.addChild(new Text(palette.dim(`Referenced sessions · ${references.map(displayText).join(', ')}`), 1, 0)) + chat.addChild(new Text(palette.dim(`Referenced sessions · ${references.map(displayText).join(', ')}`), 0, 0)) break } - const text = displayText(contentText(event.data.content).trim()) + const text = contentText(event.data.content).trim() + /* v8 ignore next -- context events with empty content are rejected by their owning producers. */ if (text) { // The tui type view lacks plugin-augmented source kinds (e.g. goal), // so read the display label without narrowing on `kind`. const labelled = source as { kind: string; plugin?: string } + /* v8 ignore next -- current plugin-augmented context sources always carry their display label. */ const label = labelled.plugin ?? labelled.kind + const xml = renderUnknownXml( + text, + resolved.maxToolOutputLines, + true, + displayText, + value => palette.muted(value), + /* v8 ignore next -- expanded context XML never asks renderUnknownXml for a collapsed summary. */ + () => '', + ) chat.addChild(new Spacer(1)) - chat.addChild(new Text(palette.dim(`Context · ${displayText(label)}`), 1, 0)) - chat.addChild(new Text(palette.muted(text), 1, 0)) + chat.addChild(new Text(palette.dim(`Context · ${displayText(label)}`), 0, 0)) + chat.addChild(new Text(xml?.join('\n') ?? palette.muted(displayText(text)), 0, 0)) } break } @@ -2369,25 +662,22 @@ export function createTuiChat( } break } + case 'step/start': + startAssistantStep(event.data) + break case 'assistant/chunk': - if (options.renderChunks) { - if (streaming === undefined) { - streaming = new StreamingAssistantComponent(showReasoning, palette, mdTheme) - chat.addChild(streaming) - } - streaming.update(event.data.chunk) - } + if (options.renderChunks) streaming?.update(event.data.chunk) break - case 'assistant/message': { - clearStreaming() - const component = new AssistantMessageComponent(event.data.content, showReasoning, palette, mdTheme) - if (component.children.length > 0) chat.addChild(component) + case 'assistant/message': + completedStreaming = undefined + if (streaming === undefined || !chat.children.includes(streaming)) startAssistantStep(event.data) + streaming?.settle(event.data.content) break - } case 'llm/retry': { - clearStreaming() + retractFailedStreaming() + const retryLimit = event.data.mode === 'always' ? '∞' : String(event.data.maxRetries) appendNotice( - `Retrying model request (${event.data.retry}/${event.data.maxRetries}) in ${event.data.delayMs}ms: ${event.data.failure.message}`, + `Retrying model request (${event.data.retry}/${retryLimit}) in ${event.data.delayMs}ms: ${event.data.failure.message}`, 'warning', ) break @@ -2395,17 +685,19 @@ export function createTuiChat( case 'tool/call': chat.addChild(new Spacer(1)) chat.addChild(parsedTool(event)) + trailStreamingTiming() break case 'tool/result': { let card = toolCards.get(event.data.callId) if (card === undefined) { - card = new ToolCardComponent('tool', { value: {}, valid: true }, undefined, resolved.maxToolOutputLines, palette) + card = new ToolCardComponent('tool', { value: {}, valid: true }, undefined, resolved.maxToolOutputLines, palette, mdTheme) chat.addChild(new Spacer(1)) chat.addChild(card) allToolCards.add(card) } card.updateResult(event.data) toolCards.delete(event.data.callId) + trailStreamingTiming() break } case 'todo/write': @@ -2416,22 +708,47 @@ export function createTuiChat( header.invalidate() updateTerminalTitle() break - case 'turn/end': + case 'step/end': + if (streaming === undefined) startAssistantStep(event.data) + streaming?.complete(event.time) + completedStreaming = streaming + streaming = undefined + break + // Every turn/end kind presents why the agent stopped: `completed` is + // presented by the settled assistant message and its Completed timing + // header; every other kind appends an explicit notice. + case 'turn/end': { clearStreaming() - if (event.data.reason.kind === 'error') { - const key = `${event.data.turn}:${event.data.reason.step}` - const message = 'failure' in event.data.reason - ? event.data.reason.failure.message - : event.data.reason.message - if (!liveErrors.delete(key)) appendNotice(message, 'error') - } else if (event.data.reason.kind === 'aborted') { - appendNotice('Turn cancelled.', 'warning') - } else if (event.data.reason.kind === 'max-tokens') { - appendNotice('The model reached its output-token limit.', 'warning') - } else if (event.data.reason.kind === 'interrupted') { - appendNotice('The previous process ended during this turn.', 'warning') + const reason = event.data.reason + switch (reason.kind) { + case 'completed': + break + case 'error': { + const key = `${event.data.turn}:${reason.step}` + const message = 'failure' in reason ? reason.failure.message : reason.message + if (!liveErrors.delete(key)) appendNotice(message, 'error') + break + } + case 'aborted': + appendNotice('Turn cancelled.', 'warning') + break + case 'max-tokens': + appendNotice('The model reached its output-token limit.', 'warning') + break + case 'disposed': + appendNotice('Turn stopped: the agent was disposed.', 'warning') + break + case 'interrupted': + appendNotice('The previous process ended during this turn.', 'warning') + break + default: + // TurnEndReasonMap is merge-extensible: a plugin-added outcome + // still names why the agent stopped rather than ending silently. + appendNotice(`Turn ended: ${(reason as { kind: string }).kind}.`, 'warning') + break } break + } default: break } @@ -2456,147 +773,38 @@ export function createTuiChat( requestRender() } - const removeAbortListener = (pending: PendingQuestion): void => { - pending.request.signal?.removeEventListener('abort', pending.onAbort) - } - - const rejectQuestion = (pending: PendingQuestion): void => { - void pending.overlay?.close() - pending.overlay = undefined - removeAbortListener(pending) - pending.reject(new UserInteractionError( - 'ask_user_question was interrupted before the user answered', - 'ASK_ABORTED', - )) - } - - const startNextQuestion = (): void => { - if (activeQuestion !== undefined || disposed) return - const pending = questionQueue.shift() - if (pending === undefined) return - activeQuestion = pending - const show = (): void => { - const question = pending.request.questions[pending.index] - if (question === undefined) { - activeQuestion = undefined - removeAbortListener(pending) - pending.resolve({ answers: pending.answers }) - startNextQuestion() - return - } - const session = overlayManager.open({ - ...pending.request.signal === undefined ? {} : { signal: pending.request.signal }, - create: () => new QuestionDialog( - question, - pending.index + 1, - pending.request.questions.length, - pending.request.questions.length - pending.answers.length, - resolved.maxQuestionOptions, - palette, - (selection) => { - pending.overlay = undefined - void session.close() - pending.answers.push({ id: question.id, ...selection }) - pending.index += 1 - show() - }, - () => { - activeQuestion = undefined - rejectQuestion(pending) - startNextQuestion() - }, - ), - options: { - width: resolved.questionDialogWidth, - maxHeight: resolved.questionDialogMaxHeight, - anchor: 'bottom-left', - margin: { bottom: 1 }, - }, - }) - pending.overlay = session - void session.closed.then((result) => { - if (pending.overlay !== session) return - pending.overlay = undefined - /* v8 ignore next 2 -- close, abort, and shutdown settle the owner before this callback */ - if (result.reason !== 'error') return - activeQuestion = undefined - removeAbortListener(pending) - pending.reject(new UserInteractionError( - `ask_user_question TUI failed: ${errorChain(result.error)}`, - 'ASK_ABORTED', - )) - startNextQuestion() - }) - requestRender() - } - show() - } - - const disposeUserInteraction = ctx.userInteraction.registerProvider({ - ask(request) { - return new Promise((resolveAnswer, reject) => { - const pending: PendingQuestion = { - request, - index: 0, - answers: [], - resolve: resolveAnswer, - reject, - overlay: undefined, - onAbort: () => { - if (activeQuestion === pending) { - activeQuestion = undefined - rejectQuestion(pending) - startNextQuestion() - return - } - // A non-active pending ask remains in the queue until this listener settles it. - questionQueue.splice(questionQueue.indexOf(pending), 1) - rejectQuestion(pending) - }, - } - request.signal?.addEventListener('abort', pending.onAbort, { once: true }) - questionQueue.push(pending) - startNextQuestion() - }) - }, + const questions = createQuestionQueue({ + ctx, + resolved, + palette, + overlayManager, + requestRender, + isDisposed, }) - /** - * Persisted sessions for this workspace, newest first. Empty when no - * persistence backend is mounted or a listing failure would otherwise block - * exit or crash `/resume`; the resume hint is best-effort convenience. - */ - const listWorkspaceSessions = async (): Promise => { - if (persistence === undefined) return [] - let all: readonly SessionHeader[] - try { - all = await persistence.list() - } catch { - // A listing failure must never block terminal exit or crash `/resume`. - return [] - } - return all - .filter(header => header.cwd === agent.session.header.cwd) - } - - /** - * The resume command for the current session — the configured template with - * every `{session}` filled — but only once the session is durably persisted, - * so a session abandoned before its first flush yields no hint (resuming that - * id would fail to load). - */ - const currentResumeCommand = async (): Promise => { - if (config.resumeCommand === undefined) return undefined - const sessions = await listWorkspaceSessions() - if (!sessions.some(header => header.id === agent.session.id)) return undefined - return config.resumeCommand.replaceAll('{session}', agent.session.id) - } + const resume = createResumeController({ + ctx, + agent, + config, + runtime, + resolved, + palette, + overlayManager, + persistence, + sessionQuery, + ui, + editor, + appendNotice, + requestRender, + isDisposed, + agentStatus, + }) const shutdown = (exitProcess: boolean): Promise => { shuttingDown ??= (async () => { disposed = true overlayManager.beginShutdown() - contextResolution = undefined + modelController.resetContextResolution() clearStatus() for (const controller of commandControllers) controller.abort(new Error('TUI disposed')) commandControllers.clear() @@ -2604,19 +812,14 @@ export function createTuiChat( referenceControllers.clear() await tuiServiceFiber?.dispose() tuiServiceFiber = undefined - if (activeQuestion !== undefined) { - const pending = activeQuestion - activeQuestion = undefined - rejectQuestion(pending) - } - for (const pending of questionQueue.splice(0)) rejectQuestion(pending) + questions.rejectAll() await overlayManager.dispose() - modelOverlay = undefined - disposeUserInteraction() + modelController.clearOverlay() + questions.unregister() await runtime.terminal.drainInput(100, 20) ui.stop() if (exitProcess) { - const command = await currentResumeCommand() + const command = await resume.currentResumeCommand() if (command !== undefined) { runtime.terminal.write(`${palette.muted('To resume this session:')} ${displayText(command)}\n`) } @@ -2640,7 +843,7 @@ export function createTuiChat( const applyColorScheme = (scheme: TerminalColorScheme): void => { if (scheme === currentScheme) return currentScheme = scheme - Object.assign(palette, createPalette(resolved.color, scheme)) + Object.assign(palette, createPalette(resolved.theme.color, scheme)) Object.assign(mdTheme, markdownTheme(palette)) // `setStatus` below re-derives `editor.borderColor` from the new palette. rebuildTranscript(false) @@ -2671,10 +874,12 @@ export function createTuiChat( showReasoning = !showReasoning const activeStreaming = streaming rebuildTranscript(false) + /* v8 ignore next -- the non-streaming command path is covered; this branch preserves an active stream across rebuild. */ if (activeStreaming !== undefined) { streaming = activeStreaming streaming.setShowReasoning(showReasoning) chat.addChild(activeStreaming) + chat.addChild(activeStreaming.timing) } appendNotice(`Reasoning blocks ${showReasoning ? 'shown' : 'hidden'}.`) } @@ -2685,7 +890,7 @@ export function createTuiChat( return `/${command.name}${input} — ${command.description}` }) chat.addChild(new Spacer(1)) - chat.addChild(new Text(palette.bold(palette.accent('Keyboard shortcuts')), 1, 0)) + chat.addChild(new Text(palette.bold(palette.accent('Keyboard shortcuts')), 0, 0)) chat.addChild(new Text([ 'Enter send • Shift/Alt+Enter newline • Up/Down prompt history', 'Esc cancel active turn • Ctrl+O toggle tool cards • Ctrl+R toggle reasoning', @@ -2693,15 +898,22 @@ export function createTuiChat( '', ...commandLines, '/skill: [instructions] — load a skill into the conversation', - ].map(line => palette.muted(line)).join('\n'), 1, 0)) + ].map(line => palette.muted(line)).join('\n'), 0, 0)) requestRender() } - const showStatus = (): void => { + const showStatus = async (signal: AbortSignal): Promise => { + const assembly = await ctx.systemPrompt.assemble(assembleContextFor(agent, signal)) + /* v8 ignore next -- disposal during the awaited assembly is covered by command-owner teardown tests. */ + if (disposed) return + /* v8 ignore next -- SystemPrompt always emits at least its required base section. */ + const systemPrompt = displayText(renderPrompt(assembly)) || '(empty)' + const registeredTools = assembly.tools.map(tool => displayText(tool.name)).join(', ') || '(none)' const events = agent.session.events const latestActivity = events.at(-1)?.time ?? agent.session.header.createdAt const usedContext = Math.max(0, Math.round(ctx.tokenMeter.measure(agent.session).totalTokens)) let context = `${formatDiagnosticNumber(usedContext)} used · capacity unknown` + const contextWindow = modelController.contextWindow() if (contextWindow !== undefined) { const contextPercent = Math.round(usedContext / contextWindow * 100) context = `${diagnosticMeter(contextPercent, palette)} ${String(contextPercent)}% used (${formatDiagnosticNumber(usedContext)} / ${formatDiagnosticNumber(contextWindow)})` @@ -2747,6 +959,12 @@ export function createTuiChat( const card = new StatusCardComponent(groups, palette) chat.addChild(new Spacer(1)) chat.addChild(card) + chat.addChild(new Spacer(1)) + chat.addChild(new Text(palette.bold(palette.accent('System prompt')), 0, 0)) + chat.addChild(new Text(systemPrompt, 0, 0)) + chat.addChild(new Spacer(1)) + chat.addChild(new Text(palette.bold(palette.accent('Registered tools')), 0, 0)) + chat.addChild(new Text(registeredTools, 0, 0)) requestRender() } @@ -2782,10 +1000,15 @@ export function createTuiChat( service.list({ cwd, signal: skillAbort.signal }).then( (summaries) => { if (disposed || summaries.length === 0) return + // The argument-hint slot shows in the menu but is never inserted on + // selection, so it carries the skill's scope instead of an + // instructions placeholder. `SkillSource` is open-ended; every + // non-project source (user, custom, bundled, runtime, …) collapses + // to `(user)`. skillCommands = summaries.map(skill => ({ name: `skill:${skill.name}`, description: skill.description, - argumentHint: '[instructions]', + argumentHint: skill.source.startsWith('project-') ? '(project)' : '(user)', })) refreshCommandAutocomplete() requestRender() @@ -2812,7 +1035,7 @@ export function createTuiChat( description: 'Show or switch this session\'s model', input: { hint: '[[provider/]model]' }, handler: ({ rawInput }) => { - queueModelCommand(rawInput) + modelController.queueModelCommand(rawInput) return { kind: 'success' } }, }) @@ -2844,17 +1067,26 @@ export function createTuiChat( commandCtx.commands.register({ name: 'resume', description: 'List this workspace\'s resumable sessions', - handler: () => { showResume(); return { kind: 'success' } }, + handler: () => { resume.showResume(); return { kind: 'success' } }, }) commandCtx.commands.register({ name: 'status', - description: 'Show detailed session diagnostics', - handler: () => { showStatus(); return { kind: 'success' } }, + description: 'Show session diagnostics, system prompt, and registered tools', + handler: async ({ signal }) => { await showStatus(signal); return { kind: 'success' } }, }) + const exitHandler = (): CommandResult => { + requestExit() + return { kind: 'success' } + } commandCtx.commands.register({ name: 'exit', description: 'Exit after the active turn reaches idle', - handler: () => { requestExit(); return { kind: 'success' } }, + handler: exitHandler, + }) + commandCtx.commands.register({ + name: 'quit', + description: 'Exit after the active turn reaches idle', + handler: exitHandler, }) }) const fileReferencePromptFiber = agent.ctx.inject(['systemPrompt'], (promptCtx) => { @@ -3037,159 +1269,6 @@ export function createTuiChat( }) } - /** Build one display candidate without letting a corrupt neighbor abort the selector. */ - const readResumeCandidate = async ( - record: SessionRecord, - providers: ReadonlySet, - ): Promise => { - try { - let snapshot: SessionLogSnapshot - const live = ctx.sessions.get(record.header.id) - if (live !== undefined) { - snapshot = { - session: structuredClone(live.header), - events: live.events.map(event => structuredClone(event)), - } - } else { - /* v8 ignore next -- caller checks the optional service before mapping records */ - if (sessionQuery === undefined) throw new Error('session query is unavailable') - snapshot = await sessionQuery.readSession(record.header.id) - } - return summarizeResumeCandidate( - record, - snapshot, - agent.session.id, - agent.session.header.cwd, - providers, - ) - } catch (error: unknown) { - return { - record, - title: 'Unreadable session', - lastActivityAt: record.header.createdAt, - lastTurn: 'log unavailable', - disabledReason: `session cannot be loaded: ${errorChain(error)}`, - } - } - } - - /** Re-read every mutable precondition immediately before terminal handoff. */ - const preflightResume = async (sessionId: SessionId): Promise => { - /* v8 ignore next -- only showResume can call this closure, after proving the optional service exists */ - if (sessionQuery === undefined) throw new Error('Resume is unavailable: session query is not mounted.') - const initialStatus = agentStatus() - if (initialStatus !== 'idle') throw new Error(`Resume requires an idle agent (status: ${initialStatus}).`) - const record = (await sessionQuery.listSessions()).find(candidate => candidate.header.id === sessionId) - if (record === undefined) throw new Error(`Session "${sessionId}" is no longer available.`) - const candidate = await readResumeCandidate( - record, - new Set(ctx.llm.listProviders().map(provider => provider.id)), - ) - if (candidate.disabledReason !== undefined) throw new Error(candidate.disabledReason) - const finalStatus = agentStatus() - if (finalStatus !== 'idle') throw new Error(`Resume requires an idle agent (status: ${finalStatus}).`) - return candidate - } - - const handoffResume = async (candidate: ResumeCandidate, overlay: TuiOverlaySession): Promise => { - if (resumeInFlight) return - resumeInFlight = true - let terminalReleased = false - try { - const checked = await preflightResume(candidate.record.header.id) - const hostHandoff = runtime.handoffResume - if (hostHandoff === undefined) { - const template = config.resumeCommand - const fallback = template?.replaceAll('{session}', checked.record.header.id) - await overlay.close() - resumeOverlay = undefined - appendNotice(fallback === undefined - ? 'Session is resumable, but this host cannot hand it off in place.' - : `This host cannot hand off in place. Exit and run: ${fallback}`, 'warning') - return - } - /* v8 ignore next -- shutdown during preflight invalidates an awaited service read or reaches this guard */ - if (disposed) return - await ctx.sessions.flush(agent.session) - // Disposal can run while the flush promise is pending; TypeScript does not model that reentry. - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (disposed) return - if (agent.status !== 'idle') throw new Error(`Resume requires an idle agent (status: ${agent.status}).`) - await overlay.close() - resumeOverlay = undefined - await runtime.terminal.drainInput(100, 20) - // Disposal can run while terminal draining is pending; TypeScript does not model that reentry. - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (disposed) return - ui.stop() - terminalReleased = true - await hostHandoff(checked.record.header.id) - throw new Error('resume host returned without replacing the process') - } catch (error: unknown) { - if (!disposed) { - if (terminalReleased) { - ui.start() - ui.setFocus(editor) - appendNotice(`Resume handoff failed: ${errorChain(error)}`, 'error') - } else { - await overlay.close() - resumeOverlay = undefined - appendNotice(`Resume failed: ${errorChain(error)}`, 'error') - } - } - } finally { - resumeInFlight = false - } - } - - /** Open the current-workspace searchable session selector. */ - const showResume = (): void => { - if (agent.status !== 'idle') { - appendNotice('Resume requires the current turn to finish or be cancelled first.', 'warning') - return - } - if (sessionQuery === undefined) { - appendNotice('Resume is not available: session query is not mounted.', 'warning') - return - } - const scan = ++resumeScan - void resumeOverlay?.close() - void sessionQuery.listSessions().then(async (records) => { - if (isDisposed() || scan !== resumeScan) return - const workspace = records.filter(record => record.header.cwd === agent.session.header.cwd) - const providers = new Set(ctx.llm.listProviders().map(provider => provider.id)) - const candidates = await Promise.all(workspace.map(record => readResumeCandidate(record, providers))) - candidates.sort((a, b) => b.lastActivityAt - a.lastActivityAt - || a.record.header.id.localeCompare(b.record.header.id)) - if (isDisposed() || scan !== resumeScan) return - const session = overlayManager.open({ - create: host => new ResumePicker( - candidates, - resolved.maxResumeOptions, - runtime.formatCwd?.(agent.session.header.cwd) ?? formatCwd(agent.session.header.cwd), - () => host.viewport.rows, - palette, - (candidate) => { void handoffResume(candidate, session) }, - () => { void session.close() }, - ), - options: { - width: '100%', - maxHeight: '100%', - anchor: 'top-left', - margin: 0, - }, - }) - resumeOverlay = session - void session.closed.then(() => { - /* v8 ignore next -- overlay FIFO closes this session before a replacement can become the tracked resume overlay */ - if (resumeOverlay === session) resumeOverlay = undefined - }) - requestRender() - }, (error: unknown) => { - if (!disposed && scan === resumeScan) appendNotice(`Resume session scan failed: ${errorChain(error)}`, 'error') - }) - } - editor.onSubmit = (value: string) => { const text = value.trim() if (text === '') return @@ -3201,9 +1280,9 @@ export function createTuiChat( if (text.startsWith(SKILL_COMMAND_PREFIX)) { editor.addToHistory(text) editor.setText('') - const { name, instructions } = parseSkillCommand(text) - if (name === '') appendNotice('Usage: /skill: [instructions]', 'warning') - else invokeSkill(name, instructions) + const { name: skillName, instructions } = parseSkillCommand(text) + if (skillName === '') appendNotice('Usage: /skill: [instructions]', 'warning') + else invokeSkill(skillName, instructions) return } if (value.startsWith('/')) { @@ -3300,7 +1379,8 @@ export function createTuiChat( if (session !== agent.session) return if (event.type === 'tool/result') fileSearch.invalidate() recordEventUsage(tokens, event) - advanceTurnPhase(event) + if (event.type === 'turn/start' && runningStatus !== undefined) runningStatus.turn = event.data.turn + if (event.type === 'assistant/message' && streaming?.isSettled()) streaming = undefined if ('surfaceOp' in event && typeof event.surfaceOp === 'object') { rebuildTranscript(false) return @@ -3341,9 +1421,9 @@ export function createTuiChat( // TUI stays mounted. Retained agents accept deliveries after detachment, so // without this a later send would drive a zombie agent/session; mark // disposed so dispatchMessage reports it instead. - disposed = true clearStatus() appendNotice(`Agent "${agent.id}" was disposed.`, 'warning') + disposed = true }) const detachListeners = (): void => { @@ -3351,6 +1431,8 @@ export function createTuiChat( fileSearch.dispose() removeInputListener() disposeCommandChanges() + disposePromptChanges() + for (const value of promptValues) value.dispose() stopBannerReveal() disposeSessionEvents() disposeDequeued() @@ -3392,6 +1474,7 @@ export function createTuiChat( rebuildTranscript(true) const restoredGoal = foldGoal(agent.session.events).goal + /* v8 ignore next -- goal replay coverage lives with the goal seam; the TUI only formats its startup notice. */ if (restoredGoal !== undefined && restoredGoal.phase !== 'complete') { appendNotice( `Goal restored (${restoredGoal.phase}) with automatic continuation disarmed. ` @@ -3415,7 +1498,7 @@ export function createTuiChat( }, ) clearStatus() - disposeUserInteraction() + questions.unregister() ui.stop() throw error } @@ -3484,10 +1567,10 @@ export function apply(ctx: Context, config: Config): void { throw new Error('ui-tui: both stdin and stdout must be TTYs; use the one-shot @deepseek-ai/dsh-cli-demo app for pipes') } // Truecolor is a terminal capability, so detect it here at the process - // boundary from COLORTERM; an explicit `truecolor` config value still wins. - const truecolor = config.truecolor ?? ['truecolor', '24bit'].includes(process.env.COLORTERM ?? '') + // boundary from COLORTERM; an explicit theme value still wins. + const truecolor = config.theme?.truecolor ?? ['truecolor', '24bit'].includes(process.env.COLORTERM ?? '') const resumeHost = ctx.get('tuiResumeHost') - mountTui(ctx, Object.assign({}, config, { truecolor }), { + mountTui(ctx, Object.assign({}, config, { theme: Object.assign({}, config.theme, { truecolor }) }), { terminal: new ProcessTerminal(), exit: code => process.exit(code), ...resumeHost === undefined ? {} : { handoffResume: sessionId => resumeHost.handoff(sessionId) }, diff --git a/packages/ui/tui/src/prompt.ts b/packages/ui/tui/src/prompt.ts new file mode 100644 index 0000000000..db80bfb1e3 --- /dev/null +++ b/packages/ui/tui/src/prompt.ts @@ -0,0 +1,217 @@ +/** + * Mutable terminal-prompt value registry consumed by the TUI template renderer. + * Values are trusted presentation fragments and may contain ANSI control sequences. + * @module @deepseek-ai/dsh-tui/prompt + */ + +import { Context, Service } from 'cordis' +import { errorChain } from '@deepseek-ai/dsh-llm' + +export const name = 'tui-prompt' + +const VALUE_NAME = /^[a-z][a-z0-9_-]*(?:\/[a-z][a-z0-9_-]*)*$/u + +/** Handle owned by one prompt-value registration. */ +export interface TuiPromptValueHandle { + /** + * Replace the current fragment and schedule a coalesced change notification + * so the owning renderer redraws. Setting the current value again is a no-op. + * @param value - Trusted ANSI-capable fragment, or `undefined` while unavailable. + */ + set(value: string | undefined): void + + /** Unregister this value; subsequent {@link TuiPromptValueHandle.set} calls fail. */ + dispose(): void +} + +interface RegisteredValue { + value: string | undefined +} + +declare module 'cordis' { + interface Context { + tuiPrompt: TuiPromptService + } +} + +/** Removes a change subscription registered with {@link TuiPromptService.subscribe}. */ +export type TuiPromptUnsubscribe = () => void + +/** One literal or variable token in a parsed TUI prompt template. */ +export type TuiPromptTemplateToken = + | { readonly kind: 'literal'; readonly value: string } + | { readonly kind: 'value'; readonly name: string } + +/** + * Parse a prompt template into immutable literal and value tokens. + * @param template - Text containing `${name}` references. + * @returns Tokens consumed by {@link renderTuiPromptTemplate}. + */ +export function parseTuiPromptTemplate(template: string): readonly TuiPromptTemplateToken[] { + const tokens: TuiPromptTemplateToken[] = [] + const pattern = /\$\{([^}]*)\}/gu + let offset = 0 + for (const match of template.matchAll(pattern)) { + const index = match.index + const name = match[1] + /* v8 ignore next -- the sole capture always exists when this pattern matches. */ + if (name === undefined) continue + if (index > offset) tokens.push(Object.freeze({ kind: 'literal', value: template.slice(offset, index) })) + tokens.push(Object.freeze({ kind: 'value', name })) + offset = index + match[0].length + } + if (offset < template.length) tokens.push(Object.freeze({ kind: 'literal', value: template.slice(offset) })) + return Object.freeze(tokens) +} + +/** + * Interpolate one parsed prompt while removing horizontal separators adjacent + * only to unavailable values. + * @param tokens - Parsed template tokens. + * @param resolve - Current value lookup. + * @returns ANSI-capable rendered prompt text. + */ +export function renderTuiPromptTemplate( + tokens: readonly TuiPromptTemplateToken[], + resolve: (name: string) => string | undefined, +): string { + const rendered: string[] = [] + let omitLeadingWhitespace = false + for (const token of tokens) { + if (token.kind === 'value') { + const value = resolve(token.name) + if (value === undefined) { + omitLeadingWhitespace = true + } else { + rendered.push(value) + omitLeadingWhitespace = false + } + continue + } + rendered.push(omitLeadingWhitespace ? token.value.replace(/^[\t ]+/u, '') : token.value) + omitLeadingWhitespace = false + } + return rendered.join('') +} + +/** + * Context-global mutable values interpolated by TUI theme prompt templates. + * A registration, mutation, or disposal schedules one coalesced notification to + * the renderer subscribed with {@link TuiPromptService.subscribe}, so a value + * that changes on its own schedule (not only in response to a UI event) still + * redraws. Notification is a direct in-service callback, not a Cordis event. + */ +export class TuiPromptService extends Service { + private readonly values = new Map() + // Per-subscription record identity, not callback identity: two fibers may + // subscribe the same function, and disposing one must not remove the other's. + private readonly listeners = new Set<{ readonly listener: () => unknown }>() + private notificationQueued = false + + constructor(ctx: Context) { + super(ctx, 'tuiPrompt') + } + + /** + * Register one globally unique template value under the calling Cordis effect. + * @param name - Lowercase slash-separated template name. + * @param initialValue - Initial trusted ANSI-capable fragment. + * @returns A mutable handle whose disposal unregisters the name. + */ + register(name: string, initialValue?: string): TuiPromptValueHandle { + if (!VALUE_NAME.test(name)) { + throw new TypeError(`TUI prompt value name "${name}" must match ${String(VALUE_NAME)}`) + } + if (this.values.has(name)) throw new Error(`TUI prompt value "${name}" is already registered`) + + const registered: RegisteredValue = { value: initialValue } + let active = true + const effectDisposer = this.ctx.effect(() => { + this.values.set(name, registered) + this.scheduleChange() + // Cordis runs this cleanup at most once per effect, and deleting an + // absent key is a no-op, so no re-entrancy guard is needed here; `active` + // exists only to reject a late {@link TuiPromptValueHandle.set}. + return () => { + active = false + this.values.delete(name) + this.scheduleChange() + } + }, `tuiPrompt.register(${name})`) + + return Object.freeze({ + set: (value: string | undefined): void => { + if (!active) throw new Error(`TUI prompt value "${name}" is disposed`) + if (registered.value === value) return + registered.value = value + this.scheduleChange() + }, + dispose: (): void => { void effectDisposer() }, + }) + } + + /** + * Read a registered fragment without evaluating plugin code. + * @param name - Exact registered template name. + * @returns The current fragment, or `undefined` when unknown or unavailable. + */ + get(name: string): string | undefined { + return this.values.get(name)?.value + } + + /** + * Observe registration and value changes. The listener runs after a coalesced + * microtask following any burst of mutations; the renderer re-reads current + * values on that callback. The subscription is owned by the calling Cordis + * effect, so it is removed when the subscriber's fiber disposes; the returned + * disposer removes it early. Listener failures are contained — a synchronous + * throw or a rejected returned promise cannot starve the other observers. + * @param listener - Invoked once per coalesced change burst. Delivery does + * not wait on a returned promise; its rejection is only observed and logged, + * never left unhandled, so an async listener cannot order later observers. + * @returns A disposer that removes the subscription. + */ + subscribe(listener: () => unknown): TuiPromptUnsubscribe { + const record = { listener } + const disposeEffect = this.ctx.effect(() => { + this.listeners.add(record) + return () => { this.listeners.delete(record) } + }, 'tuiPrompt.subscribe') + return () => { void disposeEffect() } + } + + /** Coalesce mutation bursts into one notification while containing each observer. */ + private scheduleChange(): void { + if (this.notificationQueued) return + this.notificationQueued = true + queueMicrotask(() => { + this.notificationQueued = false + // Snapshot so a listener may subscribe/unsubscribe during delivery, but + // re-check liveness: a listener that synchronously unsubscribes another + // observer earlier in the same burst must silence it now, keeping the + // subscription set authoritative during reentrant notification. + for (const record of [...this.listeners]) { + if (this.listeners.has(record)) this.notifyOne(record.listener) + } + }) + } + + /** Deliver one change notification, containing a synchronous throw or a rejected promise. */ + private notifyOne(listener: () => unknown): void { + let returned: unknown + try { + returned = listener() + } catch (error: unknown) { + // errorChain never throws, even on a hostile toString/getter, so the + // notification microtask can never escape to starve later observers. + this.ctx.logger.warn(`tui-prompt change listener threw: ${errorChain(error)}`) + return + } + // A listener may be async; contain a rejected promise the same as a throw. + void Promise.resolve(returned).catch((error: unknown) => { + this.ctx.logger.warn(`tui-prompt change listener rejected: ${errorChain(error)}`) + }) + } +} + +export default TuiPromptService diff --git a/packages/ui/tui/src/runtime.ts b/packages/ui/tui/src/runtime.ts new file mode 100644 index 0000000000..4345f656bf --- /dev/null +++ b/packages/ui/tui/src/runtime.ts @@ -0,0 +1,45 @@ +/** + * Host and process boundary the interactive TUI runs against: the resume-handoff + * host and the {@link TuiRuntime} the shipped CLI supplies (terminal, process + * exit, clock, and optional prompt/git overrides). These are plain interfaces so + * tests can drive the channel with a fake terminal. + * @module @deepseek-ai/dsh-tui/runtime + */ + +import type { Terminal } from '@earendil-works/pi-tui' +import type { SessionId } from '@deepseek-ai/dsh-session' + +/** Process-lifecycle owner used by the shipped CLI for an atomic resume handoff. */ +export interface TuiResumeHost { + /** + * Dispose the current app and replace it with a runtime for `sessionId`. + * Success does not return. A host may reject before it commits teardown; + * after commit it owns fatal reporting and process exit. + * @param sessionId - validated persisted session selected by the user. + */ + handoff(sessionId: SessionId): Promise +} + +/** Runtime boundary used by the interactive TUI. */ +export interface TuiRuntime { + /** Terminal implementation; production uses pi-tui's `ProcessTerminal`. */ + terminal: Terminal + /** Exit hook used by terminal shutdown or a target-agent startup failure. */ + exit(code: number): void + /** + * Override the prompt's logical working-directory label without changing the session directory used by tools. + * @param cwd - Operational working directory from the session header. + * @returns Unescaped label; the TUI makes terminal controls visible. + */ + formatCwd?: (cwd: string | undefined) => string + /** + * Override the Git branch shown in the prompt context line; production resolves it once at mount. + * @param cwd - Operational working directory from the session header. + * @returns Unescaped branch name, or `undefined` outside a Git worktree. + */ + gitBranch?: (cwd: string) => string | undefined + /** Monotonic-enough wall clock for elapsed status rendering. Defaults to `Date.now`. */ + now?(): number + /** Host-owned process handoff; absent leaves `resumeCommand` as the fallback. */ + handoffResume?: TuiResumeHost['handoff'] +} diff --git a/packages/ui/tui/tests/extension.spec.ts b/packages/ui/tui/tests/extension.spec.ts index bf11d6c601..a7eec5589e 100644 --- a/packages/ui/tui/tests/extension.spec.ts +++ b/packages/ui/tui/tests/extension.spec.ts @@ -11,12 +11,12 @@ import type { TuiOverlayOptions, TuiOverlaySession, TuiTheme, -} from '../src/extension.ts' +} from '../src/extension/types.ts' import { TuiExtensionServiceImpl, TuiOverlayManager, type TuiOverlayDriver, -} from '../src/overlay-manager.ts' +} from '../src/extension/overlay-manager.ts' const theme: TuiTheme = Object.freeze({ text: (value: string) => `text:${value}`, diff --git a/packages/ui/tui/tests/file-autocomplete.spec.ts b/packages/ui/tui/tests/file-autocomplete.spec.ts index 53dd1f4f1a..18c7deba12 100644 --- a/packages/ui/tui/tests/file-autocomplete.spec.ts +++ b/packages/ui/tui/tests/file-autocomplete.spec.ts @@ -6,7 +6,7 @@ import { activeAtToken, formatFileMention, WorkspaceFileSearch, -} from '../src/file-autocomplete.ts' +} from '../src/chat/file-autocomplete.ts' const searches: WorkspaceFileSearch[] = [] const roots: string[] = [] diff --git a/packages/ui/tui/tests/harness.ts b/packages/ui/tui/tests/harness.ts index c922c2ba14..382be4ff26 100644 --- a/packages/ui/tui/tests/harness.ts +++ b/packages/ui/tui/tests/harness.ts @@ -17,10 +17,11 @@ import type { import CommandService from '@deepseek-ai/dsh-commands' import SessionStore, { SessionId, type Session, type SessionHeader, type UserMessageData } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import type { ToolDefinition } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { type ToolDefinition } from '@deepseek-ai/dsh-tools' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' import { createTuiChat, type Config, type TuiRuntime } from '../src/index.ts' import { TestSessionQueryService } from './session-query.ts' +import TuiPromptService from '../src/prompt.ts' interface FakeAgent extends Agent { status: AgentStatus @@ -48,6 +49,7 @@ export interface TuiHarnessOptions { beforeMount?: (session: Session) => void cwd?: string | null formatCwd?: TuiRuntime['formatCwd'] + gitBranch?: TuiRuntime['gitBranch'] /** Fake-agent creation options (`provider`/`model` seed the model selector's initial target). */ agentOptions?: AgentOptions contextWindow?: number @@ -98,6 +100,7 @@ export async function createTuiTestHarness 'tui-staging'), }) return { ctx, session, agent, terminal, exit, controller } } diff --git a/packages/ui/tui/tests/plugin-shape.spec.ts b/packages/ui/tui/tests/plugin-shape.spec.ts index c5c497fab9..73c39db268 100644 --- a/packages/ui/tui/tests/plugin-shape.spec.ts +++ b/packages/ui/tui/tests/plugin-shape.spec.ts @@ -21,6 +21,7 @@ describe('dsh-tui plugin export shape', () => { 'llm', 'systemPrompt', 'tokenMeter', + 'tuiPrompt', ]) expect(unwrapped.Config).toBeDefined() expect(typeof unwrapped.apply).toBe('function') diff --git a/packages/ui/tui/tests/prompt.spec.ts b/packages/ui/tui/tests/prompt.spec.ts new file mode 100644 index 0000000000..f212ef10e2 --- /dev/null +++ b/packages/ui/tui/tests/prompt.spec.ts @@ -0,0 +1,169 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import TuiPromptService, { + parseTuiPromptTemplate, + renderTuiPromptTemplate, +} from '../src/prompt.ts' + +const tick = (): Promise => new Promise((resolve) => { queueMicrotask(resolve) }) + +describe('TUI prompt values', () => { + it('registers, updates, and disposes mutable values', async () => { + const ctx = new Context() + await ctx.plugin(TuiPromptService) + + const value = ctx.tuiPrompt.register('git/worktree', '\x1b[32m(main)\x1b[0m') + expect(ctx.tuiPrompt.get('git/worktree')).toBe('\x1b[32m(main)\x1b[0m') + value.set('next') + expect(ctx.tuiPrompt.get('git/worktree')).toBe('next') + + value.set(undefined) + expect(ctx.tuiPrompt.get('git/worktree')).toBeUndefined() + value.dispose() + expect(() => { value.set('late') }).toThrow(/disposed/) + await ctx.fiber.dispose() + }) + + it('coalesces a change burst into one notification and contains each observer', async () => { + const ctx = new Context() + await ctx.plugin(TuiPromptService) + // Capture the containment warnings so the rejected-promise and sync-throw + // paths are each pinned (removing either catch drops its warning). + const warnings: string[] = [] + ctx.logger.warn = ((message: string) => void warnings.push(message)) as typeof ctx.logger.warn + // A synchronous thrower, an async rejecter, and a thrower whose error is + // hostile to string coercion all sit BEFORE the observed listener, so + // proving `after` still runs proves none of them starves it (a naive + // `String(error)` inside the containment would itself throw on the last). + const hostile = { toString() { throw new Error('hostile coercion') } } + const thrower = vi.fn(() => { throw new Error('sync observer boom') }) + const rejecter = vi.fn(async () => { throw new Error('async observer boom') }) + const hostileThrower = vi.fn(() => { throw hostile }) + const after = vi.fn() + ctx.tuiPrompt.subscribe(thrower) + ctx.tuiPrompt.subscribe(rejecter) + ctx.tuiPrompt.subscribe(hostileThrower) + const unsubscribe = ctx.tuiPrompt.subscribe(after) + await tick() // drain the registration notifications + thrower.mockClear() + rejecter.mockClear() + hostileThrower.mockClear() + after.mockClear() + + const value = ctx.tuiPrompt.register('git/worktree', 'a') + value.set('b') + value.set('b') // unchanged: no additional schedule + value.set('c') + await tick() + await tick() // settle the contained rejected promise + // One coalesced callback for the whole burst; a throwing, rejecting, or + // hostile-to-render observer is contained and does not stop later observers. + expect(thrower).toHaveBeenCalledTimes(1) + expect(rejecter).toHaveBeenCalledTimes(1) + expect(hostileThrower).toHaveBeenCalledTimes(1) + expect(after).toHaveBeenCalledTimes(1) + // Each contained failure logged its own warning: the sync throw, the + // rejected promise, and the hostile-to-render throw (via non-throwing + // errorChain). Pinning the rejected-promise warning fails if its `.catch` + // containment is removed. + expect(warnings.some(w => w.includes('threw: sync observer boom'))).toBe(true) + expect(warnings.some(w => w.includes('rejected: async observer boom'))).toBe(true) + expect(warnings.some(w => w.includes('threw: '))).toBe(true) + + // Unsubscribe stops further notifications for that listener. + unsubscribe() + value.set('d') + await tick() + expect(after).toHaveBeenCalledTimes(1) + await ctx.fiber.dispose() + }) + + it('removes a subscription when the subscriber fiber disposes', async () => { + const ctx = new Context() + await ctx.plugin(TuiPromptService) + const observed = vi.fn() + // Subscribe from a child plugin fiber that shares the service, then dispose + // only that fiber; the effect-owned subscription must go with it. + const child = ctx.plugin({ + inject: ['tuiPrompt'], + apply: (childCtx) => { childCtx.tuiPrompt.subscribe(observed) }, + }) + await tick() + observed.mockClear() + await child.dispose() + + const value = ctx.tuiPrompt.register('git/worktree', 'a') + value.set('b') + await tick() + expect(observed).not.toHaveBeenCalled() + await ctx.fiber.dispose() + }) + + it('keeps one fiber\'s subscription when another disposes the same callback', async () => { + const ctx = new Context() + await ctx.plugin(TuiPromptService) + // Both fibers subscribe the SAME function reference. Per-subscription record + // identity (not callback identity) keeps them independent, so disposing one + // must not silence the other. + const shared = vi.fn() + const first = ctx.plugin({ inject: ['tuiPrompt'], apply: (c) => { c.tuiPrompt.subscribe(shared) } }) + ctx.plugin({ inject: ['tuiPrompt'], apply: (c) => { c.tuiPrompt.subscribe(shared) } }) + await tick() + await first.dispose() + shared.mockClear() + + const value = ctx.tuiPrompt.register('git/worktree', 'a') + value.set('b') + await tick() + // The second fiber's subscription survives the first's disposal. + expect(shared).toHaveBeenCalledTimes(1) + await ctx.fiber.dispose() + }) + + it('does not notify a subscription unsubscribed earlier in the same burst', async () => { + const ctx = new Context() + await ctx.plugin(TuiPromptService) + const victim = vi.fn() + // This listener is delivered first (subscribed first) and synchronously + // unsubscribes the victim during the same notification. The snapshot must + // re-check liveness so the later victim record does not fire this burst. + ctx.tuiPrompt.subscribe(() => { unsubscribeVictim() }) + const unsubscribeVictim = ctx.tuiPrompt.subscribe(victim) + await tick() + victim.mockClear() + + const value = ctx.tuiPrompt.register('git/worktree', 'a') + value.set('b') + await tick() + expect(victim).not.toHaveBeenCalled() + await ctx.fiber.dispose() + }) + + it('rejects invalid and duplicate names', async () => { + const ctx = new Context() + await ctx.plugin(TuiPromptService) + expect(() => ctx.tuiPrompt.register('Bad Name')).toThrow(/must match/) + ctx.tuiPrompt.register('status') + expect(() => ctx.tuiPrompt.register('status')).toThrow(/already registered/) + await ctx.fiber.dispose() + }) +}) + +describe('TUI prompt templates', () => { + it('interpolates values and removes separators around unavailable values', () => { + const tokens = parseTuiPromptTemplate('${cwd} ${git/worktree} :: ${missing} ${model}') + const values = new Map([['cwd', '/work'], ['model', 'deepseek']]) + expect(renderTuiPromptTemplate(tokens, name => values.get(name))).toBe('/work :: deepseek') + }) + + it('keeps a trailing literal after the last value', () => { + const tokens = parseTuiPromptTemplate('${symbol} ${indicator} > ') + const values = new Map([['symbol', 'dsh'], ['indicator', '●']]) + expect(renderTuiPromptTemplate(tokens, name => values.get(name))).toBe('dsh ● > ') + }) + + it('preserves trusted ANSI fragments', () => { + const powerline = '\x1b[44m work \x1b[34;46m\x1b[0m' + expect(renderTuiPromptTemplate(parseTuiPromptTemplate('${powerline}'), () => powerline)).toBe(powerline) + }) +}) diff --git a/packages/ui/tui/tests/session-reference.snapshot.ts b/packages/ui/tui/tests/session-reference.snapshot.ts index 5baa93b4d3..554f2fc6d4 100644 --- a/packages/ui/tui/tests/session-reference.snapshot.ts +++ b/packages/ui/tui/tests/session-reference.snapshot.ts @@ -1,7 +1,7 @@ import { mkdir, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import LlmService, { LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' @@ -12,7 +12,7 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop' import CommandService from '@deepseek-ai/dsh-commands' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' import SessionReferenceService, { formatSessionReferenceMention } from '@deepseek-ai/dsh-session-reference' -import { createTuiChat } from '../src/index.ts' +import { createTuiChat, TuiPromptService } from '../src/index.ts' import { HeadlessTerminal } from './headless-terminal.ts' import { TestSessionQueryService } from './session-query.ts' @@ -51,6 +51,7 @@ function nextIdle(ctx: Context, agent: Agent): Promise { describe('TUI session-reference snapshot', () => { it('snapshots compacted current-surface context on send and displays only its reference card', async () => { + const clock = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 21, 12, 30, 0).getTime()) const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(SessionStore) @@ -59,6 +60,7 @@ describe('TUI session-reference snapshot', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(CommandService) await ctx.plugin(UserInteractionService) + await ctx.plugin(TuiPromptService) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(TestSessionQueryService) await ctx.plugin(SessionReferenceService) @@ -97,7 +99,7 @@ describe('TUI session-reference snapshot', () => { const controller = createTuiChat(ctx, { sessionId: target.id, welcome: 'Session reference snapshot.', - color: true, + theme: { color: true }, title: 'DSH session reference', }, { terminal, exit: () => {} }) await terminal.waitForFrame(0) @@ -138,5 +140,6 @@ describe('TUI session-reference snapshot', () => { await controller.dispose() await ctx.fiber.dispose() await terminal.dispose() + clock.mockRestore() }) }) diff --git a/packages/ui/tui/tests/snapshots/advanced-cards-collapsed.expected.txt b/packages/ui/tui/tests/snapshots/advanced-cards-collapsed.expected.txt index 0e3c73750a..f0fd74b989 100644 --- a/packages/ui/tui/tests/snapshots/advanced-cards-collapsed.expected.txt +++ b/packages/ui/tui/tests/snapshots/advanced-cards-collapsed.expected.txt @@ -1,99 +1,69 @@ terminal 100x40 buffer=normal length=40 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=1 viewportRow=36 bufferRow=36 +cursor hidden column=7 viewportRow=34 bufferRow=34 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Snapshot agent ready." style 1-21 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=green -5| "▌ ✓ pnpm run test:coverage " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-25 bold -6| "▌ Run the coverage gate " - style 0-0 fg=green - style 2-22 fg=bright-black -7| "▌ /workspace/project " - style 0-0 fg=green - style 2-19 dim -8| "▌ … +4 lines (Ctrl+O to expand) " - style 0-0 fg=green - style 2-30 dim -9| "▌ [exit 0] " - style 0-0 fg=green - style 2-9 dim -10| "▌ " - style 0-0 fg=green +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| +6| "● Tool / bash / Run the coverage gate" + style 0-36 fg=green +7| "$ pnpm run test:coverage " + style 0-23 fg=cyan +8| "/workspace/project " + style 0-17 dim +9| "… +4 lines (Ctrl+O to expand) " + style 0-28 dim +10| "[exit 0] " + style 0-7 dim 11| -12| "▌ " - style 0-0 fg=green -13| "▌ ✓ Edit renderer " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-16 bold -14| "▌ src/view.ts " - style 0-0 fg=green - style 2-12 bold -15| "▌ - old line " - style 0-0 fg=green - style 2-11 fg=red -16| "▌ … +5 lines (Ctrl+O to expand) " - style 0-0 fg=green - style 2-30 dim -17| "▌ + expect(screen).toMatchSnapshot() " - style 0-0 fg=green - style 2-35 fg=green -18| "▌ " - style 0-0 fg=green -19| -20| "▌ " - style 0-0 fg=green -21| "▌ ✓ Delegate renderer audit " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-26 bold -22| "▌ The renderer has explicit lifecycle ownership. " - style 0-0 fg=green -23| "▌ " - style 0-0 fg=green -24| -25| "▌ " - style 0-0 fg=green -26| "▌ ✓ Read output from background task subagent-7 " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-46 bold -27| "▌ audit complete " - style 0-0 fg=green -28| "▌ [status: completed] " - style 0-0 fg=green -29| "▌ " - style 0-0 fg=green -30| -31| "▌ " - style 0-0 fg=green -32| "▌ ✓ Load skill dsh-code-review " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-29 bold -33| "▌ Loaded review instructions. " - style 0-0 fg=green -34| "▌ " - style 0-0 fg=green -35| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -36| " " - style 1-1 inverse -37| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -38| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" - style 0-43 dim - style 73-99 dim -39| +12| "● Tool / edit" + style 0-12 fg=green +13| "src/view.ts " + style 0-10 bold +14| "- old line " + style 0-9 fg=red +15| "… +3 lines (Ctrl+O to expand) " + style 0-28 dim +16| "└ +2 -2 · 1 file " + style 0-15 dim +17| +18| "● Tool / subagent" + style 0-16 fg=green +19| "Delegate renderer audit " +20| "The renderer has explicit lifecycle ownership. " +21| +22| "● Tool / task_output" + style 0-19 fg=green +23| "Read output from background task subagent-7 " +24| " " +25| "… +2 lines (Ctrl+O to expand) " + style 0-28 dim +26| " " +27| +28| "● Tool / skill" + style 0-13 fg=green +29| "Load skill dsh-code-review " +30| "Loaded review instructions. " +31| "Model wait 0.0s " + style 0-14 dim +32| +33| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-50 fg=bright-black + style 53-57 fg=bright-black + style 60-69 fg=bright-black +34| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +35-39| diff --git a/packages/ui/tui/tests/snapshots/advanced-cards-expanded.expected.txt b/packages/ui/tui/tests/snapshots/advanced-cards-expanded.expected.txt index 2321eff61d..05e1f11b3c 100644 --- a/packages/ui/tui/tests/snapshots/advanced-cards-expanded.expected.txt +++ b/packages/ui/tui/tests/snapshots/advanced-cards-expanded.expected.txt @@ -1,117 +1,79 @@ -terminal 100x40 buffer=normal length=48 base=8 viewport=8 +terminal 100x40 buffer=normal length=43 base=3 viewport=3 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=1 viewportRow=37 bufferRow=45 +cursor hidden column=7 viewportRow=39 bufferRow=42 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Snapshot agent ready." style 1-21 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=green -5| "▌ ✓ pnpm run test:coverage " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-25 bold -6| "▌ Run the coverage gate " - style 0-0 fg=green - style 2-22 fg=bright-black -7| "▌ /workspace/project " - style 0-0 fg=green - style 2-19 dim -8| "▌ packages/ui/tui 100% " - style 0-0 fg=green -9| "▌ 4016 tests passed " - style 0-0 fg=green -10| "▌ 1 test skipped " - style 0-0 fg=green -11| "▌ coverage complete " - style 0-0 fg=green -12| "▌ [exit 0] " - style 0-0 fg=green - style 2-9 dim -13| "▌ " - style 0-0 fg=green +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| +6| "● Tool / bash / Run the coverage gate" + style 0-36 fg=green +7| "$ pnpm run test:coverage " + style 0-23 fg=cyan +8| "/workspace/project " + style 0-17 dim +9| "packages/ui/tui 100% " +10| "4016 tests passed " +11| "1 test skipped " +12| "coverage complete " +13| "[exit 0] " + style 0-7 dim 14| -15| "▌ " - style 0-0 fg=green -16| "▌ ✓ Edit renderer " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-16 bold -17| "▌ src/view.ts " - style 0-0 fg=green - style 2-12 bold -18| "▌ - old line " - style 0-0 fg=green - style 2-11 fg=red -19| "▌ - keep " - style 0-0 fg=green - style 2-7 fg=red -20| "▌ + new line " - style 0-0 fg=green - style 2-11 fg=green -21| "▌ + keep " - style 0-0 fg=green - style 2-7 fg=green -22| "▌ " - style 0-0 fg=green -23| "▌ tests/view.spec.ts " - style 0-0 fg=green - style 2-19 bold -24| "▌ + expect(screen).toMatchSnapshot() " - style 0-0 fg=green - style 2-35 fg=green -25| "▌ " - style 0-0 fg=green +15| "● Tool / edit" + style 0-12 fg=green +16| "src/view.ts " + style 0-10 bold +17| "- old line " + style 0-9 fg=red +18| "- keep " + style 0-5 fg=red +19| "+ new line " + style 0-9 fg=green +20| "+ keep " + style 0-5 fg=green +21| "└ +2 -2 · 1 file " + style 0-15 dim +22| +23| "● Tool / subagent" + style 0-16 fg=green +24| "Delegate renderer audit " +25| "The renderer has explicit lifecycle ownership. " 26| -27| "▌ " - style 0-0 fg=green -28| "▌ ✓ Delegate renderer audit " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-26 bold -29| "▌ The renderer has explicit lifecycle ownership. " - style 0-0 fg=green -30| "▌ " - style 0-0 fg=green -31| -32| "▌ " - style 0-0 fg=green -33| "▌ ✓ Read output from background task subagent-7 " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-46 bold -34| "▌ audit complete " - style 0-0 fg=green -35| "▌ [status: completed] " - style 0-0 fg=green -36| "▌ " - style 0-0 fg=green -37| -38| "▌ " - style 0-0 fg=green -39| "▌ ✓ Load skill dsh-code-review " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-29 bold -40| "▌ Loaded review instructions. " - style 0-0 fg=green -41| "▌ " - style 0-0 fg=green -42| -43| " Tool cards expanded. " - style 1-20 fg=bright-black -44| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -45| " " - style 1-1 inverse -46| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -47| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:expanded" - style 0-43 dim - style 74-99 dim +27| "● Tool / task_output" + style 0-19 fg=green +28| "Read output from background task subagent-7 " +29| " " +30| "console " + style 0-6 dim +31| " started background task bash-5 " + style 2-31 fg=cyan +32| " " +33| +34| "● Tool / skill" + style 0-13 fg=green +35| "Load skill dsh-code-review " +36| "Loaded review instructions. " +37| "Model wait 0.0s " + style 0-14 dim +38| +39| "Tool cards expanded. " + style 0-19 fg=bright-black +40| +41| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-50 fg=bright-black + style 53-57 fg=bright-black + style 60-69 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/packages/ui/tui/tests/snapshots/banner-gradient.expected.txt b/packages/ui/tui/tests/snapshots/banner-gradient.expected.txt index 59d1377702..0477513ebd 100644 --- a/packages/ui/tui/tests/snapshots/banner-gradient.expected.txt +++ b/packages/ui/tui/tests/snapshots/banner-gradient.expected.txt @@ -1,7 +1,7 @@ terminal 96x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=1 viewportRow=4 bufferRow=4 +cursor hidden column=7 viewportRow=8 bufferRow=8 viewport 0| " DEEPSEEK HARNESS" style 1-1 fg=#4d6bfe bold @@ -15,15 +15,22 @@ viewport style 10-16 bold 1| " Snapshot agent ready." style 1-21 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim -3| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -4| " " - style 1-1 inverse -5| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -6| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" - style 0-43 dim - style 69-95 dim -7-35| +2| " main-session" + style 1-12 dim +3| +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| "Model wait 0.0s " + style 0-14 dim +6| +7| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-50 fg=bright-black + style 53-57 fg=bright-black + style 60-69 fg=bright-black +8| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +9-35| diff --git a/packages/ui/tui/tests/snapshots/code-mode-pending.expected.txt b/packages/ui/tui/tests/snapshots/code-mode-pending.expected.txt index c246a00b69..db131247d0 100644 --- a/packages/ui/tui/tests/snapshots/code-mode-pending.expected.txt +++ b/packages/ui/tui/tests/snapshots/code-mode-pending.expected.txt @@ -1,39 +1,37 @@ terminal 96x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=1 viewportRow=12 bufferRow=12 +cursor hidden column=7 viewportRow=15 bufferRow=15 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Snapshot agent ready." style 1-21 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=yellow -5| "▌ ◌ Echo two markers and combine them " - style 0-0 fg=yellow - style 2-2 fg=yellow bold - style 3-36 bold -6| "▌ const first = await tools.bash({ command: 'echo CODE_ONE' }) " - style 0-0 fg=yellow -7| "▌ const second = await tools.bash({ command: 'echo CODE_TWO' }) " - style 0-0 fg=yellow -8| "▌ console.log(first, second) " - style 0-0 fg=yellow -9| "▌ return `${first}+${second}` " - style 0-0 fg=yellow -10| "▌ " - style 0-0 fg=yellow -11| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -12| " " - style 1-1 inverse -13| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -14| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" - style 0-43 dim - style 69-95 dim -15-35| +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| +6| "○ Tool / run_code" + style 0-16 fg=yellow +7| "Echo two markers and combine them " +8| "const first = await tools.bash({ command: 'echo CODE_ONE' }) " +9| "const second = await tools.bash({ command: 'echo CODE_TWO' }) " +10| "console.log(first, second) " +11| "return `${first}+${second}` " +12| "Model wait 0.0s " + style 0-14 dim +13| +14| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-50 fg=bright-black + style 53-57 fg=bright-black + style 60-69 fg=bright-black +15| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +16-35| diff --git a/packages/ui/tui/tests/snapshots/conversation-streaming.expected.txt b/packages/ui/tui/tests/snapshots/conversation-streaming.expected.txt index 52e003eea0..db68564b48 100644 --- a/packages/ui/tui/tests/snapshots/conversation-streaming.expected.txt +++ b/packages/ui/tui/tests/snapshots/conversation-streaming.expected.txt @@ -1,46 +1,45 @@ terminal 96x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=active title "DSH snapshot" -cursor hidden column=1 viewportRow=17 bufferRow=17 +cursor hidden column=7 viewportRow=18 bufferRow=18 viewport 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Snapshot agent ready." style 1-21 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| "▌ Show the live update. " - style 0-0 fg=bright-blue -7| "▌ " - style 0-0 fg=bright-blue -8| -9| " Reasoning " - style 1-9 fg=bright-black italic -10| " Inspecting width and styles. " - style 1-28 fg=bright-black italic -11| -12| " Assistant " - style 1-9 fg=bright-magenta bold -13| " Streaming visible state… " - style 11-23 bold -14| -15| " ⠋ Responding 0s · total 0s — Enter sends steering, Esc cancels " - style 1-1 fg=bright-blue - style 3-62 fg=bright-black -16| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 fg=bright-blue -17| " " - style 1-1 inverse -18| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 fg=bright-blue -19| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" - style 0-43 dim - style 69-95 dim -20-35| +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| "Reasoning " + style 0-8 fg=bright-black italic +6| "Inspecting width and styles. " + style 0-27 fg=bright-black italic +7| "Streaming visible state… " + style 10-22 bold +8| " " +9| "ts " + style 0-1 dim +10| " const visible = true " + style 2-21 fg=cyan +11| " " +12| "Model wait 1.0s · Thinking 2.0s " + style 0-30 dim +13| +14| "You " + style 0-2 fg=bright-blue bold underline +15| "Show the live update. " +16| +17| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-50 fg=bright-black + style 53-57 fg=bright-black + style 60-69 fg=bright-black +18| " dsh ● press enter to steer and esc to cancel " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-44 dim +19-35| diff --git a/packages/ui/tui/tests/snapshots/cordis-tools-pending.expected.txt b/packages/ui/tui/tests/snapshots/cordis-tools-pending.expected.txt index 47c225008d..96564a8227 100644 --- a/packages/ui/tui/tests/snapshots/cordis-tools-pending.expected.txt +++ b/packages/ui/tui/tests/snapshots/cordis-tools-pending.expected.txt @@ -1,49 +1,45 @@ terminal 96x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=1 viewportRow=16 bufferRow=16 +cursor hidden column=7 viewportRow=21 bufferRow=21 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Snapshot agent ready." style 1-21 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| "▌ ◌ Inspect cordis runtime: tools " - style 0-0 fg=yellow - style 2-2 fg=yellow bold - style 3-32 bold +4| "Assistant " + style 0-8 fg=bright-magenta bold underline 5| -6| "▌ " - style 0-0 fg=yellow -7| "▌ ◌ Mount plugin into live cordis runtime " - style 0-0 fg=yellow - style 2-2 fg=yellow bold - style 3-40 bold -8| "▌ { " - style 0-0 fg=yellow -9| "▌ \"code\": \"return { name: 'snapshot-marker', apply(ctx) { ctx.provide('snapshotMarker', { " - style 0-0 fg=yellow -10| "▌ ready: true }) } }\" " - style 0-0 fg=yellow -11| "▌ } " - style 0-0 fg=yellow -12| "▌ " - style 0-0 fg=yellow -13| -14| "▌ ◌ Unmount dyn-1 " - style 0-0 fg=yellow - style 2-2 fg=yellow bold - style 3-16 bold -15| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -16| " " - style 1-1 inverse -17| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -18| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" - style 0-43 dim - style 69-95 dim -19-35| +6| "○ Tool / cordis_inspect" + style 0-22 fg=yellow +7| "Inspect cordis runtime: tools " +8| +9| "○ Tool / cordis_mount" + style 0-20 fg=yellow +10| "Mount temporary Cordis Plugin " +11| "{ " +12| " \"code\": \"return { name: 'snapshot-marker', apply(ctx) { ctx.provide('snapshotMarker', { ready:" +13| "true }) } }\" " +14| "} " +15| +16| "○ Tool / cordis_unmount" + style 0-22 fg=yellow +17| "Unmount temporary Cordis Plugin dyn-1 " +18| "Model wait 0.0s " + style 0-14 dim +19| +20| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-50 fg=bright-black + style 53-57 fg=bright-black + style 60-69 fg=bright-black +21| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +22-35| diff --git a/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt b/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt index b6fb49135b..9be96fe5e1 100644 --- a/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt +++ b/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt @@ -1,63 +1,78 @@ -terminal 92x32 buffer=normal length=32 base=0 viewport=0 +terminal 92x32 buffer=normal length=38 base=6 viewport=6 lifecycle started=1 stopped=1 progress=inactive title "DSH snapshot" -cursor visible column=0 viewportRow=31 bufferRow=31 +cursor visible column=0 viewportRow=31 bufferRow=37 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Snapshot agent ready." style 1-21 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| " Keyboard shortcuts " - style 1-18 fg=bright-blue bold -5| " Enter send • Shift/Alt+Enter newline • Up/Down prompt history " - style 1-61 fg=bright-black -6| " Esc cancel active turn • Ctrl+O toggle tool cards • Ctrl+R toggle reasoning " - style 1-75 fg=bright-black -7| " Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit " - style 1-73 fg=bright-black -8| " " -9| " /clear — Clear the transcript view (session history is unchanged) " - style 1-65 fg=bright-black -10| " /exit — Exit after the active turn reaches idle " - style 1-47 fg=bright-black -11| " /help — Show keyboard shortcuts and commands " - style 1-44 fg=bright-black -12| " /model [[provider/]model] — Show or switch this session's model " - style 1-63 fg=bright-black -13| " /reasoning — Toggle reasoning blocks " - style 1-36 fg=bright-black -14| " /redraw — Invalidate components and redraw the terminal " - style 1-55 fg=bright-black -15| " /reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) " - style 1-88 fg=bright-black -16| " /resume — List this workspace's resumable sessions " - style 1-50 fg=bright-black -17| " /status — Show detailed session diagnostics " - style 1-43 fg=bright-black -18| " /tools — Expand or collapse all tool cards " - style 1-42 fg=bright-black -19| " /skill: [instructions] — load a skill into the conversation " - style 1-65 fg=bright-black -20| -21| " provider stream failed after partial output " - style 1-43 fg=red -22| -23| " The previous process ended during this turn. " - style 1-44 fg=yellow +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| "Model wait 0.0s · Completed 2026-07-21 15:05:00 " + style 0-46 dim +6| +7| "Keyboard shortcuts " + style 0-17 fg=bright-blue bold +8| "Enter send • Shift/Alt+Enter newline • Up/Down prompt history " + style 0-60 fg=bright-black +9| "Esc cancel active turn • Ctrl+O toggle tool cards • Ctrl+R toggle reasoning " + style 0-74 fg=bright-black +10| "Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit " + style 0-72 fg=bright-black +11| " " +12| "/clear — Clear the transcript view (session history is unchanged) " + style 0-64 fg=bright-black +13| "/exit — Exit after the active turn reaches idle " + style 0-46 fg=bright-black +14| "/help — Show keyboard shortcuts and commands " + style 0-43 fg=bright-black +15| "/model [[provider/]model] — Show or switch this session's model " + style 0-62 fg=bright-black +16| "/quit — Exit after the active turn reaches idle " + style 0-46 fg=bright-black +17| "/reasoning — Toggle reasoning blocks " + style 0-35 fg=bright-black +18| "/redraw — Invalidate components and redraw the terminal " + style 0-54 fg=bright-black +19| "/reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) " + style 0-87 fg=bright-black +20| "/resume — List this workspace's resumable sessions " + style 0-49 fg=bright-black +21| "/status — Show session diagnostics, system prompt, and registered tools " + style 0-70 fg=bright-black +22| "/tools — Expand or collapse all tool cards " + style 0-41 fg=bright-black +23| "/skill: [instructions] — load a skill into the conversation " + style 0-64 fg=bright-black 24| -25| " Unknown command: /unknown-advanced-command " - style 1-42 fg=yellow -26| "────────────────────────────────────────────────────────────────────────────────────────────" - style 0-91 dim -27| " " - style 1-1 inverse -28| "────────────────────────────────────────────────────────────────────────────────────────────" - style 0-91 dim -29| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" - style 0-43 dim - style 65-91 dim -30-31| +25| "provider stream failed after partial output " + style 0-42 fg=red +26| +27| "The previous process ended during this turn. " + style 0-43 fg=yellow +28| +29| "Turn stopped: the agent was disposed. " + style 0-36 fg=yellow +30| +31| "Turn ended: plugin-policy. " + style 0-25 fg=yellow +32| +33| "Unknown command: /unknown-advanced-command " + style 0-41 fg=yellow +34| +35| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-50 fg=bright-black + style 53-57 fg=bright-black + style 60-69 fg=bright-black +36| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +37| diff --git a/packages/ui/tui/tests/snapshots/dynamic-workflow-pending.expected.txt b/packages/ui/tui/tests/snapshots/dynamic-workflow-pending.expected.txt index ace55782e9..9826588b32 100644 --- a/packages/ui/tui/tests/snapshots/dynamic-workflow-pending.expected.txt +++ b/packages/ui/tui/tests/snapshots/dynamic-workflow-pending.expected.txt @@ -1,46 +1,40 @@ terminal 96x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=1 viewportRow=15 bufferRow=15 +cursor hidden column=7 viewportRow=17 bufferRow=17 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Snapshot agent ready." style 1-21 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=yellow -5| "▌ ◌ workflow: tui-matrix " - style 0-0 fg=yellow - style 2-2 fg=yellow bold - style 3-23 bold -6| "▌ phase('Inspect') " - style 0-0 fg=yellow -7| "▌ const reports = await parallel([ " - style 0-0 fg=yellow -8| "▌ () => agent('Audit layout', { label: 'layout', phase: 'Inspect' }), " - style 0-0 fg=yellow -9| "▌ … +1 lines (Ctrl+O to expand) " - style 0-0 fg=yellow - style 2-30 dim -10| "▌ ]) " - style 0-0 fg=yellow -11| "▌ phase('Verify') " - style 0-0 fg=yellow -12| "▌ return { reports, verdict: 'covered' } " - style 0-0 fg=yellow -13| "▌ " - style 0-0 fg=yellow -14| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -15| " " - style 1-1 inverse -16| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -17| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" - style 0-43 dim - style 69-95 dim +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| +6| "○ Tool / workflow" + style 0-16 fg=yellow +7| "workflow: tui-matrix " +8| "phase('Inspect') " +9| "const reports = await parallel([ " +10| "… +2 lines (Ctrl+O to expand) " + style 0-28 dim +11| "]) " +12| "phase('Verify') " +13| "return { reports, verdict: 'covered' } " +14| "Model wait 0.0s " + style 0-14 dim +15| +16| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-50 fg=bright-black + style 53-57 fg=bright-black + style 60-69 fg=bright-black +17| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse 18-35| diff --git a/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt b/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt index 05c35e32e1..f93a4b47da 100644 --- a/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt +++ b/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt @@ -1,63 +1,77 @@ -terminal 92x32 buffer=normal length=32 base=0 viewport=0 +terminal 92x32 buffer=normal length=37 base=5 viewport=5 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=1 viewportRow=27 bufferRow=27 +cursor hidden column=7 viewportRow=31 bufferRow=36 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Snapshot agent ready." style 1-21 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| " Keyboard shortcuts " - style 1-18 fg=bright-blue bold -5| " Enter send • Shift/Alt+Enter newline • Up/Down prompt history " - style 1-61 fg=bright-black -6| " Esc cancel active turn • Ctrl+O toggle tool cards • Ctrl+R toggle reasoning " - style 1-75 fg=bright-black -7| " Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit " - style 1-73 fg=bright-black -8| " " -9| " /clear — Clear the transcript view (session history is unchanged) " - style 1-65 fg=bright-black -10| " /exit — Exit after the active turn reaches idle " - style 1-47 fg=bright-black -11| " /help — Show keyboard shortcuts and commands " - style 1-44 fg=bright-black -12| " /model [[provider/]model] — Show or switch this session's model " - style 1-63 fg=bright-black -13| " /reasoning — Toggle reasoning blocks " - style 1-36 fg=bright-black -14| " /redraw — Invalidate components and redraw the terminal " - style 1-55 fg=bright-black -15| " /reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) " - style 1-88 fg=bright-black -16| " /resume — List this workspace's resumable sessions " - style 1-50 fg=bright-black -17| " /status — Show detailed session diagnostics " - style 1-43 fg=bright-black -18| " /tools — Expand or collapse all tool cards " - style 1-42 fg=bright-black -19| " /skill: [instructions] — load a skill into the conversation " - style 1-65 fg=bright-black -20| -21| " provider stream failed after partial output " - style 1-43 fg=red -22| -23| " The previous process ended during this turn. " - style 1-44 fg=yellow +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| "Model wait 0.0s · Completed 2026-07-21 15:05:00 " + style 0-46 dim +6| +7| "Keyboard shortcuts " + style 0-17 fg=bright-blue bold +8| "Enter send • Shift/Alt+Enter newline • Up/Down prompt history " + style 0-60 fg=bright-black +9| "Esc cancel active turn • Ctrl+O toggle tool cards • Ctrl+R toggle reasoning " + style 0-74 fg=bright-black +10| "Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit " + style 0-72 fg=bright-black +11| " " +12| "/clear — Clear the transcript view (session history is unchanged) " + style 0-64 fg=bright-black +13| "/exit — Exit after the active turn reaches idle " + style 0-46 fg=bright-black +14| "/help — Show keyboard shortcuts and commands " + style 0-43 fg=bright-black +15| "/model [[provider/]model] — Show or switch this session's model " + style 0-62 fg=bright-black +16| "/quit — Exit after the active turn reaches idle " + style 0-46 fg=bright-black +17| "/reasoning — Toggle reasoning blocks " + style 0-35 fg=bright-black +18| "/redraw — Invalidate components and redraw the terminal " + style 0-54 fg=bright-black +19| "/reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) " + style 0-87 fg=bright-black +20| "/resume — List this workspace's resumable sessions " + style 0-49 fg=bright-black +21| "/status — Show session diagnostics, system prompt, and registered tools " + style 0-70 fg=bright-black +22| "/tools — Expand or collapse all tool cards " + style 0-41 fg=bright-black +23| "/skill: [instructions] — load a skill into the conversation " + style 0-64 fg=bright-black 24| -25| " Unknown command: /unknown-advanced-command " - style 1-42 fg=yellow -26| "────────────────────────────────────────────────────────────────────────────────────────────" - style 0-91 dim -27| " " - style 1-1 inverse -28| "────────────────────────────────────────────────────────────────────────────────────────────" - style 0-91 dim -29| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" - style 0-43 dim - style 65-91 dim -30-31| +25| "provider stream failed after partial output " + style 0-42 fg=red +26| +27| "The previous process ended during this turn. " + style 0-43 fg=yellow +28| +29| "Turn stopped: the agent was disposed. " + style 0-36 fg=yellow +30| +31| "Turn ended: plugin-policy. " + style 0-25 fg=yellow +32| +33| "Unknown command: /unknown-advanced-command " + style 0-41 fg=yellow +34| +35| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-50 fg=bright-black + style 53-57 fg=bright-black + style 60-69 fg=bright-black +36| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse diff --git a/packages/ui/tui/tests/snapshots/file-autocomplete.expected.txt b/packages/ui/tui/tests/snapshots/file-autocomplete.expected.txt index 174f21a0f4..73b7c494c5 100644 --- a/packages/ui/tui/tests/snapshots/file-autocomplete.expected.txt +++ b/packages/ui/tui/tests/snapshots/file-autocomplete.expected.txt @@ -1,24 +1,31 @@ terminal 96x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=5 viewportRow=4 bufferRow=4 +cursor hidden column=11 viewportRow=8 bufferRow=8 viewport 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Snapshot agent ready." style 1-21 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim -3| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -4| " @tsc " - style 5-5 inverse -5| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -6| " → File · terminal-special-case.t src/terminal-special-case.ts " - style 1-32 fg=bright-blue -7| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" - style 0-43 dim - style 69-95 dim -8-35| +2| " main-session" + style 1-12 dim +3| +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| "Model wait 0.0s " + style 0-14 dim +6| +7| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-50 fg=bright-black + style 53-57 fg=bright-black + style 60-69 fg=bright-black +8| " dsh > @tsc " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 11-11 inverse +9| " → File · terminal-special-case.t src/terminal-special-case.ts " + style 7-38 fg=bright-blue +10-35| diff --git a/packages/ui/tui/tests/snapshots/model-effort-switching.expected.txt b/packages/ui/tui/tests/snapshots/model-effort-switching.expected.txt deleted file mode 100644 index cef660ff49..0000000000 --- a/packages/ui/tui/tests/snapshots/model-effort-switching.expected.txt +++ /dev/null @@ -1,42 +0,0 @@ -terminal 92x32 buffer=normal length=32 base=0 viewport=0 -lifecycle started=1 stopped=0 progress=inactive -title "DSH snapshot" -cursor hidden column=92 viewportRow=15 bufferRow=15 -buffer -0| " DEEPSEEK HARNESS" - style 1-8 fg=bright-blue bold - style 10-16 bold -1| " Snapshot agent ready." - style 1-21 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim -3| "────────────────────────────────────────────────────────────────────────────────────────────" - style 0-91 dim -4| " " - style 1-1 inverse -5| "────────────────────────────────────────────────────────────────────────────────────────────" - style 0-91 dim -6| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" - style 0-43 dim - style 65-91 dim -7-12| -13| " ╭ Select model ────────────────────────────────────────────────────────────╮ " - style 8-83 fg=bright-blue -14| " │ deepseek/deepseek-v4-flash DeepSeek V4 Flash — High — current │ " - style 8-8 fg=bright-blue - style 38-77 fg=bright-black - style 83-83 fg=bright-blue -15| " │ → deepseek/deepseek-v4-pro DeepSeek V4 Pro — provider default │ " - style 8-8 fg=bright-blue - style 10-77 fg=bright-blue inverse - style 83-83 fg=bright-blue -16| " │ │ " - style 8-8 fg=bright-blue - style 83-83 fg=bright-blue -17| " │ ↑/↓ navigate • Shift+Tab reasoning • Enter select • Esc cancel │ " - style 8-8 fg=bright-blue - style 10-71 dim - style 83-83 fg=bright-blue -18| " ╰──────────────────────────────────────────────────────────────────────────╯ " - style 8-83 fg=bright-blue -19-31| diff --git a/packages/ui/tui/tests/snapshots/model-selector.expected.txt b/packages/ui/tui/tests/snapshots/model-selector.expected.txt index c86b1f1f3d..df2dbf5e33 100644 --- a/packages/ui/tui/tests/snapshots/model-selector.expected.txt +++ b/packages/ui/tui/tests/snapshots/model-selector.expected.txt @@ -8,27 +8,34 @@ buffer style 10-16 bold 1| " Snapshot agent ready." style 1-21 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim -3| "────────────────────────────────────────────────────────────────────────────────────────────" - style 0-91 dim -4| " " - style 1-1 inverse -5| "────────────────────────────────────────────────────────────────────────────────────────────" - style 0-91 dim -6| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" - style 0-43 dim - style 65-91 dim -7-12| +2| " main-session" + style 1-12 dim +3| +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| "Model wait 0.0s " + style 0-14 dim +6| +7| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-50 fg=bright-black + style 53-57 fg=bright-black + style 60-69 fg=bright-black +8| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +9-12| 13| " ╭ Select model ────────────────────────────────────────────────────────────╮ " style 8-83 fg=bright-blue -14| " │ → deepseek/deepseek-v4-flash DeepSeek V4 Flash — High — current │ " +14| " │ → deepseek/deepseek-v4-flash DeepSeek V4 Flash — current │ " style 8-8 fg=bright-blue - style 10-77 fg=bright-blue inverse + style 10-70 fg=bright-blue inverse style 83-83 fg=bright-blue -15| " │ deepseek/deepseek-v4-pro DeepSeek V4 Pro — provider default │ " +15| " │ deepseek/deepseek-v4-pro DeepSeek V4 Pro │ " style 8-8 fg=bright-blue - style 36-77 fg=bright-black + style 36-58 fg=bright-black style 83-83 fg=bright-blue 16| " │ │ " style 8-8 fg=bright-blue diff --git a/packages/ui/tui/tests/snapshots/model-switching.expected.txt b/packages/ui/tui/tests/snapshots/model-switching.expected.txt index 4e711e37c9..9bba7a9b14 100644 --- a/packages/ui/tui/tests/snapshots/model-switching.expected.txt +++ b/packages/ui/tui/tests/snapshots/model-switching.expected.txt @@ -1,27 +1,32 @@ terminal 92x32 buffer=normal length=32 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=1 viewportRow=7 bufferRow=7 +cursor hidden column=7 viewportRow=10 bufferRow=10 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Snapshot agent ready." style 1-21 fg=bright-black -2| " deepseek-v4-pro • main-session" - style 1-32 dim +2| " main-session" + style 1-12 dim 3| -4| " Model selected: deepseek/deepseek-v4-pro. Reasoning effort: provider default. New steps " - style 1-91 fg=bright-black -5| " will use it. " - style 1-12 fg=bright-black -6| "────────────────────────────────────────────────────────────────────────────────────────────" - style 0-91 dim -7| " " - style 1-1 inverse -8| "────────────────────────────────────────────────────────────────────────────────────────────" - style 0-91 dim -9| "deepseek-v4-pro /workspace/project ↑0 ↓0 0% context tools:collapsed" - style 0-41 dim - style 65-91 dim -10-31| +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| "Model wait 0.0s " + style 0-14 dim +6| +7| "Model selected: deepseek/deepseek-v4-pro. New steps will use it. " + style 0-63 fg=bright-black +8| +9| "/workspace/project (tui-staging) deepseek-v4-pro ↑0 ↓0 0% context" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-48 fg=bright-black + style 51-55 fg=bright-black + style 58-67 fg=bright-black +10| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +11-31| diff --git a/packages/ui/tui/tests/snapshots/question-dialog-single-option.expected.txt b/packages/ui/tui/tests/snapshots/question-dialog-single-option.expected.txt new file mode 100644 index 0000000000..02d1ff0c97 --- /dev/null +++ b/packages/ui/tui/tests/snapshots/question-dialog-single-option.expected.txt @@ -0,0 +1,40 @@ +terminal 56x20 buffer=normal length=20 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=0 viewportRow=19 bufferRow=19 +viewport +0| " DEEPSEEK HARNESS" + style 1-8 fg=bright-blue bold + style 10-16 bold +1| " Snapshot agent ready." + style 1-21 fg=bright-black +2| " main-session" + style 1-12 dim +3| +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| "Model wait 0.0s " + style 0-14 dim +6| +7| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 " + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-50 fg=bright-black + style 53-55 fg=bright-black +8| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +9-11| +12| " " +13| " Question 1/1 (1 unanswered) · Confirm " + style 2-38 fg=bright-black +14| " Continue with this change? " +15| " " +16| " › 1. Proceed Apply the proposed change " + style 2-13 fg=bright-blue bold + style 16-40 fg=bright-black +17| " Tab custom answer • Enter submit • Esc interrupt " + style 2-49 dim +18| " " +19| diff --git a/packages/ui/tui/tests/snapshots/question-dialog-validation.expected.txt b/packages/ui/tui/tests/snapshots/question-dialog-validation.expected.txt index 4e17a0e652..c5787378e0 100644 --- a/packages/ui/tui/tests/snapshots/question-dialog-validation.expected.txt +++ b/packages/ui/tui/tests/snapshots/question-dialog-validation.expected.txt @@ -8,12 +8,11 @@ viewport style 10-16 bold 1| " Snapshot agent ready." style 1-21 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim -3| "────────────────────────────────────────────────────────" - style 0-55 dim -4| " " - style 1-1 inverse +2| " main-session" + style 1-12 dim +3| +4| "Assistant " + style 0-8 fg=bright-magenta bold underline 5| " " 6| " Question 1/3 (3 unanswered) · Coverage " style 2-39 fg=bright-black diff --git a/packages/ui/tui/tests/snapshots/question-dialog.expected.txt b/packages/ui/tui/tests/snapshots/question-dialog.expected.txt index 220f5dc1c7..611b8262d4 100644 --- a/packages/ui/tui/tests/snapshots/question-dialog.expected.txt +++ b/packages/ui/tui/tests/snapshots/question-dialog.expected.txt @@ -8,17 +8,14 @@ viewport style 10-16 bold 1| " Snapshot agent ready." style 1-21 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim -3| "────────────────────────────────────────────────────────" - style 0-55 dim -4| " " - style 1-1 inverse -5| "────────────────────────────────────────────────────────" - style 0-55 dim -6| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context" - style 0-43 dim - style 46-55 dim +2| " main-session" + style 1-12 dim +3| +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| "Model wait 0.0s " + style 0-14 dim +6| 7| " " 8| " Question 1/3 (3 unanswered) · Coverage " style 2-39 fg=bright-black diff --git a/packages/ui/tui/tests/snapshots/retry-cancelled.expected.txt b/packages/ui/tui/tests/snapshots/retry-cancelled.expected.txt index accef4fffc..9f6b5e8c4f 100644 --- a/packages/ui/tui/tests/snapshots/retry-cancelled.expected.txt +++ b/packages/ui/tui/tests/snapshots/retry-cancelled.expected.txt @@ -1,38 +1,34 @@ terminal 96x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=1 viewportRow=13 bufferRow=13 +cursor hidden column=7 viewportRow=12 bufferRow=12 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Snapshot agent ready." style 1-21 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| "▌ Start then cancel. " - style 0-0 fg=bright-blue -7| "▌ " - style 0-0 fg=bright-blue +4| "You " + style 0-2 fg=bright-blue bold underline +5| "Start then cancel. " +6| +7| "Retrying model request (1/∞) in 1000ms: temporary transport failure " + style 0-66 fg=yellow 8| -9| " Retrying model request (1/2) in 1000ms: temporary transport failure " - style 1-67 fg=yellow +9| "Turn cancelled. " + style 0-14 fg=yellow 10| -11| " Turn cancelled. " - style 1-15 fg=yellow -12| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -13| " " - style 1-1 inverse -14| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -15| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" - style 0-43 dim - style 69-95 dim -16-35| +11| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-50 fg=bright-black + style 53-57 fg=bright-black + style 60-69 fg=bright-black +12| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +13-35| diff --git a/packages/ui/tui/tests/snapshots/retry-exhausted.expected.txt b/packages/ui/tui/tests/snapshots/retry-exhausted.expected.txt index 1b038d5a83..c642694132 100644 --- a/packages/ui/tui/tests/snapshots/retry-exhausted.expected.txt +++ b/packages/ui/tui/tests/snapshots/retry-exhausted.expected.txt @@ -1,35 +1,31 @@ terminal 96x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=1 viewportRow=11 bufferRow=11 +cursor hidden column=7 viewportRow=10 bufferRow=10 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Snapshot agent ready." style 1-21 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| "▌ Let the bounded policy exhaust. " - style 0-0 fg=bright-blue -7| "▌ " - style 0-0 fg=bright-blue +4| "You " + style 0-2 fg=bright-blue bold underline +5| "Let the bounded policy exhaust. " +6| +7| "provider still unavailable " + style 0-25 fg=red 8| -9| " provider still unavailable " - style 1-26 fg=red -10| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -11| " " - style 1-1 inverse -12| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -13| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" - style 0-43 dim - style 69-95 dim -14-35| +9| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-50 fg=bright-black + style 53-57 fg=bright-black + style 60-69 fg=bright-black +10| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +11-35| diff --git a/packages/ui/tui/tests/snapshots/retry-recovered.expected.txt b/packages/ui/tui/tests/snapshots/retry-recovered.expected.txt index c0ae86361c..4144bac651 100644 --- a/packages/ui/tui/tests/snapshots/retry-recovered.expected.txt +++ b/packages/ui/tui/tests/snapshots/retry-recovered.expected.txt @@ -1,39 +1,31 @@ terminal 96x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=1 viewportRow=14 bufferRow=14 +cursor hidden column=7 viewportRow=10 bufferRow=10 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Snapshot agent ready." style 1-21 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| "▌ Recover this request. " - style 0-0 fg=bright-blue -7| "▌ " - style 0-0 fg=bright-blue +4| "You " + style 0-2 fg=bright-blue bold underline +5| "Recover this request. " +6| +7| "Retrying model request (1/2) in 500ms: provider rate limit " + style 0-57 fg=yellow 8| -9| " Retrying model request (1/2) in 500ms: provider rate limit " - style 1-58 fg=yellow -10| -11| " Assistant " - style 1-9 fg=bright-magenta bold -12| " Recovered on the next bounded attempt. " -13| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -14| " " - style 1-1 inverse -15| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -16| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" - style 0-43 dim - style 69-95 dim -17-35| +9| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-50 fg=bright-black + style 53-57 fg=bright-black + style 60-69 fg=bright-black +10| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +11-35| diff --git a/packages/ui/tui/tests/snapshots/retry-scheduled.expected.txt b/packages/ui/tui/tests/snapshots/retry-scheduled.expected.txt index 16dee8242a..4144bac651 100644 --- a/packages/ui/tui/tests/snapshots/retry-scheduled.expected.txt +++ b/packages/ui/tui/tests/snapshots/retry-scheduled.expected.txt @@ -1,35 +1,31 @@ terminal 96x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=1 viewportRow=11 bufferRow=11 +cursor hidden column=7 viewportRow=10 bufferRow=10 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Snapshot agent ready." style 1-21 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| "▌ Recover this request. " - style 0-0 fg=bright-blue -7| "▌ " - style 0-0 fg=bright-blue +4| "You " + style 0-2 fg=bright-blue bold underline +5| "Recover this request. " +6| +7| "Retrying model request (1/2) in 500ms: provider rate limit " + style 0-57 fg=yellow 8| -9| " Retrying model request (1/2) in 500ms: provider rate limit " - style 1-58 fg=yellow -10| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -11| " " - style 1-1 inverse -12| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -13| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" - style 0-43 dim - style 69-95 dim -14-35| +9| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-50 fg=bright-black + style 53-57 fg=bright-black + style 60-69 fg=bright-black +10| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +11-35| diff --git a/packages/ui/tui/tests/snapshots/session-reference.expected.txt b/packages/ui/tui/tests/snapshots/session-reference.expected.txt index 3cd62dd243..3e0d12f045 100644 --- a/packages/ui/tui/tests/snapshots/session-reference.expected.txt +++ b/packages/ui/tui/tests/snapshots/session-reference.expected.txt @@ -1,39 +1,35 @@ terminal 96x24 buffer=normal length=24 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH session reference" -cursor hidden column=1 viewportRow=14 bufferRow=14 +cursor hidden column=7 viewportRow=14 bufferRow=14 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Session reference snapshot." style 1-27 fg=bright-black -2| " mock • target-session" - style 1-23 dim +2| " target-session" + style 1-14 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 @Source session " - 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 @Source session " +6| +7| "Referenced sessions · Source session (source-session) " + style 0-52 dim 8| -9| " Referenced sessions · Source session (source-session) " - style 1-53 dim -10| -11| " Assistant " - style 1-9 fg=bright-magenta bold -12| " Combined reference request accepted. " -13| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -14| " " - style 1-1 inverse -15| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -16| "mock /workspace/project ↑0 ↓0 tools:collapsed" - style 0-30 dim - style 81-95 dim -17-23| +9| "Assistant " + style 0-8 fg=bright-magenta bold underline +10| "Combined reference request accepted. " +11| "Model wait 0.0s · Completed 2026-07-21 12:30:00 " + style 0-46 dim +12| +13| "/workspace/project mock ↑0 ↓0" + style 0-17 fg=bright-blue bold + style 20-23 fg=bright-black + style 26-30 fg=bright-black +14| " dsh ◍ " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +15-23| diff --git a/packages/ui/tui/tests/snapshots/shell-prompt-multiline.expected.txt b/packages/ui/tui/tests/snapshots/shell-prompt-multiline.expected.txt new file mode 100644 index 0000000000..3b47364308 --- /dev/null +++ b/packages/ui/tui/tests/snapshots/shell-prompt-multiline.expected.txt @@ -0,0 +1,31 @@ +terminal 44x18 buffer=normal length=18 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=38 viewportRow=13 bufferRow=13 +viewport +0| " DEEPSEEK HARNESS" + style 1-8 fg=bright-blue bold + style 10-16 bold +1| " Snapshot agent ready." + style 1-21 fg=bright-black +2| " main-session" + style 1-12 dim +3| +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| "Model wait 0.0s " + style 0-14 dim +6| +7| "/workspace/project (tui-staging) deepseek-v" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-43 fg=bright-black +8| " ↑ 1 more " + style 1-14 dim +9| " enough detail to wrap across multiple " +10| " full-width continuation rows without " +11| " leaving a prompt-sized gap at the right " +12| " edge. " +13| " Then suggest a simpler version. " + style 38-38 inverse +14-17| diff --git a/packages/ui/tui/tests/snapshots/status-diagnostics-narrow.expected.txt b/packages/ui/tui/tests/snapshots/status-diagnostics-narrow.expected.txt index 4937592cc7..a22787ea49 100644 --- a/packages/ui/tui/tests/snapshots/status-diagnostics-narrow.expected.txt +++ b/packages/ui/tui/tests/snapshots/status-diagnostics-narrow.expected.txt @@ -1,110 +1,120 @@ -terminal 56x36 buffer=normal length=36 base=0 viewport=0 +terminal 56x36 buffer=normal length=44 base=8 viewport=8 lifecycle started=1 stopped=0 progress=inactive title "Inspect session diagnostics — DSH snapshot" -cursor hidden column=1 viewportRow=32 bufferRow=32 +cursor hidden column=7 viewportRow=35 bufferRow=43 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Inspect session diagnostics" style 1-27 fg=bright-black -2| " deepseek-v4-pro • main-session" - style 1-32 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| "▌ inspect this session " - style 0-0 fg=bright-blue -7| "▌ " - style 0-0 fg=bright-blue -8| -9| " Assistant " - style 1-9 fg=bright-magenta bold -10| " Session inspected. " -11| -12| "╭─ Session status ─────────────────────────────────────╮" +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| "Session inspected. " +6| "Model wait 0.0s " + style 0-14 dim +7| +8| "You " + style 0-2 fg=bright-blue bold underline +9| "inspect this session " +10| +11| "╭─ Session status ─────────────────────────────────────╮" style 0-2 dim style 3-16 fg=bright-blue bold style 17-55 dim -13| "│ Session: main-session │" +12| "│ Session: main-session │" style 0-0 dim style 3-12 fg=bright-black style 55-55 dim -14| "│ Title: Inspect session diagnostics │" +13| "│ Title: Inspect session diagnostics │" style 0-0 dim style 3-12 fg=bright-black style 55-55 dim -15| "│ Directory: /workspace/project │" +14| "│ Directory: /workspace/project │" style 0-0 dim style 3-12 fg=bright-black style 55-55 dim -16| "│ Model: deepseek/deepseek-v4-pro (effort │" +15| "│ Model: deepseek/deepseek-v4-pro (effort │" style 0-0 dim style 3-12 fg=bright-black style 40-55 dim -17| "│ default; reasoning blocks shown) │" +16| "│ default; reasoning blocks shown) │" style 0-0 dim style 15-46 dim style 55-55 dim -18| "│ │" +17| "│ │" style 0-0 dim style 55-55 dim -19| "│ Agent: idle · 6 events · 1 turn · 1 step · 1 │" +18| "│ Agent: idle · 6 events · 1 turn · 1 step · 1 │" style 0-0 dim style 3-12 fg=bright-black style 55-55 dim -20| "│ tool call │" +19| "│ tool call │" style 0-0 dim style 55-55 dim -21| "│ │" +20| "│ │" style 0-0 dim style 55-55 dim -22| "│ Tokens: 1,250 input + 340 output │" +21| "│ Tokens: 1,250 input + 340 output │" style 0-0 dim style 3-12 fg=bright-black style 55-55 dim -23| "│ KV cache: [███████████░░░░░] 67% hit (3,000 read │" +22| "│ KV cache: [███████████░░░░░] 67% hit (3,000 read │" style 0-0 dim style 3-12 fg=bright-black style 15-15 dim style 16-26 fg=bright-blue style 27-32 dim style 55-55 dim -24| "│ + 250 write) │" +23| "│ + 250 write) │" style 0-0 dim style 55-55 dim -25| "│ Context: [█████░░░░░░░░░░░] 33% used (42,000 / │" +24| "│ Context: [█████░░░░░░░░░░░] 33% used (42,000 / │" style 0-0 dim style 3-12 fg=bright-black style 15-15 dim style 16-20 fg=bright-blue style 21-32 dim style 55-55 dim -26| "│ 128,000) │" +25| "│ 128,000) │" style 0-0 dim style 55-55 dim -27| "│ │" +26| "│ │" style 0-0 dim style 55-55 dim -28| "│ Created: 2026-07-22 09:10:11 UTC │" +27| "│ Created: 2026-07-22 09:10:11 UTC │" style 0-0 dim style 3-12 fg=bright-black style 55-55 dim -29| "│ Active: 2026-07-22 09:10:11 UTC │" +28| "│ Active: 2026-07-22 09:10:11 UTC │" style 0-0 dim style 3-12 fg=bright-black style 55-55 dim -30| "╰──────────────────────────────────────────────────────╯" +29| "╰──────────────────────────────────────────────────────╯" style 0-55 dim -31| "────────────────────────────────────────────────────────" - style 0-55 dim -32| " " - style 1-1 inverse -33| "────────────────────────────────────────────────────────" - style 0-55 dim -34| "deepseek-v4-pro /workspace/project ↑1.3k ↓340 cache 6" - style 0-55 dim -35| +30| +31| "System prompt " + style 0-12 fg=bright-blue bold +32| "You are an AI agent powered by the DeepSeek Harness SDK." +33| " " +34| "Paths prefixed with @ are files explicitly referenced by" +35| "the user. Use the read tool when their contents are " +36| "needed; do not claim to have inspected a file before " +37| "reading it. " +38| +39| "Registered tools " + style 0-15 fg=bright-blue bold +40| "read, write " +41| +42| "/workspace/project (tui-staging) deepseek-v4-pro ↑1.3k" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-48 fg=bright-black + style 51-55 fg=bright-black +43| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse diff --git a/packages/ui/tui/tests/snapshots/status-diagnostics.expected.txt b/packages/ui/tui/tests/snapshots/status-diagnostics.expected.txt index 915d4e58ef..d8fb37bac4 100644 --- a/packages/ui/tui/tests/snapshots/status-diagnostics.expected.txt +++ b/packages/ui/tui/tests/snapshots/status-diagnostics.expected.txt @@ -1,99 +1,107 @@ -terminal 92x32 buffer=normal length=32 base=0 viewport=0 +terminal 92x32 buffer=normal length=38 base=6 viewport=6 lifecycle started=1 stopped=0 progress=inactive title "Inspect session diagnostics — DSH snapshot" -cursor hidden column=1 viewportRow=28 bufferRow=28 +cursor hidden column=7 viewportRow=31 bufferRow=37 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Inspect session diagnostics" style 1-27 fg=bright-black -2| " deepseek-v4-pro • main-session" - style 1-32 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| "▌ inspect this session " - style 0-0 fg=bright-blue -7| "▌ " - style 0-0 fg=bright-blue -8| -9| " Assistant " - style 1-9 fg=bright-magenta bold -10| " Session inspected. " -11| -12| "╭─ Session status ───────────────────────────────────────────────────────────────╮" +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| "Session inspected. " +6| "Model wait 0.0s " + style 0-14 dim +7| +8| "You " + style 0-2 fg=bright-blue bold underline +9| "inspect this session " +10| +11| "╭─ Session status ───────────────────────────────────────────────────────────────╮" style 0-2 dim style 3-16 fg=bright-blue bold style 17-81 dim -13| "│ Session: main-session │" +12| "│ Session: main-session │" style 0-0 dim style 3-12 fg=bright-black style 81-81 dim -14| "│ Title: Inspect session diagnostics │" +13| "│ Title: Inspect session diagnostics │" style 0-0 dim style 3-12 fg=bright-black style 81-81 dim -15| "│ Directory: /workspace/project │" +14| "│ Directory: /workspace/project │" style 0-0 dim style 3-12 fg=bright-black style 81-81 dim -16| "│ Model: deepseek/deepseek-v4-pro (effort default; reasoning blocks shown) │" +15| "│ Model: deepseek/deepseek-v4-pro (effort default; reasoning blocks shown) │" style 0-0 dim style 3-12 fg=bright-black style 40-79 dim style 81-81 dim -17| "│ │" +16| "│ │" style 0-0 dim style 81-81 dim -18| "│ Agent: idle · 6 events · 1 turn · 1 step · 1 tool call │" +17| "│ Agent: idle · 6 events · 1 turn · 1 step · 1 tool call │" style 0-0 dim style 3-12 fg=bright-black style 81-81 dim -19| "│ │" +18| "│ │" style 0-0 dim style 81-81 dim -20| "│ Tokens: 1,250 input + 340 output │" +19| "│ Tokens: 1,250 input + 340 output │" style 0-0 dim style 3-12 fg=bright-black style 81-81 dim -21| "│ KV cache: [███████████░░░░░] 67% hit (3,000 read + 250 write) │" +20| "│ KV cache: [███████████░░░░░] 67% hit (3,000 read + 250 write) │" style 0-0 dim style 3-12 fg=bright-black style 15-15 dim style 16-26 fg=bright-blue style 27-32 dim style 81-81 dim -22| "│ Context: [█████░░░░░░░░░░░] 33% used (42,000 / 128,000) │" +21| "│ Context: [█████░░░░░░░░░░░] 33% used (42,000 / 128,000) │" style 0-0 dim style 3-12 fg=bright-black style 15-15 dim style 16-20 fg=bright-blue style 21-32 dim style 81-81 dim -23| "│ │" +22| "│ │" style 0-0 dim style 81-81 dim -24| "│ Created: 2026-07-22 09:10:11 UTC │" +23| "│ Created: 2026-07-22 09:10:11 UTC │" style 0-0 dim style 3-12 fg=bright-black style 81-81 dim -25| "│ Active: 2026-07-22 09:10:11 UTC │" +24| "│ Active: 2026-07-22 09:10:11 UTC │" style 0-0 dim style 3-12 fg=bright-black style 81-81 dim -26| "╰────────────────────────────────────────────────────────────────────────────────╯" +25| "╰────────────────────────────────────────────────────────────────────────────────╯" style 0-81 dim -27| "────────────────────────────────────────────────────────────────────────────────────────────" - style 0-91 dim -28| " " - style 1-1 inverse -29| "────────────────────────────────────────────────────────────────────────────────────────────" - style 0-91 dim -30| "deepseek-v4-pro /workspace/project ↑1.3k ↓340 cache 67% 33% context tools:collapsed" - style 0-57 dim - style 64-91 dim -31| +26| +27| "System prompt " + style 0-12 fg=bright-blue bold +28| "You are an AI agent powered by the DeepSeek Harness SDK. " +29| " " +30| "Paths prefixed with @ are files explicitly referenced by the user. Use the read tool when " +31| "their contents are needed; do not claim to have inspected a file before reading it. " +32| +33| "Registered tools " + style 0-15 fg=bright-blue bold +34| "read, write " +35| +36| "/workspace/project (tui-staging) deepseek-v4-pro ↑1.3k ↓340 cache 67% 33% context" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-48 fg=bright-black + style 51-71 fg=bright-black + style 74-84 fg=bright-black +37| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse diff --git a/packages/ui/tui/tests/snapshots/step-timing-completed.expected.txt b/packages/ui/tui/tests/snapshots/step-timing-completed.expected.txt new file mode 100644 index 0000000000..728d7116b5 --- /dev/null +++ b/packages/ui/tui/tests/snapshots/step-timing-completed.expected.txt @@ -0,0 +1,34 @@ +terminal 96x36 buffer=normal length=36 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=7 viewportRow=11 bufferRow=11 +buffer +0| " DEEPSEEK HARNESS" + style 1-8 fg=bright-blue bold + style 10-16 bold +1| " Snapshot agent ready." + style 1-21 fg=bright-black +2| " main-session" + style 1-12 dim +3| +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| "Reasoning " + style 0-8 fg=bright-black italic +6| "Checking the result. " + style 0-19 fg=bright-black italic +7| "The result is ready. " +8| "Model wait 1.0s · Thinking 2.0s · Response 3.0s · Completed 2026-07-21 14:32:12 " + style 0-78 dim +9| +10| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-50 fg=bright-black + style 53-57 fg=bright-black + style 60-69 fg=bright-black +11| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +12-35| diff --git a/packages/ui/tui/tests/snapshots/surface-after-compaction-narrow.expected.txt b/packages/ui/tui/tests/snapshots/surface-after-compaction-narrow.expected.txt index cb629bbccd..4eeb12de71 100644 --- a/packages/ui/tui/tests/snapshots/surface-after-compaction-narrow.expected.txt +++ b/packages/ui/tui/tests/snapshots/surface-after-compaction-narrow.expected.txt @@ -1,30 +1,36 @@ terminal 44x18 buffer=normal length=18 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=1 viewportRow=9 bufferRow=9 +cursor hidden column=7 viewportRow=15 bufferRow=15 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Snapshot agent ready." style 1-21 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| " Context · compact " - style 1-17 dim -5| " Compacted summary: the prior command " - style 1-43 fg=bright-black -6| " completed and its details were retired " - style 1-43 fg=bright-black -7| " from the active surface. " - style 1-24 fg=bright-black -8| "────────────────────────────────────────────" - style 0-43 dim -9| " " - style 1-1 inverse -10| "────────────────────────────────────────────" - style 0-43 dim -11| "deepseek-v4-flash /workspace/project ↑0 ↓0" - style 0-43 dim -12-17| +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| "Model wait 0.0s " + style 0-14 dim +6| +7| "Context · workspace-context " + style 0-26 dim +8| "system-reminder " + style 0-14 fg=bright-black +9| " Additional instructions from: " +10| "nested/AGENTS.md " +11| " " +12| " Render workspace context XML clearly. " +13| +14| "/workspace/project (tui-staging) deepseek-v" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-43 fg=bright-black +15| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +16-17| diff --git a/packages/ui/tui/tests/snapshots/surface-after-compaction-wide.expected.txt b/packages/ui/tui/tests/snapshots/surface-after-compaction-wide.expected.txt index 6acf1e0483..74ca972da9 100644 --- a/packages/ui/tui/tests/snapshots/surface-after-compaction-wide.expected.txt +++ b/packages/ui/tui/tests/snapshots/surface-after-compaction-wide.expected.txt @@ -1,27 +1,37 @@ terminal 104x30 buffer=normal length=30 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=1 viewportRow=7 bufferRow=7 +cursor hidden column=7 viewportRow=14 bufferRow=14 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Snapshot agent ready." style 1-21 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| " Context · compact " - style 1-17 dim -5| " Compacted summary: the prior command completed and its details were retired from the active surface. " - style 1-100 fg=bright-black -6| "────────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-103 dim -7| " " - style 1-1 inverse -8| "────────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-103 dim -9| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" - style 0-43 dim - style 77-103 dim -10-29| +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| "Model wait 0.0s " + style 0-14 dim +6| +7| "Context · workspace-context " + style 0-26 dim +8| "system-reminder " + style 0-14 fg=bright-black +9| " Additional instructions from: nested/AGENTS.md " +10| " " +11| " Render workspace context XML clearly. " +12| +13| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-50 fg=bright-black + style 53-57 fg=bright-black + style 60-69 fg=bright-black +14| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +15-29| diff --git a/packages/ui/tui/tests/snapshots/surface-before-compaction.expected.txt b/packages/ui/tui/tests/snapshots/surface-before-compaction.expected.txt index e69fb478b0..54f04ef787 100644 --- a/packages/ui/tui/tests/snapshots/surface-before-compaction.expected.txt +++ b/packages/ui/tui/tests/snapshots/surface-before-compaction.expected.txt @@ -1,59 +1,47 @@ terminal 80x24 buffer=normal length=24 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=1 viewportRow=20 bufferRow=20 +cursor hidden column=7 viewportRow=20 bufferRow=20 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Snapshot agent ready." style 1-21 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| "▌ Old prompt with a long line that exercises wrapping before compaction. " - style 0-0 fg=bright-blue -7| "▌ " - style 0-0 fg=bright-blue +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| +6| "You " + style 0-2 fg=bright-blue bold underline +7| "Old prompt with a long line that exercises wrapping before compaction. " 8| -9| "▌ " - style 0-0 fg=green -10| "▌ ✓ pnpm run test:coverage " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-25 bold -11| "▌ Run the coverage gate " - style 0-0 fg=green - style 2-22 fg=bright-black -12| "▌ /workspace/project " - style 0-0 fg=green - style 2-19 dim -13| "▌ packages/ui/tui 100% " - style 0-0 fg=green -14| "▌ … +1 lines (Ctrl+O to expand) " - style 0-0 fg=green - style 2-30 dim -15| "▌ 1 test skipped " - style 0-0 fg=green -16| "▌ coverage complete " - style 0-0 fg=green -17| "▌ [exit 0] " - style 0-0 fg=green - style 2-9 dim -18| "▌ " - style 0-0 fg=green -19| "────────────────────────────────────────────────────────────────────────────────" - style 0-79 dim -20| " " - style 1-1 inverse -21| "────────────────────────────────────────────────────────────────────────────────" - style 0-79 dim -22| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" - style 0-43 dim - style 53-79 dim -23| +9| "● Tool / bash / Run the coverage gate" + style 0-36 fg=green +10| "$ pnpm run test:coverage " + style 0-23 fg=cyan +11| "/workspace/project " + style 0-17 dim +12| "packages/ui/tui 100% " +13| "… +1 lines (Ctrl+O to expand) " + style 0-28 dim +14| "1 test skipped " +15| "coverage complete " +16| "[exit 0] " + style 0-7 dim +17| "Model wait 0.0s " + style 0-14 dim +18| +19| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-50 fg=bright-black + style 53-57 fg=bright-black + style 60-69 fg=bright-black +20| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +21-23| diff --git a/packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt b/packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt index d476aa5c1d..506b89b0ba 100644 --- a/packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt +++ b/packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt @@ -1,74 +1,66 @@ -terminal 100x34 buffer=normal length=36 base=2 viewport=2 +terminal 100x34 buffer=normal length=34 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "Unsafe terminal title \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m" -cursor hidden column=100 viewportRow=33 bufferRow=35 +cursor hidden column=0 viewportRow=33 bufferRow=33 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Unsafe welcome \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m" style 1-60 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| "▌ Unsafe user \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " - style 0-0 fg=bright-blue -7| "▌ " - style 0-0 fg=bright-blue +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| +6| "You " + style 0-2 fg=bright-blue bold underline +7| "Unsafe user \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " 8| -9| " Reasoning " - style 1-9 fg=bright-black italic -10| " Unsafe reasoning \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " - style 1-62 fg=bright-black italic -11| -12| " Assistant " - style 1-9 fg=bright-magenta bold -13| " Unsafe assistant \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " -14| -15| "▌ " - style 0-0 fg=green -16| "▌ ✓ Unsafe title \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-61 bold -17| "▌ Unsafe description \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " - style 0-0 fg=green - style 2-65 fg=bright-black -18| "▌ /unsafe/\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " - style 0-0 fg=green - style 2-54 dim -19| "▌ Unsafe output \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " - style 0-0 fg=green -20| "▌ [signal SIG\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m] " - style 0-0 fg=green - style 2-58 fg=red -21| "▌ " - style 0-0 fg=green -22| -23| " Context · unsafe-\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " - style 1-62 dim -24| " Unsafe context \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " - style 1-60 fg=bright-black -25| -26| " Unsafe turn error \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " - style 1-63 fg=red -27| -28| " " -29| " Question 1/1 (1 unanswered) · Unsafe header \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " +9| "● Tool / unsafe / Unsafe description \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m" + style 0-81 fg=green +10| "$ Unsafe title \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " + style 0-59 fg=cyan +11| "/unsafe/\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " + style 0-52 dim +12| "Unsafe output \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " +13| "[signal SIG\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m] " + style 0-56 fg=red +14| "Model wait 0.0s · Completed 2026-07-21 15:00:00 " + style 0-46 dim +15| +16| "Context · unsafe-\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " + style 0-61 dim +17| "Unsafe context \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " + style 0-59 fg=bright-black +18| +19| "Unsafe turn error \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " + style 0-62 fg=red +20-21| +22| "Plan" + style 0-3 fg=bright-blue bold +23| " ● Unsafe todo \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m" + style 2-2 fg=yellow +24| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-50 fg=bright-black + style 53-57 fg=bright-black + style 60-69 fg=bright-black +25| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +26| " " +27| " Question 1/1 (1 unanswered) · Unsafe header \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " style 2-90 fg=bright-black -30| " Unsafe question \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " -31| " " -32| " › 1. Unsafe option \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m Unsafe detail \\x1b]2;snapshot-c " +28| " Unsafe question \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " +29| " " +30| " › 1. Unsafe option \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m Unsafe detail \\x1b]2;snapshot-c " style 2-65 fg=bright-blue bold style 67-97 fg=bright-black -33| " Tab custom answer • ↑/↓ navigate • Enter submit • Esc interrupt " - style 2-64 dim -34| " " -35| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" - style 0-43 dim - style 73-99 dim +31| " Tab custom answer • Enter submit • Esc interrupt " + style 2-49 dim +32| " " +33| diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index defae89c3c..d302ebb081 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url' import { afterAll, describe, expect, it, vi } from 'vitest' import type { Context } from 'cordis' import { agentEvents } from '@deepseek-ai/dsh-agent' -import { CallId, ReasoningEffortId, type ContentBlock } from '@deepseek-ai/dsh-llm' +import { CallId, type ContentBlock } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-llm-retry' import { SessionId, type JsonValue, type Session } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' @@ -27,6 +27,8 @@ const REFRESHING = process.env.DSH_SNAPSHOT === 'refresh' const CHECKPOINTS = [ 'conversation-streaming', + 'shell-prompt-multiline', + 'step-timing-completed', 'retry-scheduled', 'retry-recovered', 'retry-cancelled', @@ -40,12 +42,12 @@ const CHECKPOINTS = [ 'advanced-cards-expanded', 'untrusted-controls', 'question-dialog', + 'question-dialog-single-option', 'question-dialog-validation', 'surface-before-compaction', 'surface-after-compaction-narrow', 'surface-after-compaction-wide', 'model-selector', - 'model-effort-switching', 'model-switching', 'errors-and-help', 'disposed-terminal', @@ -104,7 +106,7 @@ async function setupSnapshot( cwd: options.cwd === undefined ? '/workspace/project' : options.cwd, config: Object.assign({ welcome: 'Snapshot agent ready.', - color: true, + theme: { color: true }, title: 'DSH snapshot', }, options.config), }) @@ -195,13 +197,12 @@ const ADVANCED_CARD_TOOLS: Record = { ), edit: visualTool( 'edit', - () => ({ card: 'diff', title: 'Edit renderer', diffs: [{ path: 'src/view.ts', oldText: 'old line', newText: 'new line' }] }), + () => ({ card: 'diff', title: 'Edit src/view.ts', diffs: [{ path: 'src/view.ts', oldText: 'old line', newText: 'new line' }] }), + // The real edit/write tools produce exactly one diff whose path the title + // already names, so the card omits the redundant per-file header. (): ToolResultView => ({ card: 'diff', - diffs: [ - { path: 'src/view.ts', oldText: 'old line\nkeep', newText: 'new line\nkeep' }, - { path: 'tests/view.spec.ts', oldText: null, newText: 'expect(screen).toMatchSnapshot()' }, - ], + diffs: [{ path: 'src/view.ts', oldText: 'old line\nkeep', newText: 'new line\nkeep' }], }), ), subagent: visualTool('subagent', args => ({ @@ -209,12 +210,19 @@ const ADVANCED_CARD_TOOLS: Record = { title: 'Delegate renderer audit', rawInput: (args as { prompt: string }).prompt, })), - task_output: visualTool('task_output', args => ({ - card: 'generic', - kind: 'read', - title: `Read output from background task ${(args as { task_id: string }).task_id}`, - rawInput: (args as { task_id: string }).task_id, - })), + task_output: visualTool( + 'task_output', + args => ({ + card: 'generic', + kind: 'read', + title: `Read output from background task ${(args as { task_id: string }).task_id}`, + rawInput: (args as { task_id: string }).task_id, + }), + () => ({ + card: 'generic', + content: [{ type: 'text', text: '```console\nstarted background task bash-5\n```' }], + }), + ), skill: visualTool('skill', args => ({ card: 'generic', kind: 'read', @@ -228,46 +236,64 @@ const DISPLAYED_CONTROL_PROBE = String.raw`\x1b]2;snapshot-controlled\x07\x09\x7 describe('TUI terminal-state snapshots', () => { it('pins an in-flight reasoning and Markdown stream', async () => { + let clock = new Date(2026, 6, 21, 14, 30, 0).getTime() + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => clock) const harness = await setupSnapshot() - // Freeze the loader's first animation interval so this semantic snapshot - // cannot select a different spinner frame under scheduler contention. - const frozenLoaderTimer = setInterval(() => {}, 60_000) - const intervals = vi.spyOn(globalThis, 'setInterval').mockImplementationOnce(() => frozenLoaderTimer) - try { - await renderAfter(harness, () => { - harness.agent.status = 'running' - harness.ctx.emit('agent/status', harness.agent, 'running') - appendUser(harness.session, 'Show the live update.') - harness.session.append('assistant/chunk', { - turn: 1, - step: 1, - chunk: { type: 'block-start', index: 0, blockType: 'reasoning' }, - }) - harness.session.append('assistant/chunk', { - turn: 1, - step: 1, - chunk: { type: 'reasoning-delta', index: 0, text: 'Inspecting width and styles.' }, - }) - harness.session.append('assistant/chunk', { - turn: 1, - step: 1, - chunk: { type: 'block-start', index: 1, blockType: 'text' }, - }) - harness.session.append('assistant/chunk', { - turn: 1, - step: 1, - chunk: { type: 'text-delta', index: 1, text: 'Streaming **visible state**…' }, - }) + await renderAfter(harness, () => { + harness.agent.status = 'running' + harness.ctx.emit('agent/status', harness.agent, 'running') + appendUser(harness.session, 'Show the live update.') + clock += 1_000 + harness.session.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'block-start', index: 0, blockType: 'reasoning' }, }) - const loaderIntervalMs = intervals.mock.calls[0]?.[1] - if (typeof loaderIntervalMs !== 'number') throw new Error('TUI loader did not register an animation interval') - await new Promise(resolve => setTimeout(resolve, loaderIntervalMs + 5)) - await checkpoint('conversation-streaming', harness.terminal) - } finally { - intervals.mockRestore() - clearInterval(frozenLoaderTimer) - await disposeSnapshot(harness) - } + harness.session.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'reasoning-delta', index: 0, text: 'Inspecting width and styles.' }, + }) + clock += 2_000 + harness.session.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'block-start', index: 1, blockType: 'text' }, + }) + harness.session.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'text-delta', index: 1, text: 'Streaming **visible state**…\n\n```ts\nconst visible = true\n```' }, + }) + }) + await checkpoint('conversation-streaming', harness.terminal) + await disposeSnapshot(harness) + nowSpy.mockRestore() + }) + + it('pins a completed step timing summary', async () => { + let clock = new Date(2026, 6, 21, 14, 32, 6).getTime() + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => clock) + const harness = await setupSnapshot() + await renderAfter(harness, () => { + clock += 1_000 + harness.session.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'reasoning-delta', index: 0, text: 'Checking the result.' }, + }) + clock += 2_000 + harness.session.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'text-delta', index: 1, text: 'The result is ready.' }, + }) + clock += 3_000 + harness.session.append('step/end', { turn: 1, step: 1 }) + }) + await checkpoint('step-timing-completed', harness.terminal, { includeScrollback: true }) + nowSpy.mockRestore() + await disposeSnapshot(harness) }) it('pins failed-stream retraction, scheduled retry, and eventual success', async () => { @@ -282,6 +308,9 @@ describe('TUI terminal-state snapshots', () => { harness.session.append('llm/retry', { turn: 1, step: 1, + provider: 'mock', + mode: 'normal', + policyKey: '["normal",2,["RATE_LIMIT"],1,10000,0]', retry: 1, maxRetries: 2, delayMs: 500, @@ -290,15 +319,13 @@ describe('TUI terminal-state snapshots', () => { }) await checkpoint('retry-scheduled', harness.terminal, { includeScrollback: true }) - await renderAfter(harness, () => { - harness.session.append('assistant/message', { - turn: 1, - step: 2, - provenance: { provider: 'mock', model: 'deepseek-v4-flash' }, - content: [{ type: 'text', text: 'Recovered on the next bounded attempt.' }], - }, { surfaceOp: 'append' }) - harness.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - }) + harness.session.append('assistant/message', { + turn: 1, + step: 2, + provenance: { provider: 'mock', model: 'deepseek-v4-flash' }, + content: [{ type: 'text', text: 'Recovered on the next bounded attempt.' }], + }, { surfaceOp: 'append' }) + harness.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) await checkpoint('retry-recovered', harness.terminal, { includeScrollback: true }) await disposeSnapshot(harness) }) @@ -310,8 +337,10 @@ describe('TUI terminal-state snapshots', () => { harness.session.append('llm/retry', { turn: 1, step: 1, + provider: 'mock', + mode: 'always', + policyKey: '["always",1,10000,0]', retry: 1, - maxRetries: 2, delayMs: 1_000, failure: { message: 'temporary transport failure', code: 'TRANSPORT' }, }) @@ -347,7 +376,7 @@ describe('TUI terminal-state snapshots', () => { }) it('paints the startup banner product name in the DeepSeek brand gradient on truecolor terminals', async () => { - const harness = await setupSnapshot({ config: { truecolor: true } }) + const harness = await setupSnapshot({ config: { theme: { truecolor: true } } }) await checkpoint('banner-gradient', harness.terminal, {}, true) await disposeSnapshot(harness) }) @@ -408,7 +437,7 @@ describe('TUI terminal-state snapshots', () => { await disposeSnapshot(harness) }) - it('pins cordis inspect, dynamic mount, and unmount cards with production presenters', async () => { + it('pins cordis inspect, try, and stop cards with production presenters', async () => { const harness = await setupSnapshot({ configureContext: configureAdvancedTools }) const calls = [ { id: 'cordis-1', name: 'cordis_inspect', arguments: { what: 'tools' } }, @@ -452,6 +481,7 @@ describe('TUI terminal-state snapshots', () => { }) it('renders terminal controls as inert text across transcripts, tools, dialogs, diagnostics, and title', async () => { + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 21, 15, 0, 0).getTime()) const tools = { unsafe: visualTool( 'unsafe', @@ -513,14 +543,13 @@ describe('TUI terminal-state snapshots', () => { }) const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) await harness.terminal.waitForFrame(beforeQuestion) - await renderAfter(harness, () => { - agentEvents(harness.ctx, harness.agent).emit('agent/error', 8, 3, new Error(`Unsafe live error ${CONTROL_PROBE}`)) - }) + agentEvents(harness.ctx, harness.agent).emit('agent/error', 8, 3, new Error(`Unsafe live error ${CONTROL_PROBE}`)) await checkpoint('untrusted-controls', harness.terminal, { includeScrollback: true }) controller.abort() await rejected await disposeSnapshot(harness) + nowSpy.mockRestore() }) it('pins a constrained multi-select question and its validation state', async () => { @@ -563,7 +592,37 @@ describe('TUI terminal-state snapshots', () => { await disposeSnapshot(harness) }) + it('pins a single-option question', async () => { + const harness = await setupSnapshot({ + config: { + questionDialogWidth: 200, + questionDialogMaxHeight: 16, + }, + }, { columns: 56, rows: 20 }) + const controller = new AbortController() + const beforeQuestion = harness.terminal.frames + const answer = harness.ctx.userInteraction.ask({ + questions: [{ + id: 'confirm', + header: 'Confirm', + question: 'Continue with this change?', + options: [{ label: 'Proceed', description: 'Apply the proposed change' }], + }], + signal: controller.signal, + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await harness.terminal.waitForFrame(beforeQuestion) + await checkpoint('question-dialog-single-option', harness.terminal) + controller.abort() + await rejected + await disposeSnapshot(harness) + }) + it('pins compaction surface replacement and narrow-to-wide reflow', async () => { + // Freeze the clock: the timing header hides zero-duration buckets, so a + // real-clock millisecond tick between the fixture appends and the render + // would flip `Tools 0.0s` in and out of the pinned header. + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 21, 14, 40, 0).getTime()) let replacementStart = 0 let replacementEnd = 0 let replacementSources: number[] = [] @@ -597,8 +656,11 @@ describe('TUI terminal-state snapshots', () => { await renderAfter(harness, () => { harness.session.append('user/message', { - content: [{ type: 'text', text: 'Compacted summary: the prior command completed and its details were retired from the active surface.' }], - source: { kind: 'plugin', plugin: 'compact' }, + content: [{ + type: 'text', + text: '\nAdditional instructions from: nested/AGENTS.md\n\nRender workspace context XML clearly.\n', + }], + source: { kind: 'plugin', plugin: 'workspace-context' }, }, { surfaceOp: { op: 'replace', start: replacementStart, end: replacementEnd }, sourceEventSeqs: replacementSources, @@ -610,9 +672,22 @@ describe('TUI terminal-state snapshots', () => { await renderAfter(harness, () => { harness.terminal.resize(104, 30) }) await checkpoint('surface-after-compaction-wide', harness.terminal, { includeScrollback: true }) await disposeSnapshot(harness) + nowSpy.mockRestore() + }) + + it('pins wrapped and explicit multiline shell-prompt input', async () => { + const harness = await setupSnapshot({}, { columns: 44, rows: 18 }) + await renderAfter(harness, () => { + harness.terminal.send('Explain this implementation with enough detail to wrap across multiple full-width continuation rows without leaving a prompt-sized gap at the right edge.') + harness.terminal.send('\x1b[13;2u') + harness.terminal.send('Then suggest a simpler version.') + }) + await checkpoint('shell-prompt-multiline', harness.terminal) + await disposeSnapshot(harness) }) it('pins help, unknown commands, live errors, turn failures, and terminal restoration', async () => { + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 21, 15, 5, 0).getTime()) const harness = await setupSnapshot({}, { columns: 92, rows: 32 }) await renderAfter(harness, () => { harness.terminal.send('/help') @@ -630,6 +705,12 @@ describe('TUI terminal-state snapshots', () => { turn: 2, reason: { kind: 'interrupted' }, }) + harness.session.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } }) + harness.session.append('turn/end', { turn: 3, reason: { kind: 'disposed' } }) + harness.session.append('turn/start', { turn: 4, trigger: { kind: 'message', source: { kind: 'user' } } }) + // A merge-extensible turn-end kind unknown to the TUI still surfaces its + // name so the agent never stops without a visible reason. + harness.session.append('turn/end', { turn: 4, reason: { kind: 'plugin-policy' } as never }) }) await checkpoint('errors-and-help', harness.terminal, { includeScrollback: true }) @@ -638,31 +719,11 @@ describe('TUI terminal-state snapshots', () => { await checkpoint('disposed-terminal', harness.terminal, { includeScrollback: true }) await harness.ctx.fiber.dispose() await harness.terminal.dispose() + nowSpy.mockRestore() }) - it('pins the model selector, effort cycling, and provider-default selection', async () => { - const harness = await setupSnapshot({ - catalog: { - providers: [{ id: 'deepseek', name: 'DeepSeek' }], - models: [ - { provider: 'deepseek', id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash' }, - { provider: 'deepseek', id: 'deepseek-v4-pro', name: 'DeepSeek V4 Pro' }, - ], - resolveModelInfo: (_provider, model) => Promise.resolve({ - context: { contextWindow: 128_000 }, - reasoning: { - efforts: [ - { id: ReasoningEffortId('off'), name: 'Off' }, - { id: ReasoningEffortId('high'), name: 'High' }, - { id: ReasoningEffortId('max'), name: 'Max' }, - ], - ...model === 'deepseek-v4-flash' - ? { defaultEffort: ReasoningEffortId('high') } - : {}, - }, - }), - }, - }, { columns: 92, rows: 32 }) + it('pins the model selector and selection notice', async () => { + const harness = await setupSnapshot({}, { columns: 92, rows: 32 }) await renderAfter(harness, () => { harness.terminal.send('/model') harness.terminal.send('\r') @@ -670,13 +731,6 @@ describe('TUI terminal-state snapshots', () => { await checkpoint('model-selector', harness.terminal, { includeScrollback: true }) await renderAfter(harness, () => { harness.terminal.send('\x1b[B') - harness.terminal.send('\x1b[Z') - harness.terminal.send('\x1b[Z') - harness.terminal.send('\x1b[Z') - harness.terminal.send('\x1b[Z') - }) - await checkpoint('model-effort-switching', harness.terminal, { includeScrollback: true }) - await renderAfter(harness, () => { harness.terminal.send('\r') }) await checkpoint('model-switching', harness.terminal, { includeScrollback: true }) @@ -722,6 +776,22 @@ describe('TUI terminal-state snapshots', () => { contextWindow: 128_000, contextTokens: 42_000, agentOptions: { provider: 'deepseek', model: 'deepseek-v4-pro' }, + tools: { + read: { + name: 'read', + description: 'Read a file', + parameters: {}, + output: { schema: { type: 'null' }, render: () => [] }, + execute: async () => null, + }, + write: { + name: 'write', + description: 'Write a file', + parameters: {}, + output: { schema: { type: 'null' }, render: () => [] }, + execute: async () => null, + }, + }, beforeMount(session) { appendUser(session, 'inspect this session') appendAssistant(session, [{ type: 'text', text: 'Session inspected.' }], { diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 232ef112d0..3a8ce81e63 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -3,8 +3,8 @@ import { homedir, tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import { CombinedAutocompleteProvider, type Terminal } from '@earendil-works/pi-tui' -import AgentRegistry, { agentEvents, assembleContextFor, AgentMessageId, type Agent } from '@deepseek-ai/dsh-agent' +import { CombinedAutocompleteProvider, visibleWidth, type Terminal } from '@earendil-works/pi-tui' +import AgentRegistry, { agentEvents, AgentMessageId, assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent' import { ReasoningEffortId, type LlmCallConfig, @@ -25,12 +25,13 @@ import { FILE_REFERENCE_PROMPT, mountTui, renderSkillInvocation, + TuiPromptService, resolveTuiConfig, type TuiOverlayHost, type TuiOverlaySession, type TuiRuntime, } from '../src/index.ts' -import { WorkspaceFileSearch } from '../src/file-autocomplete.ts' +import { WorkspaceFileSearch } from '../src/chat/file-autocomplete.ts' import { appendAssistant, appendUser, @@ -171,8 +172,14 @@ describe('TUI config', () => { fileSearchMaxEntries: 10_000, fileSearchExcludedDirectories: ['.git', 'node_modules'], showHardwareCursor: false, - color: true, - truecolor: false, + theme: { + color: true, + truecolor: false, + leftPrompt: '${cwd}${git/worktree}${model}${token_meter/cache_hit_rate}${context}', + rightPrompt: '${timing}', + inputPrompt: '${symbol} ${indicator}', + inputPlaceholder: 'press enter to steer and esc to cancel', + }, title: 'DeepSeek Harness', }) expect(resolveTuiConfig({ @@ -189,8 +196,7 @@ describe('TUI config', () => { fileSearchMaxEntries: 123, fileSearchExcludedDirectories: ['.git', 'generated'], showHardwareCursor: true, - color: false, - truecolor: true, + theme: { color: false, truecolor: true }, title: 'DSH', })).toEqual({ showReasoning: false, @@ -206,8 +212,14 @@ describe('TUI config', () => { fileSearchMaxEntries: 123, fileSearchExcludedDirectories: ['.git', 'generated'], showHardwareCursor: true, - color: false, - truecolor: true, + theme: { + color: false, + truecolor: true, + leftPrompt: '${cwd}${git/worktree}${model}${token_meter/cache_hit_rate}${context}', + rightPrompt: '${timing}', + inputPrompt: '${symbol} ${indicator}', + inputPlaceholder: 'press enter to steer and esc to cancel', + }, title: 'DSH', }) }) @@ -1168,10 +1180,13 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('restored thought') expect(result.terminal.output).toContain('restored answer') expect(result.terminal.output).toContain('write tests') - expect(result.terminal.output).toContain('↑1.3k ↓42') - // Exact model resolution is async; settle before reading. + expect(result.terminal.output).toContain('/opt (tui-staging) deepseek-v4-flash ↑1.3k ↓42') + expect(result.terminal.output).toContain('dsh > ') + expect(result.terminal.output).not.toContain('main-session deepseek-v4-flash') + // Context resolution is async (resolveModelContext); settle before reading. await tick() - expect(result.terminal.output).toContain('42% context tools:collapsed') + expect(result.terminal.output).toContain('42% context') + expect(result.terminal.output).not.toContain('tools:collapsed') // Narrow terminals clip the right-hand context/tools segment first; the // model-led left segment stays. result.terminal.resize(52) @@ -1186,7 +1201,15 @@ describe('pi-tui chat lifecycle and transcript', () => { result.session.append('user/message', { content: [{ type: 'text', text: ' ' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) result.session.append('steering/message', { turn: 2, content: [{ type: 'text', text: 'steering note' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) result.session.append('steering/message', { turn: 2, content: [{ type: 'text', text: '' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - result.session.append('user/message', { content: [{ type: 'text', text: 'user context' }], source: { kind: 'plugin', plugin: 'ctx' } }, { surfaceOp: 'append' }) + result.session.append('user/message', { content: [{ type: 'text', text: 'user context' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + result.session.append('user/message', { + content: [{ type: 'text', text: '\nAdditional instructions from: nested/AGENTS.md\n\nRender XML context clearly.\n' }], + source: { kind: 'plugin', plugin: 'workspace-context' }, + }, { surfaceOp: 'append' }) + result.session.append('user/message', { + content: [{ type: 'text', text: '' }], + source: { kind: 'plugin', plugin: 'workspace-control-context' }, + }, { surfaceOp: 'append' }) result.session.append('user/message', { content: [{ type: 'text', text: '' }], source: { kind: 'plugin', plugin: 'ctx' } }, { surfaceOp: 'append' }) // A non-plugin injected source (goal) has no `plugin` field, so its context // card label falls back to the source kind. @@ -1267,9 +1290,14 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('final live answer') }) - expect(result.terminal.output).toContain('Enter sends steering, Esc cancels') + expect(result.terminal.output).toContain('press enter to steer and esc to cancel') expect(result.terminal.output).toContain('Steering') expect(result.terminal.output).toContain('user context') + expect(result.terminal.output).toContain('Context · workspace-context') + expect(result.terminal.output).toContain('system-reminder') + expect(result.terminal.output).toContain('Additional instructions from: nested/AGENTS.md') + expect(result.terminal.output).not.toContain('') + expect(result.terminal.output).toContain('\\x9b') expect(result.terminal.output).toContain('Context · goal') // goal-sourced injected context labels by kind expect(result.terminal.output).toContain('Turn cancelled') expect(result.terminal.progress).toContain(true) @@ -1306,6 +1334,9 @@ describe('pi-tui chat lifecycle and transcript', () => { result.session.append('llm/retry', { turn: 1, step: 1, + provider: 'mock', + mode: 'normal', + policyKey: '["normal",2,["RATE_LIMIT"],1,10000,0]', retry: 1, maxRetries: 2, delayMs: 500, @@ -1335,9 +1366,13 @@ describe('pi-tui chat lifecycle and transcript', () => { step: 1, chunk: { type: 'text-delta', index: 0, text: 'discarded partial answer' }, }) + result.session.append('step/end', { turn: 1, step: 1 }) result.session.append('llm/retry', { turn: 1, step: 1, + provider: 'mock', + mode: 'normal', + policyKey: '["normal",2,["RATE_LIMIT"],1,10000,0]', retry: 1, maxRetries: 2, delayMs: 500, @@ -1346,25 +1381,51 @@ describe('pi-tui chat lifecycle and transcript', () => { result.session.append('llm/retry', { turn: 1, step: 2, + provider: 'mock', + mode: 'normal', + policyKey: '["normal",2,["RATE_LIMIT"],1,10000,0]', retry: 2, maxRetries: 2, delayMs: 1_000, failure: { message: 'failed before chunks', code: 'SERVER', status: 503 }, }) + result.session.append('llm/retry', { + turn: 1, + step: 3, + provider: 'mock', + mode: 'always', + policyKey: '["always",1,10000,0]', + retry: 1, + delayMs: 2_000, + failure: { message: 'retry without limit', code: 'AUTH', status: 401 }, + }) await tick() expect(result.terminal.output).toContain('Retrying model request (1/2) in 500ms: rate limited') expect(result.terminal.output).toContain('Retrying model request (2/2) in 1000ms: failed before chunks') + expect(result.terminal.output).not.toContain('discarded partial answer') + expect(result.terminal.output).toContain('Retrying model request (1/∞) in 2000ms: retry without limit') await dispose(result) }) - it('badges queued steering on the running status line and clears it as each drains', async () => { - // Pin a cwd free of the substring under test; the footer renders the path. + it('badges queued steering on the prompt context timing and clears it as each drains', async () => { + // Pin a cwd free of the substring under test; the prompt context renders the path. const result = await setup({ status: 'running', cwd: '/workspace' }) - // Running with nothing queued: the plain steering hint, no badge. - expect(result.terminal.output).toContain('— Enter sends steering, Esc cancels') + // Running with nothing queued: timing appears once in the prompt context and the editor keeps its hint. + expect(result.terminal.output).toContain('Assistant') + expect(result.terminal.output).toContain('Model wait 0.0s') + expect(result.terminal.output).toContain('press enter to steer and esc to cancel') + expect(result.terminal.output).not.toContain('│') expect(result.terminal.output).not.toContain('queued') + result.terminal.output = '' + result.terminal.send('x') + await tick() + expect(result.terminal.output).not.toContain('press enter to steer and esc to cancel') + result.terminal.send('\x7f') + await tick() + expect(result.terminal.output).toContain('press enter to steer and esc to cancel') + const submitSteering = (text: string): void => { result.terminal.send(text) result.terminal.send('\r') @@ -1382,7 +1443,7 @@ describe('pi-tui chat lifecycle and transcript', () => { } // A steering queue for a different agent never touches this status line. - const other = { ...result.agent, id: SessionId('other') } as unknown as Agent + const other = { ...result.agent, id: SessionId('other') } as Agent result.terminal.output = '' result.ctx.emit('agent/inbox/enqueue', other, { id: AgentMessageId('stub'), content: [{ type: 'text', text: 'elsewhere' }], source: { kind: 'user' } }, 'queued') await tick() @@ -1393,7 +1454,7 @@ describe('pi-tui chat lifecycle and transcript', () => { result.terminal.output = '' submitSteering('second') await tick() - expect(result.terminal.output).toContain('2 queued · Enter sends steering, Esc cancels') + expect(result.terminal.output).toContain('2 queued') // Draining one submitted message decrements the badge. result.terminal.output = '' @@ -1406,7 +1467,8 @@ describe('pi-tui chat lifecycle and transcript', () => { result.terminal.output = '' drainSteering('second') await tick() - expect(result.terminal.output).toContain('— Enter sends steering, Esc cancels') + expect(result.terminal.output).toContain('press enter to steer and esc to cancel') + expect(result.terminal.output).not.toContain('│') expect(result.terminal.output).not.toContain('queued') // A drain with no matching queued entry is ignored rather than underflowing. @@ -1438,7 +1500,7 @@ describe('pi-tui chat lifecycle and transcript', () => { result.terminal.output = '' result.ctx.emit('agent/status', result.agent, 'running') await tick() - expect(result.terminal.output).toContain('— Enter sends steering, Esc cancels') + expect(result.terminal.output).not.toContain('│') expect(result.terminal.output).not.toContain('queued') // A cancellation discards queued steering: the badge clears without drains. @@ -1464,103 +1526,403 @@ describe('pi-tui chat lifecycle and transcript', () => { result.terminal.output = '' result.ctx.emit('agent/inbox/discard', result.agent, discarded) await tick() - expect(result.terminal.output).toContain('— Enter sends steering, Esc cancels') expect(result.terminal.output).not.toContain('queued') await dispose(result) }) - it('derives the fine-grained turn phase from session lifecycle events', async () => { - // A live event before the turn runs has no status controller to move. - const idle = await setup() - // Inbox notifications do not affect the status phase while idle. - idle.ctx.emit('agent/inbox/enqueue', idle.agent, { id: AgentMessageId('stub'), content: [{ type: 'text', text: 'early' }], source: { kind: 'user' } }, 'queued') - idle.session.append('tool/call', { turn: 1, step: 0, callId: 'pre' as never, name: 'bash', arguments: '{}' }) - await tick() - expect(idle.terminal.output).not.toContain('Executing tools') - expect(idle.terminal.output).not.toContain('queued') - await dispose(idle) - + it('accumulates exclusive timing buckets across a multi-step turn', async () => { + let clock = 1_700_000_000_000 + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => clock) const result = await setup({ status: 'running' }) - expect(result.terminal.output).toContain('Waiting for the first token') + clock += 1_000 + result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'block-start', index: 0, blockType: 'reasoning' } }) + clock += 2_000 + result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 1, text: 'answering' } }) + clock += 1_000 + result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'reconsidering' } }) + clock += 2_000 + result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 1, text: 'revised' } }) + clock += 3_000 + result.session.append('tool/call', { turn: 1, step: 1, callId: 'c1' as never, name: 'bash', arguments: '{}' }) + clock += 4_000 + result.session.append('step/end', { turn: 1, step: 1 }) + result.session.append('step/start', { turn: 1, step: 2 }) + clock += 1_000 + result.session.append('assistant/chunk', { turn: 1, step: 2, chunk: { type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } } }) + clock += 2_000 + result.session.append('assistant/chunk', { turn: 1, step: 2, chunk: { type: 'text-delta', index: 0, text: 'done' } }) + clock += 3_000 result.terminal.output = '' - result.session.append('assistant/chunk', { turn: 1, step: 0, chunk: { type: 'block-start', index: 0, blockType: 'reasoning' } }) - result.session.append('assistant/chunk', { turn: 1, step: 0, chunk: { type: 'reasoning-delta', index: 0, text: 'mull it over' } }) + result.session.append('step/end', { turn: 1, step: 2 }) await tick() - expect(result.terminal.output).toContain('Thinking') - result.terminal.output = '' - result.session.append('assistant/chunk', { turn: 1, step: 0, chunk: { type: 'block-start', index: 1, blockType: 'text' } }) - result.session.append('assistant/chunk', { turn: 1, step: 0, chunk: { type: 'text-delta', index: 1, text: 'answering' } }) - await tick() - expect(result.terminal.output).toContain('Responding') - - result.terminal.output = '' - result.session.append('tool/call', { turn: 1, step: 0, callId: 'c1' as never, name: 'bash', arguments: '{}' }) - await tick() - expect(result.terminal.output).toContain('Executing tools') - - // The next step reopens the wait window and resets the executing label. - result.terminal.output = '' - result.session.append('step/start', { turn: 1, step: 1 }) - await tick() - expect(result.terminal.output).toContain('Waiting for the first token') - expect(result.terminal.output).not.toContain('Executing tools') - - await dispose(result) - }) - - it('refreshes the running status elapsed time on its own timer', async () => { - let now = 0 - const intervals = vi.spyOn(globalThis, 'setInterval') - let result: Awaited> | undefined - try { - result = await setup({ status: 'running', now: () => now }) - const refresh = intervals.mock.calls.find(([, interval]) => interval === 1_000)?.[0] - if (typeof refresh !== 'function') throw new Error('TUI did not register its elapsed-status refresh interval') - result.terminal.output = '' - // The loader repaints "0s" until the controller's own interval fires; a - // non-zero elapsed proves the refresh, not just the loader's animation. - now = 1_000 - refresh() - await tick() - expect(result.terminal.output).toContain('Waiting for the first token 1s') - } finally { - if (result !== undefined) await dispose(result) - intervals.mockRestore() - } - }) - - it('shows minutes and seconds once a step passes a minute', async () => { - const result = await setup({ status: 'running' }) - const base = Date.now() - const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(base + 95_000) - result.terminal.output = '' - result.session.append('assistant/chunk', { turn: 1, step: 0, chunk: { type: 'text-delta', index: 0, text: 'hi' } }) - await tick() - expect(result.terminal.output).toContain('total 1m') + expect(result.terminal.output).toContain('Model wait 1.0s · Thinking 4.0s · Response 4.0s · Tools 4.0s') + expect(result.terminal.output).toContain('Model wait 1.0s · Response 3.0s · Completed') + expect(result.terminal.output).not.toContain('Thinking 0s') nowSpy.mockRestore() await dispose(result) }) - it('preserves the turn phase and elapsed time across a mid-turn color-scheme change', async () => { - const result = await setup({ status: 'running' }) - const base = Date.now() - const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(base) - // Advance into `responding`, anchoring the phase clock at `base`. - result.session.append('assistant/chunk', { turn: 1, step: 0, chunk: { type: 'text-delta', index: 0, text: 'answering' } }) + it('rebuilds used subsecond buckets and the durable local completion time', async () => { + let clock = new Date(2026, 6, 21, 14, 32, 6).getTime() + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => clock) + let result: Awaited> | undefined + try { + result = await setup({ + beforeMount(session) { + clock += 250 + session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'fast' } }) + clock += 500 + session.append('step/end', { turn: 1, step: 1 }) + clock += 86_400_000 + }, + }) + const completed = 'Model wait 0.2s · Response 0.5s · Completed 2026-07-21 14:32:06' + expect(result.terminal.output).toContain(completed) + + result.terminal.output = '' + appendUser(result.session, 'rebuild the transcript') + result.terminal.resize(result.terminal.columns + 1) + await tick() + expect(result.terminal.output).toContain(completed) + } finally { + if (result !== undefined) await dispose(result) + nowSpy.mockRestore() + } + }) + + it('renders completion for a step whose opening event is unavailable', async () => { + const result = await setup({ omitInitialLifecycle: true }) + result.session.append('step/end', { turn: 1, step: 1 }) + await tick() + expect(result.terminal.output).toContain('Completed ') + expect(result.terminal.output).toContain('Assistant') + expect(result.terminal.output).toContain('Model wait 0.0s · Completed') + await dispose(result) + }) + + it('does not reuse a completed turn before the next turn starts', async () => { + let clock = 1_700_000_000_000 + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => clock) + let result: Awaited> | undefined + try { + result = await setup({ + beforeMount(session) { + clock += 2_000 + session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'done' } }) + clock += 1_000 + session.append('step/end', { turn: 1, step: 1 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + }, + }) + result.agent.status = 'running' + result.terminal.output = '' + result.ctx.emit('agent/status', result.agent, 'running') + await tick() + expect(result.terminal.output).not.toContain('Model wait') + expect(result.terminal.output).toContain('press enter to steer and esc to cancel') + expect(result.terminal.output).not.toContain('│') + expect(result.terminal.output).not.toContain('Response 1s') + + result.session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + result.session.append('step/start', { turn: 2, step: 1 }) + clock += 1_000 + result.terminal.output = '' + result.session.append('assistant/chunk', { turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: 'next' } }) + await tick() + expect(result.terminal.output).toContain('Model wait 1.0s') + expect(result.terminal.output).not.toContain('Model wait 3.0s') + } finally { + if (result !== undefined) await dispose(result) + nowSpy.mockRestore() + } + }) + + it('starts a running status before lifecycle events arrive', async () => { + const result = await setup({ status: 'running', omitInitialLifecycle: true }) + expect(result.terminal.output).not.toContain('Model wait') + await dispose(result) + }) + + it('replaces the prompt caret with a phase-specific status glyph while running', async () => { + // Hold the clock past the fade-in so the glyph is at full opacity; with + // color off the settled glyph renders as its bare character. + let clock = 0 + const result = await setup({ status: 'running', now: () => clock }) + clock = 1_000 + + // A space separates `dsh` from the caret slot: the prompt reads + // `dsh ` with the same visible width as the idle `dsh > `, so the + // cursor never shifts. Assert both the glyph slot and that constant width + // (color is off in this harness, so output carries no ANSI to strip). + const promptWidth = (): number => { + const row = result.terminal.output.split('\n').find(line => line.includes('dsh')) + if (row === undefined) throw new Error('prompt row not rendered') + return visibleWidth(row.slice(row.indexOf('dsh'), row.indexOf('dsh') + 6)) + } + + // Each phase swaps only the glyph character in the same slot at equal width. + const phaseGlyph: [() => void, string][] = [ + [() => result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'weighing' } }), 'dsh ✻ '], + [() => result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 1, text: 'answer' } }), 'dsh ● '], + [() => result.session.append('tool/call', { turn: 1, step: 1, callId: 'c1' as never, name: 'bash', arguments: '{}' }), 'dsh ⚙ '], + ] + let runningWidth: number | undefined + for (const [drive, expected] of phaseGlyph) { + result.terminal.output = '' + drive() + await tick() + expect(result.terminal.output).toContain(expected) + runningWidth ??= promptWidth() + expect(promptWidth()).toBe(runningWidth) + } + + // Idle begins a fade-out; once it settles (clock past the fade window) the + // plain `>` caret returns at the same width — no horizontal shift. The + // fade-out timer emits intermediate frames, so read the terminal's final + // rendered prompt row rather than the accumulated stream. + result.agent.status = 'idle' + result.ctx.emit('agent/status', result.agent, 'idle') + clock = 2_000 + await new Promise(resolve => setTimeout(resolve, 150)) + await tick() + const promptRow = (): string => { + const rows = result.terminal.output.split(/\r?\n|\x1b\[[0-9;]*[A-Za-z]/u).filter(r => r.includes('dsh')) + return rows.at(-1) ?? '' + } + expect(promptRow()).toContain('dsh > ') + expect(promptRow()).not.toMatch(/dsh(?:\x1b\[[0-9;]*m| )*[◍✻●⚙]/u) + expect(promptWidth()).toBe(runningWidth) + + await dispose(result) + }) + + // Extract the running glyph's interpolated gray channel from a rendered frame. + const glyphGray = (frame: string): number => { + const m = /\x1b\[38;2;(\d+);(\d+);(\d+)m●/u.exec(frame) + if (m === null) throw new Error('frame did not paint a truecolor glyph') + const [r, g, b] = [Number(m[1]), Number(m[2]), Number(m[3])] + // Pure gray: equal channels, never the blue-dominant accent. + expect(r).toBe(g) + expect(g).toBe(b) + return r + } + + it('throbs the running glyph in dim gray, breathing between trough and full without blanking, never accent', async () => { + let clock = 0 + let chunkIndex = 0 + const result = await setup({ status: 'running', config: { theme: { color: true, truecolor: true } }, now: () => clock }) + // A fresh chunk index each frame changes the streamed line, forcing the + // diffing terminal to repaint the prompt row and re-emit the glyph slot. + const frameAt = async (t: number): Promise => { + clock = t + chunkIndex += 1 + result.terminal.output = '' + result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: chunkIndex, text: '.' } }) + await tick() + return result.terminal.output + } + + // The pulse breathes between the dimmest trough gray and the settled peak + // and back, never blanking. At phase 0 (t=1400, pulse level 0, past fade-in) + // the glyph still paints the trough gray, so the breath dims but never + // disappears — a symmetric bold→dim→bold throb. + const trough = await frameAt(1_400) + expect(trough).toMatch(/●/u) + expect(glyphGray(trough)).toBe(43) + // Half a period later (t=2100, pulse peak) it paints the brightest gray. + const peak = await frameAt(2_100) + expect(glyphGray(peak)).toBe(136) + // A frame partway up the swell paints a gray strictly between trough and + // full, and the glyph is never the accent color. + const rising = await frameAt(1_680) + const grey = glyphGray(rising) + expect(grey).toBeGreaterThan(43) + expect(grey).toBeLessThan(136) + expect(rising).not.toMatch(/\x1b\[94m●/u) + + await dispose(result) + }) + + it('fades the running glyph out to the plain caret after the turn ends', async () => { + let clock = 0 + const result = await setup({ status: 'running', config: { theme: { color: true, truecolor: true } }, now: () => clock }) + clock = 1_000 + result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: '.' } }) await tick() - // Four seconds later the terminal reports a light color scheme, rebuilding - // the status loader; the phase and its elapsed time must survive the rebuild. - nowSpy.mockReturnValue(base + 4_000) + // End the turn: the last glyph fades out over 300 ms rather than vanishing. + result.agent.status = 'idle' + result.ctx.emit('agent/status', result.agent, 'idle') + await tick() + // Just after the end the glyph still paints a gray, not `>`. + expect(result.terminal.output).toMatch(/\x1b\[38;2;\d+;\d+;\d+m●/u) + expect(result.terminal.output).not.toContain('dsh \x1b[90m>') + + // While the clock stays within the fade window the timer keeps ticking + // without clearing the fade (the not-yet-elapsed branch): the last frame is + // still the fading glyph, and the plain caret has not returned. + result.terminal.output = '' + await new Promise(resolve => setTimeout(resolve, 120)) + const lastPromptRow = result.terminal.output.split(/\x1b\[[0-9;]*[A-Za-z]/u).filter(r => r.includes('dsh')).at(-1) ?? '' + expect(lastPromptRow).not.toContain('dsh \x1b[90m>') + + // Past the fade window the fade timer clears and the plain caret returns. + clock = 2_000 + result.terminal.output = '' + await new Promise(resolve => setTimeout(resolve, 120)) + await tick() + expect(result.terminal.output).not.toMatch(/dsh(?:\x1b\[[0-9;]*m| )*●/u) + expect(result.terminal.output).toContain('>') + + await dispose(result) + }) + + it('appears past the fade midpoint and disappears without truecolor, still dim not accent', async () => { + let clock = 0 + const result = await setup({ status: 'running', config: { theme: { color: true } }, now: () => clock }) + const frameAt = async (t: number): Promise => { + clock = t + result.terminal.output = '' + result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: '.' } }) + await tick() + return result.terminal.output + } + + // Without truecolor there is no per-frame gray: below the fade midpoint the + // glyph slot is blank; past it the glyph shows in the palette muted role + // (ANSI 90), never the accent (SGR 94). + const early = await frameAt(60) + expect(early).not.toMatch(/dsh(?:\x1b\[[0-9;]*m| )*●/u) + const shown = await frameAt(300) + expect(shown).toMatch(/\x1b\[90m●/u) + expect(shown).not.toMatch(/\x1b\[94m●/u) + + await dispose(result) + }) + + it('shows the plain prompt caret while idle', async () => { + const result = await setup({ now: () => 0 }) + expect(result.terminal.output).toContain('dsh > ') + expect(result.terminal.output).not.toMatch(/dsh [◍✻●⚙]/u) + await dispose(result) + }) + + it('escapes configured prompt controls while preserving registry-owned styling', async () => { + const result = await setup({ config: { theme: { leftPrompt: 'LEFT\u001B]2;unsafe\u0007 ${custom}' } } }) + result.ctx.tuiPrompt.register('custom', '\u001B[1mTRUSTED\u001B[22m') + await tick() + expect(result.terminal.output).toContain('LEFT\\x1b]2;unsafe\\x07') + expect(result.terminal.output).toContain('\u001B[1mTRUSTED\u001B[22m') + expect(result.terminal.output).not.toContain('\u001B]2;unsafe\u0007') + await dispose(result) + }) + + it('redraws when an out-of-band prompt value changes on its own schedule', async () => { + // A plugin-owned value that changes without any other UI event must still + // repaint: the registry notifies the renderer through its subscription. + const result = await setup({ config: { theme: { leftPrompt: '${custom}${model}' } } }) + const handle = result.ctx.tuiPrompt.register('custom', 'BEFORE ') + await tick() + expect(result.terminal.output).toContain('BEFORE ') + + result.terminal.output = '' + handle.set('AFTER ') + // The coalesced notification lands on a microtask; no session/agent event fires. + await tick() + expect(result.terminal.output).toContain('AFTER ') + expect(result.terminal.output).not.toContain('BEFORE ') + await dispose(result) + }) + + it('tracks steering drains without a running status line', async () => { + const result = await setup() + const source = { kind: 'user' as const } + result.ctx.emit('agent/inbox/enqueue', result.agent, { id: AgentMessageId('stub'), content: [{ type: 'text', text: 'early' }], source }, 'steering') + result.session.append('steering/message', { turn: 1, content: [{ type: 'text', text: 'early' }], source }, { surfaceOp: 'append' }) + await tick() + expect(result.terminal.output).not.toContain('queued') + await dispose(result) + }) + + it('refreshes the running turn timing on its own timer', async () => { + let now = 1_700_000_000_000 + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => now) + const intervals = vi.spyOn(globalThis, 'setInterval') + let result: Awaited> | undefined + try { + result = await setup({ status: 'running', now: () => now }) + // The running prompt animates at ~20 fps (50 ms); the same tick keeps the + // elapsed timing text current, so no separate timing-only timer exists. + const refresh = intervals.mock.calls.find(([, interval]) => interval === 50)?.[0] + if (typeof refresh !== 'function') throw new Error('TUI did not register its running-status refresh interval') + result.terminal.output = '' + now += 1_000 + refresh() + await tick() + expect(result.terminal.output).toContain('Model wait 1.0s') + } finally { + if (result !== undefined) await dispose(result) + intervals.mockRestore() + nowSpy.mockRestore() + } + }) + + it('shows minutes and seconds in accumulated timing', async () => { + let clock = 1_700_000_000_000 + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => clock) + const result = await setup({ status: 'running' }) + clock += 95_000 + result.terminal.output = '' + result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'hi' } }) + await tick() + expect(result.terminal.output).toContain('Model wait 1m35.0s') + nowSpy.mockRestore() + await dispose(result) + }) + + it('trails the completed step timing below the step tool cards, not above them', async () => { + const clock = new Date(2026, 6, 21, 12, 0, 0).getTime() + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(clock) + const result = await setup({ status: 'running' }) + // A step whose assistant message drives a tool call: the tool card is + // appended after the assistant text, so the timing footer must follow the + // tool output rather than sit above it (its first message). + appendAssistant(result.session, [ + { type: 'text', text: 'Running a command' }, + { type: 'tool-call', id: 'c1' as never, name: 'bash', arguments: '{}' }, + ]) + result.session.append('tool/call', { turn: 1, step: 1, callId: 'c1' as never, name: 'bash', arguments: '{}' }) + result.session.append('tool/result', { + turn: 1, step: 1, callId: 'c1' as never, content: [{ type: 'text', text: 'command output' }], isError: false, + }, { surfaceOp: 'append' }) + result.terminal.output = '' + result.session.append('step/end', { turn: 1, step: 1 }) + await tick() + + const frame = result.terminal.output + const toolAt = frame.indexOf('command output') + const timingAt = frame.indexOf('Completed 2026-07-21 12:00:00') + expect(toolAt).toBeGreaterThanOrEqual(0) + expect(timingAt).toBeGreaterThan(toolAt) + nowSpy.mockRestore() + await dispose(result) + }) + + it('preserves accumulated timing across a mid-turn color-scheme change', async () => { + let clock = 1_700_000_000_000 + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => clock) + const result = await setup({ status: 'running' }) + clock += 1_000 + result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'answering' } }) + clock += 4_000 result.terminal.output = '' result.terminal.send('\x1b[?997;2n') await tick() await tick() - expect(result.terminal.output).toContain('Responding 4s') - expect(result.terminal.output).not.toContain('Waiting for the first token') + expect(result.terminal.output).toContain('Model wait 1.0s · Response 4.0s') nowSpy.mockRestore() await dispose(result) @@ -1569,7 +1931,7 @@ describe('pi-tui chat lifecycle and transcript', () => { it('renders the ANSI palette and every markdown/content style', async () => { const result = await setup({ cwd: '/workspace', - config: { color: true }, + config: { theme: { color: true } }, beforeMount(session) { session.append('user/message', { content: [ @@ -1583,7 +1945,7 @@ describe('pi-tui chat lifecycle and transcript', () => { }, { surfaceOp: 'append' }) appendAssistant(session, [ { type: 'reasoning', text: 'styled reasoning' }, - { type: 'text', text: 'styled answer' }, + { type: 'text', text: 'styled answer\n\n```ts\nconst answer = 42\n```' }, ], { inputTokens: 2_000_000, outputTokens: 1_500_000 }) session.append('todo/write', { todos: [ { content: 'done', status: 'completed' }, @@ -1605,6 +1967,8 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('nested result') expect(result.terminal.output).toContain('[future-block]') expect(result.terminal.output).toContain('[content]') + expect(result.terminal.output).toContain('\x1b[36mconst answer = 42\x1b[39m') + expect(result.terminal.output).not.toContain('```') expect(result.terminal.output).toContain('↑2.0m ↓1.5m') await dispose(result) }) @@ -1642,7 +2006,7 @@ describe('pi-tui chat lifecycle and transcript', () => { }, }) await vi.waitFor(() => { - expect(homeResult.terminal.output).toContain('~ ↑25k ↓10k') + expect(homeResult.terminal.output).toContain('~ (tui-staging) deepseek-v4-flash ↑25k ↓10k') }) await dispose(homeResult) @@ -1726,6 +2090,16 @@ describe('pi-tui chat lifecycle and transcript', () => { contextTokens: 42_000, config: { showReasoning: false }, agentOptions: { provider: 'deepseek', model: 'deepseek-v4-pro' }, + tools: { + read: { + name: 'read', description: 'Read a file', parameters: {}, + output: { schema: { type: 'null' }, render: () => [] }, execute: async () => null, + }, + write: { + name: 'write', description: 'Write a file', parameters: {}, + output: { schema: { type: 'null' }, render: () => [] }, execute: async () => null, + }, + }, beforeMount(session) { session.append('session/title', { title: 'Inspect status \u001B]2;unsafe\u0007', @@ -1746,11 +2120,18 @@ describe('pi-tui chat lifecycle and transcript', () => { }) }, }) + result.ctx.systemPrompt.section({ + name: 'test:status', + order: 1, + text: 'Current instructions \u001B]2;prompt-unsafe\u0007', + }) result.agent.status = 'running' agentEvents(result.ctx, result.agent).emit('agent/status', 'running') result.terminal.send('/status') result.terminal.send('\r') - await tick() + await vi.waitFor(() => { + expect(result.terminal.output).toContain('Session status') + }) expect(result.terminal.output).toContain('Session status') expect(result.terminal.output).toContain('main-session') @@ -1763,6 +2144,11 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('[███████████░░░░░] 67% hit (3,000 read + 250 write)') expect(result.terminal.output).toContain('[█████░░░░░░░░░░░] 33% used (42,000 / 128,000)') expect(result.terminal.output).toContain('2026-07-22 09:10:11 UTC') + expect(result.terminal.output).toContain('System prompt') + expect(result.terminal.output).toContain('You are an AI agent powered by the DeepSeek Harness SDK.') + expect(result.terminal.output).toContain('Current instructions \\x1b]2;prompt-unsafe\\x07') + expect(result.terminal.output).toContain('Registered tools') + expect(result.terminal.output).toContain('read, write') expect(result.terminal.output).not.toContain('\u001B]2;unsafe\u0007') result.terminal.resize(56) @@ -1798,10 +2184,22 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('n/a (0 read + 0 write)') expect(result.terminal.output).toContain('7 used · capacity unknown') expect(result.terminal.output).toContain('2026-07-22 10:11:12 UTC') + expect(result.terminal.output).toContain('You are an AI agent powered by the DeepSeek Harness SDK.') + expect(result.terminal.output).toContain('(none)') await dispose(result) dateNow.mockRestore() }) + it('/quit exits while idle', async () => { + const result = await setup() + result.terminal.send('/quit') + result.terminal.send('\r') + await tick() + + expect(result.exit).toHaveBeenCalledWith(0) + await dispose(result) + }) + it('sends, steers, handles commands, global keys, and disposed-agent input', async () => { const result = await setup() @@ -2159,7 +2557,7 @@ describe('pi-tui chat lifecycle and transcript', () => { await mkdir(join(cwd, 'docs'), { recursive: true }) await writeFile(join(cwd, 'src', 'source-file.ts'), 'export const source = true\n') await writeFile(join(cwd, 'docs', 'design notes.md'), '# Design\n') - await writeFile(join(cwd, 'unsafe\u007ffile.ts'), 'unsafe name\n') + await writeFile(join(cwd, 'unsafe\nfile.ts'), 'unsafe name\n') const result = await setup({ cwd, tools: { @@ -2195,12 +2593,13 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('Folder · docs/') }) result.terminal.send('\t') - result.terminal.output = '' + await vi.waitFor(() => { + expect(result.terminal.output).toContain('File · design notes.md') + }) result.terminal.send('\t') await vi.waitFor(() => { expect(result.terminal.output).toContain('@"docs/design notes.md"') }) - await tick() result.terminal.send('\r') await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(2) }) expect(result.agent.sent[1]).toEqual([{ type: 'text', text: '@"docs/design notes.md"' }]) @@ -2612,14 +3011,12 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('advertised by multiple providers') expect(result.terminal.output).toContain('already alpha/a1') - const firstSelectorOutput = result.terminal.output.length result.terminal.send('/model') result.terminal.send('\r') result.terminal.send('/model') result.terminal.send('\r') - await vi.waitFor(() => { - expect(result.terminal.output.slice(firstSelectorOutput)).toContain('Select model') - }) + await tick() + expect(result.terminal.output).toContain('Select model') result.terminal.send('\x1b') await tick() @@ -2708,16 +3105,13 @@ describe('pi-tui chat lifecycle and transcript', () => { )).resolves.toEqual({ provider: 'beta', model: 'shared' }) result.agent.status = 'running' - const runningSelectorOutput = result.terminal.output.length result.terminal.send('/model') result.terminal.send('\r') - await vi.waitFor(() => { - const output = result.terminal.output.slice(runningSelectorOutput) - expect(output).toContain('Select model') - expect(output).toContain('alpha/a1') - expect(output).toContain('Alpha One — Fast — Low — current') - expect(output).toContain('Beta One — High') - }) + await tick() + expect(result.terminal.output).toContain('Select model') + expect(result.terminal.output).toContain('alpha/a1') + expect(result.terminal.output).toContain('Alpha One — Fast — Low — current') + expect(result.terminal.output).toContain('Beta One — High') result.terminal.send('\x1b[B') result.terminal.send('\x1b[B') result.terminal.send('\x1b[Z') @@ -2731,14 +3125,11 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.agent.steered).toEqual([]) initialContext.resolve({ contextWindow: 100 }) await tick() - expect(result.terminal.output).not.toContain('50% context tools:collapsed') + expect(result.terminal.output).not.toContain('50% context') - const cancelledSelectorOutput = result.terminal.output.length result.terminal.send('/model') result.terminal.send('\r') - await vi.waitFor(() => { - expect(result.terminal.output.slice(cancelledSelectorOutput)).toContain('Select model') - }) + await tick() result.terminal.send('\x1b') await tick() expect(result.agent.cancelled).not.toContain('cancelled from terminal') @@ -2746,7 +3137,8 @@ describe('pi-tui chat lifecycle and transcript', () => { result.ctx.emit('agent/status', result.agent, 'idle') await tick() expect(result.terminal.output).toContain('b1 max ') - expect(result.terminal.output).toContain('25% context tools:collapsed') + expect(result.terminal.output).toContain('25% context') + expect(result.terminal.output).not.toContain('tools:collapsed') result.terminal.send('/status') result.terminal.send('\r') await tick() @@ -2810,7 +3202,7 @@ describe('pi-tui chat lifecycle and transcript', () => { }) }, }) - expect(resumedDefault.terminal.output).toContain('default • main-session') + expect(resumedDefault.terminal.output).toContain('default ↑0 ↓0') await dispose(resumedDefault) const unset = await setup({ @@ -3042,9 +3434,9 @@ describe('pi-tui chat lifecycle and transcript', () => { await result.ctx.fiber.dispose() }) - it('cancels before /exit while running and handles agent errors/disposal', async () => { + it('cancels before /quit while running and handles agent errors/disposal', async () => { const result = await setup({ status: 'running' }) - result.terminal.send('/exit') + result.terminal.send('/quit') result.terminal.send('\r') await tick() expect(result.agent.cancelled).toContainEqual({ kind: 'user' }) @@ -3052,7 +3444,7 @@ describe('pi-tui chat lifecycle and transcript', () => { const events = await setup() const unrelatedSession = events.ctx.sessions.create(SessionId('unrelated-session')) - const unrelatedAgent = { ...events.agent, id: unrelatedSession.id, session: unrelatedSession } as unknown as Agent + const unrelatedAgent = { ...events.agent, id: unrelatedSession.id, session: unrelatedSession } unrelatedSession.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) unrelatedSession.append('todo/write', { todos: [{ content: 'hidden', status: 'pending' }] }) agentEvents(events.ctx, unrelatedAgent).emit('agent/status', 'running') @@ -3074,6 +3466,11 @@ describe('pi-tui chat lifecycle and transcript', () => { turn: 6, reason: { kind: 'error', step: 1, failure: { message: 'structured provider failure', code: 'SERVER' } }, }) + events.session.append('turn/start', { turn: 8, trigger: { kind: 'message', source: { kind: 'user' } } }) + events.session.append('turn/end', { turn: 8, reason: { kind: 'disposed' } }) + events.session.append('turn/start', { turn: 9, trigger: { kind: 'message', source: { kind: 'user' } } }) + // Merge-extensible reason kind unknown to the TUI still names the stop. + events.session.append('turn/end', { turn: 9, reason: { kind: 'plugin-policy' } as never }) agentEvents(events.ctx, events.agent).emit('agent/disposed') await tick() expect(events.terminal.output).toContain('live failure') @@ -3082,6 +3479,8 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(events.terminal.output).toContain('structured provider failure') expect(events.terminal.output).toContain('output-token limit') expect(events.terminal.output).toContain('previous process ended') + expect(events.terminal.output).toContain('Turn stopped: the agent was disposed') + expect(events.terminal.output).toContain('Turn ended: plugin-policy') expect(events.terminal.output).toContain('was disposed') await dispose(events) }) @@ -3113,14 +3512,19 @@ describe('skill slash command', () => { const skills = ctx.get('skills') if (skills === undefined) throw new Error('skills service not mounted') skills.register({ name: 'demo-skill', description: 'Demo skill for tests', source: 'runtime', provider: 'runtime', content: 'Demo instructions body.' }) + skills.register({ name: 'project-skill', description: 'Project skill for tests', source: 'project-dsh', provider: 'runtime', content: 'Project instructions body.' }) skills.register({ name: 'hidden-skill', description: 'Model-hidden skill', source: 'runtime', provider: 'runtime', content: 'Hidden instructions body.', disableModelInvocation: true }) } - it('offers non-hidden skills as slash completions and hides model-disabled ones', async () => { + it('labels slash completions by scope and hides model-disabled skills', async () => { const result = await setup({ configureContext: withSkills }) result.terminal.send('/skill') await tick() expect(result.terminal.output).toContain('demo-skill') + expect(result.terminal.output).toContain('(user)') + expect(result.terminal.output).toContain('project-skill') + expect(result.terminal.output).toContain('(project)') + expect(result.terminal.output).not.toContain('[instructions]') expect(result.terminal.output).not.toContain('hidden-skill') await dispose(result) }) @@ -3277,10 +3681,22 @@ describe('tool cards and surface replay', () => { }), presentResult: () => ({ card: 'diff', diffs: [{ path: 'a.txt', oldText: null, newText: 'created' }] }), }, + singleDiff: { + name: 'singleDiff', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [], + presentCall: () => ({ + card: 'diff', + title: 'Edit src/only.ts', + diffs: [{ path: 'src/only.ts', oldText: 'old', newText: 'new' }], + }), + }, generic: { name: 'generic', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [], presentCall: () => ({ card: 'generic', title: 'Inspect value', rawInput: { alpha: 1 } }), - presentResult: () => ({ card: 'generic', title: 'Inspected', content: [{ type: 'text', text: 'result text' }] }), + presentResult: () => ({ + card: 'generic', + title: 'Inspected', + content: [{ type: 'text', text: 'result **text**\n\n```console\nstarted background task bash-5\n```' }], + }), }, throwing: { name: 'throwing', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [], @@ -3291,6 +3707,24 @@ describe('tool cards and surface replay', () => { name: 'rawTerminal', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [], presentCall: () => ({ card: 'terminal', title: 'raw command' }), }, + // An empty-string description is treated as no description: the header omits + // the ` / ` segment, exactly as an absent description does. + emptyDescTerminal: { + name: 'emptyDescTerminal', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [], + presentCall: () => ({ card: 'terminal', title: 'blank desc command', description: '' }), + }, + // A generic card whose title only repeats the tool name and carries no + // content or rawInput renders a header with an empty body block. + emptyBody: { + name: 'emptyBody', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [], + presentCall: () => ({ card: 'generic', title: 'emptyBody' }), + }, + multilineTerminal: { + name: 'multilineTerminal', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [], + // A multi-line bash command as the title/description: the card title and the + // meta rows are single logical lines and must render inline, not break rows. + presentCall: () => ({ card: 'terminal', title: 'S=/tmp\necho "$S"', description: 'set\nand echo' }), + }, undefinedViews: { name: 'undefinedViews', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [], presentCall: () => undefined, @@ -3309,6 +3743,10 @@ describe('tool cards and surface replay', () => { name: 'symbolic', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [], presentCall: () => ({ card: 'generic', title: 'Symbol input', rawInput: Symbol('input') }), }, + knownXml: { + name: 'knownXml', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [], + presentCall: () => ({ card: 'generic', title: 'Known XML' }), + }, } it('uses terminal, diff, generic, fallback, and collapsed tool presentations', async () => { @@ -3321,10 +3759,14 @@ describe('tool cards and surface replay', () => { ['c5', 'throwing', '{}'], ['c6', 'unknown', 'not-json'], ['c7', 'rawTerminal', '{"value":"raw"}'], + ['c14', 'emptyDescTerminal', '{}'], + ['c15', 'emptyBody', '{}'], + ['c9', 'multilineTerminal', '{}'], ['c8', 'undefinedViews', '{"value":8}'], ['c10', 'empty', '{}'], ['c11', 'terminalResult', '{}'], ['c12', 'symbolic', '{}'], + ['c13', 'knownXml', '{}'], ] as const appendAssistant(result.session, [ { type: 'text', text: 'Calling tools' }, @@ -3373,11 +3815,16 @@ describe('tool cards and surface replay', () => { result.session.append('tool/result', { turn: 1, step: 1, callId: 'c11' as never, content: [{ type: 'text', text: '\nconverted terminal\n\nfinished\n' }], isError: false, }, { surfaceOp: 'append' }) + result.session.append('tool/result', { + turn: 1, step: 1, callId: 'c13' as never, + content: [{ type: 'text', text: 'literal' }], + isError: false, + }, { surfaceOp: 'append' }) result.session.append('tool/result', { turn: 1, step: 1, callId: 'orphan' as never, - content: [{ type: 'text', text: 'orphan result' }], + content: [{ type: 'text', text: '/tmp/a.txthelloworld' }], isError: true, error: { name: 'InterruptedError', code: 'interrupted' }, }, { surfaceOp: 'append' }) @@ -3386,11 +3833,32 @@ describe('tool cards and surface replay', () => { const output = result.terminal.output expect(output).toContain('Run command') expect(output).toContain('printf hello') + // A multi-line terminal title and description render inline (newline escaped + // to `\x0a`), so they cannot break onto extra rows and collide with the body. + expect(output).toContain('S=/tmp\\x0aecho "$S"') + expect(output).toContain('set\\x0aand echo') expect(output).toContain('lines (Ctrl+O to expand)') expect(output).toContain('SIGTERM') - expect(output).toContain('Edit files') + // The header is a fixed `Tool / ` frame; the tool name shows there. + expect(output).toContain('Tool / bash') + expect(output).toContain('Tool / edit') + // An empty-string terminal description contributes no ` / ` segment; + // the header ends at the tool name, and the command shows as the body $-line. + expect(output).toContain('Tool / emptyDescTerminal') + expect(output).not.toContain('Tool / emptyDescTerminal /') + expect(output).toContain('$ blank desc command') + // A card whose title only repeats the name renders header-only (empty body). + expect(output).toContain('Tool / emptyBody') + // A diff card drops its title (the paths + change footer carry the meaning). + // The first file's path is head-visible; the second file and the change + // footer sit past this card's 4-line budget and appear only when expanded. + expect(output).not.toContain('Edit files') + expect(output).toContain('a.txt') + // A generic card's presenter title moves from the header into the body. expect(output).toContain('Inspected') expect(output).toContain('result text') + expect(output).toContain('started background task bash-5') + expect(output).not.toContain('```console') expect(output).toContain('Presenter failed') expect(output).toContain('not-json') expect(output).toContain('nested output') @@ -3398,7 +3866,10 @@ describe('tool cards and surface replay', () => { expect(output).toContain('undefined presenter output') expect(output).toContain('Empty card') expect(output).toContain('converted terminal') - expect(output).toContain('orphan result') + expect(output).toContain('literal') + expect(output).toContain('path: /tmp/a.txt') + expect(output).toContain('line (number="1"): hello') + expect(output).not.toContain('') result.terminal.send('/redraw') result.terminal.send('\r') @@ -3407,11 +3878,41 @@ describe('tool cards and surface replay', () => { expect(collapsed).toContain('Run command') expect(collapsed).toContain('[exit 0]') expect(collapsed).not.toContain('▌ hello') - expect(collapsed).not.toContain('world') + expect(collapsed).not.toContain('▌ world') result.terminal.send('\x0f') await tick() expect(result.terminal.output).toContain('world') + expect(result.terminal.output).toContain('Tool cards expanded.') + expect(result.terminal.output).not.toContain('tools:expanded') expect(result.terminal.output).toContain('+ created') + expect(result.terminal.output).toContain('console') + // The multi-file diff's second-file change and its footer surface once + // expanded (`+ after` is b.txt's new text; the footer counts both files). + expect(result.terminal.output).toContain('+ after') + expect(result.terminal.output).toContain('· 2 files') + await dispose(result) + }) + + it('names a single-file diff in the body once, under a fixed Tool header', async () => { + const result = await setup({ tools }) + appendUser(result.session, 'edit one file') + appendAssistant(result.session, [ + { type: 'text', text: 'Editing' }, + { type: 'tool-call', id: 'single' as never, name: 'singleDiff', arguments: '{}' }, + ]) + result.session.append('tool/call', { + turn: 1, step: 1, callId: 'single' as never, name: 'singleDiff', arguments: '{}', + }) + await tick() + const output = result.terminal.output + // The header is a fixed `Tool / ` frame; the diff title is dropped and + // the file path shows once in the body, above the change footer. + expect(output).toContain('Tool / singleDiff') + expect(output).not.toContain('Edit src/only.ts') + expect(output.split('src/only.ts').length - 1).toBe(1) + expect(output).toContain('- old') + expect(output).toContain('+ new') + expect(output).toContain('· 1 file') await dispose(result) }) @@ -3482,6 +3983,8 @@ describe('TUI user-interaction dialogs', () => { questions: [{ id: 'other', question: 'Choose or type', options: [{ label: 'Default' }] }], }) await tick() + const singleOptionRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('Choose or type')) + expect(singleOptionRender).not.toContain('↑/↓ navigate') result.terminal.send('\t') result.terminal.send('my choice') result.terminal.send('\r') @@ -3499,7 +4002,7 @@ describe('TUI user-interaction dialogs', () => { }) it('handles option wrapping, deselection errors, and returning from custom input', async () => { - const result = await setup({ config: { color: true } }) + const result = await setup({ config: { theme: { color: true } } }) const single = result.ctx.userInteraction.ask({ questions: [{ id: 'single', question: 'Single options', options: [{ label: 'One' }, { label: 'Two' }] }], }) @@ -3723,7 +4226,7 @@ describe('TUI extension service', () => { const secondTerminal = new FakeTerminal() const secondController = createTuiChat(result.ctx, { sessionId: result.agent.id, - color: false, + theme: { color: false }, welcome: 'Mounted again.', }, { terminal: secondTerminal, @@ -3748,6 +4251,7 @@ describe('terminal mounting', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(CommandService) await ctx.plugin(UserInteractionService) + await ctx.plugin(TuiPromptService) ctx.provide('tools', { get: () => undefined } as never) const session = ctx.sessions.create(SessionId('main')) ctx.agents.register({ @@ -3755,7 +4259,7 @@ describe('terminal mounting', () => { followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), }) const terminal = new FakeTerminal() - mountTui(ctx, { color: false }, { terminal, exit: vi.fn() }) + mountTui(ctx, { theme: { color: false } }, { terminal, exit: vi.fn() }) await tick() expect(terminal.started).toBe(1) await ctx.fiber.dispose() @@ -3772,6 +4276,7 @@ describe('terminal mounting', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(CommandService) await ctx.plugin(UserInteractionService) + await ctx.plugin(TuiPromptService) ctx.provide('tools', { get: () => undefined } as never) const session = ctx.sessions.create(SessionId('main')) ctx.agents.register({ @@ -3781,9 +4286,9 @@ describe('terminal mounting', () => { const terminal = new FakeTerminal() // Mirror dsh-tui's own inject (minus loader, the absence under test). await ctx.plugin({ - inject: ['agents', 'commands', 'userInteraction', 'tools', 'llm', 'tokenMeter'], + inject: ['agents', 'commands', 'userInteraction', 'tools', 'llm', 'tokenMeter', 'tuiPrompt'], apply: (pluginCtx: Context) => { - mountTui(pluginCtx, { color: false }, { terminal, exit: vi.fn() }) + mountTui(pluginCtx, { theme: { color: false } }, { terminal, exit: vi.fn() }) }, }) await tick() @@ -3802,9 +4307,10 @@ describe('terminal mounting', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(CommandService) await ctx.plugin(UserInteractionService) + await ctx.plugin(TuiPromptService) ctx.provide('tools', { get: () => undefined } as never) const terminal = new FakeTerminal() - mountTui(ctx, { sessionId: 'late-session', color: false }, { terminal, exit: vi.fn() }) + mountTui(ctx, { sessionId: 'late-session', theme: { color: false } }, { terminal, exit: vi.fn() }) expect(terminal.started).toBe(0) const otherSession = ctx.sessions.create(SessionId('other-session')) @@ -3832,10 +4338,11 @@ describe('terminal mounting', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(CommandService) await ctx.plugin(UserInteractionService) + await ctx.plugin(TuiPromptService) ctx.provide('tools', { get: () => undefined } as never) const terminal = new FakeTerminal() const exit = vi.fn() - mountTui(ctx, { sessionId: 'main-session', color: false }, { terminal, exit }) + mountTui(ctx, { sessionId: 'main-session', theme: { color: false } }, { terminal, exit }) ctx.emit('agent-loop/config-start-failed', SessionId('other-session'), new Error('other failed')) expect(terminal.output).toBe('') @@ -3861,11 +4368,12 @@ describe('terminal mounting', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(CommandService) await ctx.plugin(UserInteractionService) + await ctx.plugin(TuiPromptService) ctx.provide('tools', { get: () => undefined } as never) const terminal = new FakeTerminal() const exit = vi.fn() - mountTui(ctx, { sessionId: 'main-session', color: false }, { terminal, exit }) + mountTui(ctx, { sessionId: 'main-session', theme: { color: false } }, { terminal, exit }) ctx.emit('agent-loop/config-start-failed', SessionId('main-session'), { toString(): string { throw new Error('coercion failed') }, }) @@ -3883,6 +4391,7 @@ describe('terminal mounting', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(CommandService) await ctx.plugin(UserInteractionService) + await ctx.plugin(TuiPromptService) ctx.provide('tools', { get: () => undefined } as never) const session = ctx.sessions.create(SessionId('failed-start-session')) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) @@ -3894,7 +4403,7 @@ describe('terminal mounting', () => { const terminal = new FakeTerminal() terminal.start = () => { throw new Error('terminal startup failed') } - expect(() => createTuiChat(ctx, { sessionId: 'failed-start-session', color: false }, { terminal, exit: vi.fn() })) + expect(() => createTuiChat(ctx, { sessionId: 'failed-start-session', theme: { color: false } }, { terminal, exit: vi.fn() })) .toThrow('terminal startup failed') await tick() expect(ctx.commands.list(ctx.agents.get(SessionId('failed-start-session'))!)).toEqual([]) @@ -3919,6 +4428,7 @@ describe('terminal mounting', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(CommandService) await ctx.plugin(UserInteractionService) + await ctx.plugin(TuiPromptService) ctx.provide('tools', { get: () => undefined } as never) const runtime: TuiRuntime = { terminal: new FakeTerminal(), exit: vi.fn() } expect(() => createTuiChat(ctx, { sessionId: 'missing' }, runtime)).toThrow('is not running') @@ -3926,9 +4436,9 @@ describe('terminal mounting', () => { }) it('detects a light terminal color scheme and switches from dark- to light-optimised ANSI codes', async () => { - const result = await setup({ config: { color: true } }) + const result = await setup({ config: { theme: { color: true } } }) // Initial render uses dark-optimised palette: SGR 2 (dim) for dim text. - expect(result.terminal.output).toContain('\x1b[2mdeepseek-v4-flash') + expect(result.terminal.output).toContain('\x1b[90mdeepseek-v4-flash') // A report matching the current scheme is a no-op: no palette rebuild or // re-render (ESC [?997;1n = dark, the startup default). @@ -3937,7 +4447,8 @@ describe('terminal mounting', () => { await tick() expect(result.terminal.output.length).toBe(beforeSameScheme) - // ESC [?997;2n reports light; ESC [?997;1n reports dark. + // Simulate the terminal responding with a light color scheme report + // (ESC [?997;2n = light, ESC [?997;1n = dark). result.terminal.send('\x1b[?997;2n') await tick() await tick() @@ -3949,10 +4460,12 @@ describe('terminal mounting', () => { // uses ANSI 90 for the same header text. expect(result.terminal.output).toContain('\x1b[90mdeepseek-v4-flash') + // Switch back to dark scheme. result.terminal.send('\x1b[?997;1n') await tick() await tick() - expect(result.terminal.output).toContain('\x1b[2mdeepseek-v4-flash') + // After switching back, a new write uses SGR 2 for the header detail. + expect(result.terminal.output).toContain('\x1b[90mdeepseek-v4-flash') await dispose(result) }) @@ -3966,12 +4479,15 @@ describe('terminal mounting', () => { } } const terminal = new QueryFailTerminal() + // Anchor cwd under $HOME so the prompt renders the `~/` abbreviation + // deterministically; process.cwd() is not guaranteed under $HOME in CI. const result = await createTuiTestHarness(terminal, vi.fn(), { - config: { color: true }, - cwd: process.cwd(), + config: { theme: { color: true } }, + cwd: join(homedir(), 'projects', 'dsh-tui'), }) await tick() - expect(terminal.output).toContain('\x1b[2mdeepseek-v4-flash') + expect(terminal.output).toContain('\x1b[94m~/') + expect(terminal.output).toContain('\x1b[90m (tui-staging)') await disposeTuiTestHarness(result) }) it('runs /reload against every file-backed loader subtree, reports completion, and rejects re-entry while in flight', async () => { @@ -4068,7 +4584,7 @@ describe('banner sweep reveal', () => { // The product name carries a per-letter 24-bit gradient from the brand // indigo to light blue; the per-letter layout is pinned by the // `banner-gradient` terminal snapshot. - const result = await setup({ config: { color: true, truecolor: true } }) + const result = await setup({ config: { theme: { color: true, truecolor: true } } }) expect(result.terminal.output).toContain('\x1b[38;2;77;107;254m') expect(result.terminal.output).toContain('\x1b[38;2;36;152;255m') expect(result.terminal.output).toContain('HARNESS') diff --git a/packages/ui/tui/tests/xml-tool-output.spec.ts b/packages/ui/tui/tests/xml-tool-output.spec.ts new file mode 100644 index 0000000000..62fb475d73 --- /dev/null +++ b/packages/ui/tui/tests/xml-tool-output.spec.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest' +import { renderUnknownXml } from '../src/components/xml-tool-output.ts' + +const render = (source: string, limit = 4, expanded = false): string[] | undefined => renderUnknownXml( + source, + limit, + expanded, + text => text.replace(/[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/gu, control => + `\\x${control.charCodeAt(0).toString(16).padStart(2, '0')}`), + text => `[label]${text}[/label]`, + count => ` … +${count} lines`, +) + +describe('unknown-tool XML rendering', () => { + it('renders nested elements and attributes as an indented tree', () => { + expect(render(` + /tmp/a.txt + file + + hello + world + +`)).toEqual([ + '[label]result[/label]', + ' [label]path:[/label] /tmp/a.txt', + ' [label]type:[/label] file', + ' [label]content[/label]', + ' [label]line (number="1"):[/label] hello', + ' [label]line (number="2"):[/label] world', + ]) + }) + + it('renders root text, CDATA, empty elements, and multiline nested text', () => { + expect(render(' \nfirst\nsecond\n ')).toEqual([ + '[label]result[/label]', + ' first', + ' second', + ]) + expect(render('\nfirst\nsecond\n', 1, true)).toEqual([ + '[label]result[/label]', + ' first', + ' second', + ]) + expect(render(']]>')).toEqual([ + '[label]result[/label]', + ' [label]value:[/label] literal ', + ' [label]empty[/label]', + ]) + }) + + it('previews each top-level child independently and expands all rows', () => { + const xml = '\na\nb\nc\nd\ne\nf\n\ng\nh\ni\nj\nk\nl\n' + expect(render(xml, 3)).toEqual([ + '[label]result[/label]', + ' [label]first[/label]', + ' a', + ' … +4 lines', + ' f', + ' [label]second[/label]', + ' g', + ' … +4 lines', + ' l', + ]) + expect(render(xml, 3, true)).toHaveLength(15) + }) + + it('bounds the collapsed child count and counts the hidden lines', () => { + const xml = `${Array.from({ length: 8 }, (_, index) => `${index}`).join('')}` + expect(render(xml, 3)).toEqual([ + '[label]result[/label]', + ' [label]item:[/label] 0', + ' [label]item:[/label] 1', + ' … +5 lines', + ' [label]item:[/label] 7', + ]) + expect(render(xml, 3, true)).toHaveLength(9) + }) + + it('escapes control characters expanded from character references', () => { + expect(render('tab csi›')).toEqual([ + '[label]result (attr="a\\\\x9bb")[/label]', + ' tab\\x09csi\\x9b', + ]) + expect(render('')).toEqual([ + '[label]result[/label]', + ' [label]value:[/label] del\\x7f', + ]) + }) + + it.each([ + 'missing close', + '', + ' ', + 'prefix /tmp/a', + '/tmp/a suffix', + '', + '', + '', + '', + '', + ' \n ', + ])('declines malformed or mixed text: %s', (source) => { + expect(render(source)).toBeUndefined() + }) +}) diff --git a/packages/ui/tui/tsdown.config.ts b/packages/ui/tui/tsdown.config.ts new file mode 100644 index 0000000000..4d985b3971 --- /dev/null +++ b/packages/ui/tui/tsdown.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'tsdown' +import baseConfig from '../../../tsdown.config.ts' + +export default defineConfig({ + ...baseConfig, + entry: ['lib/types/index.js', 'lib/types/invariant.js', 'lib/types/prompt.js'], +}) diff --git a/patches/@earendil-works__pi-tui@0.80.7.patch b/patches/@earendil-works__pi-tui@0.80.7.patch new file mode 100644 index 0000000000..c14edb5f7e --- /dev/null +++ b/patches/@earendil-works__pi-tui@0.80.7.patch @@ -0,0 +1,346 @@ +diff --git a/dist/components/editor.d.ts b/dist/components/editor.d.ts +index a6fedc9e3b36d066e34860d040db6df47d88c432..f0b20eb87686215d1d1a54274a7b1ebdf4030ef1 100644 +--- a/dist/components/editor.d.ts ++++ b/dist/components/editor.d.ts +@@ -21,7 +21,7 @@ export interface TextChunk { + * When omitted the default Intl.Segmenter is used. + * @returns Array of chunks with text and position information + */ +-export declare function wordWrapLine(line: string, maxWidth: number, preSegmented?: Intl.SegmentData[]): TextChunk[]; ++export declare function wordWrapLine(line: string, maxWidth: number, preSegmented?: Intl.SegmentData[], continuationWidth?: number): TextChunk[]; + export interface EditorTheme { + borderColor: (str: string) => string; + selectList: SelectListTheme; +@@ -29,6 +29,13 @@ export interface EditorTheme { + export interface EditorOptions { + paddingX?: number; + autocompleteMaxVisible?: number; ++ /** Omit the editor's horizontal frame. */ ++ frame?: "horizontal" | "none"; ++ /** Fixed-width prefixes for the first input row and explicit newlines. Wrapped rows start at the editor edge. */ ++ prompt?: { ++ first: string; ++ continuation: string; ++ }; + } + export declare class Editor implements Component, Focusable { + private state; +@@ -79,6 +86,11 @@ export declare class Editor implements Component, Focusable { + getAutocompleteMaxVisible(): number; + setAutocompleteMaxVisible(maxVisible: number): void; + setAutocompleteProvider(provider: AutocompleteProvider): void; ++ /** Replace fixed-width first and continuation input prefixes. */ ++ setPrompt(prompt: { ++ first: string; ++ continuation: string; ++ }): void; + /** + * Add a prompt to history for up/down arrow navigation. + * Called after successful submission. +diff --git a/dist/components/editor.js b/dist/components/editor.js +index 6c03aeec4148571558713e885ac7f7df18a511bc..0111317ccab31a1e973b92272d8a668b75a29174 100644 +--- a/dist/components/editor.js ++++ b/dist/components/editor.js +@@ -79,7 +79,7 @@ function segmentWithMarkers(text, baseSegmenter, validIds) { + * When omitted the default Intl.Segmenter is used. + * @returns Array of chunks with text and position information + */ +-export function wordWrapLine(line, maxWidth, preSegmented) { ++export function wordWrapLine(line, maxWidth, preSegmented, continuationWidth = maxWidth) { + if (!line || maxWidth <= 0) { + return [{ text: "", startIndex: 0, endIndex: 0 }]; + } +@@ -90,6 +90,7 @@ export function wordWrapLine(line, maxWidth, preSegmented) { + const chunks = []; + const segments = preSegmented ?? [...graphemeSegmenter.segment(line)]; + let currentWidth = 0; ++ let currentMaxWidth = maxWidth; + let chunkStart = 0; + // Wrap opportunity: the position after the last whitespace before a non-whitespace + // grapheme, i.e. where a line break is allowed. +@@ -102,11 +103,12 @@ export function wordWrapLine(line, maxWidth, preSegmented) { + const charIndex = seg.index; + const isWs = !isPasteMarker(grapheme) && isWhitespaceChar(grapheme); + // Overflow check before advancing. +- if (currentWidth + gWidth > maxWidth) { +- if (wrapOppIndex >= 0 && currentWidth - wrapOppWidth + gWidth <= maxWidth) { ++ if (currentWidth + gWidth > currentMaxWidth) { ++ if (wrapOppIndex >= 0 && currentWidth - wrapOppWidth + gWidth <= continuationWidth) { + // Backtrack to last wrap opportunity (the remaining content + // plus the current grapheme still fits within maxWidth). + chunks.push({ text: line.slice(chunkStart, wrapOppIndex), startIndex: chunkStart, endIndex: wrapOppIndex }); ++ currentMaxWidth = continuationWidth; + chunkStart = wrapOppIndex; + currentWidth -= wrapOppWidth; + } +@@ -117,22 +119,29 @@ export function wordWrapLine(line, maxWidth, preSegmented) { + // the current grapheme (e.g. a wide character) still exceeds + // maxWidth. + chunks.push({ text: line.slice(chunkStart, charIndex), startIndex: chunkStart, endIndex: charIndex }); ++ currentMaxWidth = continuationWidth; + chunkStart = charIndex; + currentWidth = 0; + } + wrapOppIndex = -1; + } +- if (gWidth > maxWidth) { +- // Single atomic segment wider than maxWidth (e.g. paste marker ++ if (gWidth > currentMaxWidth) { ++ if (segments.length === 1) { ++ chunks.push({ text: grapheme, startIndex: charIndex, endIndex: charIndex + grapheme.length }); ++ return chunks; ++ } ++ // Single atomic segment wider than the current line width (e.g. paste marker + // in a narrow terminal). Re-wrap it at grapheme granularity. + // The segment remains logically atomic for cursor + // movement / editing — the split is purely visual for word-wrap layout. +- const subChunks = wordWrapLine(grapheme, maxWidth); ++ const subChunks = wordWrapLine(grapheme, currentMaxWidth, undefined, continuationWidth); + for (let j = 0; j < subChunks.length - 1; j++) { + const sc = subChunks[j]; + chunks.push({ text: sc.text, startIndex: charIndex + sc.startIndex, endIndex: charIndex + sc.endIndex }); + } + const last = subChunks[subChunks.length - 1]; ++ if (subChunks.length > 1) ++ currentMaxWidth = continuationWidth; + chunkStart = charIndex + last.startIndex; + currentWidth = visibleWidth(last.text); + wrapOppIndex = -1; +@@ -189,8 +198,12 @@ export class Editor { + tui; + theme; + paddingX = 0; ++ frame = "horizontal"; ++ prompt; ++ promptWidth = 0; + // Store last render width for cursor navigation + lastWidth = 80; ++ lastContinuationWidth = 80; + // Vertical scrolling support + scrollOffset = 0; + // Border color (can be changed dynamically) +@@ -243,9 +256,29 @@ export class Editor { + this.borderColor = theme.borderColor; + const paddingX = options.paddingX ?? 0; + this.paddingX = Number.isFinite(paddingX) ? Math.max(0, Math.floor(paddingX)) : 0; ++ this.frame = options.frame ?? "horizontal"; ++ this.prompt = options.prompt; ++ if (this.prompt) { ++ const firstWidth = visibleWidth(this.prompt.first); ++ const continuationWidth = visibleWidth(this.prompt.continuation); ++ if (firstWidth !== continuationWidth) { ++ throw new Error("Editor prompt prefixes must have equal visible widths"); ++ } ++ this.promptWidth = firstWidth; ++ } + const maxVisible = options.autocompleteMaxVisible ?? 5; + this.autocompleteMaxVisible = Number.isFinite(maxVisible) ? Math.max(3, Math.min(20, Math.floor(maxVisible))) : 5; + } ++ setPrompt(prompt) { ++ const firstWidth = visibleWidth(prompt.first); ++ const continuationWidth = visibleWidth(prompt.continuation); ++ if (firstWidth !== continuationWidth) { ++ throw new Error("Editor prompt prefixes must have equal visible widths"); ++ } ++ this.prompt = prompt; ++ this.promptWidth = firstWidth; ++ this.invalidate(); ++ } + /** Set of currently valid paste IDs, for marker-aware segmentation. */ + validPasteIds() { + return new Set(this.pastes.keys()); +@@ -364,14 +397,17 @@ export class Editor { + const maxPadding = Math.max(0, Math.floor((width - 1) / 2)); + const paddingX = Math.min(this.paddingX, maxPadding); + const contentWidth = Math.max(1, width - paddingX * 2); ++ const inputWidth = Math.max(1, contentWidth - this.promptWidth); + // Layout width: with padding the cursor can overflow into it, + // without padding we reserve 1 column for the cursor. +- const layoutWidth = Math.max(1, contentWidth - (paddingX ? 0 : 1)); +- // Store for cursor navigation (must match wrapping width) ++ const layoutWidth = Math.max(1, inputWidth - (paddingX ? 0 : 1)); ++ const continuationLayoutWidth = Math.max(1, contentWidth - (paddingX ? 0 : 1)); ++ // Store for cursor navigation (must match wrapping widths) + this.lastWidth = layoutWidth; ++ this.lastContinuationWidth = continuationLayoutWidth; + const horizontal = this.borderColor("─"); + // Layout the text +- const layoutLines = this.layoutText(layoutWidth); ++ const layoutLines = this.layoutText(layoutWidth, continuationLayoutWidth); + // Calculate max visible lines: 30% of terminal height, minimum 5 lines + const terminalRows = this.tui.terminal.rows; + const maxVisibleLines = Math.max(5, Math.floor(terminalRows * 0.3)); +@@ -396,16 +432,22 @@ export class Editor { + const rightPadding = leftPadding; + // Render top border (with scroll indicator if scrolled down) + if (this.scrollOffset > 0) { +- const indicator = `─── ↑ ${this.scrollOffset} more `; +- const remaining = width - visibleWidth(indicator); +- if (remaining >= 0) { +- result.push(this.borderColor(indicator + "─".repeat(remaining))); ++ if (this.frame === "none") { ++ const indicator = `${" ".repeat(this.promptWidth)}↑ ${this.scrollOffset} more`; ++ result.push(`${leftPadding}${this.borderColor(indicator)}${" ".repeat(Math.max(0, contentWidth - visibleWidth(indicator)))}${rightPadding}`); + } + else { +- result.push(this.borderColor(truncateToWidth(indicator, width))); ++ const indicator = `─── ↑ ${this.scrollOffset} more `; ++ const remaining = width - visibleWidth(indicator); ++ if (remaining >= 0) { ++ result.push(this.borderColor(indicator + "─".repeat(remaining))); ++ } ++ else { ++ result.push(this.borderColor(truncateToWidth(indicator, width))); ++ } + } + } +- else { ++ else if (this.frame === "horizontal") { + result.push(horizontal.repeat(width)); + } + // Render each visible layout line +@@ -413,7 +455,19 @@ export class Editor { + // hardware cursor for IME candidate-window placement even while + // autocomplete (e.g. slash-command menu) is visible. + const emitCursorMarker = this.focused; +- for (const layoutLine of visibleLines) { ++ for (let visibleIndex = 0; visibleIndex < visibleLines.length; visibleIndex++) { ++ const layoutLine = visibleLines[visibleIndex]; ++ if (!layoutLine) ++ continue; ++ const absoluteIndex = this.scrollOffset + visibleIndex; ++ const prefix = this.prompt ++ ? (absoluteIndex === 0 ++ ? this.prompt.first ++ : layoutLine.isContinuation ++ ? "" ++ : this.prompt.continuation) ++ : ""; ++ const lineContentWidth = inputWidth + (layoutLine.isContinuation ? this.promptWidth : 0); + let displayText = layoutLine.text; + let lineVisibleWidth = visibleWidth(layoutLine.text); + let cursorInPadding = false; +@@ -439,34 +493,41 @@ export class Editor { + displayText = before + marker + cursor; + lineVisibleWidth = lineVisibleWidth + 1; + // If cursor overflows content width into the padding, flag it +- if (lineVisibleWidth > contentWidth && paddingX > 0) { ++ if (lineVisibleWidth > lineContentWidth && paddingX > 0) { + cursorInPadding = true; + } + } + } + // Calculate padding based on actual visible width +- const padding = " ".repeat(Math.max(0, contentWidth - lineVisibleWidth)); ++ const padding = " ".repeat(Math.max(0, lineContentWidth - lineVisibleWidth)); + const lineRightPadding = cursorInPadding ? rightPadding.slice(1) : rightPadding; + // Render the line (no side borders, just horizontal lines above and below) +- result.push(`${leftPadding}${displayText}${padding}${lineRightPadding}`); ++ result.push(`${leftPadding}${prefix}${displayText}${padding}${lineRightPadding}`); + } + // Render bottom border (with scroll indicator if more content below) + const linesBelow = layoutLines.length - (this.scrollOffset + visibleLines.length); + if (linesBelow > 0) { +- const indicator = `─── ↓ ${linesBelow} more `; +- const remaining = width - visibleWidth(indicator); +- result.push(this.borderColor(indicator + "─".repeat(Math.max(0, remaining)))); ++ if (this.frame === "none") { ++ const indicator = `${" ".repeat(this.promptWidth)}↓ ${linesBelow} more`; ++ result.push(`${leftPadding}${this.borderColor(indicator)}${" ".repeat(Math.max(0, contentWidth - visibleWidth(indicator)))}${rightPadding}`); ++ } ++ else { ++ const indicator = `─── ↓ ${linesBelow} more `; ++ const remaining = width - visibleWidth(indicator); ++ result.push(this.borderColor(indicator + "─".repeat(Math.max(0, remaining)))); ++ } + } +- else { ++ else if (this.frame === "horizontal") { + result.push(horizontal.repeat(width)); + } + // Add autocomplete list if active + if (this.autocompleteState && this.autocompleteList) { +- const autocompleteResult = this.autocompleteList.render(contentWidth); ++ const autocompleteResult = this.autocompleteList.render(inputWidth); ++ const autocompletePrefix = " ".repeat(this.promptWidth); + for (const line of autocompleteResult) { + const lineWidth = visibleWidth(line); +- const linePadding = " ".repeat(Math.max(0, contentWidth - lineWidth)); +- result.push(`${leftPadding}${line}${linePadding}${rightPadding}`); ++ const linePadding = " ".repeat(Math.max(0, inputWidth - lineWidth)); ++ result.push(`${leftPadding}${autocompletePrefix}${line}${linePadding}${rightPadding}`); + } + } + return result; +@@ -726,7 +787,7 @@ export class Editor { + this.insertCharacter(data); + } + } +- layoutText(contentWidth) { ++ layoutText(contentWidth, continuationWidth) { + const layoutLines = []; + if (this.state.lines.length === 0 || (this.state.lines.length === 1 && this.state.lines[0] === "")) { + // Empty editor +@@ -734,6 +795,7 @@ export class Editor { + text: "", + hasCursor: true, + cursorPos: 0, ++ isContinuation: false, + }); + return layoutLines; + } +@@ -749,18 +811,20 @@ export class Editor { + text: line, + hasCursor: true, + cursorPos: this.state.cursorCol, ++ isContinuation: false, + }); + } + else { + layoutLines.push({ + text: line, + hasCursor: false, ++ isContinuation: false, + }); + } + } + else { + // Line needs wrapping - use word-aware wrapping +- const chunks = wordWrapLine(line, contentWidth, [...this.segment(line, "grapheme")]); ++ const chunks = wordWrapLine(line, contentWidth, [...this.segment(line, "grapheme")], continuationWidth); + for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) { + const chunk = chunks[chunkIndex]; + if (!chunk) +@@ -796,12 +860,14 @@ export class Editor { + text: chunk.text, + hasCursor: true, + cursorPos: adjustedCursorPos, ++ isContinuation: chunkIndex > 0, + }); + } + else { + layoutLines.push({ + text: chunk.text, + hasCursor: false, ++ isContinuation: chunkIndex > 0, + }); + } + } +@@ -1439,7 +1505,7 @@ export class Editor { + * - startCol: starting column in the logical line + * - length: length of this visual line segment + */ +- buildVisualLineMap(width) { ++ buildVisualLineMap(width, continuationWidth = this.lastContinuationWidth) { + const visualLines = []; + for (let i = 0; i < this.state.lines.length; i++) { + const line = this.state.lines[i] || ""; +@@ -1453,7 +1519,7 @@ export class Editor { + } + else { + // Line needs wrapping - use word-aware wrapping +- const chunks = wordWrapLine(line, width, [...this.segment(line, "grapheme")]); ++ const chunks = wordWrapLine(line, width, [...this.segment(line, "grapheme")], continuationWidth); + for (const chunk of chunks) { + visualLines.push({ + logicalLine: i, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7e098d6eba..05752f5381 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +patchedDependencies: + '@earendil-works/pi-tui@0.80.7': 6c30c5386c0159131e1361023cddf31377f5728962524841964373312c1ed946 + importers: .: @@ -11,6 +14,9 @@ importers: '@agentclientprotocol/sdk': specifier: 0.25.1 version: 0.25.1(zod@4.4.3) + '@deepseek-ai/dsh-tool-session-query': + specifier: workspace:^ + version: link:packages/session-query/tool-session-query '@stylistic/eslint-plugin': specifier: ^5.10.0 version: 5.10.0(eslint@10.5.0(jiti@2.7.0)) @@ -2656,6 +2662,10 @@ importers: version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/llm/llm: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 devDependencies: '@deepseek-ai/dsh-brand': specifier: workspace:^ @@ -2663,6 +2673,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) @@ -2692,8 +2705,8 @@ importers: packages/llm/llm-pi-ai: dependencies: '@earendil-works/pi-ai': - specifier: ^0.81.1 - version: 0.81.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) + specifier: ^0.82.1 + version: 0.82.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) schemastery: specifier: ^3.18.0 version: 3.18.0 @@ -4421,7 +4434,10 @@ importers: dependencies: '@earendil-works/pi-tui': specifier: 0.80.7 - version: 0.80.7 + version: 0.80.7(patch_hash=6c30c5386c0159131e1361023cddf31377f5728962524841964373312c1ed946) + saxes: + specifier: 6.0.0 + version: 6.0.0 schemastery: specifier: ^3.18.0 version: 3.18.0 @@ -5743,8 +5759,8 @@ packages: search-insights: optional: true - '@earendil-works/pi-ai@0.81.1': - resolution: {integrity: sha512-hzHE7Z8l5mgJk+ke67Lge0rwS2+wbKJrFKl9o5M1R1rh33+cCT7D1AHz1OAtX5wFs90E1/BTGhyJRTUHaMxGvQ==} + '@earendil-works/pi-ai@0.82.1': + resolution: {integrity: sha512-3WFYRhEp3lQB3444EhPMBcM7zSaEUE3eJgHOR7s4081NLqbw/FsWilIKWXSua0Gv3sRr7m9xMidR3pPDE7jI/A==} engines: {node: '>=22.19.0'} hasBin: true @@ -11015,7 +11031,7 @@ snapshots: transitivePeerDependencies: - '@algolia/client-search' - '@earendil-works/pi-ai@0.81.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)': + '@earendil-works/pi-ai@0.82.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)': dependencies: '@anthropic-ai/sdk': 0.91.1(zod@4.4.3) '@aws-sdk/client-bedrock-runtime': 3.1048.0 @@ -11036,7 +11052,7 @@ snapshots: - ws - zod - '@earendil-works/pi-tui@0.80.7': + '@earendil-works/pi-tui@0.80.7(patch_hash=6c30c5386c0159131e1361023cddf31377f5728962524841964373312c1ed946)': dependencies: get-east-asian-width: 1.6.0 marked: 18.0.5 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8da07afcf0..148971ff51 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -53,4 +53,7 @@ minimumReleaseAgeExclude: - cordis@4.0.0-rc.7 # Fresh pi-ai releases carry the model catalog updates that are the whole # point of bumping it; waiting out the release age would defeat that. - - '@earendil-works/pi-ai@0.81.1' + - '@earendil-works/pi-ai@0.82.1' + +patchedDependencies: + '@earendil-works/pi-tui@0.80.7': patches/@earendil-works__pi-tui@0.80.7.patch diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 0a664ca988..a30475d683 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -97,6 +97,7 @@ function workspaceManifests(): WorkspaceManifest[] { const packageFileExtras: Readonly> = { '@deepseek-ai/dsh-helper': ['lib/assets'], + '@deepseek-ai/dsh-tui': ['lib/prompt.js'], '@deepseek-ai/dsh-scripts': [ 'lib/dev/tsdown-config.js', 'lib/local-plugin-loader-hooks.js', diff --git a/scripts/demo-cordis.mjs b/scripts/demo-cordis.mjs new file mode 100644 index 0000000000..94bbea1332 --- /dev/null +++ b/scripts/demo-cordis.mjs @@ -0,0 +1,24 @@ +/** + * Boot the self-referential Cordis tools under TUI, Web, or ACP, defaulting + * to TUI. This is a repository demo wrapper, not a product CLI feature. + */ +import { spawn } from 'node:child_process' + +const SURFACES = new Map([ + ['tui', ['--import', 'tsx', 'apps/cli/src/bin.ts', '--config', 'examples/cordis-agent/cordis.yml']], + // `dsh web` does not accept alternate configs yet. The TUI config escape + // hatch still boots this browser-only tree; the config owns port 3081. + ['web', ['--import', 'tsx', 'apps/cli/src/bin.ts', '--config', 'examples/web-cordis/cordis.yml']], + ['acp', ['--import', 'tsx', 'packages/examples/acp-demo/src/bin.ts', '--config', 'examples/acp-agent/cordis-tools.cordis.yml']], +]) + +const surface = process.argv[2] ?? 'tui' +const args = SURFACES.get(surface) +if (args === undefined || process.argv.length > 3) { + console.error('usage: pnpm run demo:cordis [tui|web|acp]') + process.exit(2) +} + +if (surface === 'web') console.log('Cordis Web: http://127.0.0.1:3081') +const child = spawn(process.execPath, args, { stdio: 'inherit' }) +child.on('exit', (code, signal) => { process.exit(signal === null ? code ?? 1 : 1) }) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 5470ec01e7..86ef6d8477 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -47,6 +47,7 @@ export const LINK_MAP: Record = { LlmFailure: 'llm-streaming.md', LlmModelInfo: 'core.md', LlmProviderInfo: 'core.md', + ResolvedRetryPolicy: 'llm-streaming.md', Message: 'core.md', MessageSource: 'core.md', PromptDecision: 'core.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 065e00fd20..1d4a7e447d 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -589,7 +589,7 @@ const APP_EXAMPLES = [ title: 'Cordis Agent App Composition', label: 'examples/cordis-agent', config: 'examples/cordis-agent/cordis.yml', - summary: '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.', + summary: '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.', }, { id: 'acp', diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 3103b9878c..7bdc8b68a8 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -210,12 +210,12 @@ const TOOL_PACKAGES: ToolPackage[] = [ dir: 'tool-cordis', source: 'packages/cordis/tool-cordis/src/index.ts', requires: ['ctx.tools'], - writes: ['tool/call', 'tool/result', 'live plugin-tree mutations (mount/unmount)'], + writes: ['tool/call', 'tool/result', 'process-local temporary Plugin lifecycle'], async mount(ctx) { await ctx.plugin(ToolCordis) }, note: - '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.', }, { pkg: '@deepseek-ai/dsh-tool-fs', diff --git a/scripts/install-lefthook.mjs b/scripts/install-lefthook.mjs index 9256a9b462..49c246df69 100644 --- a/scripts/install-lefthook.mjs +++ b/scripts/install-lefthook.mjs @@ -1,21 +1,657 @@ #!/usr/bin/env node -import { existsSync } from 'node:fs' +import { randomUUID } from 'node:crypto' +import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' import { spawnSync } from 'node:child_process' -import { join } from 'node:path' +import { isAbsolute, join, resolve } from 'node:path' -const git = spawnSync('git', ['rev-parse', '--git-dir'], { stdio: 'ignore' }) -if (git.status !== 0) process.exit(0) +const MINIMUM_GIT = [2, 26, 0] +const HOOKS_DIRECTORY = 'dsh-hooks' +const OWNERSHIP_MARKER = '.dsh-lefthook-owned' +const OWNERSHIP_MARKER_VERSION = 1 +const OWNERSHIP_MARKER_OWNER = 'deepseek-harness worktree-local lefthook hooks' +const INSTALL_LOCK = 'dsh-lefthook-install.lock' +const INSTALL_LOCK_TIMEOUT_MS = 30_000 +const INSTALL_LOCK_POLL_MS = 50 +const ALLOW_HOOKS_PATH_OVERRIDE = 'DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE' +const REPOSITORY_EXTENSION_PATTERN = '^extensions\\.' -const isWindows = process.platform === 'win32' -const lefthook = join(process.cwd(), 'node_modules', '.bin', isWindows ? 'lefthook.cmd' : 'lefthook') -if (!existsSync(lefthook)) process.exit(0) +function errorCode(error) { + return typeof error === 'object' && error !== null && 'code' in error + ? error.code + : undefined +} -// On Windows the bin shim is a `.cmd` file, and recent Node (CVE-2024-27980) -// refuses to launch `.cmd`/`.bat` via spawn without `shell: true` — it returns -// `EINVAL` with a null status, which would otherwise fail postinstall. Quote -// the path because a shell re-parses the command line and the path may contain -// spaces. POSIX needs no shell: the extensionless shim is directly executable. -const result = isWindows - ? spawnSync(`"${lefthook}"`, ['install', '--force'], { stdio: 'inherit', shell: true }) - : spawnSync(lefthook, ['install', '--force'], { stdio: 'inherit' }) -process.exit(result.status ?? 1) +function commandFailure(command, args, result) { + const stderr = typeof result.stderr === 'string' ? result.stderr.trim() : '' + const detail = result.error?.message ?? (stderr || `exit status ${String(result.status)}`) + return new Error(`${command} ${args.join(' ')} failed: ${detail}`) +} + +function capture(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd, + encoding: 'utf8', + env: process.env, + }) + if (result.status !== 0 && !options.allowStatuses?.includes(result.status)) { + throw commandFailure(command, args, result) + } + return result +} + +function git(args, root, options = {}) { + return capture('git', args, { ...options, cwd: root }) +} + +function nulValues(result) { + if (result.status !== 0) return [] + if (result.stdout === '') return [''] + const output = result.stdout.endsWith('\0') ? result.stdout.slice(0, -1) : result.stdout + return output.split('\0') +} + +function stripGitLineTerminator(output) { + const withoutLineFeed = output.endsWith('\n') ? output.slice(0, -1) : output + return process.platform === 'win32' && withoutLineFeed.endsWith('\r') + ? withoutLineFeed.slice(0, -1) + : withoutLineFeed +} + +function directFileConfigValues(root, configPath, key) { + return nulValues(git( + ['config', '--file', configPath, '--no-includes', '--null', '--get-all', key], + root, + { allowStatuses: [1] }, + )) +} + +function parseFileConfigEntries(fields, key) { + if (fields.length % 2 !== 0) { + throw new Error(`git config returned invalid file entries for ${key}`) + } + const entries = [] + for (let index = 0; index < fields.length; index += 2) { + entries.push({ origin: fields[index], value: fields[index + 1] }) + } + return entries +} + +function includedFileConfigEntries(root, configPath, key) { + const fields = nulValues(git( + ['config', '--file', configPath, '--includes', '--null', '--show-origin', '--get-all', key], + root, + { allowStatuses: [1] }, + )) + return parseFileConfigEntries(fields, key) +} + +function splitConfigNameValue(field, pattern) { + const separator = field.indexOf('\n') + if (separator < 0) throw new Error(`git config returned an invalid name and value for ${pattern}`) + return { name: field.slice(0, separator), value: field.slice(separator + 1) } +} + +function directFileConfigMatchingEntries(root, configPath, pattern) { + const fields = nulValues(git( + ['config', '--file', configPath, '--no-includes', '--null', '--show-origin', '--get-regexp', pattern], + root, + { allowStatuses: [1] }, + )) + if (fields.length % 2 !== 0) { + throw new Error(`git config returned invalid matching file entries for ${pattern}`) + } + const entries = [] + for (let index = 0; index < fields.length; index += 2) { + entries.push({ origin: fields[index], ...splitConfigNameValue(fields[index + 1], pattern) }) + } + return entries +} + +function effectiveConfigEntry(root, key) { + const fields = nulValues(git( + ['config', '--null', '--show-scope', '--show-origin', '--get', key], + root, + { allowStatuses: [1] }, + )) + if (fields.length === 0) return undefined + if (fields.length !== 3) { + throw new Error(`git config returned an invalid scoped value for ${key}`) + } + const [scope, origin, value] = fields + return { origin, scope, value } +} + +function parseGitBoolean(value, key) { + const normalized = value.toLowerCase() + if (normalized === '' || normalized === 'true' || normalized === 'yes' || normalized === 'on' || normalized === '1') return true + if (normalized === 'false' || normalized === 'no' || normalized === 'off' || normalized === '0') return false + throw new Error(`invalid Boolean value for ${key}: ${JSON.stringify(value)}`) +} + +function assertSingle(values, key) { + if (values.length > 1) throw new Error(`multiple ${key} values are not supported`) + return values[0] +} + +function worktreeConfigExtensionEnabled(root, commonConfigPath) { + const extensionText = assertSingle( + directFileConfigValues(root, commonConfigPath, 'extensions.worktreeConfig'), + 'extensions.worktreeConfig', + ) + return extensionText === undefined + ? false + : parseGitBoolean(extensionText, 'extensions.worktreeConfig') +} + +function hasDirectConfigEntries(root, configPath) { + return git(['config', '--file', configPath, '--no-includes', '--null', '--list'], root).stdout !== '' +} + +function registeredWorktreeConfigPaths(commonDirectory) { + const paths = [join(commonDirectory, 'config.worktree')] + const linkedDirectory = join(commonDirectory, 'worktrees') + try { + const entries = readdirSync(linkedDirectory, { withFileTypes: true }) + .sort((left, right) => left.name.localeCompare(right.name)) + for (const entry of entries) { + paths.push(join(linkedDirectory, entry.name, 'config.worktree')) + } + } catch (error) { + if (errorCode(error) !== 'ENOENT') throw error + } + return paths +} + +function lstatIfPresent(path) { + try { + return lstatSync(path) + } catch (error) { + if (errorCode(error) === 'ENOENT') return undefined + throw error + } +} + +function assertCommonConfigFile(commonConfigPath) { + const configStat = lstatIfPresent(commonConfigPath) + if (configStat === undefined || !configStat.isFile() || configStat.isSymbolicLink()) { + throw new Error( + `refusing common repository config ${JSON.stringify(commonConfigPath)} because it is not a regular file`, + ) + } +} + +function assertWorktreeConfigFiles(root, commonDirectory, commonConfigPath, currentConfigPath) { + const extensionEnabled = worktreeConfigExtensionEnabled(root, commonConfigPath) + for (const configPath of registeredWorktreeConfigPaths(commonDirectory)) { + const configStat = lstatIfPresent(configPath) + if (configStat === undefined) continue + if (!configStat.isFile() || configStat.isSymbolicLink()) { + const state = extensionEnabled ? 'active' : 'dormant' + throw new Error( + `refusing ${state} worktree config ${JSON.stringify(configPath)} because it is not a regular file; ` + + 'replace it with a regular worktree config or remove it before retrying', + ) + } + if (extensionEnabled) continue + if (!hasDirectConfigEntries(root, configPath)) continue + const isCurrent = normalizedPath(configPath) === normalizedPath(currentConfigPath) + const owner = isCurrent ? 'current' : 'sibling' + throw new Error( + `cannot enable extensions.worktreeConfig while ${owner} dormant worktree config ` + + `${JSON.stringify(configPath)} contains user-owned settings that enabling the extension would activate; ` + + 'inspect and migrate those settings, then enable the extension explicitly or remove them before retrying', + ) + } +} + +function assertSupportedGit(root) { + const version = git(['--version'], root).stdout.trim() + const match = /git version (\d+)\.(\d+)(?:\.(\d+))?/.exec(version) + if (match === null) throw new Error(`cannot determine Git version from ${JSON.stringify(version)}`) + const actual = [Number(match[1]), Number(match[2]), Number(match[3] ?? 0)] + for (let index = 0; index < MINIMUM_GIT.length; index += 1) { + if (actual[index] > MINIMUM_GIT[index]) return + if (actual[index] < MINIMUM_GIT[index]) { + throw new Error(`Git 2.26 or newer is required for worktree-local hooks; found ${version}`) + } + } +} + +function planWorktreeConfigMigration(root, commonConfigPath) { + const versions = directFileConfigValues(root, commonConfigPath, 'core.repositoryFormatVersion') + const versionText = assertSingle(versions, 'core.repositoryFormatVersion') + const version = Number(versionText) + if (!Number.isInteger(version) || version < 0) { + throw new Error(`unsupported core.repositoryFormatVersion: ${JSON.stringify(versionText)}`) + } + + if (version === 0) { + const extensionEntry = directFileConfigMatchingEntries( + root, + commonConfigPath, + REPOSITORY_EXTENSION_PATTERN, + )[0] + if (extensionEntry !== undefined) { + throw new Error( + `cannot upgrade core.repositoryFormatVersion from 0 while dormant repository extension ` + + `${extensionEntry.name} is configured (${configSource(extensionEntry)}); ` + + 'audit and migrate it, then set repository format 1 explicitly before retrying', + ) + } + } + + const extensionEnabled = worktreeConfigExtensionEnabled(root, commonConfigPath) + const worktreeText = assertSingle( + directFileConfigValues(root, commonConfigPath, 'core.worktree'), + 'core.worktree', + ) + if (worktreeText !== undefined) { + throw new Error( + `cannot enable extensions.worktreeConfig while core.worktree is in the common config ` + + `(file:${commonConfigPath}: ${JSON.stringify(worktreeText)}); ` + + 'move it to the main worktree config first', + ) + } + + const directBareText = assertSingle(directFileConfigValues(root, commonConfigPath, 'core.bare'), 'core.bare') + const directBare = directBareText === undefined ? undefined : parseGitBoolean(directBareText, 'core.bare') + if (directBare === true) { + throw new Error( + `cannot enable extensions.worktreeConfig for a common config with core.bare=true ` + + `(file:${commonConfigPath}: ${JSON.stringify(directBareText)})`, + ) + } + + return { directBare, extensionEnabled, version } +} + +function applyWorktreeConfigMigration(root, commonConfigPath, migration) { + const { directBare, extensionEnabled, version } = migration + if (version === 0) { + git(['config', '--file', commonConfigPath, 'core.repositoryFormatVersion', '1'], root) + } + if (!extensionEnabled) { + git(['config', '--file', commonConfigPath, 'extensions.worktreeConfig', 'true'], root) + } + if (directBare === false) { + git(['config', '--file', commonConfigPath, '--unset-all', 'core.bare'], root) + } +} + +function readInstallLock(lockPath) { + try { + return readFileSync(lockPath, 'utf8') + } catch (error) { + if (errorCode(error) === 'ENOENT') return undefined + throw error + } +} + +function installLockStat(lockPath) { + try { + return lstatSync(lockPath) + } catch (error) { + if (errorCode(error) === 'ENOENT') return undefined + throw error + } +} + +function parseInstallLock(record) { + const match = /^([1-9]\d*) ([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\n$/i.exec(record) + if (match === null) return undefined + const owner = Number(match[1]) + return Number.isSafeInteger(owner) ? owner : undefined +} + +function lockOwnerIsAlive(owner) { + try { + process.kill(owner, 0) + return true + } catch (error) { + if (errorCode(error) === 'ESRCH') return false + if (errorCode(error) === 'EPERM') return true + throw error + } +} + +function manualLockRecoveryError(lockPath, condition) { + return new Error( + `${condition} Lefthook installer lock ${JSON.stringify(lockPath)}. ` + + 'Confirm no Lefthook installer is running, remove it manually, and retry.', + ) +} + +function lockOwnershipChangedError(lockPath) { + return new Error(`Lefthook installer lock ownership changed for ${lockPath}; refusing to remove it`) +} + +function releaseInstallLock(lockPath, ownedRecord, ownedStat) { + const currentStat = installLockStat(lockPath) + if ( + currentStat === undefined + || !currentStat.isFile() + || currentStat.isSymbolicLink() + || currentStat.dev !== ownedStat.dev + || currentStat.ino !== ownedStat.ino + || readInstallLock(lockPath) !== ownedRecord + ) { + throw lockOwnershipChangedError(lockPath) + } + try { + unlinkSync(lockPath) + } catch (error) { + if (errorCode(error) === 'ENOENT') { + throw lockOwnershipChangedError(lockPath) + } + throw error + } +} + +async function acquireInstallLock(commonDirectory) { + const lockPath = join(commonDirectory, INSTALL_LOCK) + const deadline = Date.now() + INSTALL_LOCK_TIMEOUT_MS + const ownedRecord = `${String(process.pid)} ${randomUUID()}\n` + while (true) { + try { + writeFileSync(lockPath, ownedRecord, { flag: 'wx', mode: 0o600 }) + const ownedStat = installLockStat(lockPath) + if (ownedStat === undefined || !ownedStat.isFile() || ownedStat.isSymbolicLink()) { + throw lockOwnershipChangedError(lockPath) + } + return () => releaseInstallLock(lockPath, ownedRecord, ownedStat) + } catch (error) { + if (errorCode(error) !== 'EEXIST') throw error + const existingStat = installLockStat(lockPath) + if (existingStat === undefined) continue + if (!existingStat.isFile() || existingStat.isSymbolicLink()) { + throw manualLockRecoveryError(lockPath, 'invalid') + } + const existingRecord = readInstallLock(lockPath) + if (existingRecord === undefined) continue + const owner = parseInstallLock(existingRecord) + if (owner === undefined) throw manualLockRecoveryError(lockPath, 'invalid') + if (!lockOwnerIsAlive(owner)) throw manualLockRecoveryError(lockPath, 'stale') + if (Date.now() >= deadline) { + throw new Error(`timed out waiting for Lefthook installer lock ${lockPath}`) + } + await new Promise(resolveWait => setTimeout(resolveWait, INSTALL_LOCK_POLL_MS)) + } + } +} + +function ownershipMarkerContent(hooksPath) { + return `${JSON.stringify({ + version: OWNERSHIP_MARKER_VERSION, + owner: OWNERSHIP_MARKER_OWNER, + hooksPath, + })}\n` +} + +function parseOwnershipMarker(content) { + let parsed + try { + parsed = JSON.parse(content) + } catch { + return undefined + } + if ( + typeof parsed !== 'object' + || parsed === null + || parsed.version !== OWNERSHIP_MARKER_VERSION + || parsed.owner !== OWNERSHIP_MARKER_OWNER + || typeof parsed.hooksPath !== 'string' + || !isAbsolute(parsed.hooksPath) + ) { + return undefined + } + return { hooksPath: parsed.hooksPath } +} + +function inspectOwnedHooksDirectory(hooksPath) { + const markerPath = join(hooksPath, OWNERSHIP_MARKER) + if (!existsSync(hooksPath)) return undefined + const hooksStat = lstatSync(hooksPath) + if (!hooksStat.isDirectory() || hooksStat.isSymbolicLink()) { + throw new Error(`refusing to use non-directory or symlinked hooks path ${hooksPath}`) + } + if (!existsSync(markerPath)) { + throw new Error(`refusing to overwrite unowned hooks directory ${hooksPath}`) + } + const markerStat = lstatSync(markerPath) + const marker = markerStat.isFile() && !markerStat.isSymbolicLink() && markerStat.nlink === 1 + ? parseOwnershipMarker(readFileSync(markerPath, 'utf8')) + : undefined + if (marker === undefined) { + throw new Error(`refusing to overwrite hooks directory with an invalid ownership marker: ${hooksPath}`) + } + for (const name of readdirSync(hooksPath)) { + if (name === OWNERSHIP_MARKER) continue + const entryPath = join(hooksPath, name) + const entryStat = lstatSync(entryPath) + if (!entryStat.isFile() || entryStat.isSymbolicLink() || entryStat.nlink !== 1) { + throw new Error( + `refusing to overwrite non-regular or multiply linked hook entry ${JSON.stringify(entryPath)}`, + ) + } + } + return { markerPath, ...marker } +} + +function ensureOwnedHooksDirectory(hooksPath) { + const inspected = inspectOwnedHooksDirectory(hooksPath) + if (inspected !== undefined) return inspected + mkdirSync(hooksPath, { mode: 0o700 }) + const markerPath = join(hooksPath, OWNERSHIP_MARKER) + writeFileSync(markerPath, ownershipMarkerContent(hooksPath), { flag: 'wx', mode: 0o600 }) + return { markerPath, hooksPath } +} + +function updateOwnershipMarker(markerPath, hooksPath) { + writeFileSync(markerPath, ownershipMarkerContent(hooksPath), { mode: 0o600 }) +} + +function environmentWithoutCommandGitConfig() { + const env = { ...process.env } + for (const key of Object.keys(env)) { + const normalized = key.toUpperCase() + if ( + normalized === 'GIT_CONFIG_PARAMETERS' + || normalized === 'GIT_CONFIG_COUNT' + || /^GIT_CONFIG_(?:KEY|VALUE)_\d+$/.test(normalized) + ) { + delete env[key] + } + } + return env +} + +function runLefthook(root, lefthook) { + const args = ['install', '--force'] + const env = environmentWithoutCommandGitConfig() + // Node refuses to spawn Windows `.cmd` shims directly; the quoted path is + // re-parsed by cmd.exe, while POSIX can execute its extensionless shim. + const result = process.platform === 'win32' + ? spawnSync(`"${lefthook}"`, args, { cwd: root, env, stdio: 'inherit', shell: true }) + : spawnSync(lefthook, args, { cwd: root, env, stdio: 'inherit' }) + if (result.status !== 0) throw commandFailure(lefthook, args, result) +} + +function configSource(entry) { + return `${entry.origin}: ${JSON.stringify(entry.value)}` +} + +function normalizedPath(path) { + const normalized = resolve(path) + return process.platform === 'win32' ? normalized.toLowerCase() : normalized +} + +function configOriginPath(origin, root) { + if (!origin.startsWith('file:')) return undefined + const originPath = origin.slice('file:'.length) + return isAbsolute(originPath) ? originPath : resolve(root, originPath) +} + +function originIsFile(origin, root, configPath) { + const originPath = configOriginPath(origin, root) + return originPath !== undefined && normalizedPath(originPath) === normalizedPath(configPath) +} + +function refuseInheritedHooksPath(entry) { + throw new Error( + `refusing to replace user-owned core.hooksPath (${configSource(entry)}). ` + + `Chain those hooks through lefthook.yml, or, if this inherited path may remain active only in other worktrees, ` + + `rerun with ${ALLOW_HOOKS_PATH_OVERRIDE}=1`, + ) +} + +function refuseScopedHooksPath(entry) { + if (entry.scope === 'command') { + throw new Error( + `refusing to replace command-scoped core.hooksPath (${configSource(entry)}); ` + + `${ALLOW_HOOKS_PATH_OVERRIDE} cannot override transient command configuration`, + ) + } + if (entry.scope === 'worktree') { + throw new Error( + `refusing to replace worktree-scoped core.hooksPath (${configSource(entry)}); ` + + 'a worktree-specific custom path must be integrated or removed explicitly', + ) + } + throw new Error( + `refusing to replace core.hooksPath from unsupported ${entry.scope} scope (${configSource(entry)})`, + ) +} + +async function main() { + if (process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true') return + const probe = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }) + if (probe.status !== 0) return + const root = stripGitLineTerminator(probe.stdout) + const isWindows = process.platform === 'win32' + const lefthook = join(root, 'node_modules', '.bin', isWindows ? 'lefthook.cmd' : 'lefthook') + if (!existsSync(lefthook)) return + + assertSupportedGit(root) + const gitDirectory = stripGitLineTerminator(git(['rev-parse', '--absolute-git-dir'], root).stdout) + const commonOutput = stripGitLineTerminator(git(['rev-parse', '--git-common-dir'], root).stdout) + const commonDirectory = isAbsolute(commonOutput) ? commonOutput : resolve(root, commonOutput) + const commonConfigPath = join(commonDirectory, 'config') + const worktreeConfigPath = join(gitDirectory, 'config.worktree') + const hooksPath = join(gitDirectory, HOOKS_DIRECTORY) + const releaseLock = await acquireInstallLock(commonDirectory) + let installationError + + try { + assertCommonConfigFile(commonConfigPath) + assertWorktreeConfigFiles( + root, + commonDirectory, + commonConfigPath, + worktreeConfigPath, + ) + const worktreeEntries = includedFileConfigEntries(root, worktreeConfigPath, 'core.hooksPath') + const includedWorktreeEntry = worktreeEntries.find( + entry => !originIsFile(entry.origin, root, worktreeConfigPath), + ) + if (includedWorktreeEntry !== undefined) { + refuseScopedHooksPath({ ...includedWorktreeEntry, scope: 'worktree' }) + } + const worktreePath = assertSingle( + worktreeEntries.map(entry => entry.value), + 'worktree core.hooksPath', + ) + let ownedHooksDirectory + if (worktreePath !== undefined && worktreePath !== hooksPath) { + ownedHooksDirectory = inspectOwnedHooksDirectory(hooksPath) + if (ownedHooksDirectory === undefined || ownedHooksDirectory.hooksPath !== worktreePath) { + refuseScopedHooksPath({ origin: `file:${worktreeConfigPath}`, scope: 'worktree', value: worktreePath }) + } + } + const directWorktreePathIsOwned = worktreePath !== undefined + && (worktreePath === hooksPath || ownedHooksDirectory?.hooksPath === worktreePath) + const effectiveEntry = effectiveConfigEntry(root, 'core.hooksPath') + if (effectiveEntry !== undefined) { + const effectivePathIsOwned = effectiveEntry.scope === 'worktree' + && effectiveEntry.value === worktreePath + && directWorktreePathIsOwned + && originIsFile(effectiveEntry.origin, root, worktreeConfigPath) + if (!effectivePathIsOwned) { + if (effectiveEntry.scope === 'command' || effectiveEntry.scope === 'worktree') { + refuseScopedHooksPath(effectiveEntry) + } + if (!['system', 'global', 'local'].includes(effectiveEntry.scope)) { + refuseScopedHooksPath(effectiveEntry) + } + if (process.env[ALLOW_HOOKS_PATH_OVERRIDE] !== '1') { + refuseInheritedHooksPath(effectiveEntry) + } + } + } + const migration = planWorktreeConfigMigration(root, commonConfigPath) + ownedHooksDirectory = ensureOwnedHooksDirectory(hooksPath) + if ( + worktreePath !== undefined + && worktreePath !== hooksPath + && ownedHooksDirectory.hooksPath !== worktreePath + ) { + throw new Error(`hooks directory ownership changed while relocating ${JSON.stringify(worktreePath)}`) + } + applyWorktreeConfigMigration(root, commonConfigPath, migration) + + let pathChanged = false + try { + git(['config', '--worktree', 'core.hooksPath', hooksPath], root) + pathChanged = worktreePath !== hooksPath + const installedEntry = effectiveConfigEntry(root, 'core.hooksPath') + if ( + installedEntry === undefined + || installedEntry.scope !== 'worktree' + || installedEntry.value !== hooksPath + || !originIsFile(installedEntry.origin, root, worktreeConfigPath) + ) { + throw new Error('new worktree-local core.hooksPath did not become the effective direct worktree value') + } + runLefthook(root, lefthook) + updateOwnershipMarker(ownedHooksDirectory.markerPath, hooksPath) + } catch (error) { + if (pathChanged) { + try { + if (worktreePath === undefined) { + git(['config', '--worktree', '--unset-all', 'core.hooksPath'], root) + } else { + git(['config', '--worktree', 'core.hooksPath', worktreePath], root) + } + } catch (rollbackError) { + throw new AggregateError( + [error, rollbackError], + `Lefthook installation failed: ${String(error)}; ` + + `worktree hook rollback also failed: ${String(rollbackError)}`, + ) + } + } + throw error + } + } catch (error) { + installationError = error + throw error + } finally { + try { + releaseLock() + } catch (releaseError) { + if (installationError !== undefined) { + throw new AggregateError( + [installationError, releaseError], + `Lefthook installation failed: ${String(installationError)}; installer lock release also failed: ${String(releaseError)}`, + ) + } + throw releaseError + } + } +} + +try { + await main() +} catch (error) { + console.error(`[install-lefthook] ${error instanceof Error ? error.message : String(error)}`) + process.exitCode = 1 +} diff --git a/scripts/install-lefthook.spec.ts b/scripts/install-lefthook.spec.ts new file mode 100644 index 0000000000..7e30c887ec --- /dev/null +++ b/scripts/install-lefthook.spec.ts @@ -0,0 +1,714 @@ +import { spawn, spawnSync } from 'node:child_process' +import { + chmodSync, + existsSync, + linkSync, + mkdirSync, + mkdtempSync, + lstatSync, + readFileSync, + renameSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, isAbsolute, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' + +const installer = fileURLToPath(new URL('./install-lefthook.mjs', import.meta.url)) +const fixtures: string[] = [] + +interface Fixture { + container: string + env: NodeJS.ProcessEnv + linked: string + main: string +} + +interface CommandResult { + status: number | null + stderr: string + stdout: string +} + +afterEach(() => { + for (const fixture of fixtures.splice(0)) rmSync(fixture, { recursive: true, force: true }) +}) + +function commandResult(command: string, args: string[], cwd: string, env: NodeJS.ProcessEnv): CommandResult { + const result = spawnSync(command, args, { cwd, encoding: 'utf8', env }) + return { status: result.status, stderr: result.stderr, stdout: result.stdout } +} + +function gitResult(fixture: Fixture, cwd: string, args: string[]): CommandResult { + return commandResult('git', args, cwd, fixture.env) +} + +function git(fixture: Fixture, cwd: string, args: string[]): string { + const result = gitResult(fixture, cwd, args) + if (result.status !== 0) { + throw new Error(`git ${args.join(' ')} failed: ${result.stderr}`) + } + return result.stdout.trim() +} + +function write(path: string, content: string, mode?: number): void { + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, content, mode === undefined ? undefined : { mode }) +} + +function fakeLefthookSource(): string { + return `#!/usr/bin/env node +import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' +import { execFileSync } from 'node:child_process' +import { join } from 'node:path' + +if (process.argv.slice(2).join(' ') !== 'install --force') process.exit(64) +const rootOutput = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }) +const root = rootOutput.endsWith('\\n') ? rootOutput.slice(0, -1) : rootOutput +const forbiddenConfigKey = process.env.DSH_TEST_FORBIDDEN_GIT_CONFIG_KEY +if (forbiddenConfigKey !== undefined) { + try { + execFileSync('git', ['config', '--get', forbiddenConfigKey], { encoding: 'utf8' }) + process.exit(92) + } catch (error) { + if (error === null || typeof error !== 'object' || !('status' in error) || error.status !== 1) throw error + } +} +const hooksPath = execFileSync('git', ['config', '--get', 'core.hooksPath'], { encoding: 'utf8' }).trim() +mkdirSync(hooksPath, { recursive: true }) +const running = join(hooksPath, '.fake-lefthook-running') +try { + writeFileSync(running, String(process.pid), { flag: 'wx' }) +} catch { + process.exit(91) +} +const delay = Number(process.env.DSH_TEST_LEFTHOOK_DELAY_MS ?? 0) +if (delay > 0) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delay) +const shouldFail = process.env.DSH_TEST_LEFTHOOK_FAIL === '1' +if (!shouldFail) { + const binary = join(root, 'node_modules', '.bin', process.platform === 'win32' ? 'lefthook.cmd' : 'lefthook') + const config = readFileSync(join(root, 'lefthook.yml'), 'utf8').trim() + const hook = \`#!/bin/sh\\n# root=\${root}\\n# binary=\${binary}\\n# config=\${config}\\nexit 0\\n\` + for (const name of ['pre-commit', 'pre-push']) writeFileSync(join(hooksPath, name), hook, { mode: 0o755 }) +} +if (existsSync(running)) unlinkSync(running) +if (process.env.DSH_TEST_LEFTHOOK_BREAK_WORKTREE_CONFIG === '1') { + const configPath = execFileSync('git', ['rev-parse', '--git-path', 'config.worktree'], { encoding: 'utf8' }).trim() + writeFileSync(configPath, '[invalid\\n') +} +if (shouldFail) process.exit(77) +` +} + +function installFakeLefthook(root: string): void { + const binDirectory = join(root, 'node_modules/.bin') + mkdirSync(binDirectory, { recursive: true }) + writeFileSync(join(binDirectory, 'fake-lefthook.mjs'), fakeLefthookSource()) + if (process.platform === 'win32') { + writeFileSync( + join(binDirectory, 'lefthook.cmd'), + `@echo off\r\n"${process.execPath}" "%~dp0\\fake-lefthook.mjs" %*\r\n`, + ) + return + } + const shim = join(binDirectory, 'lefthook') + writeFileSync(shim, `#!/bin/sh\nexec "${process.execPath}" "$(dirname "$0")/fake-lefthook.mjs" "$@"\n`) + chmodSync(shim, 0o755) +} + +function createFixture(names: { main?: string; linked?: string } = {}): Fixture { + const container = mkdtempSync(join(tmpdir(), 'dsh-lefthook-')) + fixtures.push(container) + const main = join(container, names.main ?? 'main') + const linked = join(container, names.linked ?? 'linked') + const env: NodeJS.ProcessEnv = { + ...process.env, + CI: 'false', + GITHUB_ACTIONS: 'false', + GIT_AUTHOR_EMAIL: 'hooks@example.test', + GIT_AUTHOR_NAME: 'Hooks Test', + GIT_COMMITTER_EMAIL: 'hooks@example.test', + GIT_COMMITTER_NAME: 'Hooks Test', + GIT_CONFIG_GLOBAL: join(container, 'global.gitconfig'), + GIT_CONFIG_NOSYSTEM: '1', + HOME: container, + XDG_CONFIG_HOME: join(container, '.config'), + } + const fixture = { container, env, linked, main } + mkdirSync(main) + git(fixture, container, ['init', main]) + write(join(main, 'README.md'), '# fixture\n') + git(fixture, main, ['add', 'README.md']) + git(fixture, main, ['commit', '-m', 'fixture']) + git(fixture, main, ['worktree', 'add', '-b', 'linked', linked]) + write(join(main, 'lefthook.yml'), 'main-worktree-config\n') + write(join(linked, 'lefthook.yml'), 'linked-worktree-config\n') + installFakeLefthook(main) + installFakeLefthook(linked) + return fixture +} + +function gitDirectory(fixture: Fixture, root: string): string { + return git(fixture, root, ['rev-parse', '--absolute-git-dir']) +} + +function commonDirectory(fixture: Fixture): string { + const output = git(fixture, fixture.main, ['rev-parse', '--git-common-dir']) + return isAbsolute(output) ? output : resolve(fixture.main, output) +} + +function hooksPath(fixture: Fixture, root: string): string { + return join(gitDirectory(fixture, root), 'dsh-hooks') +} + +function installLockPath(fixture: Fixture): string { + return join(commonDirectory(fixture), 'dsh-lefthook-install.lock') +} + +async function waitForPath(path: string): Promise { + const deadline = Date.now() + 5_000 + while (!existsSync(path)) { + if (Date.now() >= deadline) throw new Error(`timed out waiting for ${path}`) + await new Promise(resolveWait => setTimeout(resolveWait, 10)) + } +} + +function runInstaller( + fixture: Fixture, + root: string, + extraEnv: NodeJS.ProcessEnv = {}, +): Promise { + return new Promise((resolveResult, reject) => { + const child = spawn(process.execPath, [installer], { + cwd: root, + env: { ...fixture.env, ...extraEnv }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + let stdout = '' + let stderr = '' + child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString() }) + child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString() }) + child.on('error', reject) + child.on('close', (status) => { resolveResult({ status, stderr, stdout }) }) + }) +} + +describe('worktree-local Lefthook installer', () => { + for (const [label, extraEnv] of [ + ['CI', { CI: 'true' }], + ['GitHub Actions', { GITHUB_ACTIONS: 'true' }], + ] satisfies [string, NodeJS.ProcessEnv][]) { + it(`skips hook installation when ${label} marks an automated job`, async () => { + const fixture = createFixture() + const common = commonDirectory(fixture) + const missingInclude = join(fixture.container, 'missing-ci-credentials.gitconfig') + git(fixture, fixture.main, [ + 'config', + '--local', + 'includeIf.gitdir:/github/workspace/.git.path', + missingInclude, + ]) + + const result = await runInstaller(fixture, fixture.main, extraEnv) + + expect(result.status, result.stderr).toBe(0) + expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1) + expect(git(fixture, fixture.main, ['config', '--get', 'core.repositoryFormatVersion'])).toBe('0') + expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false) + expect(existsSync(join(common, 'config.worktree'))).toBe(false) + }) + } + + it('isolates main and linked worktrees without changing legacy common hooks', async () => { + const fixture = createFixture() + const common = commonDirectory(fixture) + const legacyHook = join(common, 'hooks/pre-commit') + write(legacyHook, '#!/bin/sh\n# legacy hook\n', 0o755) + + const mainInstall = await runInstaller(fixture, fixture.main) + const linkedInstall = await runInstaller(fixture, fixture.linked) + expect(mainInstall.status, mainInstall.stderr).toBe(0) + expect(linkedInstall.status, linkedInstall.stderr).toBe(0) + + const mainHooks = hooksPath(fixture, fixture.main) + const linkedHooks = hooksPath(fixture, fixture.linked) + expect(mainHooks).not.toBe(linkedHooks) + expect(git(fixture, fixture.main, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(mainHooks) + expect(git(fixture, fixture.linked, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(linkedHooks) + + const mainHook = readFileSync(join(mainHooks, 'pre-commit'), 'utf8') + const linkedHook = readFileSync(join(linkedHooks, 'pre-commit'), 'utf8') + const canonicalMain = git(fixture, fixture.main, ['rev-parse', '--show-toplevel']) + const canonicalLinked = git(fixture, fixture.linked, ['rev-parse', '--show-toplevel']) + expect(mainHook).toContain(`# root=${canonicalMain}`) + expect(mainHook).toContain('# config=main-worktree-config') + expect(mainHook).not.toContain(canonicalLinked) + expect(linkedHook).toContain(`# root=${canonicalLinked}`) + expect(linkedHook).toContain('# config=linked-worktree-config') + expect(linkedHook).not.toContain(canonicalMain) + expect(readFileSync(legacyHook, 'utf8')).toBe('#!/bin/sh\n# legacy hook\n') + + const commonConfig = join(common, 'config') + expect(git(fixture, fixture.main, ['config', '--file', commonConfig, '--get', 'core.repositoryFormatVersion'])).toBe('1') + expect(git(fixture, fixture.main, ['config', '--file', commonConfig, '--get', 'extensions.worktreeConfig'])).toBe('true') + expect(gitResult(fixture, fixture.main, ['config', '--file', commonConfig, '--get', 'core.bare']).status).toBe(1) + + const mainHookBeforeRemoval = readFileSync(join(mainHooks, 'pre-commit'), 'utf8') + git(fixture, fixture.main, ['worktree', 'remove', '--force', fixture.linked]) + expect(readFileSync(join(mainHooks, 'pre-commit'), 'utf8')).toBe(mainHookBeforeRemoval) + expect(readFileSync(legacyHook, 'utf8')).toBe('#!/bin/sh\n# legacy hook\n') + }) + + it('serializes concurrent installs and keeps repeated output stable', async () => { + const fixture = createFixture() + const delayed = { DSH_TEST_LEFTHOOK_DELAY_MS: '150' } + const first = await Promise.all([ + runInstaller(fixture, fixture.main, delayed), + runInstaller(fixture, fixture.linked, delayed), + ]) + for (const result of first) expect(result.status, result.stderr).toBe(0) + + const mainHookPath = join(hooksPath(fixture, fixture.main), 'pre-push') + const initialHook = readFileSync(mainHookPath, 'utf8') + const repeated = await Promise.all([ + runInstaller(fixture, fixture.main, delayed), + runInstaller(fixture, fixture.main, delayed), + ]) + for (const result of repeated) expect(result.status, result.stderr).toBe(0) + expect(readFileSync(mainHookPath, 'utf8')).toBe(initialHook) + expect(existsSync(join(commonDirectory(fixture), 'dsh-lefthook-install.lock'))).toBe(false) + expect(existsSync(join(hooksPath(fixture, fixture.main), '.fake-lefthook-running'))).toBe(false) + }) + + it('repairs its owned absolute hook path after the checkout moves', async () => { + const fixture = createFixture() + const oldRoot = fixture.main + const first = await runInstaller(fixture, oldRoot) + expect(first.status, first.stderr).toBe(0) + const oldHooks = hooksPath(fixture, oldRoot) + const movedRoot = join(fixture.container, 'moved-main') + renameSync(oldRoot, movedRoot) + + const moved = await runInstaller(fixture, movedRoot) + + expect(moved.status, moved.stderr).toBe(0) + const movedHooks = hooksPath(fixture, movedRoot) + expect(movedHooks).not.toBe(oldHooks) + expect(git(fixture, movedRoot, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(movedHooks) + const canonicalMoved = git(fixture, movedRoot, ['rev-parse', '--show-toplevel']) + expect(readFileSync(join(movedHooks, 'pre-commit'), 'utf8')).toContain(`# root=${canonicalMoved}`) + expect(readFileSync(join(movedHooks, '.dsh-lefthook-owned'), 'utf8')).toContain( + JSON.stringify(movedHooks), + ) + }) + + it.skipIf(process.platform === 'win32')('refuses a multiply linked ownership marker before relocation rewrites it', async () => { + const fixture = createFixture() + const oldRoot = fixture.main + const first = await runInstaller(fixture, oldRoot) + expect(first.status, first.stderr).toBe(0) + const oldHooks = hooksPath(fixture, oldRoot) + const markerName = '.dsh-lefthook-owned' + const externalMarker = join(fixture.container, 'external-marker') + linkSync(join(oldHooks, markerName), externalMarker) + const externalContent = readFileSync(externalMarker, 'utf8') + const movedRoot = join(fixture.container, 'moved-main') + renameSync(oldRoot, movedRoot) + + const result = await runInstaller(fixture, movedRoot) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('invalid ownership marker') + expect(readFileSync(externalMarker, 'utf8')).toBe(externalContent) + }) + + it.skipIf(process.platform === 'win32')('refuses aliased generated hooks before Lefthook can overwrite their targets', async () => { + for (const kind of ['symlink', 'hardlink'] as const) { + const fixture = createFixture() + const first = await runInstaller(fixture, fixture.main) + expect(first.status, first.stderr).toBe(0) + const hook = join(hooksPath(fixture, fixture.main), 'pre-commit') + const externalHook = join(fixture.container, `${kind}-external-hook`) + rmSync(hook) + write(externalHook, `external ${kind} target\n`) + if (kind === 'symlink') symlinkSync(externalHook, hook) + else linkSync(externalHook, hook) + const externalContent = readFileSync(externalHook, 'utf8') + + const result = await runInstaller(fixture, fixture.main) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('non-regular or multiply linked hook entry') + expect(readFileSync(externalHook, 'utf8')).toBe(externalContent) + } + }) + + it('restores the marker-backed stale hook path when relocation reinstall fails', async () => { + const fixture = createFixture() + const oldRoot = fixture.main + const first = await runInstaller(fixture, oldRoot) + expect(first.status, first.stderr).toBe(0) + const oldHooks = hooksPath(fixture, oldRoot) + const markerName = '.dsh-lefthook-owned' + const previousMarker = readFileSync(join(oldHooks, markerName), 'utf8') + const movedRoot = join(fixture.container, 'moved-main') + renameSync(oldRoot, movedRoot) + + const failed = await runInstaller(fixture, movedRoot, { DSH_TEST_LEFTHOOK_FAIL: '1' }) + + expect(failed.status).toBe(1) + expect(failed.stderr).toContain('exit status 77') + const movedHooks = hooksPath(fixture, movedRoot) + expect(git(fixture, movedRoot, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(oldHooks) + expect(readFileSync(join(movedHooks, markerName), 'utf8')).toBe(previousMarker) + }) + + it('refuses dormant repository extensions before upgrading the repository format', async () => { + const fixture = createFixture() + const commonConfig = join(commonDirectory(fixture), 'config') + git(fixture, fixture.main, ['config', 'extensions.dshUnknown', 'true']) + expect(gitResult(fixture, fixture.main, ['status', '--porcelain']).status).toBe(0) + + const result = await runInstaller(fixture, fixture.main) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('dormant repository extension extensions.dshunknown') + expect(git(fixture, fixture.main, [ + 'config', '--file', commonConfig, '--get', 'core.repositoryFormatVersion', + ])).toBe('0') + expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1) + expect(gitResult(fixture, fixture.main, ['status', '--porcelain']).status).toBe(0) + expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false) + }) + + it('refuses direct core.worktree before enabling worktree config', async () => { + const fixture = createFixture() + const commonConfig = join(commonDirectory(fixture), 'config') + git(fixture, fixture.main, ['config', '--file', commonConfig, 'core.worktree', fixture.main]) + + const result = await runInstaller(fixture, fixture.linked) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('core.worktree is in the common config') + expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1) + expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false) + }) + + it.skipIf(process.platform === 'win32')('refuses a symlinked common repository config before writing through it', async () => { + const fixture = createFixture() + const commonConfig = join(commonDirectory(fixture), 'config') + const externalConfig = join(fixture.container, 'external-common.gitconfig') + renameSync(commonConfig, externalConfig) + symlinkSync(externalConfig, commonConfig) + const externalContent = readFileSync(externalConfig, 'utf8') + + const result = await runInstaller(fixture, fixture.main) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('common repository config') + expect(result.stderr).toContain('not a regular file') + expect(lstatSync(commonConfig).isSymbolicLink()).toBe(true) + expect(readFileSync(externalConfig, 'utf8')).toBe(externalContent) + expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false) + }) + + it('leaves stale installer locks for explicit recovery', async () => { + const fixture = createFixture() + const lockPath = installLockPath(fixture) + const completed = spawnSync(process.execPath, ['-e', '']) + expect(completed.status).toBe(0) + const staleRecord = `${String(completed.pid)} 00000000-0000-4000-8000-000000000000\n` + writeFileSync(lockPath, staleRecord) + + const results = await Promise.all(Array.from( + { length: 4 }, + () => runInstaller(fixture, fixture.main), + )) + + for (const result of results) { + expect(result.status).toBe(1) + expect(result.stderr).toContain('stale Lefthook installer lock') + expect(result.stderr).toContain('remove it manually') + } + expect(readFileSync(lockPath, 'utf8')).toBe(staleRecord) + expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false) + expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1) + }) + + it('leaves invalid installer locks for explicit recovery', async () => { + const fixture = createFixture() + const lockPath = installLockPath(fixture) + const invalidRecord = 'not an installer lock\n' + writeFileSync(lockPath, invalidRecord) + + const result = await runInstaller(fixture, fixture.main) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('invalid Lefthook installer lock') + expect(result.stderr).toContain('remove it manually') + expect(readFileSync(lockPath, 'utf8')).toBe(invalidRecord) + expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false) + }) + + it('does not release an installer lock whose ownership changed', async () => { + const fixture = createFixture() + const lockPath = installLockPath(fixture) + const runningPath = join(hooksPath(fixture, fixture.main), '.fake-lefthook-running') + const install = runInstaller(fixture, fixture.main, { DSH_TEST_LEFTHOOK_DELAY_MS: '250' }) + await waitForPath(runningPath) + const replacementRecord = 'replacement owner\n' + writeFileSync(lockPath, replacementRecord) + + const result = await install + expect(result.status).toBe(1) + expect(result.stderr).toContain('installer lock ownership changed') + expect(readFileSync(lockPath, 'utf8')).toBe(replacementRecord) + }) + + it.skipIf(process.platform === 'win32')('preserves trailing spaces in worktree paths', async () => { + const fixture = createFixture({ main: 'main ', linked: 'linked ' }) + + for (const root of [fixture.main, fixture.linked]) { + const result = await runInstaller(fixture, root) + expect(result.status, result.stderr).toBe(0) + expect(git(fixture, root, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(hooksPath(fixture, root)) + } + }) + + it('preserves user-owned hook paths unless an inherited value is explicitly overridden', async () => { + const fixture = createFixture() + const customHook = join(fixture.main, 'custom-hooks/pre-commit') + write(customHook, '#!/bin/sh\n# custom hook\n', 0o755) + git(fixture, fixture.main, ['config', 'core.hooksPath', 'custom-hooks']) + + const refused = await runInstaller(fixture, fixture.main) + expect(refused.status).toBe(1) + expect(refused.stderr).toContain('refusing to replace user-owned core.hooksPath') + expect(refused.stderr).toContain('DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1') + expect(git(fixture, fixture.main, ['config', '--get', 'core.hooksPath'])).toBe('custom-hooks') + expect(readFileSync(customHook, 'utf8')).toBe('#!/bin/sh\n# custom hook\n') + expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1) + + const optedIn = await runInstaller(fixture, fixture.main, { + DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE: '1', + }) + expect(optedIn.status, optedIn.stderr).toBe(0) + expect(git(fixture, fixture.main, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(hooksPath(fixture, fixture.main)) + expect(git(fixture, fixture.linked, ['config', '--get', 'core.hooksPath'])).toBe('custom-hooks') + expect(gitResult(fixture, fixture.linked, ['config', '--worktree', '--get', 'core.hooksPath']).status).toBe(1) + expect(readFileSync(customHook, 'utf8')).toBe('#!/bin/sh\n# custom hook\n') + + git(fixture, fixture.linked, ['config', '--worktree', 'core.hooksPath', 'linked-custom-hooks']) + const explicitWorktreePath = await runInstaller(fixture, fixture.linked, { + DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE: '1', + }) + expect(explicitWorktreePath.status).toBe(1) + expect(git(fixture, fixture.linked, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe('linked-custom-hooks') + }) + + it('refuses to activate a sibling worktree dormant hook path', async () => { + const fixture = createFixture() + const linkedConfig = join(gitDirectory(fixture, fixture.linked), 'config.worktree') + const linkedHooks = join(fixture.linked, 'custom-hooks') + git(fixture, fixture.main, ['config', '--file', linkedConfig, 'core.hooksPath', linkedHooks]) + expect(gitResult(fixture, fixture.linked, ['config', '--get', 'core.hooksPath']).status).toBe(1) + + const result = await runInstaller(fixture, fixture.main) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('sibling dormant worktree config') + expect(result.stderr).toContain(linkedConfig) + expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1) + expect(gitResult(fixture, fixture.linked, ['config', '--get', 'core.hooksPath']).status).toBe(1) + expect(git(fixture, fixture.main, ['config', '--file', linkedConfig, '--get', 'core.hooksPath'])).toBe(linkedHooks) + expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false) + }) + + it.skipIf(process.platform === 'win32')('refuses an active symlinked worktree config before writing through it', async () => { + const fixture = createFixture() + const commonConfig = join(commonDirectory(fixture), 'config') + const worktreeConfig = join(gitDirectory(fixture, fixture.main), 'config.worktree') + const externalConfig = join(fixture.container, 'external.gitconfig') + const externalContent = '[user]\n\tname = External owner\n' + write(externalConfig, externalContent) + git(fixture, fixture.main, ['config', '--file', commonConfig, 'core.repositoryFormatVersion', '1']) + git(fixture, fixture.main, ['config', '--file', commonConfig, 'extensions.worktreeConfig', 'true']) + symlinkSync(externalConfig, worktreeConfig) + + const result = await runInstaller(fixture, fixture.main) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('active worktree config') + expect(result.stderr).toContain('not a regular file') + expect(lstatSync(worktreeConfig).isSymbolicLink()).toBe(true) + expect(readFileSync(externalConfig, 'utf8')).toBe(externalContent) + expect(gitResult(fixture, fixture.main, [ + 'config', '--file', externalConfig, '--get', 'core.hooksPath', + ]).status).toBe(1) + expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false) + }) + + for (const includeKey of ['include.path', 'includeIf.onbranch:conditional.path']) { + for (const key of ['core.worktree', 'core.bare', 'extensions.dshunknown']) { + it(`ignores ${key} loaded through ${includeKey}`, async () => { + const fixture = createFixture() + const commonConfig = join(commonDirectory(fixture), 'config') + const includedConfig = join(fixture.container, `${includeKey.split('.')[0]}-${key.replace('.', '-')}.gitconfig`) + const value = key === 'core.worktree' ? fixture.main : 'true' + git(fixture, fixture.main, ['config', '--file', includedConfig, key, value]) + git(fixture, fixture.main, ['config', '--file', commonConfig, includeKey, includedConfig]) + + const result = await runInstaller(fixture, fixture.linked) + + expect(result.status, result.stderr).toBe(0) + expect(git(fixture, fixture.linked, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe( + hooksPath(fixture, fixture.linked), + ) + expect(existsSync(join(hooksPath(fixture, fixture.linked), 'pre-commit'))).toBe(true) + }) + } + } + + it('ignores an inactive global includeIf that provides a hook path for another repository', async () => { + const fixture = createFixture() + const globalConfig = fixture.env.GIT_CONFIG_GLOBAL + if (globalConfig === undefined) throw new Error('fixture global config path is missing') + const includedConfig = join(fixture.container, 'other-repository.gitconfig') + const includedHooks = join(fixture.container, 'other-repository-hooks') + git(fixture, fixture.main, ['config', '--file', includedConfig, 'core.hooksPath', includedHooks]) + git(fixture, fixture.main, [ + 'config', + '--file', + globalConfig, + `includeIf.gitdir:${join(fixture.container, 'other')}/.path`, + includedConfig, + ]) + + const result = await runInstaller(fixture, fixture.linked) + + expect(result.status, result.stderr).toBe(0) + expect(git(fixture, fixture.linked, ['config', '--get', 'core.hooksPath'])).toBe(hooksPath(fixture, fixture.linked)) + }) + + it('never overrides a command-scoped hook path', async () => { + const fixture = createFixture() + const commandHooks = join(fixture.container, 'command-hooks') + const sentinel = join(commandHooks, 'pre-commit') + write(sentinel, '#!/bin/sh\n# command-scope sentinel\n', 0o755) + + const result = await runInstaller(fixture, fixture.main, { + DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE: '1', + GIT_CONFIG_COUNT: '1', + GIT_CONFIG_KEY_0: 'core.hooksPath', + GIT_CONFIG_VALUE_0: commandHooks, + }) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('command-scoped core.hooksPath') + expect(readFileSync(sentinel, 'utf8')).toBe('#!/bin/sh\n# command-scope sentinel\n') + expect(gitResult(fixture, fixture.main, ['config', '--get', 'core.hooksPath']).status).toBe(1) + expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false) + }) + + it('does not pass unrelated command-scoped Git config to Lefthook', async () => { + const fixture = createFixture() + + const result = await runInstaller(fixture, fixture.main, { + DSH_TEST_FORBIDDEN_GIT_CONFIG_KEY: 'dsh.testSentinel', + GIT_CONFIG_COUNT: '1', + GIT_CONFIG_KEY_0: 'dsh.testSentinel', + GIT_CONFIG_VALUE_0: 'must-not-reach-lefthook', + }) + + expect(result.status, result.stderr).toBe(0) + expect(existsSync(join(hooksPath(fixture, fixture.main), 'pre-commit'))).toBe(true) + }) + + it('never overrides a hook path included by worktree config', async () => { + const fixture = createFixture() + const commonConfig = join(commonDirectory(fixture), 'config') + const worktreeConfig = join(gitDirectory(fixture, fixture.main), 'config.worktree') + const includedConfig = join(fixture.container, 'included-worktree.gitconfig') + const includedHooks = join(fixture.container, 'included-hooks') + const sentinel = join(includedHooks, 'pre-commit') + write(sentinel, '#!/bin/sh\n# included-worktree sentinel\n', 0o755) + git(fixture, fixture.main, ['config', '--file', includedConfig, 'core.hooksPath', includedHooks]) + git(fixture, fixture.main, ['config', '--file', commonConfig, 'core.repositoryFormatVersion', '1']) + git(fixture, fixture.main, ['config', '--file', commonConfig, 'extensions.worktreeConfig', 'true']) + git(fixture, fixture.main, ['config', '--file', worktreeConfig, 'include.path', includedConfig]) + + const result = await runInstaller(fixture, fixture.main, { + DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE: '1', + }) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('worktree-scoped core.hooksPath') + expect(git(fixture, fixture.main, ['config', '--get', 'core.hooksPath'])).toBe(includedHooks) + expect(readFileSync(sentinel, 'utf8')).toBe('#!/bin/sh\n# included-worktree sentinel\n') + expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false) + }) + + it('restores the previous hook lookup when Lefthook installation fails', async () => { + const fixture = createFixture() + const common = commonDirectory(fixture) + const legacyHook = join(common, 'hooks/pre-push') + write(legacyHook, '#!/bin/sh\n# legacy pre-push\n', 0o755) + + const result = await runInstaller(fixture, fixture.main, { DSH_TEST_LEFTHOOK_FAIL: '1' }) + expect(result.status).toBe(1) + expect(result.stderr).toContain('exit status 77') + expect(gitResult(fixture, fixture.main, ['config', '--worktree', '--get', 'core.hooksPath']).status).toBe(1) + expect(gitResult(fixture, fixture.main, ['config', '--get', 'core.hooksPath']).status).toBe(1) + expect(readFileSync(legacyHook, 'utf8')).toBe('#!/bin/sh\n# legacy pre-push\n') + }) + + it('reports installation and hook-path rollback failures together', async () => { + const fixture = createFixture() + + const result = await runInstaller(fixture, fixture.main, { + DSH_TEST_LEFTHOOK_BREAK_WORKTREE_CONFIG: '1', + DSH_TEST_LEFTHOOK_FAIL: '1', + }) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('Lefthook installation failed') + expect(result.stderr).toContain('exit status 77') + expect(result.stderr).toContain('worktree hook rollback also failed') + expect(result.stderr).toContain('git config --worktree --unset-all core.hooksPath failed') + }) + + it('refuses an unowned directory at the reserved worktree hook path', async () => { + const fixture = createFixture() + const reservedHook = join(hooksPath(fixture, fixture.main), 'pre-commit') + write(reservedHook, '#!/bin/sh\n# user content\n', 0o755) + + const result = await runInstaller(fixture, fixture.main) + expect(result.status).toBe(1) + expect(result.stderr).toContain('refusing to overwrite unowned hooks directory') + expect(readFileSync(reservedHook, 'utf8')).toBe('#!/bin/sh\n# user content\n') + expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1) + }) + + it.skipIf(process.platform === 'win32')('rejects Git without config-scope support before mutation', async () => { + const fixture = createFixture() + const realGit = commandResult('which', ['git'], fixture.main, fixture.env).stdout.trim() + const fakeBin = join(fixture.container, 'fake-bin') + const fakeGit = join(fakeBin, 'git') + write( + fakeGit, + `#!/bin/sh\nif [ "$1" = "--version" ]; then echo "git version 2.25.0"; exit 0; fi\nexec "${realGit}" "$@"\n`, + 0o755, + ) + + const result = await runInstaller(fixture, fixture.main, { + PATH: `${fakeBin}:${fixture.env.PATH ?? ''}`, + }) + expect(result.status).toBe(1) + expect(result.stderr).toContain('Git 2.26 or newer is required') + expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1) + expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false) + }) +}) diff --git a/scripts/install.sh b/scripts/install.sh index bc60dee983..41d5c749c1 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -3,16 +3,27 @@ # # curl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh # -# It clones the harness to ~/.dsh/source, checks host dependencies (git, Node, -# pnpm) and offers to install a missing pnpm, runs `pnpm install` (no build — -# the `bin/dsh` launcher runs the TypeScript source through the repo's own tsx), -# symlinks `dsh` onto PATH, records your API credentials in the Harness home -# (`~/.dsh`) dsh reads at boot, and drops you into `dsh`. +# It clones the harness under ~/.dsh/source (the master clone at +# ~/.dsh/source/master), adds a per-install staging worktree at +# ~/.dsh/source/staging- on branch dsh-staging/, checks +# host dependencies (git, Node, pnpm) and offers to install a missing pnpm, runs +# `pnpm install` (no build — the `bin/dsh` launcher runs the TypeScript source +# through the repo's own tsx), points the stable `~/.dsh/source/current` symlink +# at that staging worktree and symlinks `dsh` onto PATH at `current/bin/dsh`, +# records your API credentials in the Harness home (`~/.dsh`) dsh reads at boot, +# and drops you into `dsh`. Keeping every checkout under ~/.dsh/source keeps +# successive upgrades in one place instead of scattered sibling clones, and lets +# staging worktrees share the master clone's object store. The PATH symlink +# resolves through `current`, so an upgrade repoints one stable symlink instead +# of relinking PATH: the `dsh` on PATH never moves and can never dangle. # # When run from inside an existing checkout (e.g. `sh scripts/install.sh` rather -# than `curl ... | sh`) it reuses that checkout and skips the clone/update, leaving -# the working tree untouched; DSH_REF is ignored in that mode. Setting DSH_SOURCE -# to a different directory opts back into the normal clone/update path. +# than `curl ... | sh`) it reuses that checkout in place and skips the +# clone/worktree setup, leaving the working tree untouched and linking `dsh` +# straight at that checkout's `bin/dsh` (no `current` indirection — the checkout +# is not a managed staging worktree under the source container); DSH_REF is +# ignored in that mode. Setting DSH_SOURCE to a different directory opts back +# into the normal clone/worktree path. # # When run through `curl | sh` the script text arrives on stdin, so every # prompt and the final launch read the controlling terminal (/dev/tty) directly; @@ -21,7 +32,9 @@ # Overridable via environment: # DSH_REF branch or tag to clone/checkout (default: master) # DSH_REPO clone URL (default: the GitHub repo) -# DSH_SOURCE checkout location (default: ~/.dsh/source) +# DSH_SOURCE source container directory (default: ~/.dsh/source) +# DSH_MASTER master clone directory (default: $DSH_SOURCE/master) +# DSH_CURRENT stable symlink to the active worktree (default: $DSH_SOURCE/current) # DSH_BIN_DIR directory the `dsh` symlink lands in (default: ~/.local/bin) # DSH_HOME Harness home holding the personal config (default: ~/.dsh) # FIXME(install-ts): Move the post-checkout workflow into a tested TypeScript @@ -30,19 +43,31 @@ set -eu DSH_REF=${DSH_REF:-master} DSH_REPO=${DSH_REPO:-https://github.com/deepseek-harness/deepseek-harness.git} -# Remember whether the caller pinned a source location before defaulting it, so -# in-repo detection only repoints an unset DSH_SOURCE. +# DSH_SOURCE is the container directory that holds the master clone and every +# staging worktree; DSH_MASTER is the one real clone inside it. Remember whether +# the caller pinned the source container before defaulting it, so in-repo +# detection only repoints an unset DSH_SOURCE. if [ -n "${DSH_SOURCE:-}" ]; then DSH_SOURCE_EXPLICIT=1; else DSH_SOURCE_EXPLICIT=0; fi DSH_SOURCE=${DSH_SOURCE:-$HOME/.dsh/source} +DSH_MASTER=${DSH_MASTER:-$DSH_SOURCE/master} +# The stable symlink the PATH launcher resolves through: PATH -> current/bin/dsh +# -> /bin/dsh. Fresh installs and upgrades repoint this one symlink; the +# PATH launcher itself is written once and never moves. In-repo reuse ignores it. +DSH_CURRENT=${DSH_CURRENT:-$DSH_SOURCE/current} DSH_BIN_DIR=${DSH_BIN_DIR:-$HOME/.local/bin} +# One UTC basic timestamp names this install's staging branch and worktree. +DSH_STAMP=$(date -u +%Y%m%dT%H%M%SZ) +DSH_STAGING_BRANCH=dsh-staging/$DSH_STAMP +DSH_STAGING=$DSH_SOURCE/staging-$DSH_STAMP # --- in-repo detection --------------------------------------------------------- # Under `curl ... | sh` the script text arrives on stdin, so $0 is the shell # name and no file path resolves; running a checked-out copy (`sh # scripts/install.sh`) makes $0 the script file. When $0 is a readable file whose # parent is a scripts/ dir inside a real dsh checkout (bin/dsh launcher present), -# reuse that checkout and skip the clone. An explicit DSH_SOURCE pointing -# elsewhere opts back into the clone/update path. +# reuse that checkout in place — link `dsh` straight at it and skip the +# clone/worktree setup. An explicit DSH_SOURCE pointing elsewhere opts back into +# the clone/worktree path. IN_REPO=0 if [ -f "$0" ]; then _self_dir=$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd -P) || _self_dir='' @@ -52,7 +77,9 @@ if [ -f "$0" ]; then && [ -x "$_repo_root/bin/dsh" ] && [ -f "$_repo_root/scripts/install.sh" ]; then if [ "$DSH_SOURCE_EXPLICIT" = 0 ] || [ "$DSH_SOURCE" = "$_repo_root" ]; then IN_REPO=1 - DSH_SOURCE=$_repo_root + # In-repo reuse links `dsh` at this checkout as-is; the master/staging + # split applies only to fresh clone installs. + DSH_STAGING=$_repo_root fi fi fi @@ -120,7 +147,13 @@ confirm() { } printf '%s\n' "${B}DeepSeek Harness — dsh installer${RST}" -printf '%ssource %s @ %s%s\n' "$DIM" "$DSH_SOURCE" "$DSH_REF" "$RST" +if [ "$IN_REPO" = 1 ]; then + printf '%ssource %s (in-repo reuse) @ %s%s\n' "$DIM" "$DSH_STAGING" "$DSH_REF" "$RST" +else + printf '%smaster %s @ %s%s\n' "$DIM" "$DSH_MASTER" "$DSH_REF" "$RST" + printf '%sstaging %s%s\n' "$DIM" "$DSH_STAGING" "$RST" + printf '%scurrent %s%s\n' "$DIM" "$DSH_CURRENT" "$RST" +fi # --- 1. dependency check ------------------------------------------------------- step "Checking dependencies" @@ -170,36 +203,73 @@ else fi fi -# --- 2. clone (or update) the source ------------------------------------------ +# --- 2. clone the master and lay out the staging worktree --------------------- +# Fresh installs keep one real clone at $DSH_MASTER and check the running code +# out as a git worktree at $DSH_STAGING, so every checkout lives under +# $DSH_SOURCE and shares one object store. In-repo reuse links `dsh` at the +# existing checkout untouched. if [ "$IN_REPO" = 1 ]; then - step "Using existing checkout at $DSH_SOURCE" + step "Using existing checkout at $DSH_STAGING" info "running from inside the repo — skipping clone (DSH_REF ignored, working tree left untouched)" else -step "Fetching source into $DSH_SOURCE" -if [ -d "$DSH_SOURCE/.git" ]; then - info "existing checkout found — updating" - git -C "$DSH_SOURCE" fetch --depth 1 origin "$DSH_REF" - # Reset the checkout to the freshly fetched tip. FETCH_HEAD (not +step "Fetching source into $DSH_MASTER" +if [ -d "$DSH_MASTER/.git" ]; then + info "existing master clone found — updating" + git -C "$DSH_MASTER" fetch origin "$DSH_REF" + # Reset the master checkout to the freshly fetched tip. FETCH_HEAD (not # origin/) so this resolves for a tag as well as a branch, and -B makes # the re-run idempotent whether or not DSH_REF changed since the last install. - git -C "$DSH_SOURCE" checkout -q -B "$DSH_REF" FETCH_HEAD + git -C "$DSH_MASTER" checkout -q -B "$DSH_REF" FETCH_HEAD else - mkdir -p "$(dirname "$DSH_SOURCE")" - git clone --depth 1 --branch "$DSH_REF" "$DSH_REPO" "$DSH_SOURCE" + mkdir -p "$DSH_SOURCE" + git clone --branch "$DSH_REF" "$DSH_REPO" "$DSH_MASTER" fi + +step "Adding staging worktree at $DSH_STAGING" +[ -e "$DSH_STAGING" ] && die "staging path $DSH_STAGING already exists — remove it or set DSH_SOURCE elsewhere, then re-run." +# The staging worktree owns the branch dsh runs from; the master clone stays on +# $DSH_REF as the fetch/upgrade base. Exclude the per-worktree merge lock in the +# master clone's info/exclude, which every linked worktree inherits. +git -C "$DSH_MASTER" worktree add -b "$DSH_STAGING_BRANCH" "$DSH_STAGING" FETCH_HEAD 2>/dev/null \ + || git -C "$DSH_MASTER" worktree add -b "$DSH_STAGING_BRANCH" "$DSH_STAGING" HEAD +_exclude="$DSH_MASTER/.git/info/exclude" +if [ -f "$_exclude" ] && ! grep -qxF '.agents/merge.lock' "$_exclude" 2>/dev/null; then + printf '.agents/merge.lock\n' >>"$_exclude" +fi +mkdir -p "$DSH_STAGING/.agents" +: >"$DSH_STAGING/.agents/merge.lock" fi # --- 3. install dependencies (no build; the launcher runs from source) -------- step "Installing dependencies with pnpm (this can take a while)" -( cd "$DSH_SOURCE" && pnpm install ) +( cd "$DSH_STAGING" && pnpm install ) -[ -x "$DSH_SOURCE/bin/dsh" ] || die "launcher $DSH_SOURCE/bin/dsh missing after install — is DSH_REF a branch that ships apps/cli?" +[ -x "$DSH_STAGING/bin/dsh" ] || die "launcher $DSH_STAGING/bin/dsh missing after install — is DSH_REF a branch that ships apps/cli?" # --- 4. put `dsh` on PATH ------------------------------------------------------ +# Clone installs go through a stable `current` symlink so an upgrade repoints +# one symlink (current -> new worktree) and the PATH launcher never moves: +# PATH/dsh -> current/bin/dsh -> /bin/dsh. In-repo reuse links PATH +# straight at the checkout, since that checkout is not a managed worktree. step "Linking dsh into $DSH_BIN_DIR" mkdir -p "$DSH_BIN_DIR" -ln -sf "$DSH_SOURCE/bin/dsh" "$DSH_BIN_DIR/dsh" -info "linked $DSH_BIN_DIR/dsh -> $DSH_SOURCE/bin/dsh" +if [ "$IN_REPO" = 1 ]; then + DSH_LAUNCH_TARGET=$DSH_STAGING/bin/dsh + ln -sf "$DSH_LAUNCH_TARGET" "$DSH_BIN_DIR/dsh" + info "linked $DSH_BIN_DIR/dsh -> $DSH_LAUNCH_TARGET" +else + # Point `current` at this staging worktree with `ln -sfn`: -f replaces an + # existing `current` (re-run or upgrade) and -n stops `ln` from dereferencing + # an existing symlink-to-directory and dropping the new link *inside* the old + # worktree. `mv` is unusable here — BSD/macOS `mv` follows the existing dir + # symlink the same way. The swap is one unlink+symlink pair on a local fs; the + # installer holds no other process racing this path. + ln -sfn "$DSH_STAGING" "$DSH_CURRENT" + info "pointed $DSH_CURRENT -> $DSH_STAGING" + DSH_LAUNCH_TARGET=$DSH_CURRENT/bin/dsh + ln -sf "$DSH_LAUNCH_TARGET" "$DSH_BIN_DIR/dsh" + info "linked $DSH_BIN_DIR/dsh -> $DSH_LAUNCH_TARGET" +fi case ":$PATH:" in *":$DSH_BIN_DIR:"*) ON_PATH=1 ;; diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index 55c98a88f5..0019654fdc 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -36,8 +36,14 @@ return (ctx) => { name: 'snapshot_double', description: 'Double a number for executable snapshot verification.', parameters: { value: { type: 'number', required: true } }, + output: { + schema: { type: 'number' }, + render(_args, value) { + return [{ type: 'text', text: String(value) }] + } + }, async execute(args) { - return [{ type: 'text', text: String(args.value * 2) }] + return args.value * 2 } })) } @@ -182,14 +188,17 @@ def advanced_tool_followup( if not call_id.startswith("advanced-"): return None if call_id == "advanced-mount" and tool_name == "cordis_mount": - if "mounted dyn-1" not in tool_text: - raise AssertionError(f"cordis_mount returned no mount id: {tool_text}") + if "Temporary Plugin dyn-1 is running" not in tool_text: + raise AssertionError(f"cordis_mount returned no temporary Plugin id: {tool_text}") assert_advertised_tool(body, "run_code") assert_advertised_tool(body, "snapshot_double") return tool_call_chunks( "advanced-code", "run_code", - {"code": "return await tools.snapshot_double({ value: 21 })"}, + { + "code": "return await tools.snapshot_double({ value: 21 })", + "description": "Run the temporary Plugin tool", + }, ) if call_id == "advanced-code" and tool_name == "run_code": if "42" not in tool_text: @@ -228,8 +237,8 @@ def advanced_tool_followup( {"id": "dyn-1"}, ) if call_id == "advanced-unmount" and tool_name == "cordis_unmount": - if "unmounted dyn-1" not in tool_text: - raise AssertionError(f"cordis_unmount returned no disposal result: {tool_text}") + if "Temporary Plugin dyn-1 was unmounted and removed." not in tool_text: + raise AssertionError(f"cordis_unmount returned no unmount result: {tool_text}") if "snapshot_double" in advertised_tool_names(body): raise AssertionError("snapshot_double remained advertised after cordis_unmount") return text_chunks(SNAPSHOT_FINAL_TEXT) diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/result.json b/scripts/snapshots/python-sdk-single-exe/advanced/result.json index 499ddafdf8..07393e7f25 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/result.json +++ b/scripts/snapshots/python-sdk-single-exe/advanced/result.json @@ -35,9 +35,23 @@ "surfaceOp": "append" }, { - "type": "step/start", + "type": "session/title", "seq": 2, "time": 0, + "data": { + "title": "Run the advanced packaged-runtime snapsh", + "messageSeqs": [ + 1 + ], + "source": { + "kind": "fallback" + } + } + }, + { + "type": "step/start", + "seq": 3, + "time": 0, "data": { "turn": 1, "step": 1 @@ -45,25 +59,31 @@ }, { "type": "request/header", - "seq": 3, + "seq": 4, "time": 0, "data": { "header": { "config": { - "model": "smoke-model" + "provider": "deepseek", + "model": "smoke-model", + "reasoningEffort": "high" }, "system": "{{system}}", "tools": [ "bash", - "bash_kill", - "bash_output", "cordis_inspect", "cordis_mount", "cordis_unmount", "run_code", "skill", "subagent", + "task_kill", + "task_list", + "task_output", "workflow" + ], + "messagePrefix": [ + "{{messagePrefix}}" ] }, "reason": "initial" @@ -71,7 +91,7 @@ }, { "type": "assistant/chunk", - "seq": 4, + "seq": 5, "time": 0, "data": { "turn": 1, @@ -85,7 +105,7 @@ }, { "type": "assistant/chunk", - "seq": 5, + "seq": 6, "time": 0, "data": { "turn": 1, @@ -95,26 +115,7 @@ "index": 0, "id": "advanced-mount", "name": "cordis_mount", - "argumentsDelta": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n async execute(args) {\\n return [{ type: 'text', text: String(args.value * 2) }]\\n }\\n }))\\n}\\n\"}" - } - } - }, - { - "type": "assistant/chunk", - "seq": 6, - "time": 0, - "data": { - "turn": 1, - "step": 1, - "chunk": { - "type": "block-end", - "index": 0, - "block": { - "type": "tool-call", - "id": "advanced-mount", - "name": "cordis_mount", - "arguments": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n async execute(args) {\\n return [{ type: 'text', text: String(args.value * 2) }]\\n }\\n }))\\n}\\n\"}" - } + "argumentsDelta": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}" } } }, @@ -126,10 +127,13 @@ "turn": 1, "step": 1, "chunk": { - "type": "usage", - "usage": { - "inputTokens": 3, - "outputTokens": 3 + "type": "block-end", + "index": 0, + "block": { + "type": "tool-call", + "id": "advanced-mount", + "name": "cordis_mount", + "arguments": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}" } } } @@ -138,6 +142,22 @@ "type": "assistant/chunk", "seq": 8, "time": 0, + "data": { + "turn": 1, + "step": 1, + "chunk": { + "type": "usage", + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + } + } + }, + { + "type": "assistant/chunk", + "seq": 9, + "time": 0, "data": { "turn": 1, "step": 1, @@ -151,7 +171,7 @@ }, { "type": "assistant/message", - "seq": 9, + "seq": 10, "time": 0, "data": { "turn": 1, @@ -161,38 +181,42 @@ "type": "tool-call", "id": "advanced-mount", "name": "cordis_mount", - "arguments": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n async execute(args) {\\n return [{ type: 'text', text: String(args.value * 2) }]\\n }\\n }))\\n}\\n\"}" + "arguments": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}" } ], + "provenance": { + "provider": "deepseek", + "model": "smoke-model" + }, "usage": { "inputTokens": 3, "outputTokens": 3 } }, "sourceEventSeqs": [ - 4, 5, 6, 7, - 8 + 8, + 9 ], "surfaceOp": "append" }, { "type": "tool/call", - "seq": 10, + "seq": 11, "time": 0, "data": { "turn": 1, "step": 1, "callId": "advanced-mount", "name": "cordis_mount", - "arguments": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n async execute(args) {\\n return [{ type: 'text', text: String(args.value * 2) }]\\n }\\n }))\\n}\\n\"}" + "arguments": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}" } }, { "type": "tool/result", - "seq": 11, + "seq": 12, "time": 0, "data": { "turn": 1, @@ -201,19 +225,19 @@ "content": [ { "type": "text", - "text": "mounted dyn-1 (plugin \"\", state: active)" + "text": "Temporary Plugin dyn-1 is running (plugin \"\"; available until unmounted or DSH restarts)." } ], "isError": false }, "sourceEventSeqs": [ - 10 + 11 ], "surfaceOp": "append" }, { "type": "step/end", - "seq": 12, + "seq": 13, "time": 0, "data": { "turn": 1, @@ -222,7 +246,7 @@ }, { "type": "step/start", - "seq": 13, + "seq": 14, "time": 0, "data": { "turn": 1, @@ -231,18 +255,18 @@ }, { "type": "request/header", - "seq": 14, + "seq": 15, "time": 0, "data": { "header": { "config": { - "model": "smoke-model" + "provider": "deepseek", + "model": "smoke-model", + "reasoningEffort": "high" }, "system": "{{system}}", "tools": [ "bash", - "bash_kill", - "bash_output", "cordis_inspect", "cordis_mount", "cordis_unmount", @@ -250,7 +274,13 @@ "skill", "snapshot_double", "subagent", + "task_kill", + "task_list", + "task_output", "workflow" + ], + "messagePrefix": [ + "{{messagePrefix}}" ] }, "reason": "change" @@ -258,7 +288,7 @@ }, { "type": "assistant/chunk", - "seq": 15, + "seq": 16, "time": 0, "data": { "turn": 1, @@ -272,7 +302,7 @@ }, { "type": "assistant/chunk", - "seq": 16, + "seq": 17, "time": 0, "data": { "turn": 1, @@ -282,13 +312,13 @@ "index": 0, "id": "advanced-code", "name": "run_code", - "argumentsDelta": "{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}" + "argumentsDelta": "{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}" } } }, { "type": "assistant/chunk", - "seq": 17, + "seq": 18, "time": 0, "data": { "turn": 1, @@ -300,14 +330,14 @@ "type": "tool-call", "id": "advanced-code", "name": "run_code", - "arguments": "{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}" + "arguments": "{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}" } } } }, { "type": "assistant/chunk", - "seq": 18, + "seq": 19, "time": 0, "data": { "turn": 1, @@ -323,7 +353,7 @@ }, { "type": "assistant/chunk", - "seq": 19, + "seq": 20, "time": 0, "data": { "turn": 1, @@ -338,7 +368,7 @@ }, { "type": "assistant/message", - "seq": 20, + "seq": 21, "time": 0, "data": { "turn": 1, @@ -348,38 +378,55 @@ "type": "tool-call", "id": "advanced-code", "name": "run_code", - "arguments": "{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}" + "arguments": "{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}" } ], + "provenance": { + "provider": "deepseek", + "model": "smoke-model" + }, "usage": { "inputTokens": 3, "outputTokens": 3 } }, "sourceEventSeqs": [ - 15, 16, 17, 18, - 19 + 19, + 20 ], "surfaceOp": "append" }, { "type": "tool/call", - "seq": 21, + "seq": 22, "time": 0, "data": { "turn": 1, "step": 2, "callId": "advanced-code", "name": "run_code", - "arguments": "{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}" + "arguments": "{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}" + } + }, + { + "type": "tool/code-dispatch-start", + "seq": 23, + "time": 0, + "data": { + "parentCallId": "advanced-code", + "subCallId": "advanced-code:code:1", + "name": "snapshot_double", + "arguments": { + "value": 21 + } } }, { "type": "tool/code-dispatch", - "seq": 22, + "seq": 24, "time": 0, "data": { "parentCallId": "advanced-code", @@ -389,12 +436,17 @@ "value": 21 }, "isError": false, - "resultSummary": "42" + "content": [ + { + "type": "text", + "text": "42" + } + ] } }, { "type": "tool/result", - "seq": 23, + "seq": 25, "time": 0, "data": { "turn": 1, @@ -406,19 +458,16 @@ "text": "42" } ], - "isError": false, - "meta": { - "logs": [] - } + "isError": false }, "sourceEventSeqs": [ - 21 + 22 ], "surfaceOp": "append" }, { "type": "step/end", - "seq": 24, + "seq": 26, "time": 0, "data": { "turn": 1, @@ -427,7 +476,7 @@ }, { "type": "step/start", - "seq": 25, + "seq": 27, "time": 0, "data": { "turn": 1, @@ -436,7 +485,7 @@ }, { "type": "assistant/chunk", - "seq": 26, + "seq": 28, "time": 0, "data": { "turn": 1, @@ -450,7 +499,7 @@ }, { "type": "assistant/chunk", - "seq": 27, + "seq": 29, "time": 0, "data": { "turn": 1, @@ -466,7 +515,7 @@ }, { "type": "assistant/chunk", - "seq": 28, + "seq": 30, "time": 0, "data": { "turn": 1, @@ -485,7 +534,7 @@ }, { "type": "assistant/chunk", - "seq": 29, + "seq": 31, "time": 0, "data": { "turn": 1, @@ -501,7 +550,7 @@ }, { "type": "assistant/chunk", - "seq": 30, + "seq": 32, "time": 0, "data": { "turn": 1, @@ -516,7 +565,7 @@ }, { "type": "assistant/message", - "seq": 31, + "seq": 33, "time": 0, "data": { "turn": 1, @@ -529,23 +578,27 @@ "arguments": "{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}" } ], + "provenance": { + "provider": "deepseek", + "model": "smoke-model" + }, "usage": { "inputTokens": 3, "outputTokens": 3 } }, "sourceEventSeqs": [ - 26, - 27, 28, 29, - 30 + 30, + 31, + 32 ], "surfaceOp": "append" }, { "type": "tool/call", - "seq": 32, + "seq": 34, "time": 0, "data": { "turn": 1, @@ -557,7 +610,7 @@ }, { "type": "tool/result", - "seq": 33, + "seq": 35, "time": 0, "data": { "turn": 1, @@ -572,13 +625,13 @@ "isError": false }, "sourceEventSeqs": [ - 32 + 34 ], "surfaceOp": "append" }, { "type": "step/end", - "seq": 34, + "seq": 36, "time": 0, "data": { "turn": 1, @@ -587,7 +640,7 @@ }, { "type": "step/start", - "seq": 35, + "seq": 37, "time": 0, "data": { "turn": 1, @@ -596,7 +649,7 @@ }, { "type": "assistant/chunk", - "seq": 36, + "seq": 38, "time": 0, "data": { "turn": 1, @@ -610,7 +663,7 @@ }, { "type": "assistant/chunk", - "seq": 37, + "seq": 39, "time": 0, "data": { "turn": 1, @@ -626,7 +679,7 @@ }, { "type": "assistant/chunk", - "seq": 38, + "seq": 40, "time": 0, "data": { "turn": 1, @@ -645,7 +698,7 @@ }, { "type": "assistant/chunk", - "seq": 39, + "seq": 41, "time": 0, "data": { "turn": 1, @@ -661,7 +714,7 @@ }, { "type": "assistant/chunk", - "seq": 40, + "seq": 42, "time": 0, "data": { "turn": 1, @@ -676,7 +729,7 @@ }, { "type": "assistant/message", - "seq": 41, + "seq": 43, "time": 0, "data": { "turn": 1, @@ -689,23 +742,27 @@ "arguments": "{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}" } ], + "provenance": { + "provider": "deepseek", + "model": "smoke-model" + }, "usage": { "inputTokens": 3, "outputTokens": 3 } }, "sourceEventSeqs": [ - 36, - 37, 38, 39, - 40 + 40, + 41, + 42 ], "surfaceOp": "append" }, { "type": "tool/call", - "seq": 42, + "seq": 44, "time": 0, "data": { "turn": 1, @@ -717,7 +774,7 @@ }, { "type": "tool/result", - "seq": 43, + "seq": 45, "time": 0, "data": { "turn": 1, @@ -732,13 +789,13 @@ "isError": false }, "sourceEventSeqs": [ - 42 + 44 ], "surfaceOp": "append" }, { "type": "step/end", - "seq": 44, + "seq": 46, "time": 0, "data": { "turn": 1, @@ -747,7 +804,7 @@ }, { "type": "step/start", - "seq": 45, + "seq": 47, "time": 0, "data": { "turn": 1, @@ -756,7 +813,7 @@ }, { "type": "assistant/chunk", - "seq": 46, + "seq": 48, "time": 0, "data": { "turn": 1, @@ -770,7 +827,7 @@ }, { "type": "assistant/chunk", - "seq": 47, + "seq": 49, "time": 0, "data": { "turn": 1, @@ -786,7 +843,7 @@ }, { "type": "assistant/chunk", - "seq": 48, + "seq": 50, "time": 0, "data": { "turn": 1, @@ -805,7 +862,7 @@ }, { "type": "assistant/chunk", - "seq": 49, + "seq": 51, "time": 0, "data": { "turn": 1, @@ -821,7 +878,7 @@ }, { "type": "assistant/chunk", - "seq": 50, + "seq": 52, "time": 0, "data": { "turn": 1, @@ -836,7 +893,7 @@ }, { "type": "assistant/message", - "seq": 51, + "seq": 53, "time": 0, "data": { "turn": 1, @@ -849,23 +906,27 @@ "arguments": "{\"id\": \"dyn-1\"}" } ], + "provenance": { + "provider": "deepseek", + "model": "smoke-model" + }, "usage": { "inputTokens": 3, "outputTokens": 3 } }, "sourceEventSeqs": [ - 46, - 47, 48, 49, - 50 + 50, + 51, + 52 ], "surfaceOp": "append" }, { "type": "tool/call", - "seq": 52, + "seq": 54, "time": 0, "data": { "turn": 1, @@ -877,7 +938,7 @@ }, { "type": "tool/result", - "seq": 53, + "seq": 55, "time": 0, "data": { "turn": 1, @@ -886,19 +947,19 @@ "content": [ { "type": "text", - "text": "unmounted dyn-1 (plugin \"\")" + "text": "Temporary Plugin dyn-1 was unmounted and removed." } ], "isError": false }, "sourceEventSeqs": [ - 52 + 54 ], "surfaceOp": "append" }, { "type": "step/end", - "seq": 54, + "seq": 56, "time": 0, "data": { "turn": 1, @@ -907,7 +968,7 @@ }, { "type": "step/start", - "seq": 55, + "seq": 57, "time": 0, "data": { "turn": 1, @@ -916,25 +977,31 @@ }, { "type": "request/header", - "seq": 56, + "seq": 58, "time": 0, "data": { "header": { "config": { - "model": "smoke-model" + "provider": "deepseek", + "model": "smoke-model", + "reasoningEffort": "high" }, "system": "{{system}}", "tools": [ "bash", - "bash_kill", - "bash_output", "cordis_inspect", "cordis_mount", "cordis_unmount", "run_code", "skill", "subagent", + "task_kill", + "task_list", + "task_output", "workflow" + ], + "messagePrefix": [ + "{{messagePrefix}}" ] }, "reason": "change" @@ -942,7 +1009,7 @@ }, { "type": "assistant/chunk", - "seq": 57, + "seq": 59, "time": 0, "data": { "turn": 1, @@ -956,7 +1023,7 @@ }, { "type": "assistant/chunk", - "seq": 58, + "seq": 60, "time": 0, "data": { "turn": 1, @@ -970,7 +1037,7 @@ }, { "type": "assistant/chunk", - "seq": 59, + "seq": 61, "time": 0, "data": { "turn": 1, @@ -987,7 +1054,7 @@ }, { "type": "assistant/chunk", - "seq": 60, + "seq": 62, "time": 0, "data": { "turn": 1, @@ -1003,7 +1070,7 @@ }, { "type": "assistant/chunk", - "seq": 61, + "seq": 63, "time": 0, "data": { "turn": 1, @@ -1018,7 +1085,7 @@ }, { "type": "assistant/message", - "seq": 62, + "seq": 64, "time": 0, "data": { "turn": 1, @@ -1029,23 +1096,27 @@ "text": "ADVANCED_EXECUTABLE_OK" } ], + "provenance": { + "provider": "deepseek", + "model": "smoke-model" + }, "usage": { "inputTokens": 3, "outputTokens": 3 } }, "sourceEventSeqs": [ - 57, - 58, 59, 60, - 61 + 61, + 62, + 63 ], "surfaceOp": "append" }, { "type": "step/end", - "seq": 63, + "seq": 65, "time": 0, "data": { "turn": 1, @@ -1054,7 +1125,7 @@ }, { "type": "turn/end", - "seq": 64, + "seq": 66, "time": 0, "data": { "turn": 1, @@ -1113,9 +1184,29 @@ "payload": { "sessionId": "{{parent}}", "event": { - "type": "step/start", + "type": "session/title", "seq": 2, "time": 0, + "data": { + "title": "Run the advanced packaged-runtime snapsh", + "messageSeqs": [ + 1 + ], + "source": { + "kind": "fallback" + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "step/start", + "seq": 3, + "time": 0, "data": { "turn": 1, "step": 1 @@ -1129,25 +1220,31 @@ "sessionId": "{{parent}}", "event": { "type": "request/header", - "seq": 3, + "seq": 4, "time": 0, "data": { "header": { "config": { - "model": "smoke-model" + "provider": "deepseek", + "model": "smoke-model", + "reasoningEffort": "high" }, "system": "{{system}}", "tools": [ "bash", - "bash_kill", - "bash_output", "cordis_inspect", "cordis_mount", "cordis_unmount", "run_code", "skill", "subagent", + "task_kill", + "task_list", + "task_output", "workflow" + ], + "messagePrefix": [ + "{{messagePrefix}}" ] }, "reason": "initial" @@ -1161,7 +1258,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 4, + "seq": 5, "time": 0, "data": { "turn": 1, @@ -1181,7 +1278,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 5, + "seq": 6, "time": 0, "data": { "turn": 1, @@ -1191,32 +1288,7 @@ "index": 0, "id": "advanced-mount", "name": "cordis_mount", - "argumentsDelta": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n async execute(args) {\\n return [{ type: 'text', text: String(args.value * 2) }]\\n }\\n }))\\n}\\n\"}" - } - } - } - } - }, - { - "method": "session.event", - "payload": { - "sessionId": "{{parent}}", - "event": { - "type": "assistant/chunk", - "seq": 6, - "time": 0, - "data": { - "turn": 1, - "step": 1, - "chunk": { - "type": "block-end", - "index": 0, - "block": { - "type": "tool-call", - "id": "advanced-mount", - "name": "cordis_mount", - "arguments": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n async execute(args) {\\n return [{ type: 'text', text: String(args.value * 2) }]\\n }\\n }))\\n}\\n\"}" - } + "argumentsDelta": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}" } } } @@ -1234,10 +1306,13 @@ "turn": 1, "step": 1, "chunk": { - "type": "usage", - "usage": { - "inputTokens": 3, - "outputTokens": 3 + "type": "block-end", + "index": 0, + "block": { + "type": "tool-call", + "id": "advanced-mount", + "name": "cordis_mount", + "arguments": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}" } } } @@ -1252,6 +1327,28 @@ "type": "assistant/chunk", "seq": 8, "time": 0, + "data": { + "turn": 1, + "step": 1, + "chunk": { + "type": "usage", + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 9, + "time": 0, "data": { "turn": 1, "step": 1, @@ -1271,7 +1368,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/message", - "seq": 9, + "seq": 10, "time": 0, "data": { "turn": 1, @@ -1281,20 +1378,24 @@ "type": "tool-call", "id": "advanced-mount", "name": "cordis_mount", - "arguments": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n async execute(args) {\\n return [{ type: 'text', text: String(args.value * 2) }]\\n }\\n }))\\n}\\n\"}" + "arguments": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}" } ], + "provenance": { + "provider": "deepseek", + "model": "smoke-model" + }, "usage": { "inputTokens": 3, "outputTokens": 3 } }, "sourceEventSeqs": [ - 4, 5, 6, 7, - 8 + 8, + 9 ], "surfaceOp": "append" } @@ -1306,14 +1407,14 @@ "sessionId": "{{parent}}", "event": { "type": "tool/call", - "seq": 10, + "seq": 11, "time": 0, "data": { "turn": 1, "step": 1, "callId": "advanced-mount", "name": "cordis_mount", - "arguments": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n async execute(args) {\\n return [{ type: 'text', text: String(args.value * 2) }]\\n }\\n }))\\n}\\n\"}" + "arguments": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}" } } } @@ -1324,7 +1425,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/result", - "seq": 11, + "seq": 12, "time": 0, "data": { "turn": 1, @@ -1333,13 +1434,13 @@ "content": [ { "type": "text", - "text": "mounted dyn-1 (plugin \"\", state: active)" + "text": "Temporary Plugin dyn-1 is running (plugin \"\"; available until unmounted or DSH restarts)." } ], "isError": false }, "sourceEventSeqs": [ - 10 + 11 ], "surfaceOp": "append" } @@ -1351,7 +1452,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/end", - "seq": 12, + "seq": 13, "time": 0, "data": { "turn": 1, @@ -1366,7 +1467,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/start", - "seq": 13, + "seq": 14, "time": 0, "data": { "turn": 1, @@ -1381,18 +1482,18 @@ "sessionId": "{{parent}}", "event": { "type": "request/header", - "seq": 14, + "seq": 15, "time": 0, "data": { "header": { "config": { - "model": "smoke-model" + "provider": "deepseek", + "model": "smoke-model", + "reasoningEffort": "high" }, "system": "{{system}}", "tools": [ "bash", - "bash_kill", - "bash_output", "cordis_inspect", "cordis_mount", "cordis_unmount", @@ -1400,7 +1501,13 @@ "skill", "snapshot_double", "subagent", + "task_kill", + "task_list", + "task_output", "workflow" + ], + "messagePrefix": [ + "{{messagePrefix}}" ] }, "reason": "change" @@ -1414,7 +1521,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 15, + "seq": 16, "time": 0, "data": { "turn": 1, @@ -1434,7 +1541,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 16, + "seq": 17, "time": 0, "data": { "turn": 1, @@ -1444,32 +1551,7 @@ "index": 0, "id": "advanced-code", "name": "run_code", - "argumentsDelta": "{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}" - } - } - } - } - }, - { - "method": "session.event", - "payload": { - "sessionId": "{{parent}}", - "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.snapshot_double({ value: 21 })\"}" - } + "argumentsDelta": "{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}" } } } @@ -1483,6 +1565,31 @@ "type": "assistant/chunk", "seq": 18, "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.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}" + } + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 19, + "time": 0, "data": { "turn": 1, "step": 2, @@ -1503,7 +1610,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 19, + "seq": 20, "time": 0, "data": { "turn": 1, @@ -1524,7 +1631,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/message", - "seq": 20, + "seq": 21, "time": 0, "data": { "turn": 1, @@ -1534,20 +1641,24 @@ "type": "tool-call", "id": "advanced-code", "name": "run_code", - "arguments": "{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}" + "arguments": "{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}" } ], + "provenance": { + "provider": "deepseek", + "model": "smoke-model" + }, "usage": { "inputTokens": 3, "outputTokens": 3 } }, "sourceEventSeqs": [ - 15, 16, 17, 18, - 19 + 19, + 20 ], "surfaceOp": "append" } @@ -1559,14 +1670,33 @@ "sessionId": "{{parent}}", "event": { "type": "tool/call", - "seq": 21, + "seq": 22, "time": 0, "data": { "turn": 1, "step": 2, "callId": "advanced-code", "name": "run_code", - "arguments": "{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}" + "arguments": "{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}" + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "tool/code-dispatch-start", + "seq": 23, + "time": 0, + "data": { + "parentCallId": "advanced-code", + "subCallId": "advanced-code:code:1", + "name": "snapshot_double", + "arguments": { + "value": 21 + } } } } @@ -1577,7 +1707,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/code-dispatch", - "seq": 22, + "seq": 24, "time": 0, "data": { "parentCallId": "advanced-code", @@ -1587,7 +1717,12 @@ "value": 21 }, "isError": false, - "resultSummary": "42" + "content": [ + { + "type": "text", + "text": "42" + } + ] } } } @@ -1598,7 +1733,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/result", - "seq": 23, + "seq": 25, "time": 0, "data": { "turn": 1, @@ -1610,13 +1745,10 @@ "text": "42" } ], - "isError": false, - "meta": { - "logs": [] - } + "isError": false }, "sourceEventSeqs": [ - 21 + 22 ], "surfaceOp": "append" } @@ -1628,7 +1760,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/end", - "seq": 24, + "seq": 26, "time": 0, "data": { "turn": 1, @@ -1643,7 +1775,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/start", - "seq": 25, + "seq": 27, "time": 0, "data": { "turn": 1, @@ -1658,7 +1790,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 26, + "seq": 28, "time": 0, "data": { "turn": 1, @@ -1678,7 +1810,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 27, + "seq": 29, "time": 0, "data": { "turn": 1, @@ -1700,7 +1832,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 28, + "seq": 30, "time": 0, "data": { "turn": 1, @@ -1725,7 +1857,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 29, + "seq": 31, "time": 0, "data": { "turn": 1, @@ -1747,7 +1879,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 30, + "seq": 32, "time": 0, "data": { "turn": 1, @@ -1768,7 +1900,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/message", - "seq": 31, + "seq": 33, "time": 0, "data": { "turn": 1, @@ -1781,17 +1913,21 @@ "arguments": "{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}" } ], + "provenance": { + "provider": "deepseek", + "model": "smoke-model" + }, "usage": { "inputTokens": 3, "outputTokens": 3 } }, "sourceEventSeqs": [ - 26, - 27, 28, 29, - 30 + 30, + 31, + 32 ], "surfaceOp": "append" } @@ -1803,7 +1939,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/call", - "seq": 32, + "seq": 34, "time": 0, "data": { "turn": 1, @@ -1822,11 +1958,303 @@ "childSessionId": "{{child-1}}" } }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-1}}", + "event": { + "type": "turn/start", + "seq": 0, + "time": 0, + "data": { + "turn": 1, + "trigger": { + "kind": "message", + "source": { + "kind": "user" + } + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-1}}", + "event": { + "type": "user/message", + "seq": 1, + "time": 0, + "data": { + "content": [ + { + "type": "text", + "text": "Reply with exactly DIRECT_CHILD_OK and nothing else." + } + ], + "source": { + "kind": "user" + } + }, + "surfaceOp": "append" + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-1}}", + "event": { + "type": "session/title", + "seq": 2, + "time": 0, + "data": { + "title": "Reply with exactly DIRECT_CHILD_OK and", + "messageSeqs": [ + 1 + ], + "source": { + "kind": "fallback" + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-1}}", + "event": { + "type": "step/start", + "seq": 3, + "time": 0, + "data": { + "turn": 1, + "step": 1 + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-1}}", + "event": { + "type": "request/header", + "seq": 4, + "time": 0, + "data": { + "header": { + "config": { + "provider": "deepseek", + "model": "smoke-model", + "reasoningEffort": "high" + }, + "system": "{{system}}", + "tools": [ + "bash", + "cordis_inspect", + "cordis_mount", + "cordis_unmount", + "run_code", + "skill", + "snapshot_double", + "subagent", + "task_kill", + "task_list", + "task_output", + "workflow" + ], + "messagePrefix": [ + "{{messagePrefix}}" + ] + }, + "reason": "initial" + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-1}}", + "event": { + "type": "assistant/chunk", + "seq": 5, + "time": 0, + "data": { + "turn": 1, + "step": 1, + "chunk": { + "type": "block-start", + "index": 0, + "blockType": "text" + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-1}}", + "event": { + "type": "assistant/chunk", + "seq": 6, + "time": 0, + "data": { + "turn": 1, + "step": 1, + "chunk": { + "type": "text-delta", + "index": 0, + "text": "DIRECT_CHILD_OK" + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-1}}", + "event": { + "type": "assistant/chunk", + "seq": 7, + "time": 0, + "data": { + "turn": 1, + "step": 1, + "chunk": { + "type": "block-end", + "index": 0, + "block": { + "type": "text", + "text": "DIRECT_CHILD_OK" + } + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-1}}", + "event": { + "type": "assistant/chunk", + "seq": 8, + "time": 0, + "data": { + "turn": 1, + "step": 1, + "chunk": { + "type": "usage", + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-1}}", + "event": { + "type": "assistant/chunk", + "seq": 9, + "time": 0, + "data": { + "turn": 1, + "step": 1, + "chunk": { + "type": "finish", + "reason": { + "kind": "stop" + } + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-1}}", + "event": { + "type": "assistant/message", + "seq": 10, + "time": 0, + "data": { + "turn": 1, + "step": 1, + "content": [ + { + "type": "text", + "text": "DIRECT_CHILD_OK" + } + ], + "provenance": { + "provider": "deepseek", + "model": "smoke-model" + }, + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + }, + "sourceEventSeqs": [ + 5, + 6, + 7, + 8, + 9 + ], + "surfaceOp": "append" + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-1}}", + "event": { + "type": "step/end", + "seq": 11, + "time": 0, + "data": { + "turn": 1, + "step": 1 + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-1}}", + "event": { + "type": "turn/end", + "seq": 12, + "time": 0, + "data": { + "turn": 1, + "reason": { + "kind": "completed" + } + } + } + } + }, { "method": "subagent.finished", "payload": { "provider": "spawn", - "agentId": "{{agent-1}}", + "agentId": "{{child-1}}", "parentSessionId": "{{parent}}", "childSessionId": "{{child-1}}", "status": "ok", @@ -1845,7 +2273,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/result", - "seq": 33, + "seq": 35, "time": 0, "data": { "turn": 1, @@ -1860,7 +2288,7 @@ "isError": false }, "sourceEventSeqs": [ - 32 + 34 ], "surfaceOp": "append" } @@ -1872,7 +2300,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/end", - "seq": 34, + "seq": 36, "time": 0, "data": { "turn": 1, @@ -1887,7 +2315,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/start", - "seq": 35, + "seq": 37, "time": 0, "data": { "turn": 1, @@ -1902,7 +2330,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 36, + "seq": 38, "time": 0, "data": { "turn": 1, @@ -1922,7 +2350,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 37, + "seq": 39, "time": 0, "data": { "turn": 1, @@ -1944,7 +2372,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 38, + "seq": 40, "time": 0, "data": { "turn": 1, @@ -1969,7 +2397,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 39, + "seq": 41, "time": 0, "data": { "turn": 1, @@ -1991,7 +2419,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 40, + "seq": 42, "time": 0, "data": { "turn": 1, @@ -2012,7 +2440,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/message", - "seq": 41, + "seq": 43, "time": 0, "data": { "turn": 1, @@ -2025,17 +2453,21 @@ "arguments": "{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}" } ], + "provenance": { + "provider": "deepseek", + "model": "smoke-model" + }, "usage": { "inputTokens": 3, "outputTokens": 3 } }, "sourceEventSeqs": [ - 36, - 37, 38, 39, - 40 + 40, + 41, + 42 ], "surfaceOp": "append" } @@ -2047,7 +2479,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/call", - "seq": 42, + "seq": 44, "time": 0, "data": { "turn": 1, @@ -2066,11 +2498,303 @@ "childSessionId": "{{child-2}}" } }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-2}}", + "event": { + "type": "turn/start", + "seq": 0, + "time": 0, + "data": { + "turn": 1, + "trigger": { + "kind": "message", + "source": { + "kind": "user" + } + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-2}}", + "event": { + "type": "user/message", + "seq": 1, + "time": 0, + "data": { + "content": [ + { + "type": "text", + "text": "Reply with exactly WORKFLOW_CHILD_OK and nothing else." + } + ], + "source": { + "kind": "user" + } + }, + "surfaceOp": "append" + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-2}}", + "event": { + "type": "session/title", + "seq": 2, + "time": 0, + "data": { + "title": "Reply with exactly WORKFLOW_CHILD_OK and", + "messageSeqs": [ + 1 + ], + "source": { + "kind": "fallback" + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-2}}", + "event": { + "type": "step/start", + "seq": 3, + "time": 0, + "data": { + "turn": 1, + "step": 1 + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-2}}", + "event": { + "type": "request/header", + "seq": 4, + "time": 0, + "data": { + "header": { + "config": { + "provider": "deepseek", + "model": "smoke-model", + "reasoningEffort": "high" + }, + "system": "{{system}}", + "tools": [ + "bash", + "cordis_inspect", + "cordis_mount", + "cordis_unmount", + "run_code", + "skill", + "snapshot_double", + "subagent", + "task_kill", + "task_list", + "task_output", + "workflow" + ], + "messagePrefix": [ + "{{messagePrefix}}" + ] + }, + "reason": "initial" + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-2}}", + "event": { + "type": "assistant/chunk", + "seq": 5, + "time": 0, + "data": { + "turn": 1, + "step": 1, + "chunk": { + "type": "block-start", + "index": 0, + "blockType": "text" + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-2}}", + "event": { + "type": "assistant/chunk", + "seq": 6, + "time": 0, + "data": { + "turn": 1, + "step": 1, + "chunk": { + "type": "text-delta", + "index": 0, + "text": "WORKFLOW_CHILD_OK" + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-2}}", + "event": { + "type": "assistant/chunk", + "seq": 7, + "time": 0, + "data": { + "turn": 1, + "step": 1, + "chunk": { + "type": "block-end", + "index": 0, + "block": { + "type": "text", + "text": "WORKFLOW_CHILD_OK" + } + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-2}}", + "event": { + "type": "assistant/chunk", + "seq": 8, + "time": 0, + "data": { + "turn": 1, + "step": 1, + "chunk": { + "type": "usage", + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-2}}", + "event": { + "type": "assistant/chunk", + "seq": 9, + "time": 0, + "data": { + "turn": 1, + "step": 1, + "chunk": { + "type": "finish", + "reason": { + "kind": "stop" + } + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-2}}", + "event": { + "type": "assistant/message", + "seq": 10, + "time": 0, + "data": { + "turn": 1, + "step": 1, + "content": [ + { + "type": "text", + "text": "WORKFLOW_CHILD_OK" + } + ], + "provenance": { + "provider": "deepseek", + "model": "smoke-model" + }, + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + }, + "sourceEventSeqs": [ + 5, + 6, + 7, + 8, + 9 + ], + "surfaceOp": "append" + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-2}}", + "event": { + "type": "step/end", + "seq": 11, + "time": 0, + "data": { + "turn": 1, + "step": 1 + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-2}}", + "event": { + "type": "turn/end", + "seq": 12, + "time": 0, + "data": { + "turn": 1, + "reason": { + "kind": "completed" + } + } + } + } + }, { "method": "subagent.finished", "payload": { "provider": "spawn", - "agentId": "{{agent-2}}", + "agentId": "{{child-2}}", "parentSessionId": "{{parent}}", "childSessionId": "{{child-2}}", "status": "ok", @@ -2089,7 +2813,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/result", - "seq": 43, + "seq": 45, "time": 0, "data": { "turn": 1, @@ -2104,7 +2828,7 @@ "isError": false }, "sourceEventSeqs": [ - 42 + 44 ], "surfaceOp": "append" } @@ -2116,7 +2840,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/end", - "seq": 44, + "seq": 46, "time": 0, "data": { "turn": 1, @@ -2131,7 +2855,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/start", - "seq": 45, + "seq": 47, "time": 0, "data": { "turn": 1, @@ -2146,7 +2870,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 46, + "seq": 48, "time": 0, "data": { "turn": 1, @@ -2166,7 +2890,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 47, + "seq": 49, "time": 0, "data": { "turn": 1, @@ -2188,7 +2912,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 48, + "seq": 50, "time": 0, "data": { "turn": 1, @@ -2213,7 +2937,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 49, + "seq": 51, "time": 0, "data": { "turn": 1, @@ -2235,7 +2959,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 50, + "seq": 52, "time": 0, "data": { "turn": 1, @@ -2256,7 +2980,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/message", - "seq": 51, + "seq": 53, "time": 0, "data": { "turn": 1, @@ -2269,17 +2993,21 @@ "arguments": "{\"id\": \"dyn-1\"}" } ], + "provenance": { + "provider": "deepseek", + "model": "smoke-model" + }, "usage": { "inputTokens": 3, "outputTokens": 3 } }, "sourceEventSeqs": [ - 46, - 47, 48, 49, - 50 + 50, + 51, + 52 ], "surfaceOp": "append" } @@ -2291,7 +3019,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/call", - "seq": 52, + "seq": 54, "time": 0, "data": { "turn": 1, @@ -2309,7 +3037,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/result", - "seq": 53, + "seq": 55, "time": 0, "data": { "turn": 1, @@ -2318,13 +3046,13 @@ "content": [ { "type": "text", - "text": "unmounted dyn-1 (plugin \"\")" + "text": "Temporary Plugin dyn-1 was unmounted and removed." } ], "isError": false }, "sourceEventSeqs": [ - 52 + 54 ], "surfaceOp": "append" } @@ -2336,7 +3064,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/end", - "seq": 54, + "seq": 56, "time": 0, "data": { "turn": 1, @@ -2351,7 +3079,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/start", - "seq": 55, + "seq": 57, "time": 0, "data": { "turn": 1, @@ -2366,25 +3094,31 @@ "sessionId": "{{parent}}", "event": { "type": "request/header", - "seq": 56, + "seq": 58, "time": 0, "data": { "header": { "config": { - "model": "smoke-model" + "provider": "deepseek", + "model": "smoke-model", + "reasoningEffort": "high" }, "system": "{{system}}", "tools": [ "bash", - "bash_kill", - "bash_output", "cordis_inspect", "cordis_mount", "cordis_unmount", "run_code", "skill", "subagent", + "task_kill", + "task_list", + "task_output", "workflow" + ], + "messagePrefix": [ + "{{messagePrefix}}" ] }, "reason": "change" @@ -2398,7 +3132,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 57, + "seq": 59, "time": 0, "data": { "turn": 1, @@ -2418,7 +3152,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 58, + "seq": 60, "time": 0, "data": { "turn": 1, @@ -2438,7 +3172,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 59, + "seq": 61, "time": 0, "data": { "turn": 1, @@ -2461,7 +3195,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 60, + "seq": 62, "time": 0, "data": { "turn": 1, @@ -2483,7 +3217,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 61, + "seq": 63, "time": 0, "data": { "turn": 1, @@ -2504,7 +3238,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/message", - "seq": 62, + "seq": 64, "time": 0, "data": { "turn": 1, @@ -2515,17 +3249,21 @@ "text": "ADVANCED_EXECUTABLE_OK" } ], + "provenance": { + "provider": "deepseek", + "model": "smoke-model" + }, "usage": { "inputTokens": 3, "outputTokens": 3 } }, "sourceEventSeqs": [ - 57, - 58, 59, 60, - 61 + 61, + 62, + 63 ], "surfaceOp": "append" } @@ -2537,7 +3275,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/end", - "seq": 63, + "seq": 65, "time": 0, "data": { "turn": 1, @@ -2552,7 +3290,7 @@ "sessionId": "{{parent}}", "event": { "type": "turn/end", - "seq": 64, + "seq": 66, "time": 0, "data": { "turn": 1, diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl index beb80d3c85..05998f8980 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl @@ -1,13 +1,14 @@ -{"type":"session","version":0,"id":"{{child-1}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{parent}}"} +{"type":"session","version":0,"id":"{{child-1}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{parent}}","delegationDepth":1} {"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":0,"data":{"header":{"config":{"model":"smoke-model"},"system":"{{system}}","tools":["bash","bash_kill","bash_output","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","snapshot_double","subagent","workflow"]},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} -{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} -{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":9,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} -{"type":"step/end","seq":10,"time":0,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":11,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session/title","seq":2,"time":0,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","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":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["bash","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","snapshot_double","subagent","task_kill","task_list","task_output","workflow"],"messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}} +{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} +{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} +{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"provenance":{"provider":"deepseek","model":"smoke-model"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} +{"type":"step/end","seq":11,"time":0,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":12,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl index 948c835148..778c200078 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl @@ -1,13 +1,14 @@ -{"type":"session","version":0,"id":"{{child-2}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{parent}}"} +{"type":"session","version":0,"id":"{{child-2}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{parent}}","delegationDepth":1} {"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":0,"data":{"header":{"config":{"model":"smoke-model"},"system":"{{system}}","tools":["bash","bash_kill","bash_output","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","snapshot_double","subagent","workflow"]},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} -{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} -{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":9,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} -{"type":"step/end","seq":10,"time":0,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":11,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session/title","seq":2,"time":0,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","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":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["bash","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","snapshot_double","subagent","task_kill","task_list","task_output","workflow"],"messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}} +{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} +{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} +{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"provenance":{"provider":"deepseek","model":"smoke-model"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} +{"type":"step/end","seq":11,"time":0,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":12,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl index 3d09295028..1078fe4985 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl @@ -1,66 +1,68 @@ -{"type":"session","version":0,"id":"{{parent}}","createdAt":0,"cwd":"{{cwd}}"} +{"type":"session","version":0,"id":"{{parent}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Run the advanced packaged-runtime snapshot scenario."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":0,"data":{"header":{"config":{"model":"smoke-model"},"system":"{{system}}","tools":["bash","bash_kill","bash_output","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","subagent","workflow"]},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n async execute(args) {\\n return [{ type: 'text', text: String(args.value * 2) }]\\n }\\n }))\\n}\\n\"}"}}} -{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n async execute(args) {\\n return [{ type: 'text', text: String(args.value * 2) }]\\n }\\n }))\\n}\\n\"}"}}}} -{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":9,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n async execute(args) {\\n return [{ type: 'text', text: String(args.value * 2) }]\\n }\\n }))\\n}\\n\"}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} -{"type":"tool/call","seq":10,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n async execute(args) {\\n return [{ type: 'text', text: String(args.value * 2) }]\\n }\\n }))\\n}\\n\"}"}} -{"type":"tool/result","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","content":[{"type":"text","text":"mounted dyn-1 (plugin \"\", state: active)"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"} -{"type":"step/end","seq":12,"time":0,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":13,"time":0,"data":{"turn":1,"step":2}} -{"type":"request/header","seq":14,"time":0,"data":{"header":{"config":{"model":"smoke-model"},"system":"{{system}}","tools":["bash","bash_kill","bash_output","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","snapshot_double","subagent","workflow"]},"reason":"change"}} -{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"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.snapshot_double({ value: 21 })\"}"}}} -{"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.snapshot_double({ value: 21 })\"}"}}}} -{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"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.snapshot_double({ value: 21 })\"}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"} -{"type":"tool/call","seq":21,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}"}} -{"type":"tool/code-dispatch","seq":22,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"snapshot_double","arguments":{"value":21},"isError":false,"resultSummary":"42"}} -{"type":"tool/result","seq":23,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"42"}],"isError":false,"meta":{"logs":[]}},"sourceEventSeqs":[21],"surfaceOp":"append"} -{"type":"step/end","seq":24,"time":0,"data":{"turn":1,"step":2}} -{"type":"step/start","seq":25,"time":0,"data":{"turn":1,"step":3}} -{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-direct-child","name":"subagent","argumentsDelta":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}} -{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}}} -{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":30,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":31,"time":0,"data":{"turn":1,"step":3,"content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[26,27,28,29,30],"surfaceOp":"append"} -{"type":"tool/call","seq":32,"time":0,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}} -{"type":"tool/result","seq":33,"time":0,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false},"sourceEventSeqs":[32],"surfaceOp":"append"} -{"type":"step/end","seq":34,"time":0,"data":{"turn":1,"step":3}} -{"type":"step/start","seq":35,"time":0,"data":{"turn":1,"step":4}} -{"type":"assistant/chunk","seq":36,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":37,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-workflow","name":"workflow","argumentsDelta":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}}} -{"type":"assistant/chunk","seq":38,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}}}} -{"type":"assistant/chunk","seq":39,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":40,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":41,"time":0,"data":{"turn":1,"step":4,"content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[36,37,38,39,40],"surfaceOp":"append"} -{"type":"tool/call","seq":42,"time":0,"data":{"turn":1,"step":4,"callId":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}} -{"type":"tool/result","seq":43,"time":0,"data":{"turn":1,"step":4,"callId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-exe-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false},"sourceEventSeqs":[42],"surfaceOp":"append"} -{"type":"step/end","seq":44,"time":0,"data":{"turn":1,"step":4}} -{"type":"step/start","seq":45,"time":0,"data":{"turn":1,"step":5}} -{"type":"assistant/chunk","seq":46,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":47,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_unmount","argumentsDelta":"{\"id\": \"dyn-1\"}"}}} -{"type":"assistant/chunk","seq":48,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}}}} -{"type":"assistant/chunk","seq":49,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":50,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":51,"time":0,"data":{"turn":1,"step":5,"content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[46,47,48,49,50],"surfaceOp":"append"} -{"type":"tool/call","seq":52,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}} -{"type":"tool/result","seq":53,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","content":[{"type":"text","text":"unmounted dyn-1 (plugin \"\")"}],"isError":false},"sourceEventSeqs":[52],"surfaceOp":"append"} -{"type":"step/end","seq":54,"time":0,"data":{"turn":1,"step":5}} -{"type":"step/start","seq":55,"time":0,"data":{"turn":1,"step":6}} -{"type":"request/header","seq":56,"time":0,"data":{"header":{"config":{"model":"smoke-model"},"system":"{{system}}","tools":["bash","bash_kill","bash_output","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","subagent","workflow"]},"reason":"change"}} -{"type":"assistant/chunk","seq":57,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":58,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_EXECUTABLE_OK"}}} -{"type":"assistant/chunk","seq":59,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}}}} -{"type":"assistant/chunk","seq":60,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":61,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":62,"time":0,"data":{"turn":1,"step":6,"content":[{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[57,58,59,60,61],"surfaceOp":"append"} -{"type":"step/end","seq":63,"time":0,"data":{"turn":1,"step":6}} -{"type":"turn/end","seq":64,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session/title","seq":2,"time":0,"data":{"title":"Run the advanced packaged-runtime snapsh","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":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["bash","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","subagent","task_kill","task_list","task_output","workflow"],"messagePrefix":["{{messagePrefix}}"]},"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":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}}} +{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}}}} +{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"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":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}],"provenance":{"provider":"deepseek","model":"smoke-model"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} +{"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}} +{"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 \"\"; available until unmounted or DSH restarts)."}],"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":"request/header","seq":15,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["bash","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","snapshot_double","subagent","task_kill","task_list","task_output","workflow"],"messagePrefix":["{{messagePrefix}}"]},"reason":"change"}} +{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":17,"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.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}}} +{"type":"assistant/chunk","seq":18,"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.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}}}} +{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":21,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}],"provenance":{"provider":"deepseek","model":"smoke-model"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[16,17,18,19,20],"surfaceOp":"append"} +{"type":"tool/call","seq":22,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}} +{"type":"tool/code-dispatch-start","seq":23,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"snapshot_double","arguments":{"value":21}}} +{"type":"tool/code-dispatch","seq":24,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"snapshot_double","arguments":{"value":21},"isError":false,"content":[{"type":"text","text":"42"}]}} +{"type":"tool/result","seq":25,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"42"}],"isError":false},"sourceEventSeqs":[22],"surfaceOp":"append"} +{"type":"step/end","seq":26,"time":0,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":27,"time":0,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-direct-child","name":"subagent","argumentsDelta":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}} +{"type":"assistant/chunk","seq":30,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}}} +{"type":"assistant/chunk","seq":31,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":32,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":33,"time":0,"data":{"turn":1,"step":3,"content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"provenance":{"provider":"deepseek","model":"smoke-model"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[28,29,30,31,32],"surfaceOp":"append"} +{"type":"tool/call","seq":34,"time":0,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}} +{"type":"tool/result","seq":35,"time":0,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false},"sourceEventSeqs":[34],"surfaceOp":"append"} +{"type":"step/end","seq":36,"time":0,"data":{"turn":1,"step":3}} +{"type":"step/start","seq":37,"time":0,"data":{"turn":1,"step":4}} +{"type":"assistant/chunk","seq":38,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":39,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-workflow","name":"workflow","argumentsDelta":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}}} +{"type":"assistant/chunk","seq":40,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}}}} +{"type":"assistant/chunk","seq":41,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":42,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":43,"time":0,"data":{"turn":1,"step":4,"content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}],"provenance":{"provider":"deepseek","model":"smoke-model"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[38,39,40,41,42],"surfaceOp":"append"} +{"type":"tool/call","seq":44,"time":0,"data":{"turn":1,"step":4,"callId":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}} +{"type":"tool/result","seq":45,"time":0,"data":{"turn":1,"step":4,"callId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-exe-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false},"sourceEventSeqs":[44],"surfaceOp":"append"} +{"type":"step/end","seq":46,"time":0,"data":{"turn":1,"step":4}} +{"type":"step/start","seq":47,"time":0,"data":{"turn":1,"step":5}} +{"type":"assistant/chunk","seq":48,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":49,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_unmount","argumentsDelta":"{\"id\": \"dyn-1\"}"}}} +{"type":"assistant/chunk","seq":50,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}}}} +{"type":"assistant/chunk","seq":51,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":52,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":53,"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":"smoke-model"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[48,49,50,51,52],"surfaceOp":"append"} +{"type":"tool/call","seq":54,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}} +{"type":"tool/result","seq":55,"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":[54],"surfaceOp":"append"} +{"type":"step/end","seq":56,"time":0,"data":{"turn":1,"step":5}} +{"type":"step/start","seq":57,"time":0,"data":{"turn":1,"step":6}} +{"type":"request/header","seq":58,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["bash","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","subagent","task_kill","task_list","task_output","workflow"],"messagePrefix":["{{messagePrefix}}"]},"reason":"change"}} +{"type":"assistant/chunk","seq":59,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":60,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_EXECUTABLE_OK"}}} +{"type":"assistant/chunk","seq":61,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}}}} +{"type":"assistant/chunk","seq":62,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":63,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":64,"time":0,"data":{"turn":1,"step":6,"content":[{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}],"provenance":{"provider":"deepseek","model":"smoke-model"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[59,60,61,62,63],"surfaceOp":"append"} +{"type":"step/end","seq":65,"time":0,"data":{"turn":1,"step":6}} +{"type":"turn/end","seq":66,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index cb25d0f061..96eaad8c33 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -8,19 +8,19 @@ }, { "role": "user", - "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Install\n\nInstall `dsh` with one command:\n\n```sh\ncurl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh\n```\n\nThe installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, and prompts for a DeepSeek API key.\n\nThe 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.\n\n## Use DeepSeek Harness\n\n### Web UI\n\nFor the recommended local interface, build the frontend after installation and after each update, then start the Web UI:\n\n```sh\npnpm --dir ~/.dsh/source run build && pnpm --dir ~/.dsh/source run build:web\ndsh web\n```\n\nThe Web UI is served at `http://127.0.0.1:3080` by default.\n\n### TUI\n\nStart the full-screen terminal interface:\n\n```sh\ndsh\n```\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\ndsh -p \"summarize this workspace\"\n```\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell execution; reusable skills; task tracking; subagents and workflows; persistent sessions; and context compaction. The TUI also includes Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/cordis/tool-cordis/README.md).\n\n## Community\n\nFollow DeepSeek Harness on Twitter for project updates.\n\n## Development\n\n```sh\npnpm install\npnpm run test:coverage\n```\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\nDeepSeek Harness is currently pre-release.\n\n## License\n\n[BSD 3-Clause](LICENSE)\n" + "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Install\n\nInstall `dsh` with one command:\n\n```sh\ncurl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh\n```\n\nThe installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, and prompts for a DeepSeek API key.\n\nThe 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.\n\n## Use DeepSeek Harness\n\n### Web UI\n\nFor 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):\n\n```sh\ndsh_bin=$(cd \"$(dirname \"$(command -v dsh)\")\" && pwd -P)/$(basename \"$(command -v dsh)\")\nwhile [ -L \"$dsh_bin\" ]; do\n link=$(readlink \"$dsh_bin\")\n case $link in /*) dsh_bin=$link ;; *) dsh_bin=$(cd \"$(dirname \"$dsh_bin\")\" && cd \"$(dirname \"$link\")\" && pwd -P)/$(basename \"$link\") ;; esac\ndone\ndsh_dir=$(cd \"$(dirname \"$dsh_bin\")/..\" && pwd -P)\npnpm --dir \"$dsh_dir\" run build && pnpm --dir \"$dsh_dir\" run build:web\ndsh web\n```\n\nThe Web UI is served at `http://127.0.0.1:3080` by default.\n\n### TUI\n\nStart the full-screen terminal interface:\n\n```sh\ndsh\n```\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\ndsh -p \"summarize this workspace\"\n```\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell execution; reusable skills; task tracking; subagents and workflows; persistent sessions; and context compaction. The TUI also includes Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/cordis/tool-cordis/README.md).\n\n## Community\n\nFollow DeepSeek Harness on Twitter for project updates.\n\n## Development\n\n```sh\npnpm install\npnpm run test:coverage\n```\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\nDeepSeek Harness is currently pre-release.\n\n## License\n\n[BSD 3-Clause](LICENSE)\n" }, { "role": "assistant", - "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(编程智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 安装\n\n使用一条命令安装 `dsh`:\n\n```sh\ncurl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh\n```\n\n安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥。\n\n安装器会将 DeepSeek Harness 克隆到 `~/.dsh/source`,把 `dsh` 链接到 `~/.local/bin`,然后启动它。再次运行该命令会更新源码目录。其他安装位置和选项见 [`scripts/install.sh`](scripts/install.sh)。\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n推荐在本地使用 Web UI。安装完成后以及每次更新后,请先构建前端,再启动 Web UI:\n\n```sh\npnpm --dir ~/.dsh/source run build && pnpm --dir ~/.dsh/source run build:web\ndsh web\n```\n\nWeb UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### TUI\n\n启动全屏终端界面:\n\n```sh\ndsh\n```\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\ndsh -p \"summarize this workspace\"\n```\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 执行、可复用 skill(技能)、任务跟踪、subagent 与工作流、持久化会话,以及上下文压缩(context compaction)。TUI 还包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均可组合为 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/cordis/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 DeepSeek Harness 微信社区申请页面 申请加入。\n\n

\n \"DeepSeek\n

\n\n## 开发\n\n```sh\npnpm install\npnpm run test:coverage\n```\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于预发布阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n" + "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(编程智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 安装\n\n使用一条命令安装 `dsh`:\n\n```sh\ncurl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh\n```\n\n安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥。\n\n安装器会把所有检出都放在 `~/.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)。\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n推荐在本地使用 Web UI。安装完成后以及每次更新后,请先构建前端,再启动 Web UI。通过 `dsh` 启动器解析当前运行的检出,这样无论当前是哪个 staging worktree,命令都成立(启动器会经由稳定的 `current` 符号链接解析):\n\n```sh\ndsh_bin=$(cd \"$(dirname \"$(command -v dsh)\")\" && pwd -P)/$(basename \"$(command -v dsh)\")\nwhile [ -L \"$dsh_bin\" ]; do\n link=$(readlink \"$dsh_bin\")\n case $link in /*) dsh_bin=$link ;; *) dsh_bin=$(cd \"$(dirname \"$dsh_bin\")\" && cd \"$(dirname \"$link\")\" && pwd -P)/$(basename \"$link\") ;; esac\ndone\ndsh_dir=$(cd \"$(dirname \"$dsh_bin\")/..\" && pwd -P)\npnpm --dir \"$dsh_dir\" run build && pnpm --dir \"$dsh_dir\" run build:web\ndsh web\n```\n\nWeb UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### TUI\n\n启动全屏终端界面:\n\n```sh\ndsh\n```\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\ndsh -p \"summarize this workspace\"\n```\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 执行、可复用 skill(技能)、任务跟踪、subagent 与工作流、持久化会话,以及上下文压缩(context compaction)。TUI 还包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均可组合为 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/cordis/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 DeepSeek Harness 微信社区申请页面 申请加入。\n\n

\n \"DeepSeek\n

\n\n## 开发\n\n```sh\npnpm install\npnpm run test:coverage\n```\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于预发布阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n" }, { "role": "user", - "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThis onboarding guide helps project contributors get started with the local environment, daily workflow, and CI flow; see the Agent Notes for design rationale and technical trade-offs.\n\n## Prerequisites\n\n- 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).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git.\n- Optional: a DeepSeek API key for the TUI, headless, and ACP automation demos and real-API e2e tests.\n\n## First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe 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.\n\nIf hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\npnpm exec lefthook install --force\n```\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nThat first typecheck runs the whole-repo `tsc -b` graph: it emits every package/vendor `lib/types` and checks examples, tests, and scripts through the two no-emit aggregates described below.\n\n## TypeScript project layout\n\nThe repository's TypeScript configuration has exactly three roles; every tsconfig file plays one of them.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, references to the two aggregates. The whole-repo `tsc -b tsconfig.json` graph, the tsserver discovery entry, and — through the inherited `paths` — the resolution config for tsx running `examples/` and `scripts/` (their nearest tsconfig is this file). | No |\n| `tsconfig.host.json` | Host aggregate: host-side packages (via references), examples, tests, scripts, website. Excludes `packages/client`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the client aggregate and every `packages/client/*` package. | No |\n\nHost and client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Two disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges. Program-backed generators and gates (`scripts/ts-project.ts` consumers, doc-typecheck standalone mode) are host-only by decision; the client side gains program-backed tooling only with a concrete need.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Decision record: [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md); the tsc-first emit pipeline is the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md).\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n## Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n## Git hooks\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` runs staged-file ESLint fixes, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates).\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nThe hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of both Git hooks and is not an agent instruction.\n\n## CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n## Daily commands\n\nUse these from the repo root:\n\n```sh\npnpm run test # unit tests\npnpm run test:coverage # unit tests with per-file coverage gates\npnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint # eslint .\npnpm run lint:fix # eslint . --fix\npnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs # fail if generated relationship docs are stale\npnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling\npnpm run gen-translation-brief # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits)\npnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph # fail if docs/module-graph.md is stale\npnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable\npnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\nWhen changing package public behavior, update the relevant README or JSDoc in the same change. `pnpm run doc-sync` catches checked TypeScript snippets, generated doc freshness, markdown wrap/link drift, type equivalence, translation pairing, Mermaid syntax, and doc budgets, but broader prose/API sync still needs review.\n\n## Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe full-screen interactive coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:tui\n```\n\nThe self-referential cordis-agent demo can inspect and modify its live plugin runtime and needs the same credentials:\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n## Documenting types verbatim (`ts type-equiv`)\n\nThe [core data structures](core-data-structures/core.md) docs paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n\n## Architecture context\n\nRead `docs/architecture.md` before changing anything under `packages/`. The codebase is built around Cordis plugins, event-sourced sessions, typed service seams, and explicit extension points.\n" + "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThis onboarding guide helps project contributors get started with the local environment, daily workflow, and CI flow; see the Agent Notes for design rationale and technical trade-offs.\n\n## Prerequisites\n\n- 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).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the TUI, headless, and ACP automation demos and real-API e2e tests.\n\n## First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe 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).\n\nIf hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nThe 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.\n\nBefore 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.\n\nAfter 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.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nThat first typecheck runs the whole-repo `tsc -b` graph: it emits every package/vendor `lib/types` and checks examples, tests, and scripts through the two no-emit aggregates described below.\n\n## TypeScript project layout\n\nThe repository's TypeScript configuration has exactly three roles; every tsconfig file plays one of them.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, references to the two aggregates. The whole-repo `tsc -b tsconfig.json` graph, the tsserver discovery entry, and — through the inherited `paths` — the resolution config for tsx running `examples/` and `scripts/` (their nearest tsconfig is this file). | No |\n| `tsconfig.host.json` | Host aggregate: host-side packages (via references), examples, tests, scripts, website. Excludes `packages/client`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the client aggregate and every `packages/client/*` package. | No |\n\nHost and client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Two disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges. Program-backed generators and gates (`scripts/ts-project.ts` consumers, doc-typecheck standalone mode) are host-only by decision; the client side gains program-backed tooling only with a concrete need.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Decision record: [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md); the tsc-first emit pipeline is the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md).\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n## Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n## Git hooks\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` runs staged-file ESLint fixes, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates).\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nThe hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of both Git hooks and is not an agent instruction.\n\n## CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n## Daily commands\n\nUse these from the repo root:\n\n```sh\npnpm run test # unit tests\npnpm run test:coverage # unit tests with per-file coverage gates\npnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint # eslint .\npnpm run lint:fix # eslint . --fix\npnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs # fail if generated relationship docs are stale\npnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling\npnpm run gen-translation-brief # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits)\npnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph # fail if docs/module-graph.md is stale\npnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable\npnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\nWhen changing package public behavior, update the relevant README or JSDoc in the same change. `pnpm run doc-sync` catches checked TypeScript snippets, generated doc freshness, markdown wrap/link drift, type equivalence, translation pairing, Mermaid syntax, and doc budgets, but broader prose/API sync still needs review.\n\n## Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe full-screen interactive coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:tui\n```\n\nThe self-referential cordis-agent demo can inspect and modify its live plugin runtime and needs the same credentials:\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n## Documenting types verbatim (`ts type-equiv`)\n\nThe [core data structures](core-data-structures/core.md) docs paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n\n## Architecture context\n\nRead `docs/architecture.md` before changing anything under `packages/`. The codebase is built around Cordis plugins, event-sourced sessions, typed service seams, and explicit extension points.\n" }, { "role": "assistant", - "content": "# 开发指南\n\n[English](development.md) | 中文\n\n本指南覆盖参与 DeepSeek Harness 开发所需的本地环境搭建、日常工作流与 CI 流程;设计动机与技术权衡请查阅相应 Agent Note。\n\n## 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git。\n- 可选:一个 DeepSeek API key,用于 TUI、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n## 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程同时会运行根目录的 `postinstall` 脚本,该脚本通过 `scripts/install-lefthook.mjs` 从仓库 dev 依赖安装 lefthook。包装脚本使用 lefthook 经过评审的 `--force` 模式,确保已存在 `core.hooksPath` 的关联 worktree 不会导致正常的 `pnpm run …` 命令失败。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装:\n\n```sh\npnpm exec lefthook install --force\n```\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n首次类型检查会执行全仓 `tsc -b tsconfig.json` 图:发射每个 package/vendor 的 `lib/types`,并通过下述两个 no-emit 聚合检查示例、测试和脚本。\n\n## TypeScript 项目布局\n\n仓库的 TypeScript 配置只有三种角色;每个 tsconfig 文件恰好扮演其中一种。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个聚合。全仓 `tsc -b tsconfig.json` 图、tsserver 发现入口,并经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置(它们最近的 tsconfig 就是此文件)。 | 否 |\n| `tsconfig.host.json` | host 聚合:host 侧各包(经 references)、示例、测试、脚本、website。排除 `packages/client`。 | 是 |\n| `tsconfig.client.json` | client 聚合:`packages/client/*` 各包及其测试、`apps/web`。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 client 聚合和每个 `packages/client/*` 包 extends。 | 否 |\n\nhost 与 client 保持两个聚合 program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个聚合,一个 paths 门面也可以横跨两侧。由此推出两条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个聚合展平进一个 program 会撞上 `Context` 合并冲突。基于 program 的生成器与门禁(`scripts/ts-project.ts` 的消费者、doc-typecheck standalone 模式)按决策仅覆盖 host 侧;client 侧只在出现真实需求时再获得基于 program 的工具。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。决策记录:[solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md);tsc-first 发射管线见 [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n## 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n## Git 钩子\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 运行对暂存文件的 ESLint 修复,检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于两个 Git 钩子,也不是对 agent 的指令。\n\n## CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n## 日常命令\n\n在仓库根目录使用:\n\n```sh\npnpm run test # unit tests\npnpm run test:coverage # unit tests with per-file coverage gates\npnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint # eslint .\npnpm run lint:fix # eslint . --fix\npnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs # fail if generated relationship docs are stale\npnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling\npnpm run gen-translation-brief # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits)\npnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph # fail if docs/module-graph.md is stale\npnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable\npnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\n修改 package 的公开行为时,请在同一个变更中更新相关 README 或 JSDoc。`pnpm run doc-sync` 能检测到被检查的 TypeScript 片段、生成文档的新鲜度、Markdown 换行/链接漂移、type-equiv、翻译配对、Mermaid 语法和文档预算,但更广泛的行文/API 同步仍需评审把关。\n\n## 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n全屏交互式 coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:tui\n```\n\n自指的 cordis-agent 演示可以检查并修改其实时插件运行时,并需要相同的凭证:\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n## 逐字记录类型(`ts type-equiv`)\n\n[核心数据结构](core-data-structures/core.md)文档会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n\n## 架构上下文\n\n在修改 `packages/` 目录下的任何内容之前,请先阅读 `docs/architecture.md`。这套代码围绕 Cordis 插件、事件溯源的会话、类型化的服务 seam 与显式扩展点构建。\n" + "content": "# 开发指南\n\n[English](development.md) | 中文\n\n本指南覆盖参与 DeepSeek Harness 开发所需的本地环境搭建、日常工作流与 CI 流程;设计动机与技术权衡请查阅相应 Agent Note。\n\n## 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 TUI、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n## 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程同时会运行根目录的 `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)。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n包装层会拒绝用户自有的 `core.hooksPath` 值。继承自系统、全局或共用仓库配置的路径必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`;命令作用域和 worktree 作用域的自定义路径必须显式集成或移除。\n\n启用 worktree 配置之前,请迁移格式 0 共用配置中直接设置的 `extensions.*`,并迁移直接设置的 `core.worktree` 或 `core.bare=true`,以及任何非空且尚未生效的 `config.worktree`。共用配置和每个 worktree 配置都必须是常规文件,而自有钩子目录只能包含不带别名的常规文件。\n\n检出目录移动后,请重新运行包装层,使其重新定位自有路径并重新生成钩子。对于陈旧或无效的安装程序锁,请先确认没有安装程序正在运行,再移除报告的锁并重试。若安装和钩子路径回滚都失败,请在重试前检查报告的 worktree 配置。完整安全契约由 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 统一定义。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n首次类型检查会执行全仓 `tsc -b tsconfig.json` 图:发射每个 package/vendor 的 `lib/types`,并通过下述两个 no-emit 聚合检查示例、测试和脚本。\n\n## TypeScript 项目布局\n\n仓库的 TypeScript 配置只有三种角色;每个 tsconfig 文件恰好扮演其中一种。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个聚合。全仓 `tsc -b tsconfig.json` 图、tsserver 发现入口,并经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置(它们最近的 tsconfig 就是此文件)。 | 否 |\n| `tsconfig.host.json` | host 聚合:host 侧各包(经 references)、示例、测试、脚本、website。排除 `packages/client`。 | 是 |\n| `tsconfig.client.json` | client 聚合:`packages/client/*` 各包及其测试、`apps/web`。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 client 聚合和每个 `packages/client/*` 包 extends。 | 否 |\n\nhost 与 client 保持两个聚合 program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个聚合,一个 paths 门面也可以横跨两侧。由此推出两条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个聚合展平进一个 program 会撞上 `Context` 合并冲突。基于 program 的生成器与门禁(`scripts/ts-project.ts` 的消费者、doc-typecheck standalone 模式)按决策仅覆盖 host 侧;client 侧只在出现真实需求时再获得基于 program 的工具。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。决策记录:[solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md);tsc-first 发射管线见 [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n## 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n## Git 钩子\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 运行对暂存文件的 ESLint 修复,检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于两个 Git 钩子,也不是对 agent 的指令。\n\n## CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n## 日常命令\n\n在仓库根目录使用:\n\n```sh\npnpm run test # unit tests\npnpm run test:coverage # unit tests with per-file coverage gates\npnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint # eslint .\npnpm run lint:fix # eslint . --fix\npnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs # fail if generated relationship docs are stale\npnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling\npnpm run gen-translation-brief # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits)\npnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph # fail if docs/module-graph.md is stale\npnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable\npnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\n修改 package 的公开行为时,请在同一个变更中更新相关 README 或 JSDoc。`pnpm run doc-sync` 能检测到被检查的 TypeScript 片段、生成文档的新鲜度、Markdown 换行/链接漂移、type-equiv、翻译配对、Mermaid 语法和文档预算,但更广泛的行文/API 同步仍需评审把关。\n\n## 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n全屏交互式 coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:tui\n```\n\n自指的 cordis-agent 演示可以检查并修改其实时插件运行时,并需要相同的凭证:\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n## 逐字记录类型(`ts type-equiv`)\n\n[核心数据结构](core-data-structures/core.md)文档会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n\n## 架构上下文\n\n在修改 `packages/` 目录下的任何内容之前,请先阅读 `docs/architecture.md`。这套代码围绕 Cordis 插件、事件溯源的会话、类型化的服务 seam 与显式扩展点构建。\n" }, { "role": "user", diff --git a/scripts/verify-md-links.ts b/scripts/verify-md-links.ts index 191a4c9c92..969b0f5e44 100644 --- a/scripts/verify-md-links.ts +++ b/scripts/verify-md-links.ts @@ -25,6 +25,7 @@ const PATTERNS = [ 'AGENTS.md', 'packages/AGENTS.md', '.agents/skills/**/*.md', + 'skills/**/*.md', ] /** A broken relative link: a target path that does not resolve to a file. */ diff --git a/scripts/verify-mermaid.ts b/scripts/verify-mermaid.ts index 79e80d802d..0cdf8982a4 100644 --- a/scripts/verify-mermaid.ts +++ b/scripts/verify-mermaid.ts @@ -26,6 +26,7 @@ const PATTERNS = [ 'AGENTS.md', 'packages/AGENTS.md', '.agents/skills/**/*.md', + 'skills/**/*.md', ] interface Block { diff --git a/skills/dsh-customize/SKILL.md b/skills/dsh-customize/SKILL.md new file mode 100644 index 0000000000..74da68b578 --- /dev/null +++ b/skills/dsh-customize/SKILL.md @@ -0,0 +1,35 @@ +--- +name: dsh-customize +description: 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. +--- + +# DSH Customize + +Make personal DSH changes in task worktrees and integrate them under the staging lock. Repository instructions still apply. + +## Find staging + +Do not assume a path or branch name. DSH is usually installed from source with a personal staging branch; create one for the user only when none exists. + +1. Inspect `command -v dsh` in the user's launch environment before resolving symlinks. +2. Follow the launcher through the full symlink chain to identify the source checkout. The standard [`scripts/install.sh`](../../scripts/install.sh) keeps every checkout under one container `${DSH_SOURCE}` (default `~/.dsh/source`): the master clone at `${DSH_SOURCE}/master` and each staging checkout as a git worktree `${DSH_SOURCE}/staging-`. `${DSH_BIN_DIR}/dsh` links to `${DSH_SOURCE}/current/bin/dsh`, and the stable `current` symlink points at the active staging worktree, so resolve `current` to reach the real checkout. All paths are configurable; an older install may link PATH straight at a worktree (no `current`) or use scattered sibling clones — follow the launcher rather than assuming a layout. +3. Verify the checkout with Git, then record its branch, tip, status, remotes, worktrees, in-progress operations, and applicable `AGENTS.md` files. +4. Treat the launcher checkout's branch as staging unless the user says otherwise. The installed launcher must resolve to a staging worktree on a staging branch, never the master clone or a task, preparation, review, publication, or detached checkout. Ask if the launcher, checkout, or branch ownership is ambiguous; warn explicitly for a detached HEAD, the master clone, or a non-staging branch. + +## Customize + +1. Create a fresh task branch and worktree from the recorded staging tip, using the repository-required worktree location — default to `.worktrees/` under the repository root unless the repository requires otherwise. Never implement or commit directly on staging. +2. Implement the change, then select and run the repository-required review and checks. If a check fails, fix the cause and rerun it before integration. +3. For TUI or interactive behavior, test the assembled application interactively in a dedicated tmux session; unit tests and snapshots alone are insufficient. +4. Record the task tip and confirm the task worktree is clean before integration. + +## Integrate under the lock + +1. Resolve the worktree that owns staging and use `/.agents/merge.lock`. Keep it Git-ignored; never remove or replace it. Require `flock`. +2. Acquire the lock, then re-check branch ownership, exact staging tip, clean status, and absence of an in-progress Git operation. If staging moved, unlock and restart discovery against its current owner's lock. +3. Hold the same lock through final precondition checks, `git merge --no-ff`, required post-merge checks, conflict handling, and rollback. +4. If the merge or a post-merge check fails, abort the merge or restore the recorded clean staging tip before unlocking. Never discard unknown user files. +5. Before unlocking, verify staging's branch, commit, clean status, and required checks. Report that evidence and the commands run. +6. Remove the task worktree and branch only when their commits are reachable from staging and no longer needed. + +Use [`dsh-upstream-customization`](../dsh-upstream-customization/SKILL.md) when the user wants to contribute a personal feature upstream. diff --git a/skills/dsh-upgrade/SKILL.md b/skills/dsh-upgrade/SKILL.md new file mode 100644 index 0000000000..7570672cc8 --- /dev/null +++ b/skills/dsh-upgrade/SKILL.md @@ -0,0 +1,46 @@ +--- +name: dsh-upgrade +description: 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. +--- + +# DSH Upgrade + +Prepare and validate the upgrade in a fresh staging worktree of the master clone, leave the worktree the installed launcher currently uses unchanged, then atomically repoint the stable `current` symlink once. Read and follow [`dsh-customize`](../dsh-customize/SKILL.md) before starting; it owns checkout discovery and lock handling. + +## Layout + +A source-installed DSH keeps every checkout under one container directory `` (default `~/.dsh/source`): the master clone at `/master` (remote tracking `master`, the fetch/upgrade base, never a launcher target) and each staging checkout as a git worktree `/staging-` on branch `dsh-staging/`. The stable symlink `/current` points at the active staging worktree, and the PATH launcher links to `/current/bin/dsh`, so the launcher resolves PATH -> `current` -> staging worktree. Cutover repoints `current` alone; the PATH launcher is written once at install and never moves. All worktrees share the master clone's single `.git` object store; the master clone's `.git/info/exclude` is inherited by every linked worktree, so one `.agents/merge.lock` entry there excludes the lock in all of them. An older install may link PATH straight at a worktree (no `current`) or use scattered sibling clones; if so, follow the recorded launcher checkout rather than assuming this layout, treat that sibling clone as its own master, and create `current` and repoint PATH to `current/bin/dsh` as a one-time migration at cutover. + +## Names + +One upgrade attempt uses one UTC basic timestamp `YYYYMMDDTHHMMSSZ` for all names: + +- new staging worktree: `/staging-`; +- preparation branch: `dsh-upgrade/prepare-`; +- installed staging branch: `dsh-staging/`; +- fetched upstream ref: `refs/dsh-upgrade/upstream-`; +- recovery ref: `refs/dsh-upgrade/recovery-`; +- recorded `current` target before cutover: the old staging worktree path, kept for symlink rollback. + +The worktree name is always `staging-` under ``, never derived from the current staging directory name, so successive upgrades stay in one place and do not accumulate timestamps. The preparation branch and private refs are local-only and must never be pushed. Before starting, reject a current staging branch named exactly `dsh-staging`, because Git cannot also create `dsh-staging/`; require the user to choose a non-conflicting staging namespace rather than silently renaming it. If the new staging worktree path exists, resume only when it is a clean worktree of this master clone whose recorded old tip, upstream ref, recovery ref, and named branches exactly match this attempt; otherwise stop. Never add an ad hoc suffix or delete an unknown directory. + +## Upgrade + +1. Resolve the installed launcher, its staging worktree and branch, the master clone, the current DSH process source, and authoritative upstream. Record exact tips, paths, clean status, remotes, dependencies, worktrees, and in-progress Git operations. Require the installed staging worktree to be clean and its `.agents/merge.lock` to exist and be Git-excluded. Never stash automatically. +2. Treat the staging worktree behind the installed launcher as immutable for the whole attempt: do not touch its branch, HEAD, index, tracked or untracked files, dependencies, worktree registration, or lock file. Fetching into the shared master clone and creating new branches, worktrees, and private refs there are allowed because they are append-only and never alter the old worktree's checkout; opening and holding the existing lock is the only operation on the old worktree. +3. Allocate the timestamp and new staging worktree path. Acquire the installed worktree's existing `.agents/merge.lock`, repeat every precondition, and keep it through preparation, validation, and the `current` cutover. If staging moves while waiting, unlock and restart with a new timestamp; remove only attempt artifacts that this run created and verified as disposable. +4. In the master clone, create `refs/dsh-upgrade/recovery-` at the recorded old staging tip and `dsh-upgrade/prepare-` from that tip. Fetch exact authoritative upstream `master` into `refs/dsh-upgrade/upstream-` and record its object ID. Add a fresh worktree `/staging-` checked out on the preparation branch. Confirm the master clone's `.git/info/exclude` excludes `.agents/merge.lock`, which the new worktree inherits. +5. Inspect the Git log and commit ranges between the staging base, old staging tip, and fetched upstream tip. Identify incoming upstream changes, personal commits to preserve, likely duplicates, and conflict-prone areas before rebasing. +6. In the new worktree, rebase the preparation branch onto the fetched upstream commit. Preserve intentional customizations and drop behavior already upstream. If upstream contains the customization and its remaining local diff only documents that customization, prefer upstream and drop the documentary diff rather than retaining a stale local account. Preserve documentation only when it adds a current, independently useful contract absent upstream. Abort without changing the installed launcher when resolution is uncertain. +7. Install dependencies in the new worktree, review the resulting diff, and run the repository-required checks. Fix failures and rerun affected checks. Test the new worktree's `bin/dsh` directly. +8. Point `dsh-staging/` at the validated prepared tip and check it out in the new worktree. Ensure its `.agents/merge.lock` exists (Git-excluded through the shared master exclude). Verify its branch, exact commit, clean status, remotes, dependencies, and absence of in-progress Git operations, then smoke its `bin/dsh` from a clean temporary workspace. The preparation branch remains temporary; the timestamped staging branch owns the installed commit. +9. Recheck the old worktree, existing lock, launcher, `current`, master clone, new worktree, refs, and exact tips. Record `current`'s pre-cutover target, then repoint `current` at the new staging worktree in one atomic swap with `ln -sfn` (the `-n` stops `ln` from dereferencing the existing directory symlink and writing the link inside the old worktree; `mv` behaves the same way and is unusable). Leave the PATH launcher alone once it already resolves through `current`; if a legacy install still links PATH straight at a worktree, create `current` and repoint PATH to `current/bin/dsh` as a one-time migration here. The `current` target must be a clean staging worktree on a staging branch and must never be the master clone or a preparation, feature, review, publication, or detached checkout. Smoke the installed `dsh` command from a clean temporary workspace. +10. On failure before the `current` cutover, leave `current`, the launcher, and the old worktree unchanged and remove only verified attempt artifacts created by this run (including the new worktree registration if empty). On failure during or after cutover, inspect `current`'s observed target before acting; if cutover did not verify, atomically repoint `current` back to its recorded pre-cutover target with `ln -sfn` and verify that `dsh` starts from the unchanged old staging worktree. This rollback is the sole exception allowing `current` to return to the old staging worktree. Never retry a side-effecting operation blindly. +11. Release the old worktree's lock and tell the user to restart DSH through the installed launcher. The current process may continue from the old worktree, but no operation may mutate or remove it until the restarted process proves that it runs from `dsh-staging/` and the user confirms stability. Avoid customization integration during this confirmation window; if rollback is required after new work lands, reconcile that work explicitly rather than silently stranding it. +12. After confirmation, remove the preparation branch if no process uses it. Keep the old staging worktree and branch, the recovery ref, and the recorded pre-cutover `current` target as rollback until the user explicitly approves their removal; leave the actual `git worktree remove` and directory deletion to the user. Report old, upstream, prepared, and new staging commits; both staging worktree paths and branches; the master clone path; process-source evidence; the `current` pre-cutover target and cutover; commands and checks; final status; recovery ref; and retained rollback artifacts. + +The installed launcher always resolves through `current` to a staging worktree, never the master clone. Upgrade preparation adds a new worktree that shares the master object store while leaving the old worktree's checkout untouched; cutover is one atomic `current` repoint to the separately validated timestamped staging worktree, and the PATH launcher never moves. + +## Recommend upstream candidates + +After a successful upgrade, load [`dsh-upstream-customization`](../dsh-upstream-customization/SKILL.md) and classify each remaining personal customization by its rules. For each candidate, explain its classification and upstream value and recommend whether to propose it, then ask which named candidate, if any, the user wants to upstream. The answer selects a candidate to start that skill's publication workflow; it is not publishing approval, which that workflow still requires. diff --git a/skills/dsh-upstream-customization/SKILL.md b/skills/dsh-upstream-customization/SKILL.md new file mode 100644 index 0000000000..944f4b083c --- /dev/null +++ b/skills/dsh-upstream-customization/SKILL.md @@ -0,0 +1,27 @@ +--- +name: dsh-upstream-customization +description: 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. +--- + +# DSH Upstream Customization + +Classify and propose personal customizations upstream one feature at a time. + +## Classify + +- **Definitely propose:** bug fixes. +- **Propose:** additive, non-conflicting features implemented as plugins; visual improvements. +- **Do not propose without maintainer approval:** intrusive changes that alter existing architecture, core behavior, or broad contracts. +- Explain the classification and upstream value. If unsure whether a change is intrusive, treat it as intrusive. + +Classification and a recommendation are not publishing approval. Obtain explicit user approval naming one feature before pushing or opening a PR; approval for another feature, an upgrade, or local integration does not apply. + +## Publish an approved feature + +1. Fetch current upstream `master`, then rebuild only the approved feature on a fresh branch and worktree at that exact commit. Never publish the personal staging branch or unrelated customizations. +2. Follow repository instructions for implementation, review, testing, disclosure, PR writing, and pre-push checks. Fix failures and rerun the affected checks before publishing. +3. Review the outgoing commits and diff against upstream. Confirm they contain only the approved feature, no credentials or personal data, and a clean worktree. +4. Reconfirm the approved feature name and publishing target before the first push. Do not infer authorization from earlier local work. +5. Push only that branch and open only a draft PR. Keep its description synchronized with later changes. +6. For a TUI feature, preferably attach a screenshot from the assembled application after removing credentials and personal data. +7. Report the upstream base and branch commits, commands and checks run, pushed branch, and draft PR URL. diff --git a/tsconfig.host.json b/tsconfig.host.json index 545f326801..f3f1e7b462 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -20,6 +20,7 @@ "apps/web/tests/replay-round-trip.e2e.ts", "apps/web/tests/seeded-history.e2e.ts", "apps/web/tests/code-mode-round.e2e.ts", + "apps/web/tests/cordis-tool-round.e2e.ts", "apps/cli/tests/**/*.ts", "examples/*/src/**/*.ts", "examples/*/start.ts",