refactor(agent-loop): separate injected context from turns

This commit is contained in:
_Kerman
2026-07-24 16:05:52 +08:00
parent 712448a2d2
commit 45fc7fda3d
45 changed files with 470 additions and 873 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
2026-07-17-one-send-one-turn.md: 86c056b53700d0e0c02e04a99cf044fb311f5840 2026-07-17-one-send-one-turn.md: 12055e9dcb6f59fb5a8f33a987717e37e20d9a1f
2026-07-17-one-send-one-turn.zh.md: 3ef9973480481d11d1183760c9fc1f3c247629f4 2026-07-17-one-send-one-turn.zh.md: d57c10b127a0e9610957d9eb201ddd07eff64915

View File

@@ -22,9 +22,9 @@ If messages A and B are both processed, B's turn starts only after A records `tu
Prompt admission decides one message at a time. An allowed prompt becomes that turn's `user/message`; a blocked prompt records one durable `prompt/blocked` and closes its one-message turn as `rejected`. Mixed-batch and all-blocked-batch branches do not exist. Prompt admission decides one message at a time. An allowed prompt becomes that turn's `user/message`; a blocked prompt records one durable `prompt/blocked` and closes its one-message turn as `rejected`. Mixed-batch and all-blocked-batch branches do not exist.
The no-batching rule applies only to ordinary `send()`. Running `steer()` puts input in a separate steering FIFO. While a turn remains open, the loop records that input at the next steering checkpoint, which comes before either a model request or the decision whether to continue. Steering makes another step the default, but continuation or terminal policy can still stop before the step starts. Steering left after the turn closes and its durability checkpoint settles becomes later queued input; terminal `agent/turn-stop`, cancellation, or disposal can discard it. When the agent is idle, `steer()` delegates to `send()`, so it creates an independent ordinary queue item. The no-batching rule applies only to ordinary `send()`. Running `steer()` puts input in the outbox. While a turn remains open, the loop records that input at the next step boundary and steering makes another step the default. A failure before that boundary leaves the steering staged without waking the agent; `retry()` or a later prompt takes it, while cancellation or disposal can discard it. When the agent is idle, `steer()` delegates to `send()`, so it creates an independent ordinary queue item.
`inject()` continues to add model-facing context without submitting an ordinary message; its existing turn-enclosure and flush behavior stays unchanged. `cancel()` remains a whole-agent operation that can clear all unstarted ordinary and steering input and abort the current step. `status` and `whenIdle()` also describe the whole agent, not one message. Several one-message turns can share one `running` interval, including turn close and its checkpoint, so `running` does not prove that a turn is open. `inject()` continues to add model-facing context without submitting an ordinary message. During a turn it waits in the outbox for a safe step boundary; while idle it appends and flushes a `user/message` directly, without opening a turn or running the model. `whenIdle()` and disposal await that flush. `cancel()` remains a whole-agent operation that can clear all unstarted ordinary and steering input and abort the current step. `status` and `whenIdle()` also describe the whole agent, not one message. Several one-message turns can share one `running` interval, so `running` does not prove that a turn is open.
## Alternatives considered ## Alternatives considered
@@ -36,7 +36,7 @@ The no-batching rule applies only to ordinary `send()`. Running `steer()` puts i
- A built-stdio test submits two lines and observes two model requests and two turn boundaries. - A built-stdio test submits two lines and observes two model requests and two turn boundaries.
- Delayed and rejected first-turn checkpoints keep the next turn waiting and prove that its request sees the preceding assistant result. - Delayed and rejected first-turn checkpoints keep the next turn waiting and prove that its request sees the preceding assistant result.
- Failure-path tests cover prompt veto, listener failure, broad cancellation, disposal, and failure before `turn/start`; recorded turns stay balanced, messages do not merge, and surviving queued work still drains. - Failure-path tests cover prompt veto, listener failure, broad cancellation, disposal, and failure before `turn/start`; recorded turns stay balanced, messages do not merge, and surviving queued work still drains.
- Separate tests cover open-turn, post-turn-close, and idle `steer()`, plus `inject()`, whole-agent status, and `whenIdle()`. - Separate tests cover open-turn, failed-turn, and idle `steer()`, plus `inject()`, whole-agent status, and `whenIdle()`.
## Consequences ## Consequences

View File

@@ -22,9 +22,9 @@ Status: implemented
提示词准入每次只决定一条消息。获准提示词成为该轮次的 `user/message`;被阻止的提示词记录一条持久的 `prompt/blocked`,并让自己的单消息轮次以 `rejected` 关闭。实现中不存在混合批次或全阻止批次分支。 提示词准入每次只决定一条消息。获准提示词成为该轮次的 `user/message`;被阻止的提示词记录一条持久的 `prompt/blocked`,并让自己的单消息轮次以 `rejected` 关闭。实现中不存在混合批次或全阻止批次分支。
上述不合批规则只适用于普通 `send()`。agent 运行时,`steer()` 会把输入放入独立的 steering中途引导FIFO。只要当前轮次仍然打开agent loop 就会在下一个 steering 检查点记录该输入;该检查点位于模型请求或继续轮次的决策之前。收到 steering 会把再执行一步作为默认选择,但继续轮次的策略或终止策略仍可在该步骤开始前停止。轮次关闭且其持久性检查点处理结束后,剩余的 steering 会成为后续排队输入;终止性的 `agent/turn-stop`取消或 dispose 可以将其丢弃。agent 空闲时,`steer()` 委托给 `send()`,因此会创建一个独立的普通队列项。 上述不合批规则只适用于普通 `send()`。agent 运行时,`steer()` 会把输入放入 outbox。只要当前轮次仍然打开agent loop 就会在下一个步骤边界记录该输入,而 steering中途引导会默认让循环再执行一个步骤。在到达该边界前发生失败会让 steering 保持暂存且不唤醒 agent`retry()` 或后续提示词会取走它,而取消或 dispose 可以将其丢弃。agent 空闲时,`steer()` 委托给 `send()`,因此会创建一个独立的普通队列项。
`inject()` 继续添加面向模型的上下文,而不提交普通消息;其现有的轮次封闭与持久化刷新行为保持不变`cancel()` 仍是面向整个 agent 的操作,可以清空所有尚未启动的普通输入和 steering并中止当前步骤。`status``whenIdle()` 描述的也是整个 agent而不是某一条消息。多个单消息轮次可以共用一个 `running` 区间,该区间还可能覆盖轮次关闭及其检查点,因此 `running` 不表示轮次一定处于打开状态。 `inject()` 继续添加面向模型的上下文,而不提交普通消息。轮次打开时,该上下文会留在 outbox 中等待安全的步骤边界agent 空闲时,系统会直接追加一条 `user/message` 并完成持久化刷新,既不打开轮次,也不运行模型。`whenIdle()` 和 dispose 会等待该刷新完成`cancel()` 仍是面向整个 agent 的操作,可以清空所有尚未启动的普通输入和 steering并中止当前步骤。`status``whenIdle()` 描述的也是整个 agent而不是某一条消息。多个单消息轮次可以共用一个 `running` 区间,因此 `running` 不表示轮次一定处于打开状态。
## 曾考虑的替代方案 ## 曾考虑的替代方案
@@ -36,7 +36,7 @@ Status: implemented
- stdio 构建产物测试提交两行输入,并观察到两个模型请求和两个轮次边界。 - stdio 构建产物测试提交两行输入,并观察到两个模型请求和两个轮次边界。
- 延迟和拒绝第一个轮次的检查点,都能让下一个轮次保持等待,并证明其请求可以看到前一条助手结果。 - 延迟和拒绝第一个轮次的检查点,都能让下一个轮次保持等待,并证明其请求可以看到前一条助手结果。
- 失败路径测试覆盖提示词否决、监听器失败、广义取消、dispose 和 `turn/start` 之前的失败;已记录的轮次保持边界平衡,消息不会合并,仍需处理的排队工作也能继续清空。 - 失败路径测试覆盖提示词否决、监听器失败、广义取消、dispose 和 `turn/start` 之前的失败;已记录的轮次保持边界平衡,消息不会合并,仍需处理的排队工作也能继续清空。
- 其他测试分别覆盖轮次打开时、轮次关闭后和空闲时的 `steer()`,以及 `inject()`、面向整个 agent 的状态和 `whenIdle()` - 其他测试分别覆盖轮次打开时、轮次失败后和空闲时的 `steer()`,以及 `inject()`、面向整个 agent 的状态和 `whenIdle()`
## 后果 ## 后果

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
architecture.md: d1051eecf51d8d1c7f8c235b0c5dd80478b43316 architecture.md: 154c7f69fca5d4c599563ac26d01f75138be26e3
architecture.zh.md: 502c0248a9d2c62af165ce07eb19489b76c5f6ee architecture.zh.md: 54ce614ca0998b09a075323f93aed3ac07fb5edb

View File

@@ -78,46 +78,41 @@ choose declarative identity and fresh/resume path
-> enable driving -> agent/session-start(source) -> start driver -> enable driving -> agent/session-start(source) -> start driver
forever: forever:
wait for a queued message wait for a queued message
emit agent/status(running) claimed message -> agent/prompt-submit
TURN: blocked prompt -> park without opening a turn
'turn/start' allowed prompt:
claimed message + contexts -> agent/prompt-submit emit agent/status(running)
allowed prompt -> 'user/message' with prompt-prefix context baked in; append separate contexts 'turn/start'
blocked prompt -> 'prompt/blocked' -> 'turn/end'(rejected) append prompt + additional contexts as separate 'user/message' events
STEP loop: STEP loop:
drain steering with the same prefix/separate context placement (no prompt-submit) agent/step
drain injected context and steering (steering bypasses prompt-submit)
assemble system prompt and tool schemas assemble system prompt and tool schemas
agent/session-prefix (first step)
agent/pre-step
snapshot the derived messages (the reconstruction boundary) snapshot the derived messages (the reconstruction boundary)
'step/start' 'step/start'
agent/request (config only) -> log request/header -> checkpoint -> llm/stream (frozen) agent/request (config only) -> log request/header -> llm/stream (frozen)
on final adapter-path or terminal in-band failure: 'assistant/chunk'
'step/end' 'assistant/message'
agent/request-error(original error, failure facts, immutable prior failures, signal) schedule tool calls by ctx.tools.executionMode:
retry in the next numbered step or preserve the original error exclusive -> one-call barrier
otherwise: parallel -> rolling pool, <= maxParallelToolCalls in flight; reclassify before start
'assistant/chunk' each start -> 'tool/call' -> ordered tools/pre-execute -> concurrent tools/execute
agent/step-result each model-order result -> ordered tools/post-execute -> 'tool/result'
'assistant/message' (transformed content or empty success anchor after step-result rejection) drain accepted tool context and steering
schedule tool calls by ctx.tools.executionMode: 'step/end'
exclusive -> one-call barrier continue when tools or steering require another step
parallel -> rolling pool, <= maxParallelToolCalls in flight; reclassify before start otherwise agent/stopping -> drain once more -> continue only for steering
each start -> 'tool/call' -> ordered tools/pre-execute -> checkpoint -> concurrent tools/execute 'turn/end' -> agent/idle
each model-order result -> ordered tools/post-execute -> 'tool/result' start the next waking queued message, or emit agent/status(idle)
append accepted tool-batch context after all recorded results, then steering
agent/post-step -> checkpoint complete response/results idle inject:
'step/end' append 'user/message' -> flush persistence
agent/turn-continuation do not open a turn or run the model
agent/turn-stop (terminal policy)
stop unless tools or continuation policy ask for another step
'turn/end'
checkpoint persistence and notify idle/running status
``` ```
Each step assembles ordered prompt sections, tool schemas, and variables; unknown references fail the turn. `dsh-system-prompt` owns identity and persona, while the loop supplies `model` and `cwd` ([prompt ownership](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)). Each step assembles ordered prompt sections, tool schemas, and variables; unknown references fail the turn. `dsh-system-prompt` owns identity and persona, while the loop supplies `model` and `cwd` ([prompt ownership](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)).
Tool-time context—including async `inject()` and post-tool `additionalContexts`—settles after results. Steering drains before `agent/post-step`, which sees durable output, results, context, and steering. Leftovers queue. Terminal `agent/turn-stop` remains authoritative through close/flush; later steering is discarded while queued prompts remain. Tool-time context—including active-turn `inject()` and post-tool `additionalContexts`—settles after results. Steering drains at the same boundary and requests another step. Idle `inject()` instead appends and flushes context immediately without changing turn numbering; `whenIdle()` and disposal await that flush.
Pruning precedes summaries; overflow retries require durable progress. Bounded transient retries compose on `agent/request-error`; cancellation wins ([compaction](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md), [retry](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md)). Pruning precedes summaries; overflow retries require durable progress. Bounded transient retries compose on `agent/request-error`; cancellation wins ([compaction](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md), [retry](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md)).
@@ -127,7 +122,7 @@ Adapter failures close the step before `agent/request-error` with exact `Error`,
Other failures use `agent/error`. Cancellation and disposal beat recovery; undispatched tool calls get synthetic `tool/call`/`ABORTED_BEFORE_DISPATCH` pairs. The turn signal retires before `turn/end`. Effective `cancel()` emits its typed cause before clearing queues and aborting; observers cannot veto, idle calls emit nothing, and durability records `aborted`. Disposal awaits quiescence ([decision](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)). Other failures use `agent/error`. Cancellation and disposal beat recovery; undispatched tool calls get synthetic `tool/call`/`ABORTED_BEFORE_DISPATCH` pairs. The turn signal retires before `turn/end`. Effective `cancel()` emits its typed cause before clearing queues and aborting; observers cannot veto, idle calls emit nothing, and durability records `aborted`. Disposal awaits quiescence ([decision](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)).
Session events are turn-enclosed. Reload closes an interrupted tail with a synthetic `interrupted` turn end. Post-close failures report only through `agent/error`; no safe in-turn position remains. Each turn has one `TurnEndReason`; [TurnEndReasonMap](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap) owns the variants. Turn and step execution events are turn-enclosed; an idle injected `user/message` may sit between turns. Reload closes an interrupted turn tail with a synthetic `interrupted` turn end. Post-close failures report only through `agent/error`; no safe in-turn position remains. Each turn has one `TurnEndReason`; [TurnEndReasonMap](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap) owns the variants.
### Agent Handles ### Agent Handles

View File

@@ -78,46 +78,41 @@ choose declarative identity and fresh/resume path
-> enable driving -> agent/session-start(source) -> start driver -> enable driving -> agent/session-start(source) -> start driver
forever: forever:
wait for a queued message wait for a queued message
emit agent/status(running) claimed message -> agent/prompt-submit
TURN: blocked prompt -> park without opening a turn
'turn/start' allowed prompt:
claimed message + contexts -> agent/prompt-submit emit agent/status(running)
allowed prompt -> 'user/message' with prompt-prefix context baked in; append separate contexts 'turn/start'
blocked prompt -> 'prompt/blocked' -> 'turn/end'(rejected) append prompt + additional contexts as separate 'user/message' events
STEP loop: STEP loop:
drain steering with the same prefix/separate context placement (no prompt-submit) agent/step
drain injected context and steering (steering bypasses prompt-submit)
assemble system prompt and tool schemas assemble system prompt and tool schemas
agent/session-prefix (first step)
agent/pre-step
snapshot the derived messages (the reconstruction boundary) snapshot the derived messages (the reconstruction boundary)
'step/start' 'step/start'
agent/request (config only) -> log request/header -> checkpoint -> llm/stream (frozen) agent/request (config only) -> log request/header -> llm/stream (frozen)
on final adapter-path or terminal in-band failure: 'assistant/chunk'
'step/end' 'assistant/message'
agent/request-error(original error, failure facts, immutable prior failures, signal) schedule tool calls by ctx.tools.executionMode:
retry in the next numbered step or preserve the original error exclusive -> one-call barrier
otherwise: parallel -> rolling pool, <= maxParallelToolCalls in flight; reclassify before start
'assistant/chunk' each start -> 'tool/call' -> ordered tools/pre-execute -> concurrent tools/execute
agent/step-result each model-order result -> ordered tools/post-execute -> 'tool/result'
'assistant/message' (transformed content or empty success anchor after step-result rejection) drain accepted tool context and steering
schedule tool calls by ctx.tools.executionMode: 'step/end'
exclusive -> one-call barrier continue when tools or steering require another step
parallel -> rolling pool, <= maxParallelToolCalls in flight; reclassify before start otherwise agent/stopping -> drain once more -> continue only for steering
each start -> 'tool/call' -> ordered tools/pre-execute -> checkpoint -> concurrent tools/execute 'turn/end' -> agent/idle
each model-order result -> ordered tools/post-execute -> 'tool/result' start the next waking queued message, or emit agent/status(idle)
append accepted tool-batch context after all recorded results, then steering
agent/post-step -> checkpoint complete response/results idle inject:
'step/end' append 'user/message' -> flush persistence
agent/turn-continuation do not open a turn or run the model
agent/turn-stop (terminal policy)
stop unless tools or continuation policy ask for another step
'turn/end'
checkpoint persistence and notify idle/running status
``` ```
每个步骤都会组装有序提示词片段、工具 schema 和变量;未知引用会使该轮次失败。`dsh-system-prompt` 负责身份和角色设定,循环则提供 `model``cwd`[提示词归属](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md))。 每个步骤都会组装有序提示词片段、工具 schema 和变量;未知引用会使该轮次失败。`dsh-system-prompt` 负责身份和角色设定,循环则提供 `model``cwd`[提示词归属](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md))。
工具执行阶段的上下文,包括异步 `inject()` 和工具执行后的 `additionalContexts`,会在结果产生后稳定。steering中途引导会在 `agent/post-step` 前排空;该事件会观察持久输出、结果、上下文和 steering。余留内容进入队列。终止型 `agent/turn-stop` 在关闭和刷写期间始终具有最终决定权;后续 steering 会被丢弃,排队提示词仍予保留 工具执行阶段的上下文,包括活跃轮次内的 `inject()` 和工具执行后的 `additionalContexts`,会在结果记录完毕后落定。steering中途引导会在同一边界排空,并请求再执行一个步骤。空闲状态下的 `inject()` 则会立即追加上下文并完成持久化刷新,且不改变轮次编号;`whenIdle()` 和 dispose资源释放会等待该刷新完成
裁剪先于摘要;溢出重试必须取得持久进展。有界的瞬态重试在 `agent/request-error` 上组合;取消优先([压缩](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)、[重试](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md))。 裁剪先于摘要;溢出重试必须取得持久进展。有界的瞬态重试在 `agent/request-error` 上组合;取消优先([压缩](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)、[重试](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md))。
@@ -125,9 +120,9 @@ forever:
适配器故障会先关闭步骤,再进入 `agent/request-error`;该事件会收到准确的 `Error``LlmFailure` 和历史记录。重试会开启另一个步骤;成功会清除历史记录;重试耗尽后,故障存入 `turn/end`。失败分片不会提交消息或工具。 适配器故障会先关闭步骤,再进入 `agent/request-error`;该事件会收到准确的 `Error``LlmFailure` 和历史记录。重试会开启另一个步骤;成功会清除历史记录;重试耗尽后,故障存入 `turn/end`。失败分片不会提交消息或工具。
其他故障使用 `agent/error`。取消和资源释放均优先于恢复;尚未分派的工具调用会得到合成的 `tool/call`/`ABORTED_BEFORE_DISPATCH` 对。轮次信号会在 `turn/end` 前失效。实际生效的 `cancel()` 会在清空队列和中止前发出类型化原因;观察方不能否决该操作,空闲状态下的调用不发出任何事件,持久化会记录 `aborted`。dispose(资源释放)会等待系统停稳([决策](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md))。 其他故障使用 `agent/error`。取消和资源释放均优先于恢复;尚未分派的工具调用会得到合成的 `tool/call`/`ABORTED_BEFORE_DISPATCH` 对。轮次信号会在 `turn/end` 前失效。实际生效的 `cancel()` 会在清空队列和中止前发出类型化原因;观察方不能否决该操作,空闲状态下的调用不发出任何事件,持久化会记录 `aborted`。dispose 会等待系统停稳([决策](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md))。
会话事件均位于轮次边界内。重新加载会用合成的 `interrupted` 轮次结束事件闭合中断的日志尾部。关闭后的故障只通过 `agent/error` 报告;此时已没有安全的轮次内位置。每个轮次有一个 `TurnEndReason`;各变体由 [TurnEndReasonMap](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap) 统一定义。 轮次和步骤的执行事件均位于轮次边界内;空闲时注入的 `user/message` 可以位于两个轮次之间。重新加载会用合成的 `interrupted` 轮次结束事件闭合中断轮次的日志尾部。关闭后的故障只通过 `agent/error` 报告;此时已没有安全的轮次内位置。每个轮次有一个 `TurnEndReason`;各变体由 [TurnEndReasonMap](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap) 统一定义。
### Agent 句柄 ### Agent 句柄

View File

@@ -1,18 +1,18 @@
# `@deepseek-ai/dsh-session-reference` # `@deepseek-ai/dsh-session-reference`
`ctx.sessionReferences` prepares bounded, read-only snapshots of other sessions as prompt-prefix context. It consumes `ctx.sessionQuery` and the backend-independent compact checkpoint marker; SQLite FTS is not required. The standard TUI and ACP demo bundles mount it, while other hosts may call the service directly. `ctx.sessionReferences` prepares bounded, read-only snapshots of other sessions as additional context. It consumes `ctx.sessionQuery` and the backend-independent compact checkpoint marker; SQLite FTS is not required. The standard TUI and ACP demo bundles mount it, while other hosts may call the service directly.
## Public API ## Public API
- `listCandidates(agent, query?, limit?)` lists sessions other than `agent.id`, filters case-insensitively by id or cwd, and ranks same-cwd, cwd-less, then other-cwd records while preserving `listSessions()` creation order within each group. Each selected candidate uses its latest log-backed title as the mention label and falls back to the session id; titles and message bodies are not searched. - `listCandidates(agent, query?, limit?)` lists sessions other than `agent.id`, filters case-insensitively by id or cwd, and ranks same-cwd, cwd-less, then other-cwd records while preserving `listSessions()` creation order within each group. Each selected candidate uses its latest log-backed title as the mention label and falls back to the session id; titles and message bodies are not searched.
- `prepare(agent, content, references, signal?)` preserves first-mention order, deduplicates ids, rejects self-reference and more than the configured distinct-source limit, reads every source in parallel, and returns detached content plus zero or one aggregated `HookContext`. Any invalid reference, failed read, cancellation, or budget failure rejects before the host calls `send()` or `steer()`. - `prepare(agent, content, references, signal?)` preserves first-mention order, deduplicates ids, rejects self-reference and more than the configured distinct-source limit, reads every source in parallel, and returns detached content plus zero or one aggregated `AdditionalContext`. Any invalid reference, failed read, cancellation, or budget failure rejects before the host injects context and sends or steers the direct message.
- `encodeSessionReferenceUri()` and `decodeSessionReferenceUri()` implement `dsh-session:<base64url(JSON.stringify(sessionId))>` so every JavaScript string id round-trips exactly. `formatSessionReferenceMention()` emits `@[label](uri)`, and `parseSessionReferenceText()` replaces Markdown mentions or bare canonical URIs with readable `@label` text while returning structured references. Explicit Markdown mentions reject every malformed URI; bare text is considered a reference only when a non-empty base64url-shaped payload follows the scheme, and a matching noncanonical candidate still fails. Empty or punctuation-only scheme mentions remain ordinary discussion text. - `encodeSessionReferenceUri()` and `decodeSessionReferenceUri()` implement `dsh-session:<base64url(JSON.stringify(sessionId))>` so every JavaScript string id round-trips exactly. `formatSessionReferenceMention()` emits `@[label](uri)`, and `parseSessionReferenceText()` replaces Markdown mentions or bare canonical URIs with readable `@label` text while returning structured references. Explicit Markdown mentions reject every malformed URI; bare text is considered a reference only when a non-empty base64url-shaped payload follows the scheme, and a matching noncanonical candidate still fails. Empty or punctuation-only scheme mentions remain ordinary discussion text.
## Snapshot semantics ## Snapshot semantics
Preparation calls `ctx.sessionQuery.readSurface()` once per distinct source and never rereads it after enqueue. It projects only direct-user `user/message`, direct-user `steering/message`, assistant text, and `user/message` checkpoints carrying the canonical `dsh-compact` source marker from the folded current surface. For a source prompt that already contains baked prefix context, projection reads only its model-hidden display content, preventing recursive snapshot propagation. Shadowed pre-compaction events, tools, reasoning, context, plugin-generated user messages other than marked compact checkpoints, and unfinished assistant chunks are excluded. A compacted source therefore contributes its latest checkpoint plus retained later conversation, not restored shadowed text. Preparation calls `ctx.sessionQuery.readSurface()` once per distinct source and never rereads it after delivery. It projects only direct-user `user/message`, direct-user `steering/message`, assistant text, and `user/message` checkpoints carrying the canonical `dsh-compact` source marker from the folded current surface. Synthetic context, shadowed pre-compaction events, tools, reasoning, plugin-generated user messages other than marked compact checkpoints, and unfinished assistant chunks are excluded. A compacted source therefore contributes its latest checkpoint plus retained later conversation, not restored shadowed text.
The context uses a typed `{ kind: 'session-reference', ... }` source with `placement: 'prompt-prefix'`. That source records version `1`, source ids and labels, capture seqs, compact presence, retained/omitted message counts, omitted UTF-8 bytes, and truncation state. AgentLoop writes the snapshot, `## My request:` delimiter, and effective prompt into one `user/message` or `steering/message`; the same event's model-hidden envelope retains the direct prompt and source for TUI/ACP replay. Later source mutation, compaction, or deletion cannot change target replay. The context uses a typed `{ kind: 'session-reference', ... }` source. That source records version `1`, source ids and labels, capture seqs, compact presence, retained/omitted message counts, omitted UTF-8 bytes, and truncation state. Hosts call `inject()` with the snapshot before delivering the direct prompt, so the session log keeps two simple messages with independent provenance. TUI and ACP replay the direct user message normally and render the injected snapshot as a compact reference card. Later source mutation, compaction, or deletion cannot change target replay.
## Configuration ## Configuration
@@ -30,7 +30,7 @@ Retention applies `maxReferenceBytes` independently to each source, keeps compac
#### What the model sees #### What the model sees
The model sees one user-role message in this order: the `## Referenced sessions` untrusted snapshot, the `## My request:` delimiter, then the current message with its readable `@label`. The warning forbids following instructions, permission claims, or tool requests from the snapshot unless the current user repeats them. Labels, cwd values, ids, and conversation text are serialized as JSON inside `<referenced-sessions>` tags; every data `<` is emitted as the lossless JSON escape `\u003c`, so source text cannot spell a framing tag. The model sees two consecutive user-role messages: the `## Referenced sessions` untrusted snapshot, then the current message with its readable `@label`. The warning forbids following instructions, permission claims, or tool requests from the snapshot unless the current user repeats them. Labels, cwd values, ids, and conversation text are serialized as JSON inside `<referenced-sessions>` tags; every data `<` is emitted as the lossless JSON escape `\u003c`, so source text cannot spell a framing tag.
#### Token effect #### Token effect
@@ -38,7 +38,7 @@ Each referenced message adds the fixed warning plus up to three serialized snaps
#### KV Cache effect #### KV Cache effect
The combined snapshot and request are append-only at the target message boundary and preserve earlier cacheable history. Different references or source capture contents change the new suffix only; later target compaction may invalidate reuse from its replacement boundary. The snapshot and request append as adjacent target messages and preserve earlier cacheable history. Different references or source capture contents change the new suffix only; later target compaction may invalidate reuse from its replacement boundary.
## Known Limitations and Deferred Work ## Known Limitations and Deferred Work

View File

@@ -7,7 +7,7 @@
import { Context, Service } from 'cordis' import { Context, Service } from 'cordis'
import z from 'schemastery' import z from 'schemastery'
import type { Agent, HookContext } from '@deepseek-ai/dsh-agent' import type { AdditionalContext, Agent } from '@deepseek-ai/dsh-agent'
import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { SessionId } from '@deepseek-ai/dsh-session' import type { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionSurfaceSnapshot } from '@deepseek-ai/dsh-session-query' import type { SessionSurfaceSnapshot } from '@deepseek-ai/dsh-session-query'
@@ -148,7 +148,7 @@ export class SessionReferenceService extends Service {
* @param content - already host-normalized readable message content. * @param content - already host-normalized readable message content.
* @param references - structured source sessions in mention order. * @param references - structured source sessions in mention order.
* @param signal - optional cancellation boundary for host request teardown. * @param signal - optional cancellation boundary for host request teardown.
* @returns detached content and zero or one prepared contexts. * @returns detached content and optional referenced-session context.
*/ */
async prepare( async prepare(
agent: Agent, agent: Agent,
@@ -158,7 +158,7 @@ export class SessionReferenceService extends Service {
): Promise<PreparedReferencedMessage> { ): Promise<PreparedReferencedMessage> {
const acceptedContent = structuredClone(content) const acceptedContent = structuredClone(content)
const inputs = normalizeReferences(agent.id, references, this.config.maxReferences) const inputs = normalizeReferences(agent.id, references, this.config.maxReferences)
if (inputs.length === 0) return { content: acceptedContent, contexts: [] } if (inputs.length === 0) return { content: acceptedContent }
assertNotCancelled(signal) assertNotCancelled(signal)
let prepared: PreparedSource[] let prepared: PreparedSource[]
try { try {
@@ -192,12 +192,11 @@ export class SessionReferenceService extends Service {
inputIndex: index, inputIndex: index,
})), })),
} }
const context: HookContext = { const additionalContext: AdditionalContext = {
source, source,
content: [{ type: 'text', text: prompt }], content: [{ type: 'text', text: prompt }],
placement: 'prompt-prefix',
} }
return { content: acceptedContent, contexts: [context] } return { content: acceptedContent, additionalContext }
} }
private renderSources(sources: readonly PreparedSource[]): RenderedSource[] { private renderSources(sources: readonly PreparedSource[]): RenderedSource[] {

View File

@@ -1,7 +1,6 @@
/** Current-surface projection and byte-bounded rendering. */ /** Current-surface projection and byte-bounded rendering. */
import { isCompactCheckpointSource } from '@deepseek-ai/dsh-compact' import { isCompactCheckpointSource } from '@deepseek-ai/dsh-compact'
import { displayPromptContent } from '@deepseek-ai/dsh-session'
import type { SessionSurfaceSnapshot } from '@deepseek-ai/dsh-session-query' import type { SessionSurfaceSnapshot } from '@deepseek-ai/dsh-session-query'
import { assertNever } from '@deepseek-ai/dsh-llm' import { assertNever } from '@deepseek-ai/dsh-llm'
import { TextRetainer } from '@deepseek-ai/dsh-retention' import { TextRetainer } from '@deepseek-ai/dsh-retention'
@@ -41,13 +40,13 @@ function projectSessionConversation(snapshot: SessionSurfaceSnapshot): Projected
case 'user/message': { case 'user/message': {
const checkpoint = isCompactCheckpointSource(event.data.source) const checkpoint = isCompactCheckpointSource(event.data.source)
if (!checkpoint && event.data.source.kind !== 'user') break if (!checkpoint && event.data.source.kind !== 'user') break
const text = textContent(displayPromptContent(event.data)) const text = textContent(event.data.content)
if (text !== '') conversation.push({ role: 'user', text, checkpoint, originalText: text, omittedBytes: 0 }) if (text !== '') conversation.push({ role: 'user', text, checkpoint, originalText: text, omittedBytes: 0 })
break break
} }
case 'steering/message': { case 'steering/message': {
if (event.data.source.kind !== 'user') break if (event.data.source.kind !== 'user') break
const text = textContent(displayPromptContent(event.data)) const text = textContent(event.data.content)
if (text !== '') conversation.push({ role: 'user', text, checkpoint: false, originalText: text, omittedBytes: 0 }) if (text !== '') conversation.push({ role: 'user', text, checkpoint: false, originalText: text, omittedBytes: 0 })
break break
} }

View File

@@ -1,6 +1,6 @@
/** Public session-reference request, candidate, and preparation records. */ /** Public session-reference request, candidate, and preparation records. */
import type { HookContext } from '@deepseek-ai/dsh-agent' import type { AdditionalContext } from '@deepseek-ai/dsh-agent'
import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { SessionId } from '@deepseek-ai/dsh-session' import type { SessionId } from '@deepseek-ai/dsh-session'
@@ -48,12 +48,12 @@ export interface SessionReferenceCandidate {
createdAt: number createdAt: number
} }
/** Message payload and the zero-or-one durable snapshot contexts bound to it. */ /** Direct message content and optional referenced-session context. */
export interface PreparedReferencedMessage { export interface PreparedReferencedMessage {
/** Readable message content after host mention tokens are removed. */ /** Readable message content after host mention tokens are removed. */
content: ContentBlock[] content: ContentBlock[]
/** Empty without references; otherwise one aggregated untrusted context. */ /** Aggregated untrusted snapshot, absent when the message has no references. */
contexts: HookContext[] additionalContext?: AdditionalContext
} }
/** Text-only projected conversation item. */ /** Text-only projected conversation item. */

View File

@@ -238,11 +238,9 @@ describe('session reference discovery and preparation', () => {
[{ sessionId: source.id, label: 'source' }], [{ sessionId: source.id, label: 'source' }],
) )
expect(prepared.content).toEqual([{ type: 'text', text: 'use @source' }]) expect(prepared.content).toEqual([{ type: 'text', text: 'use @source' }])
expect(prepared.contexts).toHaveLength(1) const context = prepared.additionalContext
const context = prepared.contexts[0]
if (context?.content[0]?.type !== 'text') throw new Error('expected text context') if (context?.content[0]?.type !== 'text') throw new Error('expected text context')
expect(context.source).toMatchObject({ kind: 'session-reference' }) expect(context.source).toMatchObject({ kind: 'session-reference' })
expect(context.placement).toBe('prompt-prefix')
expect(context.content[0].text).toContain('untrusted, read-only snapshot') expect(context.content[0].text).toContain('untrusted, read-only snapshot')
expect(promptData(context.content[0].text)).toEqual([{ expect(promptData(context.content[0].text)).toEqual([{
sessionId: 'source', sessionId: 'source',
@@ -276,21 +274,17 @@ describe('session reference discovery and preparation', () => {
expect(context.content[0].text).not.toContain('later source mutation') expect(context.content[0].text).not.toContain('later source mutation')
}) })
it('projects only the direct prompt when a source message contains baked prefix context', async () => { it('excludes injected context when projecting a referenced session', async () => {
const ctx = await harness() const ctx = await harness()
const target = ctx.sessions.create(SessionId('target')) const target = ctx.sessions.create(SessionId('target'))
const source = ctx.sessions.create(SessionId('source')) const source = ctx.sessions.create(SessionId('source'))
source.append('user/message', { source.append('user/message', {
content: [ content: [{ type: 'text', text: 'nested referenced snapshot must not propagate' }],
{ type: 'text', text: 'nested referenced snapshot must not propagate' }, source: { kind: 'plugin', plugin: 'session-reference' },
{ type: 'text', text: '\n\n## My request:\n' }, }, { surfaceOp: 'append' })
{ type: 'text', text: 'direct source question' }, source.append('user/message', {
], content: [{ type: 'text', text: 'direct source question' }],
source: { kind: 'user' }, source: { kind: 'user' },
envelope: {
displayContent: [{ type: 'text', text: 'direct source question' }],
prefixContexts: [{ source: { kind: 'plugin', plugin: 'session-reference' } }],
},
}, { surfaceOp: 'append' }) }, { surfaceOp: 'append' })
const prepared = await ctx.sessionReferences.prepare( const prepared = await ctx.sessionReferences.prepare(
@@ -298,7 +292,7 @@ describe('session reference discovery and preparation', () => {
[{ type: 'text', text: 'inspect source' }], [{ type: 'text', text: 'inspect source' }],
[{ sessionId: source.id }], [{ sessionId: source.id }],
) )
const context = prepared.contexts[0] const context = prepared.additionalContext
if (context?.content[0]?.type !== 'text') throw new Error('expected text context') if (context?.content[0]?.type !== 'text') throw new Error('expected text context')
expect(promptData(context.content[0].text)).toMatchObject([{ expect(promptData(context.content[0].text)).toMatchObject([{
conversation: [{ role: 'user', text: 'direct source question' }], conversation: [{ role: 'user', text: 'direct source question' }],
@@ -322,7 +316,7 @@ describe('session reference discovery and preparation', () => {
[{ type: 'text', text: 'use @source' }], [{ type: 'text', text: 'use @source' }],
[{ sessionId: source.id }], [{ sessionId: source.id }],
) )
const context = prepared.contexts[0] const context = prepared.additionalContext
if (context?.content[0]?.type !== 'text') throw new Error('expected text context') if (context?.content[0]?.type !== 'text') throw new Error('expected text context')
const prompt = context.content[0].text const prompt = context.content[0].text
expect(prompt).toMatch(/^## Referenced sessions\n/u) expect(prompt).toMatch(/^## Referenced sessions\n/u)
@@ -347,14 +341,14 @@ describe('session reference discovery and preparation', () => {
const content = [{ type: 'text' as const, text: 'go' }] const content = [{ type: 'text' as const, text: 'go' }]
const withoutReferences = await ctx.sessionReferences.prepare(agent, content, []) const withoutReferences = await ctx.sessionReferences.prepare(agent, content, [])
expect(withoutReferences).toEqual({ content, contexts: [] }) expect(withoutReferences).toEqual({ content })
expect(withoutReferences.content).not.toBe(content) expect(withoutReferences.content).not.toBe(content)
await expect(ctx.sessionReferences.prepare(agent, content, [ await expect(ctx.sessionReferences.prepare(agent, content, [
{ sessionId: one.id, label: 'first' }, { sessionId: one.id, label: 'first' },
{ sessionId: one.id, label: 'ignored duplicate' }, { sessionId: one.id, label: 'ignored duplicate' },
{ sessionId: two.id }, { sessionId: two.id },
])).resolves.toMatchObject({ contexts: [{ source: { references: [{ label: 'first' }, { label: 'two' }] } }] }) ])).resolves.toMatchObject({ additionalContext: { source: { references: [{ label: 'first' }, { label: 'two' }] } } })
await expect(ctx.sessionReferences.prepare(agent, content, [{ sessionId: target.id }])) await expect(ctx.sessionReferences.prepare(agent, content, [{ sessionId: target.id }]))
.rejects.toThrow(expectCode('SESSION_REFERENCE_SELF_REFERENCE')) .rejects.toThrow(expectCode('SESSION_REFERENCE_SELF_REFERENCE'))
await expect(ctx.sessionReferences.prepare(agent, content, [null as never])) await expect(ctx.sessionReferences.prepare(agent, content, [null as never]))
@@ -425,7 +419,7 @@ describe('session reference discovery and preparation', () => {
) )
const prepared = await ctx.sessionReferences.prepare(fakeAgent(target), [{ type: 'text', text: 'go' }], [{ sessionId: source.id }]) const prepared = await ctx.sessionReferences.prepare(fakeAgent(target), [{ type: 'text', text: 'go' }], [{ sessionId: source.id }])
const context = prepared.contexts[0] const context = prepared.additionalContext
if (context?.content[0]?.type !== 'text') throw new Error('expected text context') if (context?.content[0]?.type !== 'text') throw new Error('expected text context')
const data = promptData(context.content[0].text) as unknown[] const data = promptData(context.content[0].text) as unknown[]
expect(Buffer.byteLength(stringifyTagSafeJson(data[0]), 'utf8')).toBeLessThanOrEqual(360) expect(Buffer.byteLength(stringifyTagSafeJson(data[0]), 'utf8')).toBeLessThanOrEqual(360)
@@ -459,7 +453,7 @@ describe('session reference discovery and preparation', () => {
[{ type: 'text', text: 'go' }], [{ type: 'text', text: 'go' }],
sources.map(source => ({ sessionId: source.id })), sources.map(source => ({ sessionId: source.id })),
) )
const context = prepared.contexts[0] const context = prepared.additionalContext
if (context?.content[0]?.type !== 'text') throw new Error('expected text context') if (context?.content[0]?.type !== 'text') throw new Error('expected text context')
const data = promptData(context.content[0].text) as unknown[] const data = promptData(context.content[0].text) as unknown[]
const sizes = data.map(source => Buffer.byteLength(stringifyTagSafeJson(source), 'utf8')) const sizes = data.map(source => Buffer.byteLength(stringifyTagSafeJson(source), 'utf8'))
@@ -492,17 +486,12 @@ describe('session reference discovery and preparation', () => {
[{ type: 'text', text: 'use @source' }], [{ type: 'text', text: 'use @source' }],
[{ sessionId: source.id }], [{ sessionId: source.id }],
) )
const context = prepared.contexts[0] const context = prepared.additionalContext
if (context === undefined) throw new Error('expected prepared context') if (context === undefined) throw new Error('expected prepared context')
target.append('user/message', context, { surfaceOp: 'append' })
target.append('user/message', { target.append('user/message', {
content: [...context.content, { type: 'text', text: '\n\n## My request:\n' }, ...prepared.content], content: prepared.content,
source: { kind: 'user' }, source: { kind: 'user' },
envelope: {
displayContent: prepared.content,
prefixContexts: [{
source: context.source,
}],
},
}, { surfaceOp: 'append' }) }, { surfaceOp: 'append' })
const before = target.deriveMessages() const before = target.deriveMessages()
@@ -529,7 +518,7 @@ describe('session reference discovery and preparation', () => {
expect(ctx.sessions.get(source.id)).toBeUndefined() expect(ctx.sessions.get(source.id)).toBeUndefined()
expect(target.deriveMessages()).toEqual(before) expect(target.deriveMessages()).toEqual(before)
expect(JSON.stringify(before)).toContain('durable referenced fact') expect(JSON.stringify(before)).toContain('durable referenced fact')
expect(JSON.stringify(before)).toContain('## My request:') expect(JSON.stringify(before)).toContain('use @source')
expect(JSON.stringify(before)).not.toContain('later source mutation') expect(JSON.stringify(before)).not.toContain('later source mutation')
expect(new Session(SessionId('replayed-target'), target.events).deriveMessages()).toEqual(before) expect(new Session(SessionId('replayed-target'), target.events).deriveMessages()).toEqual(before)
}) })

View File

@@ -4,7 +4,7 @@
* @module @deepseek-ai/dsh-workspace-context/state * @module @deepseek-ai/dsh-workspace-context/state
*/ */
import type { Agent, HookContext } from '@deepseek-ai/dsh-agent' import type { AdditionalContext, Agent } from '@deepseek-ai/dsh-agent'
import type { Message } from '@deepseek-ai/dsh-llm' import type { Message } from '@deepseek-ai/dsh-llm'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import type { FileSystem, FsVersion } from '@deepseek-ai/dsh-fs' import type { FileSystem, FsVersion } from '@deepseek-ai/dsh-fs'
@@ -77,14 +77,14 @@ export interface InstructionVersionUpdate {
/** Rendered reconciliation plus cache transitions awaiting final policy. */ /** Rendered reconciliation plus cache transitions awaiting final policy. */
export interface ReconciledInstructionContext { export interface ReconciledInstructionContext {
context: WorkspaceHookContext context: WorkspaceAdditionalContext
versionUpdates: InstructionVersionUpdate[] versionUpdates: InstructionVersionUpdate[]
} }
/** Plugin-owned workspace context. */ /** Plugin-owned workspace context. */
export type WorkspaceHookContext = HookContext export type WorkspaceAdditionalContext = AdditionalContext
function workspaceContextHook(text: string, changes: WorkspaceInstructionChange[]): WorkspaceHookContext { function workspaceContextHook(text: string, changes: WorkspaceInstructionChange[]): WorkspaceAdditionalContext {
return { return {
content: [{ type: 'text', text }], content: [{ type: 'text', text }],
source: { kind: 'workspace-instructions', changes }, source: { kind: 'workspace-instructions', changes },
@@ -327,7 +327,7 @@ export function observeInstructionSessionEvent(
*/ */
export function commitPendingInstructionContexts( export function commitPendingInstructionContexts(
agent: Agent, agent: Agent,
contexts: readonly HookContext[] | undefined, contexts: readonly AdditionalContext[] | undefined,
pendingBySession: WeakMap<object, Map<string, PendingInstructionChange>>, pendingBySession: WeakMap<object, Map<string, PendingInstructionChange>>,
): WorkspaceInstructionChange[] { ): WorkspaceInstructionChange[] {
const committed: WorkspaceInstructionChange[] = [] const committed: WorkspaceInstructionChange[] = []

View File

@@ -7,7 +7,7 @@ import Loader from '@cordisjs/plugin-loader'
import * as workspaceContext from '@deepseek-ai/dsh-workspace-context' import * as workspaceContext from '@deepseek-ai/dsh-workspace-context'
import LlmService, { CallId, type Message, type StreamChunk } from '@deepseek-ai/dsh-llm' import LlmService, { CallId, type Message, type StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionId, SESSION_FORMAT_VERSION, type SessionEvent } from '@deepseek-ai/dsh-session' import SessionStore, { Session, SessionId, SESSION_FORMAT_VERSION, type SessionEvent } from '@deepseek-ai/dsh-session'
import AgentRegistry, { AgentMessageId, type Agent, type HookContext } from '@deepseek-ai/dsh-agent' import AgentRegistry, { AgentMessageId, type AdditionalContext, type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop' import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { FileSystem, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' import { FileSystem, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
import type { import type {
@@ -203,12 +203,12 @@ function blocksText(blocks: { type: string; text?: string }[] | undefined): stri
return blocks?.map(block => block.type === 'text' ? block.text ?? '' : '').join('\n') ?? '' return blocks?.map(block => block.type === 'text' ? block.text ?? '' : '').join('\n') ?? ''
} }
function workspaceContextOf(result: { additionalContexts?: HookContext[] }): HookContext | undefined { function workspaceContextOf(result: { additionalContexts?: AdditionalContext[] }): AdditionalContext | undefined {
return result.additionalContexts?.find(context => return result.additionalContexts?.find(context =>
context.source.kind === 'workspace-instructions') context.source.kind === 'workspace-instructions')
} }
function workspaceChangeContext(scope: string, digest: string): HookContext { function workspaceChangeContext(scope: string, digest: string): AdditionalContext {
return { return {
content: [{ type: 'text', text: `instructions for ${scope}` }], content: [{ type: 'text', text: `instructions for ${scope}` }],
source: { source: {
@@ -218,7 +218,7 @@ function workspaceChangeContext(scope: string, digest: string): HookContext {
} }
} }
function appendAdditionalContexts(agent: Agent, result: { additionalContexts?: HookContext[] }): number | undefined { function appendAdditionalContexts(agent: Agent, result: { additionalContexts?: AdditionalContext[] }): number | undefined {
let lastSeq: number | undefined let lastSeq: number | undefined
for (const context of result.additionalContexts ?? []) { for (const context of result.additionalContexts ?? []) {
lastSeq = agent.session.append('user/message', { lastSeq = agent.session.append('user/message', {

View File

@@ -52,7 +52,7 @@ Configured agents start automatically. A model call requires both `provider` and
The concrete `Agent` class, its `Inbox`, `runLoop`, and instance-bound publication/start controls are package-internal. The package root exports only the plugin/service/config contract, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than naming, constructing, or starting driver internals. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy. The concrete `Agent` class, its `Inbox`, `runLoop`, and instance-bound publication/start controls are package-internal. The package root exports only the plugin/service/config contract, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than naming, constructing, or starting driver internals. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy.
The unified `send()` primitive materializes content, resolved source, and attached contexts once as a detached, deeply frozen lossless-JSON record, then routes it by (`target` × `wakeup`); `followup`/`steer`/`inject` are its fixed-preset aliases. A `next-turn` item joins the queued FIFO (waking the driver unless `wakeup: false`); if claimed, it is the sole ordinary message in its turn, and its contexts are the prompt waterfall's default additional contexts that materialize only after admission. Absent or `separate` placement appends an independent injected `user/message`; `prompt-prefix` placement bakes the context, the stable `## My request:` delimiter, and effective request into one `user/message`, whose model-hidden envelope retains display content and context descriptors. The waterfall's returned allow is authoritative, so a listener wrapping `next()` preserves downstream `content` and `additionalContexts` unless it intentionally replaces them. A successor waits for the preceding ordinary turn's checkpoint to settle, while cancellation, disposal, a prompt block, or a pre-start failure may drop its contexts with the message. A running `next-step`/wakeup `steer()` enters the same record shape in the steering FIFO without dispatching `agent/prompt-submit`; its next checkpoint applies the same separate-or-prefix placement to `steering/message`, while policy can still stop before another step. Steering left after turn close and its checkpoint becomes later queued input with contexts intact unless terminal turn policy, cancellation, or disposal discards it. `next-step`/no-wakeup `inject()` bypasses the FIFOs and appends durable context directly: an open-turn injection uses the same accepted-value boundary but defers in a FIFO while the current step executes assistant tool calls (successful batches place it after all results, interrupted batches drain it before turn close), and an idle injection wraps a one-shot `injection` turn. Every FIFO enqueue publishes `agent/inbox/enqueue`; the driver's claims publish `agent/inbox/dequeue`, and `cancel()` without `keepInbox` publishes `agent/inbox/discard`. Malformed data throws before enqueue or append. The unified `send()` primitive routes content and source by (`target` × `wakeup`); `followup`/`steer`/`inject` are its fixed-preset aliases. A `next-turn` item joins the queued FIFO, waking the driver unless `wakeup: false`; admission happens before any turn opens. An allowed prompt and the prompt waterfall's `additionalContexts` enter the outbox as separate messages, then `run()` opens the turn and drains them together. A running `next-step`/wakeup `steer()` enters the same outbox without prompt admission and normally causes another step. A `next-step`/no-wakeup `inject()` waits there only while a turn is open; while idle it appends and flushes a `user/message` immediately without opening a turn or running the model. Every inbox enqueue publishes `agent/inbox/enqueue`; taking it publishes `agent/inbox/dequeue`, and `cancel()` without `keepInbox` publishes `agent/inbox/discard`.
### Loop lifecycle (`loop.ts`) ### Loop lifecycle (`loop.ts`)

View File

@@ -1,16 +1,7 @@
/** /**
* The concrete Agent, in the naive-agent shape: the agent IS the machine. * Concrete Agent loop over two pending-input lists: queued prompts each open a
* Two inboxes — `queued` (prompts, one turn each) and `outbox` (steering + * turn, while admitted input, steering, and injected context enter through the
* injected or admitted input, taken at turn start and every step boundary). * outbox at step boundaries. Every request is derived from the session log.
* `kick()` claims one queued prompt and resolves admission before `run()` owns
* the turn boundaries, step loop, settlement, and idle handoff.
*
* The session log IS the transcript: every take appends, every step re-derives
* (`session.deriveMessages()`), so editing history between steps is naturally
* legal — recovery is "observe the error idle, repair the log, retry()".
* Because the outbox is only ever taken at a step boundary, nothing can land
* between an assistant tool-call batch and its results; wire adjacency needs
* no dedicated machinery.
* *
* @module dsh-agent-loop/agent * @module dsh-agent-loop/agent
*/ */
@@ -27,7 +18,6 @@ import type {
AgentInterruptReason, AgentInterruptReason,
AgentOptions, AgentOptions,
AgentStatus, AgentStatus,
HookContext,
IdleReason, IdleReason,
PromptDecision, PromptDecision,
SendOptions, SendOptions,
@@ -44,66 +34,31 @@ import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-tools' import type {} from '@deepseek-ai/dsh-tools'
import { executeToolCalls } from './tool-calls.ts' import { executeToolCalls } from './tool-calls.ts'
/** A prompt waiting for a turn of its own. */ /** One message waiting in the queued or steering inbox. */
interface QueuedMessage { interface PendingMessage {
id: AgentMessageIdType id: AgentMessageIdType
content: ContentBlock[] content: ContentBlock[]
source: MessageSource source: MessageSource
contexts: HookContext[]
wakeup: boolean wakeup: boolean
} }
/** One model-facing input awaiting the next step boundary. */ /** Model-facing input awaiting the next step boundary. */
interface OutboxItem { interface OutboxItem extends PromptMessageData {
data: PromptMessageData /** Present only when this input is a live inbox item. */
steering?: QueuedMessage steering?: PendingMessage
} }
/** Build one live inbox event payload from a queued message. */ /** Build one live inbox event payload from a pending message. */
function inboxMessage(message: QueuedMessage, steering: boolean): AgentMessage { function inboxMessage(message: PendingMessage, steering: boolean): AgentMessage {
return { return {
id: message.id, id: message.id,
content: message.content, content: message.content,
source: message.source, source: message.source,
contexts: message.contexts,
steering, steering,
wakeup: message.wakeup, wakeup: message.wakeup,
} }
} }
const PROMPT_PREFIX_REQUEST_DELIMITER: ContentBlock = {
type: 'text',
text: '\n\n## My request:\n',
}
/** Bake prompt-prefix contexts into one reconstructable prompt event. */
function preparePromptMessage(
content: ContentBlock[],
source: MessageSource,
contexts: readonly HookContext[],
): { data: PromptMessageData; separateContexts: HookContext[] } {
const prefixContexts = contexts.filter(context => context.placement === 'prompt-prefix')
const separateContexts = contexts.filter(context => context.placement !== 'prompt-prefix')
if (prefixContexts.length === 0) return { data: { content, source }, separateContexts }
return {
data: {
content: [
...prefixContexts.flatMap(context => context.content),
PROMPT_PREFIX_REQUEST_DELIMITER,
...content,
],
source,
envelope: {
displayContent: content,
prefixContexts: prefixContexts.map(context => ({
source: context.source,
})),
},
},
separateContexts,
}
}
/** Stable runtime-only reason used when lifecycle teardown interrupts a turn. */ /** Stable runtime-only reason used when lifecycle teardown interrupts a turn. */
export const DISPOSED_INTERRUPT_REASON = Object.freeze({ kind: 'disposed' } as const) export const DISPOSED_INTERRUPT_REASON = Object.freeze({ kind: 'disposed' } as const)
@@ -126,26 +81,21 @@ function withoutToolCalls(message: Message): Message {
return { ...message, content: message.content.filter(block => block.type !== 'tool-call') } return { ...message, content: message.content.filter(block => block.type !== 'tool-call') }
} }
// ---------------------------------------------------------------------------
// The agent.
// ---------------------------------------------------------------------------
/** /**
* The concrete {@link Agent}: the classic naive agent loop — whole derived * The concrete {@link Agent}: each `run()` owns one turn and repeats model
* history in, one assistant message out, loop until a reply owes no tool call. * steps while tools or steering require another request.
* One `run()` owns one complete turn.
*/ */
export class ReactLoopAgent extends Agent { export class ReactLoopAgent extends Agent {
/** Prompts awaiting a turn of their own: one dequeued per turn, FIFO. */ /** Prompts awaiting individual turns. */
private queued: QueuedMessage[] = [] private queued: PendingMessage[] = []
/** Taken whole at every step boundary; caller-editable until taken (taken = entered the log). */ /** Input taken into the session log at step boundaries. */
private outbox: OutboxItem[] = [] private outbox: OutboxItem[] = []
/** Whether observers see one running drain interval; queued turns share it. */ /** Whether observers see a running interval; consecutive turns share it. */
private busy = false private busy = false
/** The claimed activity's abort owner, spanning prompt admission and its turn. */ /** Abort owner for the current admission or turn. */
private turnAbort: AbortController | undefined private abort: AbortController | undefined
/** Resolves when the current admission-plus-turn activity fully exits. */ /** Resolves when the current admission and turn exit. */
done: Promise<void> = Promise.resolve() done: Promise<void> = Promise.resolve()
/** The agent-scoped registration boundary; the lifecycle owner unwinds it after {@link done}. */ /** The agent-scoped registration boundary; the lifecycle owner unwinds it after {@link done}. */
@@ -153,13 +103,9 @@ export class ReactLoopAgent extends Agent {
/** The agent's scoped composition context ({@link Agent.ctx}). */ /** The agent's scoped composition context ({@link Agent.ctx}). */
readonly ctx: Context readonly ctx: Context
/** /** Last turn number opened by this loop or present in its seeded log. */
* The last turn number this machine (or the seeded log) opened. The machine
* is the session's only turn author, so after the one seed scan below it
* simply counts.
*/
private lastTurn: number private lastTurn: number
/** Whether the machine owes the log a `turn/end` / `step/end` right now. */ /** Whether the session log is owed a matching turn end event. */
private turnOpen = false private turnOpen = false
private stepOpen = false private stepOpen = false
@@ -171,7 +117,6 @@ export class ReactLoopAgent extends Agent {
) { ) {
super() super()
this.lastTurn = session.events.findLast(event => event.type === 'turn/start')?.data.turn ?? 0 this.lastTurn = session.events.findLast(event => event.type === 'turn/start')?.data.turn ?? 0
// The scope is keyed by this agent — an opaque identity, fine mid-construction.
this.scope = createScope(loopCtx, this) this.scope = createScope(loopCtx, this)
this.ctx = this.scope.ctx.extend({ agent: this }) this.ctx = this.scope.ctx.extend({ agent: this })
} }
@@ -181,51 +126,35 @@ export class ReactLoopAgent extends Agent {
return this.busy ? 'running' : 'idle' return this.busy ? 'running' : 'idle'
} }
// -------------------------------------------------------------------------
// Public driving verbs.
// -------------------------------------------------------------------------
/** Accept and route one unified send item. */ /** Accept and route one unified send item. */
send(content: ContentBlock[], options: SendOptions = {}): AgentMessageIdType { send(content: ContentBlock[], options: SendOptions = {}): AgentMessageIdType {
const id = AgentMessageId(randomUUID()) const id = AgentMessageId(randomUUID())
const target = options.target ?? 'next-turn' const target = options.target ?? 'next-turn'
const wakeup = options.wakeup ?? true const wakeup = options.wakeup ?? true
if (target === 'next-step' && !wakeup) { if (target === 'next-step' && !wakeup) {
const context: HookContext = { const source = options.source ?? { kind: 'plugin', plugin: '' }
content, if (this.turnOpen) {
source: options.source ?? { kind: 'plugin', plugin: '' }, this.outbox.push({ content, source })
}
if (this.turnAbort !== undefined) {
this.outbox.push({ data: { content: context.content, source: context.source } })
return id return id
} }
this.session.append('user/message', { content, source }, { surfaceOp: 'append' })
const turn = ++this.lastTurn const previous = this.done
let opened = false const flush = this.loopCtx.sessions.flush(this.session).catch((error: unknown) => {
try { this.loopCtx.logger.warn(`agent "${this.id}": flush after idle injection failed: ${errorChain(toError(error))}`)
this.session.append('turn/start', { turn, trigger: { kind: 'injection', source: context.source } }) })
opened = true this.done = Promise.all([previous, flush]).then(() => undefined)
this.session.append('user/message', context, { surfaceOp: 'append' })
} finally {
if (opened) this.session.append('turn/end', { turn, reason: { kind: 'completed' } })
}
return id return id
} }
const steering = target === 'next-step' && this.turnAbort !== undefined const steering = target === 'next-step' && this.turnOpen
const message: QueuedMessage = { const message: PendingMessage = {
id, id,
content, content,
source: options.source ?? { kind: 'user' }, source: options.source ?? { kind: 'user' },
contexts: options.contexts ?? [],
wakeup, wakeup,
} }
if (steering) { if (steering) {
const prepared = preparePromptMessage(message.content, message.source, message.contexts) this.outbox.push({ content: message.content, source: message.source, steering: message })
this.outbox.push({ data: prepared.data, steering: message })
for (const context of prepared.separateContexts) {
this.outbox.push({ data: { content: context.content, source: context.source } })
}
} else { } else {
this.queued.push(message) this.queued.push(message)
} }
@@ -242,7 +171,7 @@ export class ReactLoopAgent extends Agent {
* all owned by the factory. * all owned by the factory.
*/ */
cancel(cause: AgentInterruptReason, options: CancelOptions = {}): void { cancel(cause: AgentInterruptReason, options: CancelOptions = {}): void {
if (this.turnAbort !== undefined || this.queued.length > 0 || this.outbox.length > 0) { if (this.abort !== undefined || this.queued.length > 0 || this.outbox.length > 0) {
// Observe-only: coordination consumers update their state before the // Observe-only: coordination consumers update their state before the
// inboxes clear; listener failures are contained by the dispatcher. // inboxes clear; listener failures are contained by the dispatcher.
if (cause.kind !== 'disposed') emitAgentEvent(this.loopCtx, this, 'agent/cancel-requested', cause) if (cause.kind !== 'disposed') emitAgentEvent(this.loopCtx, this, 'agent/cancel-requested', cause)
@@ -258,7 +187,7 @@ export class ReactLoopAgent extends Agent {
if (discarded.length > 0) emitAgentEvent(this.loopCtx, this, 'agent/inbox/discard', discarded) if (discarded.length > 0) emitAgentEvent(this.loopCtx, this, 'agent/inbox/discard', discarded)
} }
const reason = Object.freeze({ kind: cause.kind }) const reason = Object.freeze({ kind: cause.kind })
this.turnAbort?.abort(reason) this.abort?.abort(reason)
} }
/** /**
@@ -268,133 +197,74 @@ export class ReactLoopAgent extends Agent {
* @throws while a turn is running — there is nothing to retry yet. * @throws while a turn is running — there is nothing to retry yet.
*/ */
retry(): void { retry(): void {
if (this.turnAbort !== undefined) throw new Error(`agent "${this.id}" cannot retry while busy`) if (this.abort !== undefined) throw new Error(`agent "${this.id}" cannot retry while busy`)
this.done = this.loopCtx.agents.withInitiator(this, () => this.run({ kind: 'retry' })) const previous = this.done
const run = this.loopCtx.agents.withInitiator(this, () => this.run({ kind: 'retry' }))
this.done = Promise.all([previous, run]).then(() => undefined)
} }
/** Resolve at idle quiescence: no run driving and no waking prompt waiting. */ /** Resolve at idle quiescence: no run driving and no waking prompt waiting. */
async whenIdle(): Promise<void> { async whenIdle(): Promise<void> {
// `done` is replaced per run, so re-reading it each lap follows chained // `done` is replaced by runs and idle-injection flushes. Re-read after
// turns; a run failure still counts as quiescence for the waiter. // every settlement so work admitted by a synchronous observer is included.
while (this.turnAbort !== undefined || this.queued.some(message => message.wakeup)) { while (true) {
await this.done.catch(() => undefined) const done = this.done
await done.catch(() => undefined)
if (done === this.done && this.abort === undefined && !this.queued.some(message => message.wakeup)) return
} }
} }
// -------------------------------------------------------------------------
// The machine.
// -------------------------------------------------------------------------
/** Claim and admit the next queued prompt, then start its turn. */ /** Claim and admit the next queued prompt, then start its turn. */
private kick(): void { private kick(): void {
if (this.turnAbort !== undefined || !this.queued.some(message => message.wakeup)) return if (this.abort !== undefined || !this.queued.some(message => message.wakeup)) return
const message = this.queued.shift() const message = this.queued.shift()
if (message === undefined) return if (message === undefined) return
emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', inboxMessage(message, false)) emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', inboxMessage(message, false))
const controller = new AbortController() const admission = new AbortController()
this.turnAbort = controller this.abort = admission
this.done = this.loopCtx.agents.withInitiator(this, async () => { const previous = this.done
const signal = controller.signal const admissionTask = this.loopCtx.agents.withInitiator(this, async () => {
const signal = admission.signal
const trigger: TurnTrigger = { kind: 'message', source: message.source } const trigger: TurnTrigger = { kind: 'message', source: message.source }
let admitted = false
try { try {
signal.throwIfAborted() signal.throwIfAborted()
const decision = await this.loopCtx.waterfall( const decision = await this.loopCtx.waterfall(
agentCarrier(this), 'agent/prompt-submit', this, message.content, message.source, signal, agentCarrier(this), 'agent/prompt-submit', this, message.content, message.source, signal,
() => Promise.resolve<PromptDecision>({ () => Promise.resolve<PromptDecision>({ kind: 'allow' }),
kind: 'allow',
...message.contexts.length === 0 ? {} : { additionalContexts: message.contexts },
}),
) )
signal.throwIfAborted() signal.throwIfAborted()
if (decision.kind === 'block') { if (decision.kind === 'allow') {
this.rejectPrompt(trigger, message, decision.reason, controller) this.outbox.push({ content: decision.content ?? message.content, source: message.source })
return for (const context of decision.additionalContexts ?? []) {
} else { this.outbox.push({ content: context.content, source: context.source })
const prepared = preparePromptMessage(
decision.content ?? message.content,
message.source,
decision.additionalContexts ?? [],
)
this.outbox.push({ data: prepared.data })
for (const context of prepared.separateContexts) {
this.outbox.push({ data: { content: context.content, source: context.source } })
} }
admitted = true
} }
} catch (error: unknown) { } catch (error: unknown) {
this.failAdmission(trigger, error, controller) if (agentInterruptReasonOf(signal) === undefined) {
const failure = toError(error)
this.loopCtx.logger.warn(`agent "${this.id}": prompt admission failed: ${errorChain(failure)}`)
}
}
if (this.abort === admission) this.abort = undefined
if (!admitted) {
this.continueOrIdle()
return return
} }
if (this.turnAbort === controller) this.turnAbort = undefined
await this.run(trigger) await this.run(trigger)
}) })
} this.done = Promise.all([previous, admissionTask]).then(() => undefined)
/** Record a policy-blocked prompt as a zero-step turn. */
private rejectPrompt(
trigger: TurnTrigger,
message: QueuedMessage,
rejection: string,
controller: AbortController,
): void {
if (!this.busy) {
this.busy = true
emitAgentEvent(this.loopCtx, this, 'agent/status', 'running')
}
const signal = controller.signal
const turn = ++this.lastTurn
let reason: TurnEndReason = { kind: 'rejected', reason: rejection }
let idle: IdleReason = { kind: 'completed' }
try {
signal.throwIfAborted()
this.session.append('turn/start', { turn, trigger })
this.turnOpen = true
signal.throwIfAborted()
this.drainOutbox(turn)
this.session.append('prompt/blocked', {
content: message.content,
source: message.source,
reason: rejection,
})
} catch (error: unknown) {
({ reason, idle } = this.settle(turn, 0, error, signal))
} finally {
this.finishTurn(controller, turn, 0, reason, idle)
}
}
/** Settle a prompt-admission failure without entering the step loop. */
private failAdmission(trigger: TurnTrigger, failure: unknown, controller: AbortController): void {
if (!this.busy) {
this.busy = true
emitAgentEvent(this.loopCtx, this, 'agent/status', 'running')
}
const signal = controller.signal
const turn = ++this.lastTurn
let reason: TurnEndReason = { kind: 'completed' }
let idle: IdleReason = { kind: 'completed' }
try {
signal.throwIfAborted()
this.session.append('turn/start', { turn, trigger })
this.turnOpen = true
signal.throwIfAborted()
this.drainOutbox(turn)
throw failure
} catch (error: unknown) {
({ reason, idle } = this.settle(turn, 0, error, signal))
} finally {
this.finishTurn(controller, turn, 0, reason, idle)
}
} }
/** Own one complete turn over input already admitted by {@link kick}, or retry history as-is. */ /** Own one complete turn over input already admitted by {@link kick}, or retry history as-is. */
private async run(trigger: TurnTrigger): Promise<void> { private async run(trigger: TurnTrigger): Promise<void> {
if (this.turnAbort !== undefined) throw new Error(`agent "${this.id}" is already running`) if (this.abort !== undefined) throw new Error(`agent "${this.id}" is already running`)
const controller = new AbortController() const controller = new AbortController()
this.turnAbort = controller this.abort = controller
if (!this.busy) { if (!this.busy) {
this.busy = true this.busy = true
emitAgentEvent(this.loopCtx, this, 'agent/status', 'running') emitAgentEvent(this.loopCtx, this, 'agent/status', 'running')
@@ -415,9 +285,9 @@ export class ReactLoopAgent extends Agent {
while (true) { while (true) {
step += 1 step += 1
const { owes, maxTokens } = await this.step(turn, step, signal) const { continueTurn, maxTokens } = await this.step(turn, step, signal)
if (maxTokens) reason = { kind: 'max-tokens' } if (maxTokens) reason = { kind: 'max-tokens' }
if (owes || this.outbox.some(item => item.steering !== undefined)) continue if (continueTurn || this.outbox.some(item => item.steering !== undefined)) continue
await this.loopCtx.serial(agentCarrier(this), 'agent/stopping', this, turn, signal) await this.loopCtx.serial(agentCarrier(this), 'agent/stopping', this, turn, signal)
signal.throwIfAborted() signal.throwIfAborted()
if (!this.drainOutbox(turn)) break if (!this.drainOutbox(turn)) break
@@ -425,17 +295,36 @@ export class ReactLoopAgent extends Agent {
} catch (error: unknown) { } catch (error: unknown) {
({ reason, idle } = this.settle(turn, step, error, signal)) ({ reason, idle } = this.settle(turn, step, error, signal))
} finally { } finally {
this.finishTurn(controller, turn, step, reason, idle) try {
if (this.stepOpen) {
this.stepOpen = false
this.session.append('step/end', { turn, step })
}
if (this.turnOpen) {
// Re-entrant turn/end listeners must route new input to a later turn.
this.turnOpen = false
this.session.append('turn/end', { turn, reason })
}
} catch (error: unknown) {
const err = toError(error)
this.loopCtx.logger.warn(`agent "${this.id}": closing turn ${turn} failed: ${errorChain(err)}`)
emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, err)
}
if (this.abort === controller) this.abort = undefined
emitAgentEvent(this.loopCtx, this, 'agent/idle', turn, idle)
this.continueOrIdle()
} }
} }
/** /**
* One whole step: the `agent/step` seam, take the outbox, derive the * Run the `agent/step` seam, commit pending input, derive one request, and
* history, one request, its tool calls — bracketed by the durable * execute its tool calls inside one durable step boundary.
* step/start / step/end pair. The naive core: whole history in, one
* assistant message out.
*/ */
private async step(turn: number, step: number, signal: AbortSignal): Promise<{ owes: boolean; maxTokens: boolean }> { private async step(
turn: number,
step: number,
signal: AbortSignal,
): Promise<{ continueTurn: boolean; maxTokens: boolean }> {
const { session } = this const { session } = this
// The single between-steps seam: listeners inject, steer, or edit the log // The single between-steps seam: listeners inject, steer, or edit the log
@@ -462,7 +351,6 @@ export class ReactLoopAgent extends Agent {
const request = await this.buildRequest(turn, step, assembly.tools, system, boundaryMessages, signal) const request = await this.buildRequest(turn, step, assembly.tools, system, boundaryMessages, signal)
// --- Model call (streaming-first; raw chunks are the replay record) ---
const assembler = new BlockAssembler() const assembler = new BlockAssembler()
const chunkSeqs: number[] = [] const chunkSeqs: number[] = []
const stream = this.loopCtx.llm.stream(request) const stream = this.loopCtx.llm.stream(request)
@@ -507,26 +395,22 @@ export class ReactLoopAgent extends Agent {
{ surfaceOp: 'append', sourceEventSeqs: chunkSeqs }, { surfaceOp: 'append', sourceEventSeqs: chunkSeqs },
) )
// Dispatch may overlap; policy, durable results, and result context stay
// model-ordered. Tool-produced context rides the outbox like any other
// injection, so it lands after the batch's results — adjacency-safe.
const toolCalls = assembled.content.filter(block => block.type === 'tool-call') const toolCalls = assembled.content.filter(block => block.type === 'tool-call')
let concluded = false let concluded = false
if (toolCalls.length > 0) { if (toolCalls.length > 0) {
({ concluded } = await executeToolCalls( ({ concluded } = await executeToolCalls(
this.loopCtx, turn, step, toolCalls, signal, this.loopCtx, turn, step, toolCalls, signal,
context => this.outbox.push({ data: { content: context.content, source: context.source } }), context => this.outbox.push({ content: context.content, source: context.source }),
)) ))
} }
// Steering/context that arrived during streaming or tool execution lands // Tool results stay adjacent to their calls; input accepted during the
// inside the step (after the batch's results — adjacency-safe). // request enters the log only after the complete result batch.
const steered = this.drainOutbox(turn) const steered = this.drainOutbox(turn)
session.append('step/end', { turn, step }) session.append('step/end', { turn, step })
this.stepOpen = false this.stepOpen = false
// Owed: live tool calls none of which concluded the turn, or steering.
return { return {
owes: (toolCalls.length > 0 && !concluded) || steered, continueTurn: (toolCalls.length > 0 && !concluded) || steered,
maxTokens: finish.kind === 'max-tokens', maxTokens: finish.kind === 'max-tokens',
} }
} }
@@ -566,7 +450,7 @@ export class ReactLoopAgent extends Agent {
...system ? { system } : {}, ...system ? { system } : {},
...tools.length > 0 ? { tools } : {}, ...tools.length > 0 ? { tools } : {},
}) })
// Log the header the request will ACTUALLY use, only when it differs // Log the header the request will use only when it differs
// from the folded baseline — reconstruction folds the log, so an // from the folded baseline — reconstruction folds the log, so an
// unchanged header needs no new snapshot. // unchanged header needs no new snapshot.
const baseline = session.requestHeader() const baseline = session.requestHeader()
@@ -588,18 +472,18 @@ export class ReactLoopAgent extends Agent {
})) }))
} }
/** Commit the outbox whole and report whether it contained steering. */ /** Commit the outbox and report whether it contained steering. */
private drainOutbox(turn: number): boolean { private drainOutbox(turn: number): boolean {
let steered = false let steered = false
for (const item of this.outbox.splice(0)) { for (const item of this.outbox.splice(0)) {
const message = item.steering const { steering: message, ...data } = item
if (message === undefined) { if (message === undefined) {
this.session.append('user/message', item.data, { surfaceOp: 'append' }) this.session.append('user/message', data, { surfaceOp: 'append' })
continue continue
} }
steered = true steered = true
emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', inboxMessage(message, true)) emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', inboxMessage(message, true))
this.session.append('steering/message', { turn, ...item.data }, { surfaceOp: 'append' }) this.session.append('steering/message', { turn, ...data }, { surfaceOp: 'append' })
} }
return steered return steered
} }
@@ -632,61 +516,12 @@ export class ReactLoopAgent extends Agent {
} }
} }
/** Close the owed boundaries, exactly once per turn. Durability is persistence's own eager concern. */ /** Continue with a waking prompt, or publish the idle status. */
private closeTurn(turn: number, step: number, reason: TurnEndReason): void { private continueOrIdle(): void {
if (this.stepOpen) { if (this.abort !== undefined) return
this.stepOpen = false if (this.queued.some(message => message.wakeup)) {
this.session.append('step/end', { turn, step }) this.kick()
} } else if (this.busy) {
if (this.turnOpen) {
this.turnOpen = false
this.session.append('turn/end', { turn, reason })
}
}
/** Close one claimed turn and hand the machine back to the idle boundary. */
private finishTurn(
controller: AbortController,
turn: number,
step: number,
reason: TurnEndReason,
idle: IdleReason,
): void {
try {
this.closeTurn(turn, step, reason)
} catch (error: unknown) {
// A rejected boundary append must not strand the running interval.
const err = toError(error)
this.loopCtx.logger.warn(`agent "${this.id}": closing turn ${turn} failed: ${errorChain(err)}`)
emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, err)
}
if (this.turnAbort === controller) this.turnAbort = undefined
this.idle(turn, idle)
}
/**
* The turn boundary's tail (naive `idle()`): no turn owner remains,
* the idle report fires (a listener may synchronously `retry()` or `send()`
* here — both are legal now), leftover steering becomes queued prompts, and
* the next run opens while the queue is non-empty; otherwise the machine
* parks.
*/
private idle(turn: number, idle: IdleReason): void {
// Requeue BEFORE the idle report so earlier-arrived steering keeps its
// FIFO position ahead of anything a listener send()s synchronously.
for (const item of this.outbox.splice(0)) {
const message = item.steering
if (message === undefined) {
this.outbox.push(item)
continue
}
this.queued.push(message)
}
emitAgentEvent(this.loopCtx, this, 'agent/idle', turn, idle)
// A synchronous idle listener may retry()/send(), installing a new owner.
if (this.turnAbort !== undefined) return // a listener already re-opened
if (this.queued.some(message => message.wakeup)) this.kick()
else {
this.busy = false this.busy = false
emitAgentEvent(this.loopCtx, this, 'agent/status', 'idle') emitAgentEvent(this.loopCtx, this, 'agent/status', 'idle')
} }

View File

@@ -11,7 +11,7 @@
import type { Context } from 'cordis' import type { Context } from 'cordis'
import { assertNever, type ToolCallBlock } from '@deepseek-ai/dsh-llm' import { assertNever, type ToolCallBlock } from '@deepseek-ai/dsh-llm'
import type { HookContext } from '@deepseek-ai/dsh-agent' import type { AdditionalContext } from '@deepseek-ai/dsh-agent'
import type { Session } from '@deepseek-ai/dsh-session' import type { Session } from '@deepseek-ai/dsh-session'
import { TOOL_ABORTED_BEFORE_DISPATCH, TOOL_REGISTRY_SCHEDULER, type ToolExecutionInput, type ToolExecutionMode, type ToolExecutionResult, type ToolRunContext } from '@deepseek-ai/dsh-tools' import { TOOL_ABORTED_BEFORE_DISPATCH, TOOL_REGISTRY_SCHEDULER, type ToolExecutionInput, type ToolExecutionMode, type ToolExecutionResult, type ToolRunContext } from '@deepseek-ai/dsh-tools'
@@ -58,7 +58,7 @@ export async function executeToolCalls(
step: number, step: number,
toolCalls: ToolCallBlock[], toolCalls: ToolCallBlock[],
signal: AbortSignal, signal: AbortSignal,
acceptContext: (context: HookContext) => void, acceptContext: (context: AdditionalContext) => void,
): Promise<{ concluded: boolean }> { ): Promise<{ concluded: boolean }> {
const agent = ctx.agents.requireInitiator() const agent = ctx.agents.requireInitiator()
const { session } = agent const { session } = agent
@@ -120,7 +120,7 @@ async function runGroup(
group: PlannedCall[], group: PlannedCall[],
mode: ToolExecutionMode['kind'], mode: ToolExecutionMode['kind'],
signal: AbortSignal, signal: AbortSignal,
acceptContext: (context: HookContext) => void, acceptContext: (context: AdditionalContext) => void,
): Promise<GroupOutcome> { ): Promise<GroupOutcome> {
const { session } = ctx.agents.requireInitiator() const { session } = ctx.agents.requireInitiator()
const { maxParallelToolCalls } = ctx.agentLoop.config const { maxParallelToolCalls } = ctx.agentLoop.config

View File

@@ -25,8 +25,8 @@ New emit: `agent/idle (agent, turn, reason: IdleReason)` fires once per closed t
- `send()` — unchanged (queued FIFO, one turn each). - `send()` — unchanged (queued FIFO, one turn each).
- `steer()` while running — enters the outbox; taken whole at the next step - `steer()` while running — enters the outbox; taken whole at the next step
boundary. Steering left when the turn closes becomes a queued prompt. boundary. A turn failure leaves untaken steering staged without waking the
There is NO terminal-stop discard of steering anymore. agent; `retry()` or a later prompt takes it.
- `inject()` while the machine is busy — enters the outbox (a `context/message` - `inject()` while the machine is busy — enters the outbox (a `context/message`
appears at the NEXT step boundary, not immediately). While idle — writes a appears at the NEXT step boundary, not immediately). While idle — writes a
one-shot turn (`turn/start(injection)` + `context/message` + `turn/end`) and one-shot turn (`turn/start(injection)` + `context/message` + `turn/end`) and
@@ -43,10 +43,10 @@ New emit: `agent/idle (agent, turn, reason: IdleReason)` fires once per closed t
- `kick()` runs SYNCHRONOUSLY from `send()` when idle: status flips to - `kick()` runs SYNCHRONOUSLY from `send()` when idle: status flips to
`running` inside the `send()` call. There is no parked driver loop, no `running` inside the `send()` call. There is no parked driver loop, no
waitForQueued, no microtask collection window. waitForQueued, no microtask collection window.
- One `run()` = one turn. The idle tail (`idle()`) runs after turn/end + - One `run()` = one turn. After `turn/end`, it emits `agent/idle`, then either
flush: it sets `busy=false`, emits `agent/idle`, requeues leftover steering, starts the next waking queued prompt or flips status to `idle`. Residual
then either kicks the next turn or settles `whenIdle` waiters and flips outbox input does not wake the agent. Status stays `running` continuously
status to `idle`. Status stays `running` continuously across queued turns. across queued turns.
- `step/end` is appended INSIDE the step (after tools + the in-step outbox - `step/end` is appended INSIDE the step (after tools + the in-step outbox
drain), before `agent/continue` runs. The old `post-step → step/end` drain), before `agent/continue` runs. The old `post-step → step/end`
window no longer exists. window no longer exists.

View File

@@ -83,130 +83,71 @@ describe('Agent', () => {
await ctx.fiber.dispose() await ctx.fiber.dispose()
}) })
it('inject() decides enclosure from the LOG (open turn), not agent status', async () => { it('idle inject() appends context and flushes without opening a turn', async () => {
const adapter = new MockAdapter([textResponse('ok')]) const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter) const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const release = Promise.withResolvers<void>()
let flushes = 0
ctx.on('session/flush', async (session) => {
if (session !== agent.session) return
flushes += 1
await release.promise
})
// Simulate an OPEN turn in the log while the agent is idle (status is not a agent.inject([{ type: 'text', text: 'context' }], { source: { kind: 'plugin', plugin: 'p' } })
// reliable open-turn signal). inject must append into that open turn, NOT expect(agent.session.events.map(event => event.type)).toEqual(['user/message'])
// wrap a new one. expect(agent.status).toBe('idle')
agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) expect(adapter.requests).toHaveLength(0)
agent.inject([{ type: 'text', text: 'mid' }], { source: { kind: 'plugin', plugin: 'p' } })
expect(agent.session.events.filter(e => e.type === 'turn/start')).toHaveLength(1)
expect(agent.session.events.at(-1)!.type).toBe('user/message')
// Close the turn; now inject must wrap its own one-shot injection turn. let idle = false
agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) const settled = agent.whenIdle().then(() => { idle = true })
agent.inject([{ type: 'text', text: 'after' }], { source: { kind: 'plugin', plugin: 'p' } }) await Promise.resolve()
const starts = agent.session.events.filter(e => e.type === 'turn/start') expect(flushes).toBe(1)
expect(starts).toHaveLength(2) expect(idle).toBe(false)
const last = starts[1]! release.resolve()
expect(last.type === 'turn/start' && last.data.trigger.kind).toBe('injection') await settled
expect(agent.session.events.at(-1)!.type).toBe('turn/end') // turn-enclosed
}) })
it('inject() defaults its source to an empty plugin, never user', async () => { it('inject() defaults its source to an empty plugin, never user', async () => {
const adapter = new MockAdapter([textResponse('ok')]) const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter) const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
agent.inject([{ type: 'text', text: 'no explicit source' }]) agent.inject([{ type: 'text', text: 'no explicit source' }])
const injected = agent.session.events.at(-1)! const injected = agent.session.events.at(-1)!
expect(injected.type === 'user/message' && injected.data.source).toEqual({ kind: 'plugin', plugin: '' }) expect(injected.type === 'user/message' && injected.data.source).toEqual({ kind: 'plugin', plugin: '' })
await agent.whenIdle()
}) })
it('idle inject() contains a failing flush (logs, does not throw into the caller)', async () => { it('idle inject() contains a failing flush without inventing an agent turn error', async () => {
const adapter = new MockAdapter([textResponse('ok')]) const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter) const ctx = await harness(adapter)
// A persistence-like listener whose flush rejects.
ctx.on('session/flush', () => { throw new Error('disk gone') }) ctx.on('session/flush', () => { throw new Error('disk gone') })
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const errors: Error[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => { errors.push(error) })
// inject() is synchronous and fires a fire-and-forget flush; a rejecting
// flush must be contained (logged), never thrown into the caller.
expect(() => { agent.inject([{ type: 'text', text: 'notice' }], { source: { kind: 'plugin', plugin: 'p' } }) }).not.toThrow() expect(() => { agent.inject([{ type: 'text', text: 'notice' }], { source: { kind: 'plugin', plugin: 'p' } }) }).not.toThrow()
await new Promise(r => setTimeout(r, 20)) // let the contained flush settle await agent.whenIdle()
expect(errors).toEqual([])
expect(agent.session.events.map(event => event.type)).toEqual(['user/message'])
expect(warn).toHaveBeenCalledWith(expect.stringContaining('flush after idle injection failed')) expect(warn).toHaveBeenCalledWith(expect.stringContaining('flush after idle injection failed'))
warn.mockRestore() warn.mockRestore()
}) })
it('idle inject() closes its one-shot turn AND still checkpoints even if the append throws', async () => { it('idle inject() does not flush input rejected before append', async () => {
const adapter = new MockAdapter([textResponse('ok')]) const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter) const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let flushes = 0 let flushes = 0
ctx.on('session/flush', () => { flushes += 1 }) ctx.on('session/flush', () => { flushes += 1 })
// Non-serializable injected content makes Session.append throw AFTER
// turn/start was recorded. The turn/end must still be appended (finally),
// AND the durability checkpoint must still fire — the balanced turn is in
// memory and a crash before the next turn/dispose would otherwise lose it.
expect(() => { expect(() => {
agent.inject([{ type: 'text', text: 'x', bad: 1n } as never], { source: { kind: 'plugin', plugin: 'p' } }) agent.inject([{ type: 'text', text: 'x', bad: 1n } as never], { source: { kind: 'plugin', plugin: 'p' } })
}).toThrow(/non-JSON-serializable/) }).toThrow(/non-JSON-serializable/)
const types = agent.session.events.map(e => e.type)
expect(types).toEqual(['turn/start', 'turn/end']) // balanced, no open turn
await new Promise(r => setTimeout(r, 10)) // let the fire-and-forget flush run
expect(flushes).toBe(1) // checkpoint fired despite the throw
})
it('idle inject() still checkpoints when a listener throws on the synthetic turn/end', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let flushes = 0
ctx.on('session/flush', () => { flushes += 1 })
// Session contains a throwing post-commit turn/end observer. The accepted
// boundary still triggers the idle injection's durability checkpoint.
let threw = false
ctx.on('session/event', (_s, event) => {
if (!threw && event.type === 'turn/end') { threw = true; throw new Error('boom turn/end') }
})
expect(() => { agent.inject([{ type: 'text', text: 'notice' }], { source: { kind: 'plugin', plugin: 'p' } }) }).not.toThrow()
const types = agent.session.events.map(e => e.type)
expect(types).toEqual(['turn/start', 'user/message', 'turn/end']) // balanced
await new Promise(r => setTimeout(r, 10))
expect(flushes).toBe(1) // checkpoint fired despite the throwing turn/end listener
})
it('idle inject() reports a failing flush via agent/error (step 0) AND the logger', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
// A non-Error rejection exercises the String() normalization branch.
ctx.on('session/flush', () => { throw 'disk gone' })
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const errors: { turn: number; step: number; message: string }[] = []
ctx.on('agent/error', (_a, turn, step, error) => void errors.push({ turn, step, message: error.message }))
agent.inject([{ type: 'text', text: 'notice' }], { source: { kind: 'plugin', plugin: 'p' } })
await new Promise(r => setTimeout(r, 20)) // let the contained flush settle
// Reported via agent/error (step 0 — the idle-injection convention) so
// plugins monitoring agent/error see idle-injection persistence failures,
// mirroring the loop's post-turn/end flush path. A non-Error throw is
// normalized to an Error.
expect(errors).toEqual([{ turn: 1, step: 0, message: 'disk gone' }])
expect(warn).toHaveBeenCalledWith(expect.stringContaining('flush after idle injection failed'))
warn.mockRestore()
})
it('idle inject() with a non-serializable source opens no turn (nothing to close)', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
// A non-serializable source makes the turn/start append throw BEFORE the
// event is pushed (Session.append validates before push), so NO turn opens.
// The finally's isTurnOpen() guard sees no open turn and appends nothing —
// the log stays empty, not left with a dangling turn/start.
expect(() => {
agent.inject([{ type: 'text', text: 'x' }], { source: { kind: 'plugin', plugin: 'p', bad: 1n } as never })
}).toThrow(/non-JSON-serializable/)
expect(agent.session.events).toHaveLength(0) expect(agent.session.events).toHaveLength(0)
expect(flushes).toBe(0)
}) })
it('steer() when idle falls through to send() and starts a turn', async () => { it('steer() when idle falls through to send() and starts a turn', async () => {

View File

@@ -114,53 +114,6 @@ describe('agent/prompt-submit', () => {
expect(sent).toContain('extra ctx') expect(sent).toContain('extra ctx')
}) })
it('bakes prompt-prefix contexts and a request delimiter into one durable user message', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('prefixed'), { provider: 'mock', model: 'mock' })
ctx.on('agent/prompt-submit', async (_agent, _content, _source, _signal, next): Promise<PromptDecision> => {
const downstream = await next()
return downstream.kind === 'block'
? downstream
: { ...downstream, content: [{ type: 'text', text: 'rewritten request' }] }
})
agent.send([{ type: 'text', text: 'original request' }], {
contexts: [{
content: [{ type: 'text', text: 'untrusted prefix' }],
source: { kind: 'plugin', plugin: 'prefix' },
placement: 'prompt-prefix',
}],
})
await waitForIdle(ctx, agent)
const log = events(agent)
const user = log.find(event => event.type === 'user/message')
expect(user?.type === 'user/message' && user.data).toEqual({
content: [
{ type: 'text', text: 'untrusted prefix' },
{ type: 'text', text: '\n\n## My request:\n' },
{ type: 'text', text: 'rewritten request' },
],
source: { kind: 'user' },
envelope: {
displayContent: [{ type: 'text', text: 'rewritten request' }],
prefixContexts: [{
source: { kind: 'plugin', plugin: 'prefix' },
}],
},
})
expect(log.some(event => event.type === 'user/message' && event.data.source.kind === 'plugin')).toBe(false)
expect(adapter.requests[0]?.messages.at(-1)).toEqual({
role: 'user',
content: [
{ type: 'text', text: 'untrusted prefix' },
{ type: 'text', text: '\n\n## My request:\n' },
{ type: 'text', text: 'rewritten request' },
],
})
})
it('runs pre-step after prompt rewrites and injected context become durable', async () => { it('runs pre-step after prompt rewrites and injected context become durable', async () => {
const adapter = new MockAdapter([textResponse('ok')]) const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter) const ctx = await harness(adapter)
@@ -198,9 +151,7 @@ describe('agent/prompt-submit', () => {
const reasons: TurnEndReason[] = [] const reasons: TurnEndReason[] = []
ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
agent.send([{ type: 'text', text: 'do something' }], { agent.send([{ type: 'text', text: 'do something' }])
contexts: [{ content: [{ type: 'text', text: 'must be dropped' }], source: { kind: 'plugin', plugin: 'test' } }],
})
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
// the model was never called // the model was never called

View File

@@ -374,25 +374,52 @@ describe('agent loop', () => {
expect(adapter.requests).toHaveLength(2) expect(adapter.requests).toHaveLength(2)
}) })
it('inject() while idle wraps context in a one-shot turn, visible to the next request', async () => { it('keeps steering staged after a failed step until retry', async () => {
const adapter = new MockAdapter([textResponse('recovered')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('failed-steering'), { provider: 'mock', model: 'mock' })
let fail = true
ctx.on('agent/step', (subject) => {
if (subject !== agent || !fail) return
fail = false
subject.steer([{ type: 'text', text: 'pending steering' }])
throw new Error('step failed')
})
send(agent, 'prompt')
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(0)
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
expect(agent.session.events.some(event => event.type === 'steering/message')).toBe(false)
const idle = waitForIdle(ctx, agent)
agent.retry()
await idle
expect(adapter.requests).toHaveLength(1)
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2)
expect(agent.session.events.some(event => event.type === 'steering/message')).toBe(true)
expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('pending steering')
})
it('inject() while idle appends context without opening a turn', async () => {
const adapter = new MockAdapter([textResponse('ok')]) const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter) const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.inject([{ type: 'text', text: 'file changed: a.ts' }], { source: { kind: 'plugin', plugin: 'watcher' } }) agent.inject([{ type: 'text', text: 'file changed: a.ts' }], { source: { kind: 'plugin', plugin: 'watcher' } })
// The idle inject records a self-contained turn (turn/start → user/message
// → turn/end) so the event stays turn-enclosed, but does NOT run the model.
await new Promise(r => setTimeout(r, 20))
expect(agent.status).toBe('idle') expect(agent.status).toBe('idle')
expect(adapter.requests).toHaveLength(0) expect(adapter.requests).toHaveLength(0)
const injectedTurn = agent.session.events.filter(e => e.type === 'turn/start') expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(0)
expect(injectedTurn).toHaveLength(1) expect(agent.session.events.at(-1)).toMatchObject({
const it0 = injectedTurn[0]! type: 'user/message',
expect(it0.type === 'turn/start' && it0.data.trigger.kind).toBe('injection') data: { source: { kind: 'plugin', plugin: 'watcher' } },
expect(agent.session.events.at(-1)!.type).toBe('turn/end') // turn-enclosed })
send(agent, 'go') send(agent, 'go')
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
const flat = JSON.stringify(adapter.requests[0]!.messages) const flat = JSON.stringify(adapter.requests[0]!.messages)
expect(flat).toContain('file changed: a.ts') expect(flat).toContain('file changed: a.ts')
expect(flat).not.toContain('<context source=') expect(flat).not.toContain('<context source=')

View File

@@ -454,16 +454,15 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
}) })
it('an idle inject() is flushed durably on its own (survives without explicit flush/dispose)', async () => { it('an idle inject() is flushed durably on its own (survives without explicit flush/dispose)', async () => {
// Idle injection creates and flushes a one-shot turn. No explicit flush or // No clean disposal follows, so disk presence proves the idle injection's
// clean disposal follows, so disk presence proves its own checkpoint ran. // own checkpoint ran without a synthetic turn.
const adapter1 = new MockAdapter([textResponse('answer')]) const adapter1 = new MockAdapter([textResponse('answer')])
const { ctx: ctx1, root } = await persistentHarness(adapter1) const { ctx: ctx1, root } = await persistentHarness(adapter1)
const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1) await waitForIdle(ctx1, a1)
a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } }) a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } })
// Let inject()'s fire-and-forget flush settle (NO explicit flush/dispose). await a1.whenIdle()
await new Promise(r => setTimeout(r, 30))
// A SEPARATE backend reads the on-disk log — proving the inject persisted // A SEPARATE backend reads the on-disk log — proving the inject persisted
// itself, not a later dispose drain. // itself, not a later dispose drain.
@@ -476,16 +475,14 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
await ctx1.fiber.dispose() await ctx1.fiber.dispose()
}) })
it('an idle inject() survives persist + resume (turn-enclosed, not dropped as crash tail)', async () => { it('an idle inject() survives persist + resume without a synthetic turn', async () => {
// Turn enclosure keeps idle context out of crash-tail repair, so it must
// survive persistence and resume.
const adapter1 = new MockAdapter([textResponse('answer')]) const adapter1 = new MockAdapter([textResponse('answer')])
const { ctx: ctx1, root } = await persistentHarness(adapter1) const { ctx: ctx1, root } = await persistentHarness(adapter1)
const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1) await waitForIdle(ctx1, a1)
a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } }) a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } })
await ctx1.sessions.flush(a1.session) await a1.whenIdle()
await ctx1.fiber.dispose() await ctx1.fiber.dispose()
// Lifecycle 2: resume; the injected context is still in the derived history. // Lifecycle 2: resume; the injected context is still in the derived history.

View File

@@ -48,7 +48,7 @@ The lifecycle edges have two important local caveats. `agent/created` runs after
Most interception points are cooperative waterfalls returning seam-specific decisions. Turn-scoped asynchronous seams receive one explicit `AbortSignal`, with `signal` immediately before a waterfall's final `next`; listeners may cooperate but must not retain it as authority over another turn. The signal remains authoritative through terminal policy and is retired immediately before `turn/end` publication, so terminal observers and the following durability flush cannot cancel completed turn work. `agent/pre-step` and `agent/post-step` are serial checkpoints around a step's durable work, while `agent/request-error` is the failed-model-request recovery waterfall: it receives the exact error, normalized failure facts, immutable prior-retried facts, and signal after the failed step closes; a retry opens a new numbered step. `agent/turn-stop` is the terminal serial fold: it runs after ordinary continuation and steering folding, and a returned stop remains in force through turn close and flush so later steering cannot create an extra step or turn. Ordinary queued prompts remain intact. Effective broad cancellation first emits the observe-only `agent/cancel-requested` with its resolved typed cause, then clears queues and aborts; notification failures are contained and cannot veto the stop. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns signal lifetime; the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way) owns scoped dispatch and terminal settlement. Most interception points are cooperative waterfalls returning seam-specific decisions. Turn-scoped asynchronous seams receive one explicit `AbortSignal`, with `signal` immediately before a waterfall's final `next`; listeners may cooperate but must not retain it as authority over another turn. The signal remains authoritative through terminal policy and is retired immediately before `turn/end` publication, so terminal observers and the following durability flush cannot cancel completed turn work. `agent/pre-step` and `agent/post-step` are serial checkpoints around a step's durable work, while `agent/request-error` is the failed-model-request recovery waterfall: it receives the exact error, normalized failure facts, immutable prior-retried facts, and signal after the failed step closes; a retry opens a new numbered step. `agent/turn-stop` is the terminal serial fold: it runs after ordinary continuation and steering folding, and a returned stop remains in force through turn close and flush so later steering cannot create an extra step or turn. Ordinary queued prompts remain intact. Effective broad cancellation first emits the observe-only `agent/cancel-requested` with its resolved typed cause, then clears queues and aborts; notification failures are contained and cannot veto the stop. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns signal lifetime; the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way) owns scoped dispatch and terminal settlement.
`PromptDecision.additionalContexts` is an array so every context keeps its own source and placement. `SendOptions.contexts` binds the same shape to one queued message before prompt interception: the default allow decision carries it forward, while a blocked prompt records no context. Absent or `separate` placement writes an independent injected `user/message`; `prompt-prefix` writes the context, `## My request:` delimiter, and effective prompt into one `user/message` or `steering/message`, whose model-hidden envelope retains the direct prompt and context sources for human replay. A listener that wraps a downstream allow preserves its `content` and `additionalContexts` unless it intentionally replaces either field; the returned allow is authoritative. A `ContinuationDecision` reason is narrower: it becomes a `steering/message` without attached contexts. `PromptDecision.additionalContexts` is an array so every context keeps its own source. Allowed prompt content and every additional context become separate model-facing `user/message` events before the turn runs. A listener that wraps a downstream allow preserves its `content` and `additionalContexts` unless it intentionally replaces either field; the returned allow is authoritative.
Turn and step boundaries and the model token stream are durable `session/event` facts rather than mirrored `agent/*` notifications. Consumers read `turn/*`, `step/*`, and `assistant/chunk` from the session feed; tool policy and outcome observation belong to the complete pipeline documented by [`dsh-tools`](../tools/README.md). Turn and step boundaries and the model token stream are durable `session/event` facts rather than mirrored `agent/*` notifications. Consumers read `turn/*`, `step/*`, and `assistant/chunk` from the session feed; tool policy and outcome observation belong to the complete pipeline documented by [`dsh-tools`](../tools/README.md).
@@ -56,10 +56,10 @@ Turn and step boundaries and the model token stream are durable `session/event`
The handle every plugin programs against: The handle every plugin programs against:
- `agent.send(content, options?)` — the one delivery primitive over the (`target` × `wakeup`) matrix; `Agent` is an abstract class whose `followup`/`steer`/`inject` aliases are fixed-preset delegates to it. It returns the accepted message's opaque `AgentMessageId`, which the message's `agent/inbox/enqueue`/`dequeue`/`discard` events carry so a caller can correlate a queued item with its lifecycle. `target: 'next-turn'` (default) queues one independent FIFO item that, if claimed, becomes the sole ordinary message in its turn; `wakeup` (default `true`) wakes a parked driver, while `wakeup: false` queues without waking. `target: 'next-step'` with `wakeup: true` submits steering, and with `wakeup: false` injects durable context without running the model. Omitting `options.source` attests direct human input as `{ kind: 'user' }` (injection defaults to `{ kind: 'plugin', plugin: '' }`) and may authorize policy consumers, so plugins, schedulers, and other non-human producers provide their own source. Content, resolved source, and `options.contexts` become one detached, deeply frozen lossless-JSON record before `agent/inbox/enqueue` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input. After admission, separate contexts become injected `user/message` events, while prompt-prefix contexts are baked before the effective request in the same `user/message`; a block or replacement of the default additional-context decision can discard them. The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the turn rationale. - `agent.send(content, options?)` — the one delivery primitive over the (`target` × `wakeup`) matrix; `Agent` is an abstract class whose `followup`/`steer`/`inject` aliases are fixed-preset delegates to it. It returns the accepted message's opaque `AgentMessageId`, which the message's `agent/inbox/enqueue`/`dequeue`/`discard` events carry so a caller can correlate a queued item with its lifecycle. `target: 'next-turn'` (default) queues one independent FIFO item that, if admitted, becomes the sole ordinary prompt in its turn; `wakeup` (default `true`) wakes a parked driver, while `wakeup: false` queues without waking. `target: 'next-step'` with `wakeup: true` submits steering, and with `wakeup: false` injects durable context without running the model. Omitting `options.source` attests direct human input as `{ kind: 'user' }` (injection defaults to `{ kind: 'plugin', plugin: '' }`) and may authorize policy consumers, so plugins, schedulers, and other non-human producers provide their own source. The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the turn rationale.
- `agent.followup(content, options?)` — the `next-turn`/wakeup preset of `send()`: queue an ordinary follow-up turn and wake the driver. - `agent.followup(content, options?)` — the `next-turn`/wakeup preset of `send()`: queue an ordinary follow-up turn and wake the driver.
- `agent.steer(content, options?)` — the `next-step`/wakeup preset: while running, queue steering for the next checkpoint without dispatching `agent/prompt-submit`; when idle, delegate to a woken follow-up. Attached contexts remain in the same frozen record; separate contexts append immediately after the steering event, while prompt-prefix contexts are baked into that steering event. Both survive late-steering conversion to queued input and disappear with their message on cancellation or terminal discard. Policy can still stop before another step; after turn close and its checkpoint, remaining steering becomes later queued input unless terminal turn policy, cancellation, or disposal discards it. - `agent.steer(content, options?)` — the `next-step`/wakeup preset: while a turn is open, stage steering for its next safe boundary without dispatching `agent/prompt-submit`; when idle, delegate to a woken follow-up. Cancellation or disposal may discard pending steering.
- `agent.inject(content, options?)` — the `next-step`/no-wakeup preset: accept detached in-session context without running the model; the next request sees its `user/message` with `content` rendered verbatim as user-role input and provenance carried entirely by `source`. While a turn is open it joins that turn, deferring FIFO while the current tool batch executes and draining before turn close if execution is interrupted; while idle it is wrapped in a one-shot `injection` turn and durability checkpoint ([the turn-enclosure invariant](../../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)). Injection bypasses the FIFOs and emits no `agent/inbox/*` event. - `agent.inject(content, options?)` — the `next-step`/no-wakeup preset: append model-facing context without running the model; the next request sees a verbatim user-role message whose provenance is carried by `source`. While a turn is open, injection waits in the outbox for the next safe boundary. While idle, it appends immediately and starts a durability flush without opening a turn; `whenIdle()` and disposal await that flush. Injection emits no `agent/inbox/*` event.
- `agent.cancel(cause, options?)` — cancel the active turn and, unless `options.keepInbox`, ALL pending work. Callers must choose the `user | parent` cause explicitly; an active holder copies its discriminant into a detached frozen signal reason before aborting. An effective call emits `agent/cancel-requested` with the cause before clearing queued and steering work; dropped items are reported on `agent/inbox/discard`, and observers may synchronize state but cannot veto cancellation. `keepInbox: true` aborts the turn but preserves queued and steering items (no discard, and un-started work is not dropped). The same-process typed seam adds no runtime validation or compatibility fallback for untyped callers. Repeated active-turn cancellation is first-wins for the signal, and idle cancellation is a safe no-op with no notification. ACP maps to `user`, while in-process parent propagation maps to `parent`. The cause is runtime-only; durable `turn/end` stays coarse `aborted`. - `agent.cancel(cause, options?)` — cancel the active turn and, unless `options.keepInbox`, ALL pending work. Callers must choose the `user | parent` cause explicitly; an active holder copies its discriminant into a detached frozen signal reason before aborting. An effective call emits `agent/cancel-requested` with the cause before clearing queued and steering work; dropped items are reported on `agent/inbox/discard`, and observers may synchronize state but cannot veto cancellation. `keepInbox: true` aborts the turn but preserves queued and steering items (no discard, and un-started work is not dropped). The same-process typed seam adds no runtime validation or compatibility fallback for untyped callers. Repeated active-turn cancellation is first-wins for the signal, and idle cancellation is a safe no-op with no notification. ACP maps to `user`, while in-process parent propagation maps to `parent`. The cause is runtime-only; durable `turn/end` stays coarse `aborted`.
- `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit). A non-owner's quiescence-observation hook: it observes the work settling WITHOUT tearing the agent down. Teardown is separate — a lifecycle owner stops and unregisters via `AgentHandle.dispose()`, which awaits the loop exit directly. - `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit). A non-owner's quiescence-observation hook: it observes the work settling WITHOUT tearing the agent down. Teardown is separate — a lifecycle owner stops and unregisters via `AgentHandle.dispose()`, which awaits the loop exit directly.
- `agent.session`, `agent.status`, `agent.options`, `agent.id` - `agent.session`, `agent.status`, `agent.options`, `agent.id`
@@ -109,5 +109,5 @@ Prefix-stable while an agent's scoped registrations are unchanged. Setup or relo
- **Inter-agent channels beyond delegation** — shared state, streaming child output, and background/poll semantics remain outside the current synchronous `ctx.subagents` seam. - **Inter-agent channels beyond delegation** — shared state, streaming child output, and background/poll semantics remain outside the current synchronous `ctx.subagents` seam.
- **`agent/session-start` cannot gate startup** — it remains a synchronous, veto-less notification; async composition that must finish before publication belongs in the factory's `setup(agentCtx)` transaction instead. - **`agent/session-start` cannot gate startup** — it remains a synchronous, veto-less notification; async composition that must finish before publication belongs in the factory's `setup(agentCtx)` transaction instead.
- **`cancel()` clears the inbox by default** — it aborts the in-flight turn plus queued and steering work; `cancel(cause, { keepInbox: true })` aborts only the turn and preserves pending items. There is still no step-only abort that keeps the in-flight turn running ([stop-surface Agent Note](../../../.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.md)). - **`cancel()` clears the inbox by default** — it aborts the in-flight turn plus queued and steering work; `cancel(cause, { keepInbox: true })` aborts only the turn and preserves pending items. There is still no step-only abort that keeps the in-flight turn running ([stop-surface Agent Note](../../../.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.md)).
- **`HookContext` carries exactly one `MessageSource`** — contributions from several plugins merged onto one tool call collapse under one source; mixed provenance is unrepresentable. - **`AdditionalContext` carries exactly one `MessageSource`** — contributions from several plugins merged onto one tool call collapse under one source; mixed provenance is unrepresentable.
- **`SessionStartSource` reserves `'clear'`/`'compact'` with no emitter yet** — only `'startup'`/`'resume'` occur until the driving subsystems land (`TODO(compaction)`). - **`SessionStartSource` reserves `'clear'`/`'compact'` with no emitter yet** — only `'startup'`/`'resume'` occur until the driving subsystems land (`TODO(compaction)`).

View File

@@ -72,12 +72,6 @@ export interface SendOptions {
*/ */
wakeup?: boolean wakeup?: boolean
source?: MessageSource source?: MessageSource
/**
* Model-facing contexts captured with this inbox item. A queued prompt exposes
* them through the default `agent/prompt-submit` allow decision, while steering
* records them directly at its next checkpoint.
*/
contexts?: HookContext[]
} }
/** Options accepted by the fixed-preset aliases, which own `target` and `wakeup`. */ /** Options accepted by the fixed-preset aliases, which own `target` and `wakeup`. */
@@ -111,7 +105,6 @@ export interface AgentMessage {
id: AgentMessageId id: AgentMessageId
content: ContentBlock[] content: ContentBlock[]
source: MessageSource source: MessageSource
contexts: HookContext[]
/** Whether the item joined the steering FIFO (`next-step`) rather than the queued FIFO. */ /** Whether the item joined the steering FIFO (`next-step`) rather than the queued FIFO. */
steering: boolean steering: boolean
/** Whether the item is marked to wake the driver or force a continuation. */ /** Whether the item is marked to wake the driver or force a continuation. */
@@ -136,29 +129,20 @@ export interface CancelOptions {
*/ */
export type AgentStatus = 'idle' | 'running' export type AgentStatus = 'idle' | 'running'
/** Model-facing context injected by a listener or atomically attached to one inbox message. */ /** Additional model-facing context produced beside a prompt or tool result. */
export interface HookContext { export interface AdditionalContext {
content: ContentBlock[] content: ContentBlock[]
source: MessageSource source: MessageSource
/**
* Model placement. Absent or `separate` records an independent injected
* `user/message`; `prompt-prefix` prepends this context and a stable
* request delimiter to the same user-role message as its attached prompt.
*/
placement?: 'separate' | 'prompt-prefix'
} }
/** /**
* Prompt interception result. `allow.content` replaces the prompt. Each * Prompt interception result. `allow.content` replaces the prompt, while
* `additionalContexts` entry follows its declared placement: separate context * `additionalContexts` appends model-facing context before the turn starts.
* message by default, or a prefix inside the prompt's user-role message. * An `allow` returned by a listener is authoritative: a listener wrapping
* `block` records a durable `prompt/blocked` and ends the claimed prompt's * `next()` preserves both fields unless it intentionally replaces them.
* zero-step turn as rejected. An `allow` returned by a listener is
* authoritative: a listener wrapping `next()` preserves downstream `content`
* and `additionalContexts` unless it intentionally replaces them.
*/ */
export type PromptDecision = export type PromptDecision =
| { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: HookContext[] } | { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: AdditionalContext[] }
| { kind: 'block'; reason: string } | { kind: 'block'; reason: string }
/** /**
@@ -206,14 +190,13 @@ export abstract class Agent {
* - `next-step` with `wakeup:true` submits steering into the active turn * - `next-step` with `wakeup:true` submits steering into the active turn
* (idle falls back to a woken `next-turn`). * (idle falls back to a woken `next-turn`).
* - `next-step` with `wakeup:false` injects durable model-facing context * - `next-step` with `wakeup:false` injects durable model-facing context
* without running the model: an open turn joins at the current log position * without running the model: an open turn stages it for the next safe log
* (deferred behind an executing tool batch until it settles), and an idle * position, while an idle injection appends it immediately without opening
* inject records a one-shot turn with its own durability checkpoint. * a turn.
* *
* Attached contexts share the same snapshot and ownership boundary. Invalid * Invalid input throws synchronously before any notification, enqueue, or append.
* input throws synchronously before any notification, enqueue, or append.
* @param content - the model-facing content blocks to deliver. * @param content - the model-facing content blocks to deliver.
* @param options - target queue, wakeup decision, source, contexts, and meta. * @param options - target queue, wakeup decision, and source.
* @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events. * @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events.
*/ */
abstract send(content: ContentBlock[], options?: SendOptions): AgentMessageId abstract send(content: ContentBlock[], options?: SendOptions): AgentMessageId
@@ -238,7 +221,7 @@ export abstract class Agent {
* `next-turn`/wakeup preset of {@link send}. The item becomes the sole * `next-turn`/wakeup preset of {@link send}. The item becomes the sole
* ordinary message of its own turn. * ordinary message of its own turn.
* @param content - the prompt content blocks. * @param content - the prompt content blocks.
* @param options - source and attached contexts. * @param options - message source.
* @returns the accepted message's {@link AgentMessageId}. * @returns the accepted message's {@link AgentMessageId}.
*/ */
followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId {
@@ -248,12 +231,12 @@ export abstract class Agent {
/** /**
* Submit steering into the running turn — the `next-step`/wakeup preset of * Submit steering into the running turn — the `next-step`/wakeup preset of
* {@link send}. An open turn records it at the next steering checkpoint before * {@link send}. An open turn records it at the next steering checkpoint before
* a request or continuation decision; policy may stop before another step. * a request or stop decision. If the turn fails before that boundary, the
* After turn close and its checkpoint, any remainder is queued for a later * remainder stays staged without waking the agent; retry or a later prompt
* turn; terminal `agent/turn-stop`, cancellation, or disposal may discard it. * takes it. Idle steering falls back to a woken follow-up turn, while
* Idle steering falls back to a woken follow-up turn. * cancellation or disposal may discard pending steering.
* @param content - the steering content blocks. * @param content - the steering content blocks.
* @param options - source and attached contexts. * @param options - message source.
* @returns the accepted message's {@link AgentMessageId}. * @returns the accepted message's {@link AgentMessageId}.
*/ */
steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId {
@@ -261,15 +244,13 @@ export abstract class Agent {
} }
/** /**
* Append detached model-facing context without running the model — the * Append model-facing context without running the model — the
* `next-step`/no-wakeup preset of {@link send}. An open-turn injection joins * `next-step`/no-wakeup preset of {@link send}. An open-turn injection stages
* at the current log position unless the current tool batch is executing; * at the next safe log position; an idle injection appends immediately
* then it waits FIFO until that batch settles and drains before turn close * without opening a turn. An omitted source defaults to
* even when interrupted. Idle injection uses a one-shot turn and durability * `{ kind: 'plugin', plugin: '' }`.
* checkpoint. Disposal awaits idle checkpoints; flush failures report through
* `agent/error`. An omitted source defaults to `{ kind: 'plugin', plugin: '' }`.
* @param content - the injected context content blocks. * @param content - the injected context content blocks.
* @param options - source and durable model-hidden meta. * @param options - context source.
* @returns the accepted message's {@link AgentMessageId}. * @returns the accepted message's {@link AgentMessageId}.
*/ */
inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId {
@@ -340,11 +321,9 @@ declare module 'cordis' {
/** /**
* Pending inbox items were dropped without delivering them, so every * Pending inbox items were dropped without delivering them, so every
* enqueued id receives exactly one terminal `agent/inbox/dequeue` OR * enqueued id receives exactly one terminal `agent/inbox/dequeue` OR
* `agent/inbox/discard`. Emitters: `cancel()` without `keepInbox` (after * `agent/inbox/discard`. `cancel()` without `keepInbox`, including disposal,
* `agent/cancel-requested`, before the abort); a terminal `agent/turn-stop` * emits this after `agent/cancel-requested` when applicable and before
* dropping pending steering (in-turn and on the post-turn late-steering * aborting the active work. Fires once per drop with every dropped item.
* drain); and disposal of any still-pending items (before
* `agent/status('disposed')`). Fires once per drop with every dropped item.
* @param agent - the agent whose inbox items were dropped. * @param agent - the agent whose inbox items were dropped.
* @param messages - the discarded messages in FIFO order (queued then steering); never empty. * @param messages - the discarded messages in FIFO order (queued then steering); never empty.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
@@ -378,8 +357,7 @@ declare module 'cordis' {
// ---- the machine's extension seams ---- // ---- the machine's extension seams ----
/** /**
* Allow, rewrite, or block one claimed prompt before it becomes a user * Allow, rewrite, or block one claimed prompt before it becomes a user
* message. Call `next()` for the unchanged default, including contexts * message. Call `next()` for the unchanged default. The signal controls only this turn;
* captured with the queued item. The signal controls only this turn;
* listeners may cooperate with it but must not retain it for another turn. * listeners may cooperate with it but must not retain it for another turn.
* @param agent - the agent whose turn claimed the message. * @param agent - the agent whose turn claimed the message.
* @param content - the claimed message's blocks, as queued. * @param content - the claimed message's blocks, as queued.

View File

@@ -58,7 +58,7 @@ describe('agent status invariants', () => {
}) })
describe('agent inbox invariants', () => { describe('agent inbox invariants', () => {
const info = (steering: boolean) => ({ id: AgentMessageId('m'), content: [], source: { kind: 'user' as const }, contexts: [], steering, wakeup: true }) const info = (steering: boolean) => ({ id: AgentMessageId('m'), content: [], source: { kind: 'user' as const }, steering, wakeup: true })
it('accepts a dequeue and a discard covered by prior enqueues', async () => { it('accepts a dequeue and a discard covered by prior enqueues', async () => {
const ctx = await setup() const ctx = await setup()

View File

@@ -42,8 +42,8 @@ describe('scoped-dispatch invariants', () => {
'agent/created': [agent], 'agent/created': [agent],
'agent/disposed': [agent], 'agent/disposed': [agent],
'agent/status': [agent, 'idle'], 'agent/status': [agent, 'idle'],
'agent/inbox/enqueue': [agent, { id: AgentMessageId('m'), content: [], source: { kind: 'user' }, contexts: [], steering: false, wakeup: true }], 'agent/inbox/enqueue': [agent, { id: AgentMessageId('m'), content: [], source: { kind: 'user' }, steering: false, wakeup: true }],
'agent/inbox/dequeue': [agent, { id: AgentMessageId('m'), content: [], source: { kind: 'user' }, contexts: [], steering: false, wakeup: true }], 'agent/inbox/dequeue': [agent, { id: AgentMessageId('m'), content: [], source: { kind: 'user' }, steering: false, wakeup: true }],
'agent/inbox/discard': [agent, []], 'agent/inbox/discard': [agent, []],
'agent/cancel-requested': [agent, { kind: 'user' }], 'agent/cancel-requested': [agent, { kind: 'user' }],
'agent/session-start': [agent, 'startup'], 'agent/session-start': [agent, 'startup'],

View File

@@ -64,7 +64,7 @@ Providers stream token-sized deltas, so a raw log stores hundreds of `assistant/
`request/header` records a full canonical snapshot of the non-history request envelope with reason `initial`, `resume`, or `change`. `foldRequestHeader()` selects the latest snapshot; legacy delta events and the removed `fallback` reason are rejected. `messagePrefix` remains separate from derived history. See the [reconstructable-requests Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md). `request/header` records a full canonical snapshot of the non-history request envelope with reason `initial`, `resume`, or `change`. `foldRequestHeader()` selects the latest snapshot; legacy delta events and the removed `fallback` reason are rejected. `messagePrefix` remains separate from derived history. See the [reconstructable-requests Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md).
A `user/message` renders its `content` verbatim as a user-role message whether it is a direct human prompt, a synthetic injection, or an admitted goal round; its typed `source` is the only channel that tells them apart and carries any domain-specific durable facts. A `user/message` or `steering/message` with prompt-prefix context keeps the exact combined model bytes in `content` and stores a model-hidden `envelope` containing the direct `displayContent` and prefix context sources. `displayPromptContent()` selects the human-facing prompt without changing derived history. A `user/message` renders its `content` verbatim as a user-role message whether it is a direct human prompt, a synthetic injection, or an admitted goal round; its typed `source` is the only channel that tells them apart and carries any domain-specific durable facts. Turn execution remains enclosed by `turn/start` and `turn/end`, while an idle injection may append and flush a `user/message` between turns without running the model.
`tool/result` persists the model-facing content, optional internal failure identity, and optional presentation metadata. A tool's successful canonical `value` and human-readable canonical failure message remain execution-local; rendered error content is the replay-authoritative message. This preserves the existing event shape and does not change `SESSION_FORMAT_VERSION`. `tool/result` persists the model-facing content, optional internal failure identity, and optional presentation metadata. A tool's successful canonical `value` and human-readable canonical failure message remain execution-local; rendered error content is the replay-authoritative message. This preserves the existing event shape and does not change `SESSION_FORMAT_VERSION`.

View File

@@ -11,9 +11,9 @@ import { isAbsolute } from 'node:path'
import { deepFreeze } from '@deepseek-ai/dsh-llm' import { deepFreeze } from '@deepseek-ai/dsh-llm'
import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope' import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
import type { Scoped } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope'
import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm' import type { Message } from '@deepseek-ai/dsh-llm'
import { SESSION_FORMAT_VERSION, SessionId } from './types.ts' import { SESSION_FORMAT_VERSION, SessionId } from './types.ts'
import type { CreateSessionOptions, EpochHeader, OutOfBandSessionEventType, PromptMessageData, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType, TurnTrigger } from './types.ts' import type { CreateSessionOptions, EpochHeader, OutOfBandSessionEventType, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType, TurnTrigger } from './types.ts'
import { snapshotJsonValue } from './json.ts' import { snapshotJsonValue } from './json.ts'
import { SurfaceManager } from './surface.ts' import { SurfaceManager } from './surface.ts'
import type { SessionSurface } from './surface.ts' import type { SessionSurface } from './surface.ts'
@@ -29,15 +29,6 @@ export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult } from '
export { foldSurface, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts' export { foldSurface, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts'
export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts' export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts'
/**
* Return the human-facing prompt blocks from a durable prompt message.
* @param data - ordinary or steering prompt event data.
* @returns the effective direct prompt, excluding baked prefix context.
*/
export function displayPromptContent(data: PromptMessageData): ContentBlock[] {
return data.envelope?.displayContent ?? data.content
}
/** /**
* Find the latest closed message-triggered turn, excluding injection and * Find the latest closed message-triggered turn, excluding injection and
* plugin-owned zero-step turns. * plugin-owned zero-step turns.
@@ -534,9 +525,7 @@ export class Session {
switch (event.type) { switch (event.type) {
// Ordinary prompts, injected context, and mid-turn steering project // Ordinary prompts, injected context, and mid-turn steering project
// identically in user role: the event's model-facing content stays // identically in user role: the event's model-facing content stays
// verbatim. A prompt envelope is model-hidden display metadata; its // verbatim. The message's `source` and steering's `turn` are log-only. Do NOT
// prefix bytes are already present in content. The message's `source`/`meta`
// and steering's `turn` are also log-only. Do NOT
// re-add per-type framing (e.g. `<context>`/`<steering>`) here: framing is // re-add per-type framing (e.g. `<context>`/`<steering>`) here: framing is
// caller-owned — a producer bakes it into `content`, as workspace-context // caller-owned — a producer bakes it into `content`, as workspace-context
// does with `<system-reminder>` — or, if reintroduced, must be driven by // does with `<system-reminder>` — or, if reintroduced, must be driven by

View File

@@ -66,8 +66,8 @@ function validateEvent(
let nextStep = trace.nextStep let nextStep = trace.nextStep
let pendingCalls: SessionTraceTransition['pendingCalls'] = { kind: 'none' } let pendingCalls: SessionTraceTransition['pendingCalls'] = { kind: 'none' }
// SessionEventMap is merge-extensible, so the default enforces turn // Model input may be appended between turns without running the model.
// enclosure for package-added events as well as the built-in variants. // Merge-extensible package events remain turn-enclosed by default.
switch (event.type) { switch (event.type) {
case 'turn/start': { case 'turn/start': {
if (trace.openTurn !== null) { if (trace.openTurn !== null) {
@@ -141,6 +141,8 @@ function validateEvent(
pendingCalls = { kind: 'delete', callId: event.data.callId } pendingCalls = { kind: 'delete', callId: event.data.callId }
break break
} }
case 'user/message':
break
default: { default: {
if (trace.openTurn === null) { if (trace.openTurn === null) {
fail(`${event.type} appended outside any open turn (every event must be turn-enclosed)`) fail(`${event.type} appended outside any open turn (every event must be turn-enclosed)`)

View File

@@ -183,25 +183,6 @@ export interface EpochHeader {
*/ */
export type RequestHeaderReason = 'initial' | 'resume' | 'change' export type RequestHeaderReason = 'initial' | 'resume' | 'change'
/** Durable model-hidden annotation for one context baked into a prompt message. */
export interface PromptPrefixContext {
/** Producer provenance retained for transcript presentation and inspection. */
source: MessageSource
}
/**
* Human-facing view of a prompt whose exact model content includes prefixed
* context. `content` on the owning event remains the reconstructable model
* input; this envelope prevents transcript, title, and re-reference consumers
* from treating the baked context as direct human text.
*/
export interface PromptMessageEnvelope {
/** Effective user prompt after interception rewrites, without baked context. */
displayContent: ContentBlock[]
/** Ordered descriptors for contexts already baked into the event content. */
prefixContexts: PromptPrefixContext[]
}
/** /**
* Shared payload for user, injected-context, and steering prompt messages. A * Shared payload for user, injected-context, and steering prompt messages. A
* direct human prompt, a synthetic `agent.inject()` context, and mid-turn * direct human prompt, a synthetic `agent.inject()` context, and mid-turn
@@ -210,12 +191,10 @@ export interface PromptMessageEnvelope {
* not by event type. * not by event type.
*/ */
export interface PromptMessageData { export interface PromptMessageData {
/** Exact model-facing blocks, including any baked prompt-prefix contexts. */ /** Exact model-facing blocks. */
content: ContentBlock[] content: ContentBlock[]
/** Producer provenance for the direct prompt. */ /** Producer provenance. */
source: MessageSource source: MessageSource
/** Present only when prompt-prefix contexts were baked into `content`. */
envelope?: PromptMessageEnvelope
} }
/** /**
@@ -226,10 +205,7 @@ export interface PromptMessageData {
*/ */
export interface SessionEventMap { export interface SessionEventMap {
/** /**
* Opens turn `turn`. `trigger` records what started it — one claimed queued * Opens turn `turn`. `trigger` records what started the model loop.
* message or an idle-time injection. The turn is the durability/replay
* boundary: every event sits between a `turn/start` and its matching
* `turn/end` (the turn-enclosure invariant).
*/ */
'turn/start': { turn: number; trigger: TurnTrigger } 'turn/start': { turn: number; trigger: TurnTrigger }
/** /**
@@ -248,9 +224,8 @@ export interface SessionEventMap {
* (the queued message claimed for this turn), a synthetic `agent.inject()` * (the queued message claimed for this turn), a synthetic `agent.inject()`
* context (file-change notices, subdir AGENTS.md, skill content, cron * context (file-change notices, subdir AGENTS.md, skill content, cron
* notifications, …), or an admitted goal continuation round. All three * notifications, …), or an admitted goal continuation round. All three
* project their `content` verbatim; `source` (with a non-`user` kind marking * project their `content` verbatim; `source` tells them apart. An idle
* injected context) is the only channel that tells them apart. An idle * injection may append this event between turns without running the model.
* injection wraps this event in a one-shot turn so the log stays turn-enclosed.
*/ */
'user/message': PromptMessageData 'user/message': PromptMessageData
/** /**

View File

@@ -102,7 +102,7 @@ describe('session-log invariants', () => {
} as never) }).toThrow(/seq must strictly increase/) } as never) }).toThrow(/seq must strictly increase/)
}) })
it('enforces turn numbering and enclosure', async () => { it('enforces turn numbering and encloses events other than idle context', async () => {
const first = await setup() const first = await setup()
const open = first.ctx.sessions.create() const open = first.ctx.sessions.create()
open.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) open.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
@@ -119,9 +119,9 @@ describe('session-log invariants', () => {
const outside = (await setup()).ctx.sessions.create() const outside = (await setup()).ctx.sessions.create()
expect(() => outside.append('user/message', { expect(() => outside.append('user/message', {
content: [{ type: 'text', text: 'hi' }], content: [{ type: 'text', text: 'idle context' }],
source: { kind: 'user' }, source: { kind: 'plugin', plugin: 'test' },
}, { surfaceOp: 'append' })).toThrow(/outside any open turn/) }, { surfaceOp: 'append' })).not.toThrow()
expect(() => outside.append('steering/message', { expect(() => outside.append('steering/message', {
turn: 1, turn: 1,
content: [{ type: 'text', text: 'go' }], content: [{ type: 'text', text: 'go' }],

View File

@@ -2,7 +2,6 @@ import { describe, expect, expectTypeOf, it, vi } from 'vitest'
import { Context } from 'cordis' import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm' import { CallId } from '@deepseek-ai/dsh-llm'
import SessionStore, { import SessionStore, {
displayPromptContent,
findLastMessageTurnEnd, findLastMessageTurnEnd,
SESSION_FORMAT_VERSION, SESSION_FORMAT_VERSION,
Session, Session,
@@ -136,35 +135,6 @@ describe('Session', () => {
expect(steeringMessage!.content).toEqual([{ type: 'text', text: 'focus on tests' }]) expect(steeringMessage!.content).toEqual([{ type: 'text', text: 'focus on tests' }])
}) })
it('derives baked prompt context while exposing only the direct prompt for display', () => {
const session = new Session(SessionId('prompt-envelope'))
const event = session.append('user/message', {
content: [
{ type: 'text', text: 'background' },
{ type: 'text', text: '\n\n## My request:\n' },
{ type: 'text', text: 'question' },
],
source: { kind: 'user' },
envelope: {
displayContent: [{ type: 'text', text: 'question' }],
prefixContexts: [{ source: { kind: 'plugin', plugin: 'reference' } }],
},
}, { surfaceOp: 'append' })
expect(session.deriveMessages()).toEqual([{
role: 'user',
content: [
{ type: 'text', text: 'background' },
{ type: 'text', text: '\n\n## My request:\n' },
{ type: 'text', text: 'question' },
],
}])
expect(displayPromptContent(event.data)).toEqual([{ type: 'text', text: 'question' }])
expect(Object.isFrozen(event.data.envelope?.displayContent)).toBe(true)
expect(new Session(SessionId('prompt-envelope-replay'), session.events).deriveMessages())
.toEqual(session.deriveMessages())
})
it('keeps context source durable in the event while hiding it from the projection', () => { it('keeps context source durable in the event while hiding it from the projection', () => {
const session = new Session(SessionId('s2-raw')) const session = new Session(SessionId('s2-raw'))
session.append('user/message', { session.append('user/message', {

View File

@@ -42,7 +42,7 @@ The live registry pipeline has three transformable waterfalls, then the definiti
- `ToolExecutionToken` — a fresh branded `Symbol` assigned by the registry. It supports equality correlation only and never crosses a model, log, or worker boundary. - `ToolExecutionToken` — a fresh branded `Symbol` assigned by the registry. It supports equality correlation only and never crosses a model, log, or worker boundary.
- `ToolExecution` — the readonly pipeline view: immutable `{ token, callId, name, arguments, signal, agent?, parent? }`; the registry separately retains and re-fuses the original caller signal. `ToolDispatchExecution` is the `tools/execute`-only view whose required signal is mutable, so a wrapper may replace and restore it but cannot delete it. A nested call's `parent` is a `ToolExecutionToken`, not an execution object. - `ToolExecution` — the readonly pipeline view: immutable `{ token, callId, name, arguments, signal, agent?, parent? }`; the registry separately retains and re-fuses the original caller signal. `ToolDispatchExecution` is the `tools/execute`-only view whose required signal is mutable, so a wrapper may replace and restore it but cannot delete it. A nested call's `parent` is a `ToolExecutionToken`, not an execution object.
- `ToolRunContext` — the execution passed to a tool body, extending `ToolExecution` with `deferContext(context)`. Composite tools use it to ferry context produced by nested dispatches to the outer result even when the tool later throws or cancellation wins; it never injects immediately. - `ToolRunContext` — the execution passed to a tool body, extending `ToolExecution` with `deferContext(context)`. Composite tools use it to ferry context produced by nested dispatches to the outer result even when the tool later throws or cancellation wins; it never injects immediately.
- `ToolExecutionResult` — discriminated execution-local outcome. Success is `{ isError:false, value:JsonValue, content, meta?, additionalContexts? }`; failure is `{ isError:true, error:{ message, info? }, content, meta?, additionalContexts? }` and has no value. Call identity stays on the immutable `ToolExecution`. The registry snapshots, validates, and freezes the canonical value before rendering, then materializes the durable presentation fields before final observation. `ToolFailure.info` carries an internal `{ name, code }` for a `HarnessError`; `additionalContexts` preserves every deferred or post-execute `HookContext` for the loop's post-result FIFO. - `ToolExecutionResult` — discriminated execution-local outcome. Success is `{ isError:false, value:JsonValue, content, meta?, additionalContexts? }`; failure is `{ isError:true, error:{ message, info? }, content, meta?, additionalContexts? }` and has no value. Call identity stays on the immutable `ToolExecution`. The registry snapshots, validates, and freezes the canonical value before rendering, then materializes the durable presentation fields before final observation. `ToolFailure.info` carries an internal `{ name, code }` for a `HarnessError`; `additionalContexts` preserves every deferred or post-execute `AdditionalContext` for the loop's post-result FIFO.
- `PreToolDecision``{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. Input rewrite is deliberately not offered; `ask` is serviced by [`ctx.approval`](../../ui/user-approval/README.md) when mounted and otherwise degrades to deny. - `PreToolDecision``{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. Input rewrite is deliberately not offered; `ask` is serviced by [`ctx.approval`](../../ui/user-approval/README.md) when mounted and otherwise degrades to deny.
- `PostToolDecision` — accept may replace `content` or `value`, never both, and may attach `additionalContexts`; block turns feedback into a valueless failure. Content replacement preserves the canonical value and metadata. Value replacement is revalidated and rerenders content/metadata. Accept preserves tool-deferred contexts before decision contexts; block discards tool-deferred contexts and exposes only contexts explicitly supplied by the blocking decision. - `PostToolDecision` — accept may replace `content` or `value`, never both, and may attach `additionalContexts`; block turns feedback into a valueless failure. Content replacement preserves the canonical value and metadata. Value replacement is revalidated and rerenders content/metadata. Accept preserves tool-deferred contexts before decision contexts; block discards tool-deferred contexts and exposes only contexts explicitly supplied by the blocking decision.
- `ToolGuard``(execution) => string | undefined`; the returned string is a final monotonic denial reason evaluated after the reorderable pre-execute waterfall and before dispatch. - `ToolGuard``(execution) => string | undefined`; the returned string is a final monotonic denial reason evaluated after the reorderable pre-execute waterfall and before dispatch.

View File

@@ -10,7 +10,7 @@ import { AnonymousEntries, NamedEntries, ScopedLayers, scopeOf, scopeTarget } fr
import type { ScopeKey, ScopeLayer, Scoped } from '@deepseek-ai/dsh-scope' import type { ScopeKey, ScopeLayer, Scoped } from '@deepseek-ai/dsh-scope'
import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm' import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
import { assertNever, deepFreeze, HarnessError } from '@deepseek-ai/dsh-llm' import { assertNever, deepFreeze, HarnessError } from '@deepseek-ai/dsh-llm'
import type { Agent, HookContext } from '@deepseek-ai/dsh-agent' import type { AdditionalContext, Agent } from '@deepseek-ai/dsh-agent'
import { snapshotJsonValue } from '@deepseek-ai/dsh-session' import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
import type { JsonValue } from '@deepseek-ai/dsh-session' import type { JsonValue } from '@deepseek-ai/dsh-session'
import type { ToolProviderResult } from '@deepseek-ai/dsh-system-prompt' import type { ToolProviderResult } from '@deepseek-ai/dsh-system-prompt'
@@ -306,7 +306,7 @@ export interface ToolRunContext extends ToolExecution {
* the agent loop. Contexts retain their individual source and metadata and * the agent loop. Contexts retain their individual source and metadata and
* are emitted in call order. * are emitted in call order.
*/ */
deferContext(context: HookContext): void deferContext(context: AdditionalContext): void
/** Mark a successful final result as terminal for the current agent turn. */ /** Mark a successful final result as terminal for the current agent turn. */
concludeTurn(): void concludeTurn(): void
} }
@@ -440,7 +440,7 @@ export interface ToolExecutionSuccess {
readonly content: ContentBlock[] readonly content: ContentBlock[]
readonly error?: never readonly error?: never
readonly meta?: JsonValue readonly meta?: JsonValue
readonly additionalContexts?: HookContext[] readonly additionalContexts?: AdditionalContext[]
/** The agent loop stops after committing this successful result batch. */ /** The agent loop stops after committing this successful result batch. */
readonly concludesTurn?: true readonly concludesTurn?: true
} }
@@ -452,7 +452,7 @@ export interface ToolExecutionFailure {
readonly value?: never readonly value?: never
readonly content: ContentBlock[] readonly content: ContentBlock[]
readonly meta?: JsonValue readonly meta?: JsonValue
readonly additionalContexts?: HookContext[] readonly additionalContexts?: AdditionalContext[]
readonly concludesTurn?: never readonly concludesTurn?: never
} }
@@ -475,9 +475,9 @@ export type PreToolDecision =
* next request, or block by turning corrective feedback into an error result. * next request, or block by turning corrective feedback into an error result.
*/ */
export type PostToolDecision = export type PostToolDecision =
| { kind: 'accept'; content?: ContentBlock[]; value?: never; additionalContexts?: HookContext[] } | { kind: 'accept'; content?: ContentBlock[]; value?: never; additionalContexts?: AdditionalContext[] }
| { kind: 'accept'; value: JsonValue; content?: never; additionalContexts?: HookContext[] } | { kind: 'accept'; value: JsonValue; content?: never; additionalContexts?: AdditionalContext[] }
| { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: HookContext[] } | { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: AdditionalContext[] }
/** /**
* Best-effort human-readable message from an arbitrary thrown value: Error * Best-effort human-readable message from an arbitrary thrown value: Error
@@ -652,7 +652,7 @@ export class ToolRegistry extends Service {
} }
/** Context deferred by a running tool body, keyed by its scheduler-owned execution. */ /** Context deferred by a running tool body, keyed by its scheduler-owned execution. */
private deferredContexts = new WeakMap<ToolRunContext, HookContext[]>() private deferredContexts = new WeakMap<ToolRunContext, AdditionalContext[]>()
/** Successful executions whose tool body declared the current turn complete. */ /** Successful executions whose tool body declared the current turn complete. */
private concludingExecutions = new WeakSet<ToolExecution>() private concludingExecutions = new WeakSet<ToolExecution>()
/** Enclosing transport tokens marked terminal by a successful nested call. */ /** Enclosing transport tokens marked terminal by a successful nested call. */
@@ -969,7 +969,7 @@ export class ToolRegistry extends Service {
} }
private createExecution(exec: ToolExecutionInput): ScheduledToolPreparation | { kind: 'ready'; exec: MutableToolRunContext } { private createExecution(exec: ToolExecutionInput): ScheduledToolPreparation | { kind: 'ready'; exec: MutableToolRunContext } {
const deferredContexts: HookContext[] = [] const deferredContexts: AdditionalContext[] = []
const token = createExecutionToken() const token = createExecutionToken()
const callId = exec.callId const callId = exec.callId
const name = exec.name const name = exec.name
@@ -987,7 +987,7 @@ export class ToolRegistry extends Service {
signal, signal,
...agent !== undefined ? { agent } : {}, ...agent !== undefined ? { agent } : {},
...parent !== undefined ? { parent } : {}, ...parent !== undefined ? { parent } : {},
deferContext(context: HookContext): void { deferContext(context: AdditionalContext): void {
deferredContexts.push(context) deferredContexts.push(context)
}, },
concludeTurn(): void { concludeTurn(): void {

View File

@@ -8,7 +8,7 @@
import type { Context } from 'cordis' import type { Context } from 'cordis'
import z from 'schemastery' import z from 'schemastery'
import type { Agent, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent' import type { AdditionalContext, Agent, PromptDecision } from '@deepseek-ai/dsh-agent'
import type { MessageSource } from '@deepseek-ai/dsh-llm' import type { MessageSource } from '@deepseek-ai/dsh-llm'
import type { PostToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools' import type { PostToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools'
@@ -142,7 +142,7 @@ function validateThresholds(values: number[]): number[] {
* Prepend the guard's reminder while preserving every downstream context's * Prepend the guard's reminder while preserving every downstream context's
* source and metadata. * source and metadata.
*/ */
function prependContext(ours: HookContext, theirs: HookContext[] | undefined): HookContext[] { function prependContext(ours: AdditionalContext, theirs: AdditionalContext[] | undefined): AdditionalContext[] {
return [ours, ...theirs ?? []] return [ours, ...theirs ?? []]
} }
@@ -184,7 +184,7 @@ export function apply(ctx: Context, config: Config): void {
* same pipeline), and a model hammering a denied call is exactly the loop * same pipeline), and a model hammering a denied call is exactly the loop
* worth breaking. * worth breaking.
*/ */
function observe(exec: ToolExecution): HookContext | undefined { function observe(exec: ToolExecution): AdditionalContext | undefined {
// A direct `ctx.tools.execute()` caller has no model to remind and no id // A direct `ctx.tools.execute()` caller has no model to remind and no id
// to key on; only agent-loop calls participate. // to key on; only agent-loop calls participate.
if (!exec.agent) return undefined if (!exec.agent) return undefined

View File

@@ -12,7 +12,7 @@
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import type { Context } from 'cordis' import type { Context } from 'cordis'
import z from 'schemastery' import z from 'schemastery'
import type { Agent, ContinuationDecision, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent' import type { AdditionalContext, Agent, ContinuationDecision, PromptDecision } from '@deepseek-ai/dsh-agent'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-session-persistence' import type {} from '@deepseek-ai/dsh-session-persistence'
import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools' import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
@@ -182,15 +182,15 @@ export function apply(ctx: Context, config: Config): void {
// TODO(hook-continue-false): `merged.stop` is logged but needs a run-level halt seam. // TODO(hook-continue-false): `merged.stop` is logged but needs a run-level halt seam.
/** Build a HookContext from accumulated additionalContext strings, or undefined when none. */ /** Build additional model context from hook output, or return undefined when empty. */
function contextFrom(merged: MergedHookOutcome): HookContext | undefined { function contextFrom(merged: MergedHookOutcome): AdditionalContext | undefined {
if (merged.additionalContext.length === 0) return undefined if (merged.additionalContext.length === 0) return undefined
const content: ContentBlock[] = merged.additionalContext.map(text => ({ type: 'text', text })) const content: ContentBlock[] = merged.additionalContext.map(text => ({ type: 'text', text }))
return { content, source: PLUGIN_SOURCE } return { content, source: PLUGIN_SOURCE }
} }
/** Prepend one context without flattening downstream provenance or metadata. */ /** Prepend one context without flattening downstream provenance or metadata. */
function prependContext(ours: HookContext, theirs: HookContext[] | undefined): HookContext[] { function prependContext(ours: AdditionalContext, theirs: AdditionalContext[] | undefined): AdditionalContext[] {
return [ours, ...theirs ?? []] return [ours, ...theirs ?? []]
} }

View File

@@ -15,7 +15,7 @@
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import type { Context } from 'cordis' import type { Context } from 'cordis'
import z from 'schemastery' import z from 'schemastery'
import type { Agent, ContinuationDecision, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent' import type { AdditionalContext, Agent, ContinuationDecision, PromptDecision } from '@deepseek-ai/dsh-agent'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-session-persistence' import type {} from '@deepseek-ai/dsh-session-persistence'
import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools' import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
@@ -163,14 +163,14 @@ export function apply(ctx: Context, config: Config): void {
// TODO(hook-continue-false): `merged.stop` is logged but needs a run-level halt seam. // TODO(hook-continue-false): `merged.stop` is logged but needs a run-level halt seam.
function contextFrom(merged: MergedHookOutcome): HookContext | undefined { function contextFrom(merged: MergedHookOutcome): AdditionalContext | undefined {
if (merged.additionalContext.length === 0) return undefined if (merged.additionalContext.length === 0) return undefined
const content: ContentBlock[] = merged.additionalContext.map(text => ({ type: 'text', text })) const content: ContentBlock[] = merged.additionalContext.map(text => ({ type: 'text', text }))
return { content, source: PLUGIN_SOURCE } return { content, source: PLUGIN_SOURCE }
} }
/** Prepend one context without flattening downstream provenance or metadata. */ /** Prepend one context without flattening downstream provenance or metadata. */
function prependContext(ours: HookContext, theirs: HookContext[] | undefined): HookContext[] { function prependContext(ours: AdditionalContext, theirs: AdditionalContext[] | undefined): AdditionalContext[] {
return [ours, ...theirs ?? []] return [ours, ...theirs ?? []]
} }

View File

@@ -14,7 +14,6 @@ import type {
SessionEvent, SessionEvent,
SessionEventMap, SessionEventMap,
} from '@deepseek-ai/dsh-session' } from '@deepseek-ai/dsh-session'
import { displayPromptContent } from '@deepseek-ai/dsh-session'
import { fallbackSessionTitle, normalizeSessionTitle } from './normalize.ts' import { fallbackSessionTitle, normalizeSessionTitle } from './normalize.ts'
export { fallbackSessionTitle, normalizeSessionTitle, truncateTitleUtf8 } from './normalize.ts' export { fallbackSessionTitle, normalizeSessionTitle, truncateTitleUtf8 } from './normalize.ts'
@@ -202,7 +201,7 @@ export function collectSessionTitleMessages(
for (const event of events) { for (const event of events) {
if (throughSeq !== undefined && event.seq > throughSeq) break if (throughSeq !== undefined && event.seq > throughSeq) break
if (event.type !== 'user/message' || event.data.source.kind !== 'user') continue if (event.type !== 'user/message' || event.data.source.kind !== 'user') continue
const content = displayPromptContent(event.data) const content = event.data.content
const text = content const text = content
.filter((block): block is Extract<(typeof content)[number], { type: 'text' }> => block.type === 'text') .filter((block): block is Extract<(typeof content)[number], { type: 'text' }> => block.type === 'text')
.map(block => block.text) .map(block => block.text)

View File

@@ -82,16 +82,8 @@ describe('SessionTitleService', () => {
trigger: { kind: 'message', source: { kind: 'user' } }, trigger: { kind: 'message', source: { kind: 'user' } },
}) })
session.append('user/message', { session.append('user/message', {
content: [ content: [{ type: 'text', text: 'Explain this referenced session' }],
{ type: 'text', text: 'referenced snapshot title must stay hidden' },
{ type: 'text', text: '\n\n## My request:\n' },
{ type: 'text', text: 'Explain this referenced session' },
],
source: { kind: 'user' }, source: { kind: 'user' },
envelope: {
displayContent: [{ type: 'text', text: 'Explain this referenced session' }],
prefixContexts: [{ source: { kind: 'plugin', plugin: 'session-reference' } }],
},
}, { surfaceOp: 'append' }) }, { surfaceOp: 'append' })
await settleTitles() await settleTitles()

View File

@@ -54,13 +54,14 @@ import { assertNever, CallId } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-llm-retry' import type {} from '@deepseek-ai/dsh-llm-retry'
import { import {
installAgentLlmTarget, installAgentLlmTarget,
type AdditionalContext,
type Agent, type Agent,
type AgentLlmTarget as LlmTarget, type AgentLlmTarget as LlmTarget,
type AgentLlmTargetRef as LlmTargetRef, type AgentLlmTargetRef as LlmTargetRef,
} from '@deepseek-ai/dsh-agent' } from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-commands' import type {} from '@deepseek-ai/dsh-commands'
import { encodeSessionReferenceUri } from '@deepseek-ai/dsh-session-reference' import { encodeSessionReferenceUri } from '@deepseek-ai/dsh-session-reference'
import { displayPromptContent, SessionId, type JsonValue } from '@deepseek-ai/dsh-session' import { SessionId, type JsonValue } from '@deepseek-ai/dsh-session'
// Side-effect type import: resolves `ctx.get('permission')` to the service. // Side-effect type import: resolves `ctx.get('permission')` to the service.
import type {} from '@deepseek-ai/dsh-permission' import type {} from '@deepseek-ai/dsh-permission'
import type { SessionEvent, TodoItem, TurnEndReason } from '@deepseek-ai/dsh-session' import type { SessionEvent, TodoItem, TurnEndReason } from '@deepseek-ai/dsh-session'
@@ -1056,7 +1057,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
} }
const { text } = referencedPrompt const { text } = referencedPrompt
let preparedContent: ContentBlock[] = [{ type: 'text', text }] let preparedContent: ContentBlock[] = [{ type: 'text', text }]
let preparedContexts: NonNullable<Parameters<Agent['send']>[1]>['contexts'] = [] let additionalContext: AdditionalContext | undefined
if (referencedPrompt.references.length > 0) { if (referencedPrompt.references.length > 0) {
const sessionReferences = ctx.get('sessionReferences') const sessionReferences = ctx.get('sessionReferences')
if (sessionReferences === undefined) { if (sessionReferences === undefined) {
@@ -1072,7 +1073,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
controller.signal, controller.signal,
) )
preparedContent = prepared.content preparedContent = prepared.content
preparedContexts = prepared.contexts additionalContext = prepared.additionalContext
} catch (error: unknown) { } catch (error: unknown) {
if (controller.signal.aborted) return { stopReason: 'cancelled' } if (controller.signal.aborted) return { stopReason: 'cancelled' }
throw invalidParams(`session reference preparation failed: ${renderThrown(error)}`) throw invalidParams(`session reference preparation failed: ${renderThrown(error)}`)
@@ -1088,7 +1089,10 @@ export function apply(ctx: Context, config: AcpConfig): void {
// produces an error stop reason). // produces an error stop reason).
const stopReason = await new Promise<StopReason>((resolve, reject) => { const stopReason = await new Promise<StopReason>((resolve, reject) => {
rec.inflight = { resolve, reject, turn: undefined } rec.inflight = { resolve, reject, turn: undefined }
rec.agent.send(preparedContent, { source: { kind: 'user' }, contexts: preparedContexts }) if (additionalContext !== undefined) {
rec.agent.inject(additionalContext.content, { source: additionalContext.source })
}
rec.agent.send(preparedContent, { source: { kind: 'user' } })
}) })
return { stopReason } return { stopReason }
}, },
@@ -1380,7 +1384,7 @@ export function streamSessionEventUpdate(
// Replay the user's prompt so a loaded session shows both sides of each // Replay the user's prompt so a loaded session shows both sides of each
// turn. Live prompt turns suppress this path to avoid duplicating what // turn. Live prompt turns suppress this path to avoid duplicating what
// the client just sent. // the client just sent.
for (const block of displayPromptContent(event.data)) { for (const block of event.data.content) {
const content = harnessBlockToAcpContent(block) const content = harnessBlockToAcpContent(block)
if (content !== undefined) { if (content !== undefined) {
notify({ sessionId, update: { sessionUpdate: 'user_message_chunk', content } }) notify({ sessionId, update: { sessionUpdate: 'user_message_chunk', content } })

View File

@@ -352,7 +352,7 @@ describe('acp bridge', () => {
expect(harness.ctx.agents.get(SessionId(sessionId))?.session.events).toHaveLength(0) expect(harness.ctx.agents.get(SessionId(sessionId))?.session.events).toHaveLength(0)
}) })
it('prepares ACP session resource links and inline mentions before one atomic send', async () => { it('injects ACP session references before sending the direct prompt', async () => {
harness = await makeBridgeHarness({ storageDir, withSessionReferences: true, script: [textResponse('ok')] }) harness = await makeBridgeHarness({ storageDir, withSessionReferences: true, script: [textResponse('ok')] })
const source = harness.ctx.sessions.create(SessionId('source'), { meta: { cwd: '/source' } }) const source = harness.ctx.sessions.create(SessionId('source'), { meta: { cwd: '/source' } })
source.append('user/message', { source.append('user/message', {
@@ -372,23 +372,21 @@ describe('acp bridge', () => {
expect(result.stopReason).toBe('end_turn') expect(result.stopReason).toBe('end_turn')
const target = harness.ctx.agents.get(SessionId(sessionId))!.session const target = harness.ctx.agents.get(SessionId(sessionId))!.session
const user = target.events.find(event => event.type === 'user/message') const context = target.events.find(event =>
expect(user?.type === 'user/message' && user.data.envelope).toMatchObject({ event.type === 'user/message' && event.data.source.kind === 'session-reference')
displayContent: [{ type: 'text', text: 'use @source-inline and @source-link' }], expect(context?.type === 'user/message' && context.data.source).toMatchObject({
prefixContexts: [{ kind: 'session-reference',
source: { kind: 'plugin', plugin: 'session-reference' }, references: [{ sessionId: 'source', label: 'source-inline' }],
meta: {
kind: 'session-reference',
references: [{ sessionId: 'source', label: 'source-inline' }],
},
}],
}) })
expect(target.events.some(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toBe(false) const user = target.events.find(event =>
event.type === 'user/message' && event.data.source.kind === 'user')
expect(user?.type === 'user/message' && user.data.content).toEqual([
{ type: 'text', text: 'use @source-inline and @source-link' },
])
const request = JSON.stringify(harness.adapter.requests[0]?.messages) const request = JSON.stringify(harness.adapter.requests[0]?.messages)
expect(request).toContain('untrusted, read-only snapshot') expect(request).toContain('untrusted, read-only snapshot')
expect(request).toContain('source background') expect(request).toContain('source background')
expect(request.indexOf('source background')).toBeLessThan(request.indexOf('## My request:')) expect(request.indexOf('source background')).toBeLessThan(request.indexOf('use @source-inline and @source-link'))
expect(request.indexOf('## My request:')).toBeLessThan(request.indexOf('use @source-inline and @source-link'))
}) })
it('rejects a failed referenced-session read before starting a turn', async () => { it('rejects a failed referenced-session read before starting a turn', async () => {

View File

@@ -209,22 +209,11 @@ describe('streamSessionEventUpdate', () => {
expect(updatesFor(evt('user/message', { content: [], source: { kind: 'user' } }))).toEqual([]) expect(updatesFor(evt('user/message', { content: [], source: { kind: 'user' } }))).toEqual([])
}) })
it('replays only the direct prompt from a prefixed user message', () => { it('does not replay injected context as a direct user prompt', () => {
expect(updatesFor(evt('user/message', { expect(updatesFor(evt('user/message', {
content: [ content: [{ type: 'text', text: 'internal context' }],
{ type: 'text', text: 'internal prefix' }, source: { kind: 'plugin', plugin: 'reference' },
{ type: 'text', text: '\n\n## My request:\n' }, }))).toEqual([])
{ type: 'text', text: 'visible request' },
],
source: { kind: 'user' },
envelope: {
displayContent: [{ type: 'text', text: 'visible request' }],
prefixContexts: [{ source: { kind: 'plugin', plugin: 'reference' } }],
},
}))).toEqual([{
sessionUpdate: 'user_message_chunk',
content: { type: 'text', text: 'visible request' },
}])
}) })
it('can suppress user/message chunks for live prompt turns', () => { it('can suppress user/message chunks for live prompt turns', () => {

View File

@@ -44,7 +44,6 @@ import {
type AgentLlmTarget, type AgentLlmTarget,
type AgentLlmTargetRef, type AgentLlmTargetRef,
type AgentStatus, type AgentStatus,
type HookContext,
} from '@deepseek-ai/dsh-agent' } from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-agent-loop' import type {} from '@deepseek-ai/dsh-agent-loop'
import type {} from '@deepseek-ai/dsh-token-meter' import type {} from '@deepseek-ai/dsh-token-meter'
@@ -58,7 +57,6 @@ import type {
} from '@deepseek-ai/dsh-llm' } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-llm-retry' import type {} from '@deepseek-ai/dsh-llm-retry'
import { import {
displayPromptContent,
SessionId, SessionId,
type JsonValue, type JsonValue,
type Session, type Session,
@@ -1551,13 +1549,6 @@ function sessionReferenceCard(source: unknown): string[] | undefined {
return labels return labels
} }
function promptReferenceCards(event: Extract<SessionEvent, { type: 'user/message' | 'steering/message' }>): string[][] {
return event.data.envelope?.prefixContexts.flatMap((context) => {
const card = sessionReferenceCard(context.source)
return card === undefined ? [] : [card]
}) ?? []
}
function activeToolCallIds(session: Session, active: ReadonlySet<number>): Set<string> { function activeToolCallIds(session: Session, active: ReadonlySet<number>): Set<string> {
const ids = new Set<string>() const ids = new Set<string>()
for (const event of session.events) { for (const event of session.events) {
@@ -1974,28 +1965,20 @@ export function createTuiChat(
} }
break break
} }
const text = displayText(contentText(displayPromptContent(event.data)).trim()) const text = displayText(contentText(event.data.content).trim())
if (text) { if (text) {
chat.addChild(new Spacer(1)) chat.addChild(new Spacer(1))
chat.addChild(new UserMessageComponent(text, palette, mdTheme)) chat.addChild(new UserMessageComponent(text, palette, mdTheme))
if (options.addHistory) editor.addToHistory(text) if (options.addHistory) editor.addToHistory(text)
} }
for (const references of promptReferenceCards(event)) {
chat.addChild(new Spacer(1))
chat.addChild(new Text(palette.dim(`Referenced sessions · ${references.map(displayText).join(', ')}`), 1, 0))
}
break break
} }
case 'steering/message': { case 'steering/message': {
const text = displayText(contentText(displayPromptContent(event.data)).trim()) const text = displayText(contentText(event.data.content).trim())
if (text) { if (text) {
chat.addChild(new Spacer(1)) chat.addChild(new Spacer(1))
chat.addChild(new UserMessageComponent(text, palette, mdTheme, 'Steering')) chat.addChild(new UserMessageComponent(text, palette, mdTheme, 'Steering'))
} }
for (const references of promptReferenceCards(event)) {
chat.addChild(new Spacer(1))
chat.addChild(new Text(palette.dim(`Referenced sessions · ${references.map(displayText).join(', ')}`), 1, 0))
}
break break
} }
case 'prompt/blocked': case 'prompt/blocked':
@@ -2518,19 +2501,19 @@ export function createTuiChat(
).finally(() => { commandControllers.delete(controller) }) ).finally(() => { commandControllers.delete(controller) })
} }
const dispatchMessage = (content: ContentBlock[], contexts: HookContext[]): void => { const dispatchMessage = (content: ContentBlock[]): void => {
if (disposed) { if (disposed) {
appendNotice(`Agent "${agent.id}" is disposed.`, 'error') appendNotice(`Agent "${agent.id}" is disposed.`, 'error')
} else if (agent.status === 'running') { } else if (agent.status === 'running') {
agent.steer(content, { source: { kind: 'user' }, contexts }) agent.steer(content, { source: { kind: 'user' } })
} else { } else {
agent.send(content, { source: { kind: 'user' }, contexts }) agent.send(content, { source: { kind: 'user' } })
} }
} }
/** Deliver a user turn to the agent: steer while running, send while idle, or report a disposed agent. */ /** Deliver a user turn to the agent: steer while running, send while idle, or report a disposed agent. */
const deliver = (payload: string): void => { const deliver = (payload: string): void => {
dispatchMessage([{ type: 'text', text: payload }], []) dispatchMessage([{ type: 'text', text: payload }])
} }
/** Load a manually invoked skill and deliver its rendered body as a user turn, reporting lookup outcomes as notices. */ /** Load a manually invoked skill and deliver its rendered body as a user turn, reporting lookup outcomes as notices. */
@@ -2671,7 +2654,7 @@ export function createTuiChat(
if (parsed.references.length === 0) { if (parsed.references.length === 0) {
editor.addToHistory(text) editor.addToHistory(text)
editor.setText('') editor.setText('')
dispatchMessage([{ type: 'text', text: parsed.text }], []) dispatchMessage([{ type: 'text', text: parsed.text }])
return return
} }
const sessionReferences = ctx.get('sessionReferences') const sessionReferences = ctx.get('sessionReferences')
@@ -2692,7 +2675,10 @@ export function createTuiChat(
if (disposed) return if (disposed) return
editor.addToHistory(text) editor.addToHistory(text)
if (editor.getText() === value) editor.setText('') if (editor.getText() === value) editor.setText('')
dispatchMessage(prepared.content, prepared.contexts) if (prepared.additionalContext !== undefined) {
agent.inject(prepared.additionalContext.content, { source: prepared.additionalContext.source })
}
dispatchMessage(prepared.content)
}, (error: unknown) => { }, (error: unknown) => {
if (!disposed && !controller.signal.aborted) { if (!disposed && !controller.signal.aborted) {
restoreSubmittedInput() restoreSubmittedInput()

View File

@@ -22,6 +22,8 @@ interface FakeAgent extends Agent {
sentOptions: (SendOptions | undefined)[] sentOptions: (SendOptions | undefined)[]
steered: ContentBlock[][] steered: ContentBlock[][]
steeredOptions: (SendOptions | undefined)[] steeredOptions: (SendOptions | undefined)[]
injected: ContentBlock[][]
injectedOptions: (SendOptions | undefined)[]
cancelled: AgentCancelCause[] cancelled: AgentCancelCause[]
} }
@@ -138,6 +140,8 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
const steered: ContentBlock[][] = [] const steered: ContentBlock[][] = []
const sentOptions: (SendOptions | undefined)[] = [] const sentOptions: (SendOptions | undefined)[] = []
const steeredOptions: (SendOptions | undefined)[] = [] const steeredOptions: (SendOptions | undefined)[] = []
const injected: ContentBlock[][] = []
const injectedOptions: (SendOptions | undefined)[] = []
const cancelled: AgentCancelCause[] = [] const cancelled: AgentCancelCause[] = []
const agent: FakeAgent = { const agent: FakeAgent = {
id: sessionId, id: sessionId,
@@ -149,6 +153,8 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
sentOptions, sentOptions,
steered, steered,
steeredOptions, steeredOptions,
injected,
injectedOptions,
cancelled, cancelled,
send(content, options) { send(content, options) {
sent.push(content) sent.push(content)
@@ -165,7 +171,11 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
steeredOptions.push(options) steeredOptions.push(options)
return AgentMessageId('stub') return AgentMessageId('stub')
}, },
inject: () => AgentMessageId('stub'), inject(content, options) {
injected.push(content)
injectedOptions.push(options)
return AgentMessageId('stub')
},
cancel(cause = { kind: 'user' }) { cancel(cause = { kind: 'user' }) {
cancelled.push(cause) cancelled.push(cause)
}, },

View File

@@ -24,10 +24,10 @@ class SnapshotAdapter extends LlmAdapter {
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> { async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
this.requests.push(options) this.requests.push(options)
const prompt = options.messages.at(-1) const [context, prompt] = options.messages.slice(-2)
if (prompt?.role !== 'user' || prompt.content.length !== 3 if (context?.role !== 'user' || prompt?.role !== 'user'
|| prompt.content[1]?.type !== 'text' || prompt.content[1].text !== '\n\n## My request:\n') { || prompt.content[0]?.type !== 'text' || prompt.content[0].text !== 'Use @Source session') {
throw new Error('session reference did not reach the model as one prefixed user message') throw new Error('session reference context did not precede the direct user message')
} }
yield { type: 'block-start', index: 0, blockType: 'text' } yield { type: 'block-start', index: 0, blockType: 'text' }
yield { type: 'text-delta', index: 0, text: 'Combined reference request accepted.' } yield { type: 'text-delta', index: 0, text: 'Combined reference request accepted.' }
@@ -113,21 +113,17 @@ describe('TUI session-reference snapshot', () => {
expect(request).toContain('Recent retained question.') expect(request).toContain('Recent retained question.')
expect(request).not.toContain('SHADOWED OLD USER') expect(request).not.toContain('SHADOWED OLD USER')
expect(request).not.toContain('SHADOWED OLD ASSISTANT') expect(request).not.toContain('SHADOWED OLD ASSISTANT')
const user = target.session.events.find(event => event.type === 'user/message') const context = target.session.events.find(event =>
expect(user?.type === 'user/message' && user.data.envelope).toMatchObject({ event.type === 'user/message' && event.data.source.kind === 'session-reference')
displayContent: [{ type: 'text', text: 'Use @Source session' }], expect(context?.type === 'user/message' && context.data.source).toMatchObject({
prefixContexts: [{ kind: 'session-reference',
source: { references: [{ sessionId: 'source-session', compacted: true }],
kind: 'session-reference',
references: [{ sessionId: 'source-session', compacted: true }],
} as never,
}],
}) })
expect(user?.type === 'user/message' && user.data.content[1]).toEqual({ const user = target.session.events.find(event =>
type: 'text', event.type === 'user/message' && event.data.source.kind === 'user')
text: '\n\n## My request:\n', expect(user?.type === 'user/message' && user.data.content).toEqual([
}) { type: 'text', text: 'Use @Source session' },
expect(target.session.events.some(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toBe(false) ])
const snapshot = await terminal.snapshot({ includeScrollback: true }) const snapshot = await terminal.snapshot({ includeScrollback: true })
if (REFRESHING) { if (REFRESHING) {

View File

@@ -575,7 +575,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
expect(result.terminal.output).not.toContain('queued') expect(result.terminal.output).not.toContain('queued')
const queueSteering = (text: string): void => { const queueSteering = (text: string): void => {
result.ctx.emit('agent/inbox/enqueue', result.agent, { id: AgentMessageId('stub'), content: [{ type: 'text', text }], source: { kind: 'user' }, contexts: [], steering: true, wakeup: true }) result.ctx.emit('agent/inbox/enqueue', result.agent, { id: AgentMessageId('stub'), content: [{ type: 'text', text }], source: { kind: 'user' }, steering: true, wakeup: true })
} }
const drainSteering = (text: string): void => { const drainSteering = (text: string): void => {
result.session.append('steering/message', { turn: 1, content: [{ type: 'text', text }], source: { kind: 'user' } }, { surfaceOp: 'append' }) result.session.append('steering/message', { turn: 1, content: [{ type: 'text', text }], source: { kind: 'user' } }, { surfaceOp: 'append' })
@@ -584,7 +584,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
// A steering queue for a different agent never touches this status line. // 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 unknown as Agent
result.terminal.output = '' result.terminal.output = ''
result.ctx.emit('agent/inbox/enqueue', other, { id: AgentMessageId('stub'), content: [{ type: 'text', text: 'elsewhere' }], source: { kind: 'user' }, contexts: [], steering: true, wakeup: true }) result.ctx.emit('agent/inbox/enqueue', other, { id: AgentMessageId('stub'), content: [{ type: 'text', text: 'elsewhere' }], source: { kind: 'user' }, steering: true, wakeup: true })
await tick() await tick()
expect(result.terminal.output).not.toContain('queued') expect(result.terminal.output).not.toContain('queued')
@@ -597,7 +597,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
// A non-steering queue (an idle-style send) leaves the badge untouched. // A non-steering queue (an idle-style send) leaves the badge untouched.
result.terminal.output = '' result.terminal.output = ''
result.ctx.emit('agent/inbox/enqueue', result.agent, { id: AgentMessageId('stub'), content: [{ type: 'text', text: 'sent' }], source: { kind: 'user' }, contexts: [], steering: false, wakeup: true }) result.ctx.emit('agent/inbox/enqueue', result.agent, { id: AgentMessageId('stub'), content: [{ type: 'text', text: 'sent' }], source: { kind: 'user' }, steering: false, wakeup: true })
drainSteering('first') drainSteering('first')
await tick() await tick()
expect(result.terminal.output).toContain('1 queued') expect(result.terminal.output).toContain('1 queued')
@@ -651,7 +651,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
const idle = await setup() const idle = await setup()
// A steering queue arriving while idle has no status line to badge, so the // A steering queue arriving while idle has no status line to badge, so the
// refresh is a no-op beyond requesting a render. // refresh is a no-op beyond requesting a render.
idle.ctx.emit('agent/inbox/enqueue', idle.agent, { id: AgentMessageId('stub'), content: [{ type: 'text', text: 'early' }], source: { kind: 'user' }, contexts: [], steering: true, wakeup: true }) idle.ctx.emit('agent/inbox/enqueue', idle.agent, { id: AgentMessageId('stub'), content: [{ type: 'text', text: 'early' }], source: { kind: 'user' }, steering: true, wakeup: true })
idle.session.append('tool/call', { turn: 1, step: 0, callId: 'pre' as never, name: 'bash', arguments: '{}' }) idle.session.append('tool/call', { turn: 1, step: 0, callId: 'pre' as never, name: 'bash', arguments: '{}' })
await tick() await tick()
expect(idle.terminal.output).not.toContain('Executing tools') expect(idle.terminal.output).not.toContain('Executing tools')
@@ -1063,19 +1063,18 @@ describe('pi-tui chat lifecycle and transcript', () => {
result.terminal.send('\r') result.terminal.send('\r')
await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(1) }) await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(1) })
expect(result.agent.sent).toEqual([[{ type: 'text', text: '@Source chat' }]]) expect(result.agent.sent).toEqual([[{ type: 'text', text: '@Source chat' }]])
expect(result.agent.sentOptions[0]?.contexts).toHaveLength(1) expect(result.agent.injected).toHaveLength(1)
const mention = formatSessionReferenceMention({ sessionId: sourceId, label: 'Source chat' }) const mention = formatSessionReferenceMention({ sessionId: sourceId, label: 'Source chat' })
expect(result.agent.sentOptions[0]?.contexts).toMatchObject([{ expect(result.agent.injectedOptions[0]?.source)
source: { kind: 'session-reference', references: [{ sessionId: 'source-session' }] }, .toMatchObject({ kind: 'session-reference', references: [{ sessionId: 'source-session' }] })
}])
result.agent.status = 'running' result.agent.status = 'running'
result.terminal.send(`steer ${mention}`) result.terminal.send(`steer ${mention}`)
result.terminal.send('\r') result.terminal.send('\r')
await vi.waitFor(() => { expect(result.agent.steered).toHaveLength(1) }) await vi.waitFor(() => { expect(result.agent.steered).toHaveLength(1) })
expect(result.agent.steered).toEqual([[{ type: 'text', text: 'steer @Source chat' }]]) expect(result.agent.steered).toEqual([[{ type: 'text', text: 'steer @Source chat' }]])
expect(result.agent.steeredOptions[0]?.contexts).toHaveLength(1) expect(result.agent.injected).toHaveLength(2)
await dispose(result) await dispose(result)
}) })
@@ -1115,7 +1114,6 @@ describe('pi-tui chat lifecycle and transcript', () => {
result.terminal.send('\r') result.terminal.send('\r')
await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(1) }) await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(1) })
expect(result.agent.sent[0]).toEqual([{ type: 'text', text: '@src/source-file.ts' }]) expect(result.agent.sent[0]).toEqual([{ type: 'text', text: '@src/source-file.ts' }])
expect(result.agent.sentOptions[0]?.contexts).toEqual([])
result.terminal.send('@do') result.terminal.send('@do')
await vi.waitFor(() => { await vi.waitFor(() => {
@@ -1130,7 +1128,6 @@ describe('pi-tui chat lifecycle and transcript', () => {
result.terminal.send('\r') result.terminal.send('\r')
await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(2) }) await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(2) })
expect(result.agent.sent[1]).toEqual([{ type: 'text', text: '@"docs/design notes.md"' }]) expect(result.agent.sent[1]).toEqual([{ type: 'text', text: '@"docs/design notes.md"' }])
expect(result.agent.sentOptions[1]?.contexts).toEqual([])
result.terminal.send('@unsafe') result.terminal.send('@unsafe')
await tick() await tick()
@@ -1226,9 +1223,8 @@ describe('pi-tui chat lifecycle and transcript', () => {
expect(result.agent.sent).toEqual([[ expect(result.agent.sent).toEqual([[
{ type: 'text', text: '@evil\\x1b\\x07\\x9b\\x0as' }, { type: 'text', text: '@evil\\x1b\\x07\\x9b\\x0as' },
]]) ]])
expect(result.agent.sentOptions[0]?.contexts).toMatchObject([{ expect(result.agent.injectedOptions[0]?.source)
meta: { references: [{ sessionId: unsafeId }] }, .toMatchObject({ references: [{ sessionId: unsafeId }] })
}])
await dispose(result) await dispose(result)
}) })
@@ -1314,52 +1310,37 @@ describe('pi-tui chat lifecycle and transcript', () => {
expect(result.terminal.output).toContain('keep @[') expect(result.terminal.output).toContain('keep @[')
result.session.append('user/message', { result.session.append('user/message', {
content: [ content: [{ type: 'text', text: 'hidden snapshot payload' }],
{ type: 'text', text: 'hidden baked snapshot payload' }, source: {
{ type: 'text', text: '\n\n## My request:\n' }, kind: 'session-reference',
{ type: 'text', text: 'visible referenced question' }, references: [{ sessionId: 'prefixed', label: 'Prefixed source' }],
], } as never,
}, { surfaceOp: 'append' })
result.session.append('user/message', {
content: [{ type: 'text', text: 'visible referenced question' }],
source: { kind: 'user' }, source: { kind: 'user' },
envelope: {
displayContent: [{ type: 'text', text: 'visible referenced question' }],
prefixContexts: [{
source: {
kind: 'session-reference',
references: [{ sessionId: 'prefixed', label: 'Prefixed source' }],
} as never,
}],
},
}, { surfaceOp: 'append' }) }, { surfaceOp: 'append' })
await tick() await tick()
expect(result.terminal.output).toContain('visible referenced question') expect(result.terminal.output).toContain('visible referenced question')
expect(result.terminal.output).toContain('Referenced sessions · Prefixed source (prefixed)') expect(result.terminal.output).toContain('Referenced sessions · Prefixed source (prefixed)')
expect(result.terminal.output).not.toContain('hidden baked snapshot payload') expect(result.terminal.output).not.toContain('hidden snapshot payload')
result.session.append('user/message', {
content: [{ type: 'text', text: 'hidden steering context' }],
source: {
kind: 'session-reference',
references: [{ sessionId: 'steering-source', label: 'Steering source' }],
} as never,
}, { surfaceOp: 'append' })
result.session.append('steering/message', { result.session.append('steering/message', {
turn: 1, turn: 1,
content: [ content: [{ type: 'text', text: 'visible steering prompt' }],
{ type: 'text', text: 'hidden non-reference prefix' },
{ type: 'text', text: '\n\n## My request:\n' },
{ type: 'text', text: 'visible steering prompt' },
],
source: { kind: 'user' }, source: { kind: 'user' },
envelope: {
displayContent: [{ type: 'text', text: 'visible steering prompt' }],
prefixContexts: [
{ source: { kind: 'plugin', plugin: 'other' } },
{
source: {
kind: 'session-reference',
references: [{ sessionId: 'steering-source', label: 'Steering source' }],
} as never,
},
],
},
}, { surfaceOp: 'append' }) }, { surfaceOp: 'append' })
await tick() await tick()
expect(result.terminal.output).toContain('visible steering prompt') expect(result.terminal.output).toContain('visible steering prompt')
expect(result.terminal.output).toContain('Referenced sessions · Steering source (steering-source)') expect(result.terminal.output).toContain('Referenced sessions · Steering source (steering-source)')
expect(result.terminal.output).not.toContain('hidden non-reference prefix') expect(result.terminal.output).not.toContain('hidden steering context')
result.session.append('user/message', { result.session.append('user/message', {
content: [{ type: 'text', text: 'secret full snapshot payload' }], content: [{ type: 'text', text: 'secret full snapshot payload' }],
@@ -1426,7 +1407,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
let release: (() => void) | undefined let release: (() => void) | undefined
const prepare = vi.spyOn(result.ctx.sessionReferences, 'prepare').mockImplementation( const prepare = vi.spyOn(result.ctx.sessionReferences, 'prepare').mockImplementation(
(_agent, content) => new Promise((resolve) => { (_agent, content) => new Promise((resolve) => {
release = () => { resolve({ content, contexts: [] }) } release = () => { resolve({ content }) }
}), }),
) )
result.terminal.send(value) result.terminal.send(value)
@@ -1474,7 +1455,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
let resolveAfterDispose: (() => void) | undefined let resolveAfterDispose: (() => void) | undefined
const latePrepare = vi.spyOn(lateSuccess.ctx.sessionReferences, 'prepare').mockImplementation( const latePrepare = vi.spyOn(lateSuccess.ctx.sessionReferences, 'prepare').mockImplementation(
(_agent, content) => new Promise((resolve) => { (_agent, content) => new Promise((resolve) => {
resolveAfterDispose = () => { resolve({ content, contexts: [] }) } resolveAfterDispose = () => { resolve({ content }) }
}), }),
) )
lateSuccess.terminal.send(value) lateSuccess.terminal.send(value)