refactor(agent): unify sourced message delivery

This commit is contained in:
_Kerman
2026-07-24 22:38:50 +08:00
parent 009d113e0e
commit 992cf894af
197 changed files with 1890 additions and 2132 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
core.md: 882352e7dc814443468195f57b9c3f7c9401b170
core.zh.md: e686216530f827ad8b94b42a43e3fa66a2d3a36e
core.md: 697ac0811dc0f5b9aa3c27998bfa4ea3ff39cba3
core.zh.md: a3fb8f9f97cf05d5476c5e6b84cc35246111ba41

View File

@@ -216,8 +216,7 @@ interface GenerateOptions {
/**
* Ordered conversation messages, exactly as the provider sees them (after
* the `system` slot). A loop-built request assembles them as
* `EpochHeader.messagePrefix` + the derived history (dsh-agent-loop); a
* hand-built one-shot passes any list.
* the derived history (dsh-agent-loop); a hand-built one-shot passes any list.
*/
messages: Message[]
/** System prompt text (adapters map to the provider's system slot). */
@@ -287,11 +286,11 @@ The model-facing `ToolSchema` is the wire shape; the registered `ToolDefinition`
### The request envelope: `LlmCallConfig` and the logged header
The loop builds each request from logged state. `EpochHeader` records call config, rendered prompt, authoritative returned tool order (configured by `toolOrder`, or lexicographic when unset), and session prefix through full `request/header` snapshots. Together with derived history, this makes the request reconstructable from the session log. See [session.md](session.md#the-request-header-event-requestheader) and the [reconstructability Agent Note](../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md).
The loop builds each request from logged state. `EpochHeader` records call config, rendered prompt, and authoritative returned tool order (configured by `toolOrder`, or lexicographic when unset) through full `request/header` snapshots. Together with derived history, this makes the request reconstructable from the session log. See [session.md](session.md#the-request-header-event-requestheader) and the [reconstructability Agent Note](../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md).
`agent/request` receives a frozen call-config seed and may return a replacement to switch provider, model, or sampling. `agent/session-prefix` composes request-only prefix messages once per loop instance, and the header records the exact result used. Requests reaching `llm/stream` are deep-frozen, so mutation throws, and carry a process-local loop identity so observers do not confuse separately logged frozen auxiliary calls with conversation requests.
`agent/request` receives a frozen call-config seed and may return a replacement to switch provider, model, or sampling. Requests reaching `llm/stream` are deep-frozen, so mutation throws, and carry a process-local loop identity so observers do not confuse separately logged frozen auxiliary calls with conversation requests.
On the wire, a loop-built request reads in this order: the `system` slot (the rendered prompt assembly) → `messagePrefix` (the frozen session prefix) → the derived history — the boundary snapshot, whose tail is the newest `user/message` on a turn's first step and the previous step's tool results on later steps. The prefix never enters the derived history; its durable record is the header events, and the dev invariant recomputes exactly this equation against every loop-built request.
On the wire, a loop-built request reads the `system` slot (the rendered prompt assembly) followed by the derived history — the boundary snapshot, whose tail is the newest `user/message` on a turn's first step and the previous step's tool results on later steps. The dev invariant recomputes exactly this equation against every loop-built request.
FIXME(call-config-shape): revisit the exact definition of this type — which fields are genuinely epoch-level for cache purposes (`model` certainly; the sampling scalars sit here out of caution), and where provider-specific extras (reasoning options, extra body params) belong when an adapter needs them.
@@ -353,7 +352,7 @@ type SessionEvent<T extends SessionEventType = SessionEventType> = {
}[T]
```
The thirteen event variants (`turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `prompt/blocked`, `assistant/chunk`, `assistant/message`, `tool/call`, `tool/result`, `steering/message`, `todo/write`, `request/header`), the `deriveMessages()` projection rules, the `TurnTrigger`/`TurnEndReason` reasons, and the turn-enclosure invariant are on **[session.md](session.md)**. How the log is made durable — the `SessionPersistence` seam, JSONL/SQLite backends, the `session/flush` checkpoint, crash recovery, and `SessionHeader` — is on **[persistence.md](persistence.md)**.
The twelve event variants (`turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `assistant/chunk`, `assistant/message`, `tool/call`, `tool/result`, `steering/message`, `todo/write`, `request/header`), the `deriveMessages()` projection rules, the `TurnTrigger`/`TurnEndReason` reasons, and the turn-enclosure invariant are on **[session.md](session.md)**. How the log is made durable — the `SessionPersistence` seam, JSONL/SQLite backends, the `session/flush` checkpoint, crash recovery, and `SessionHeader` — is on **[persistence.md](persistence.md)**.
## The agent handle
@@ -378,8 +377,7 @@ type SendTarget = 'next-turn' | 'next-step'
* (`next-turn`/wakeup), {@link Agent.steer} (`next-step`/wakeup), and
* {@link Agent.inject} (`next-step`/no-wakeup).
*
* The object is complete so routing and provenance are explicit; callers that
* want the ordinary user-message preset use {@link Agent.followup}.
* The object is complete so routing policy is explicit.
*/
interface SendOptions {
/** Queue the item joins. */
@@ -392,20 +390,10 @@ interface SendOptions {
* (the injection preset).
*/
wakeup: boolean
/** Producer provenance; direct human input uses `{ kind: 'user' }`. */
source: MessageSource
}
```
The fixed-preset aliases own `target` and `wakeup`, so they accept only the remaining fields:
```ts type-equiv
/** Options accepted by the fixed-preset aliases, which own `target` and `wakeup`. */
interface AliasSendOptions {
/** Producer provenance; each alias supplies its documented default when omitted. */
source?: MessageSource
}
```
The fixed-preset aliases own `target` and `wakeup`; their `UserMessageData` input carries both content and provenance.
`send` returns the accepted message's opaque `AgentMessageId`, stable across that message's `agent/inbox/*` events:
@@ -423,8 +411,8 @@ The `agent/inbox/*` live events carry one accepted message; injection bypasses t
/**
* One accepted {@link Agent.send} message, carried by the `agent/inbox/*` live
* events. `id` is the value `send` returned to the caller, stable across this
* message's enqueue, dequeue, and discard events. Source defaults are already
* applied, so these are the exact values the item was accepted with.
* message's enqueue, dequeue, and discard events. Its content and source are
* the exact input values accepted by the agent.
*/
interface AgentMessage extends UserMessageData {
/** The id `send` returned for this message. */
@@ -480,11 +468,11 @@ interface Agent {
* without running the model: an open turn stages it for the next safe log
* position, while an idle injection appends it immediately without opening
* a turn.
* @param content - the model-facing content blocks to deliver.
* @param options - target queue, wakeup decision, and source.
* @param input - model-facing content and its producer provenance.
* @param options - target queue and wakeup decision.
* @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events.
*/
send(content: ContentBlock[], options: SendOptions): AgentMessageId
send(input: UserMessageData, options: SendOptions): AgentMessageId
/**
* Clear queued and steering work — unless `keepInbox` — and abort the active
@@ -504,11 +492,10 @@ interface Agent {
* Queue an ordinary follow-up turn and wake the driver — the
* `next-turn`/wakeup preset of {@link send}. The item becomes the sole
* ordinary message of its own turn.
* @param content - the prompt content blocks.
* @param options - message source.
* @param input - prompt content and its producer provenance.
* @returns the accepted message's {@link AgentMessageId}.
*/
followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId
followup(input: UserMessageData): AgentMessageId
/**
* Submit steering into the running turn — the `next-step`/wakeup preset of
@@ -517,23 +504,20 @@ interface Agent {
* remainder stays staged without waking the agent; retry or a later prompt
* takes it. Idle steering falls back to a woken follow-up turn, while
* cancellation or disposal may discard pending steering.
* @param content - the steering content blocks.
* @param options - message source.
* @param input - steering content and its producer provenance.
* @returns the accepted message's {@link AgentMessageId}.
*/
steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId
steer(input: UserMessageData): AgentMessageId
/**
* Append model-facing context without running the model — the
* `next-step`/no-wakeup preset of {@link send}. An open-turn injection stages
* at the next safe log position; an idle injection appends immediately
* without opening a turn. An omitted source defaults to
* `{ kind: 'plugin', plugin: '' }`.
* @param content - the injected context content blocks.
* @param options - context source.
* without opening a turn.
* @param input - injected context and its producer provenance.
* @returns the accepted message's {@link AgentMessageId}.
*/
inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId
inject(input: UserMessageData): AgentMessageId
/**
* Re-open a turn on the current session log without a new prompt — the
@@ -558,15 +542,10 @@ The process-local initiator carried by `ctx.agents` is the exact `Agent` above,
## Interception decisions
Prompt and post-tool decisions share `AdditionalContext`, the same `UserMessageData` content/source shape used by durable user-role input. Each `additionalContexts` entry becomes a separate `user/message`, preserving its provenance. Hook bridges map their native decision fields onto these typed results.
Prompt and post-tool decisions use the same `UserMessageData` content/source shape as durable user-role input. Each `additionalContexts` entry becomes a separate `user/message`, preserving its provenance. Hook bridges map their native decision fields onto these typed results.
Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts)
```ts type-equiv
/** Additional model-facing context produced beside a prompt or tool result. */
type AdditionalContext = UserMessageData
```
`agent/prompt-submit` returns a `PromptDecision` before a turn opens. Allow may rewrite the claimed prompt or attach `additionalContexts`; block rejects admission without creating turn events:
```ts type-equiv
@@ -577,7 +556,7 @@ type AdditionalContext = UserMessageData
* `next()` preserves both fields unless it intentionally replaces them.
*/
type PromptDecision =
| { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: AdditionalContext[] }
| { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: UserMessageData[] }
| { kind: 'block'; reason: string }
```

View File

@@ -218,8 +218,7 @@ interface GenerateOptions {
/**
* Ordered conversation messages, exactly as the provider sees them (after
* the `system` slot). A loop-built request assembles them as
* `EpochHeader.messagePrefix` + the derived history (dsh-agent-loop); a
* hand-built one-shot passes any list.
* the derived history (dsh-agent-loop); a hand-built one-shot passes any list.
*/
messages: Message[]
/** System prompt text (adapters map to the provider's system slot). */
@@ -289,11 +288,11 @@ interface ToolSchema {
### 请求信封:`LlmCallConfig` 与记录的 header
循环从已记录状态构建每个请求。`EpochHeader` 通过完整的 `request/header` 快照记录调用配置、渲染后的提示词权威返回工具顺序(由 `toolOrder` 配置;未配置时按字典序)以及会话前缀。结合派生历史,请求便可由会话日志重建。见 [session.md](session.md#the-request-header-event-requestheader) 与[可重建性 Agent Noteagent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)。
循环从已记录状态构建每个请求。`EpochHeader` 通过完整的 `request/header` 快照记录调用配置、渲染后的提示词权威返回工具顺序(由 `toolOrder` 配置;未配置时按字典序)。结合派生历史,请求便可由会话日志重建。见 [session.md](session.md#the-request-header-event-requestheader) 与[可重建性 Agent Noteagent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)。
`agent/request` 接收冻结的调用配置种子,并可返回替代值以切换提供方、模型或采样参数。`agent/session-prefix` 为每个循环实例组合一次仅用于请求的 prefix 消息header 记录实际使用的确切结果。到达 `llm/stream` 的请求会被深度冻结,因此变更会抛异常;请求还携带进程本地循环标识,使观察者不会把单独记录的冻结辅助调用误认成对话请求。
`agent/request` 接收冻结的调用配置种子,并可返回替代值以切换提供方、模型或采样参数。到达 `llm/stream` 的请求会被深度冻结,因此变更会抛异常;请求还携带进程本地循环标识,使观察者不会把单独记录的冻结辅助调用误认成对话请求。
在协议格式上,循环构建的请求按此顺序读取`system` 槽位(渲染后的提示词组装)→ `messagePrefix`(冻结的会话前缀)→ 派生历史——边界快照,其尾部在轮次首步是最新的 `user/message`,在后续步骤是上一步的工具结果。前缀从不进入派生历史;它的持久记录是 header 事件,开发不变式针对每个循环构建的请求精确重算此等式。
在协议格式上,循环构建的请求读取 `system` 槽位(渲染后的提示词组装),再读取派生历史——边界快照,其尾部在轮次首步是最新的 `user/message`,在后续步骤是上一步的工具结果。开发不变式针对每个循环构建的请求精确重算此等式。
FIXME(call-config-shape):重新审视此类型的精确定义——出于缓存目的,哪些字段确实属于 epoch 层级(`model` 肯定属于;采样标量目前出于谨慎放在这里),以及适配器需要时,提供方特有的额外项(推理选项、额外 body 参数)应归属何处。
@@ -355,7 +354,7 @@ type SessionEvent<T extends SessionEventType = SessionEventType> = {
}[T]
```
种事件变体(`turn/start`、`turn/end`、`step/start`、`step/end`、`user/message`、`prompt/blocked`、`assistant/chunk`、`assistant/message`、`tool/call`、`tool/result`、`steering/message`、`todo/write`、`request/header`)、`deriveMessages()` 投影规则、`TurnTrigger`/`TurnEndReason` 原因以及轮次封闭不变量都在 **[session.md](session.md)** 中。日志如何持久化——`SessionPersistence` seam、JSONL/SQLite 后端、`session/flush` 检查点、崩溃恢复与 `SessionHeader`——则在 **[persistence.md](persistence.md)** 中。
种事件变体(`turn/start`、`turn/end`、`step/start`、`step/end`、`user/message`、`assistant/chunk`、`assistant/message`、`tool/call`、`tool/result`、`steering/message`、`todo/write`、`request/header`)、`deriveMessages()` 投影规则、`TurnTrigger`/`TurnEndReason` 原因以及轮次封闭不变量都在 **[session.md](session.md)** 中。日志如何持久化——`SessionPersistence` seam、JSONL/SQLite 后端、`session/flush` 检查点、崩溃恢复与 `SessionHeader`——则在 **[persistence.md](persistence.md)** 中。
## Agent 句柄
@@ -380,8 +379,7 @@ type SendTarget = 'next-turn' | 'next-step'
* (`next-turn`/wakeup), {@link Agent.steer} (`next-step`/wakeup), and
* {@link Agent.inject} (`next-step`/no-wakeup).
*
* The object is complete so routing and provenance are explicit; callers that
* want the ordinary user-message preset use {@link Agent.followup}.
* The object is complete so routing policy is explicit.
*/
interface SendOptions {
/** Queue the item joins. */
@@ -394,20 +392,10 @@ interface SendOptions {
* (the injection preset).
*/
wakeup: boolean
/** Producer provenance; direct human input uses `{ kind: 'user' }`. */
source: MessageSource
}
```
固定预设别名拥有 `target` `wakeup`,因此只接受其余字段:
```ts type-equiv
/** Options accepted by the fixed-preset aliases, which own `target` and `wakeup`. */
interface AliasSendOptions {
/** Producer provenance; each alias supplies its documented default when omitted. */
source?: MessageSource
}
```
固定预设别名拥有 `target` `wakeup`;它们的 `UserMessageData` 输入同时携带内容与来源。
`send` 返回被接受消息的不透明 `AgentMessageId`,并在该消息的 `agent/inbox/*` 事件中保持稳定:
@@ -425,8 +413,8 @@ type AgentMessageId = Branded<'AgentMessageId'>
/**
* One accepted {@link Agent.send} message, carried by the `agent/inbox/*` live
* events. `id` is the value `send` returned to the caller, stable across this
* message's enqueue, dequeue, and discard events. Source defaults are already
* applied, so these are the exact values the item was accepted with.
* message's enqueue, dequeue, and discard events. Its content and source are
* the exact input values accepted by the agent.
*/
interface AgentMessage extends UserMessageData {
/** The id `send` returned for this message. */
@@ -482,11 +470,11 @@ interface Agent {
* without running the model: an open turn stages it for the next safe log
* position, while an idle injection appends it immediately without opening
* a turn.
* @param content - the model-facing content blocks to deliver.
* @param options - target queue, wakeup decision, and source.
* @param input - model-facing content and its producer provenance.
* @param options - target queue and wakeup decision.
* @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events.
*/
send(content: ContentBlock[], options: SendOptions): AgentMessageId
send(input: UserMessageData, options: SendOptions): AgentMessageId
/**
* Clear queued and steering work — unless `keepInbox` — and abort the active
@@ -506,11 +494,10 @@ interface Agent {
* Queue an ordinary follow-up turn and wake the driver — the
* `next-turn`/wakeup preset of {@link send}. The item becomes the sole
* ordinary message of its own turn.
* @param content - the prompt content blocks.
* @param options - message source.
* @param input - prompt content and its producer provenance.
* @returns the accepted message's {@link AgentMessageId}.
*/
followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId
followup(input: UserMessageData): AgentMessageId
/**
* Submit steering into the running turn — the `next-step`/wakeup preset of
@@ -519,23 +506,20 @@ interface Agent {
* remainder stays staged without waking the agent; retry or a later prompt
* takes it. Idle steering falls back to a woken follow-up turn, while
* cancellation or disposal may discard pending steering.
* @param content - the steering content blocks.
* @param options - message source.
* @param input - steering content and its producer provenance.
* @returns the accepted message's {@link AgentMessageId}.
*/
steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId
steer(input: UserMessageData): AgentMessageId
/**
* Append model-facing context without running the model — the
* `next-step`/no-wakeup preset of {@link send}. An open-turn injection stages
* at the next safe log position; an idle injection appends immediately
* without opening a turn. An omitted source defaults to
* `{ kind: 'plugin', plugin: '' }`.
* @param content - the injected context content blocks.
* @param options - context source.
* without opening a turn.
* @param input - injected context and its producer provenance.
* @returns the accepted message's {@link AgentMessageId}.
*/
inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId
inject(input: UserMessageData): AgentMessageId
/**
* Re-open a turn on the current session log without a new prompt — the
@@ -560,15 +544,10 @@ cause 是必选且由 TypeScript 强制约束的同进程输入。活跃持有
## 拦截决策
提示词决策与工具后决策共享 `AdditionalContext`,它与持久用户角色输入使用相同的 `UserMessageData` content/source 形状。每个 `additionalContexts` 项都会成为一条单独的 `user/message`,并保留其来源信息。钩子桥接层会把原生决策字段映射到这些类型化结果。
提示词决策与工具后决策直接使用 `UserMessageData`,即持久用户角色输入使用的 content/source 形状。每个 `additionalContexts` 项都会成为一条单独的 `user/message`,并保留其来源信息。钩子桥接层会把原生决策字段映射到这些类型化结果。
源码:[`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts)
```ts type-equiv
/** Additional model-facing context produced beside a prompt or tool result. */
type AdditionalContext = UserMessageData
```
`agent/prompt-submit` 在轮次打开前返回 `PromptDecision`。`allow` 可以改写已领取的提示词或附加 `additionalContexts``block` 会拒绝接纳,且不创建轮次事件:
```ts type-equiv
@@ -579,7 +558,7 @@ type AdditionalContext = UserMessageData
* `next()` preserves both fields unless it intentionally replaces them.
*/
type PromptDecision =
| { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: AdditionalContext[] }
| { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: UserMessageData[] }
| { kind: 'block'; reason: string }
```

View File

@@ -62,7 +62,7 @@ Every adapter MUST obey these, and every consumer may rely on them:
- **Provider stalls are bounded at the transport.** Both shipping remote adapters expose positive finite `streamIdleTimeoutMs` with a five-minute default. The watchdog arms only while iterator `next()` is outstanding, uses one stable signal for the whole request, maps its own expiry to `TIMEOUT`, and keeps an earlier caller abort as `ABORTED`.
- **Context overflow has one canonical code.** Both DeepSeek adapters classify explicit provider detail through `isContextWindowExceededError()` and surface `CONTEXT_WINDOW_EXCEEDED`, whether the failure arrives as a thrown HTTP `LlmError` or an in-band finish error. Consumers route on the code, never provider text.
- **Every provider HTTP request carries the app-attribution header.** Adapters send `attributionHeaders()` (below) - the `User-Agent` baseline - and prove it with a wire-level test (mock server asserting the received header, or the library's header hook for a library-backed adapter).
- **Replay state is adapter-owned.** A successful `finish` may carry lossless-JSON state needed to reconstruct a native provider response. The loop stores it with the assembled assistant message unless an `agent/step-result` listener rewrote the content. On a later request, `LlmService` passes the state only when the historical provider and target provider are currently registered to the exact same adapter instance. That adapter validates the state and owns any cross-model or cross-provider conversion; other adapters receive the provider-neutral content and provenance without the private state.
- **Replay state is adapter-owned.** A successful `finish` may carry lossless-JSON state needed to reconstruct a native provider response. The loop stores it with the assembled assistant message. On a later request, `LlmService` passes the state only when the historical provider and target provider are currently registered to the exact same adapter instance. That adapter validates the state and owns any cross-model or cross-provider conversion; other adapters receive the provider-neutral content and provenance without the private state.
This contract is pinned down by two deliberately independent implementations: `dsh-llm-deepseek` (hand-rolled fetch/SSE) and `dsh-llm-pi-ai` (a generic multi-provider adapter through `@earendil-works/pi-ai`). The library-backed adapter exercises the finish-chunk error path, while transport-boundary tests prove each idle watchdog stops its actual request.

View File

@@ -44,7 +44,7 @@ interface PreparedReferencedMessage {
/** Readable message content after host mention tokens are removed. */
content: ContentBlock[]
/** Aggregated untrusted snapshot, absent when the message has no references. */
additionalContext?: AdditionalContext
additionalContext?: UserMessageData
}
```

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
session.md: cd646d1d5913b0850bb9e1596a5f5f9795d74e4b
session.zh.md: cf29447ecfd2383193a5185fbe78891253d48be0
session.md: b46206a84103fa262b2b62b0e18654b9eb7e7cb0
session.zh.md: b89597cd01af7e5178a63b675c20551e47ca7621

View File

@@ -58,11 +58,6 @@ interface SessionEventMap {
* injection may append this event between turns without running the model.
*/
'user/message': UserMessageData
/**
* Durable record of a prompt veto and its reason. It is log-only: the blocked
* prompt never enters the model-visible surface, and its turn runs zero steps.
*/
'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string }
/** Raw stream chunk — token-level replay fidelity. */
'assistant/chunk': { turn: number; step: number; chunk: StreamChunk }
/**
@@ -152,13 +147,13 @@ interface TodoItem {
### The request header event: `request/header`
The request envelope — the `EpochHeader` (call config + rendered system prompt + assembled tool schemas + the session prefix) — is logged session state, so every conversation request is a pure function of the log (the reconstructability Agent Note). A full `request/header` snapshot with reason `'initial'` or `'resume'` records each loop-instance boundary; a later changed request records another full snapshot with reason `'change'`. `foldRequestHeader(events)` reconstructs the header by selecting the latest snapshot. The event is not a `SurfaceEventType`: it produces no LLM message.
The request envelope — the `EpochHeader` (call config + rendered system prompt + assembled tool schemas) — is logged session state, so every conversation request is a pure function of the log (the reconstructability Agent Note). A full `request/header` snapshot with reason `'initial'` or `'resume'` records each loop-instance boundary; a later changed request records another full snapshot with reason `'change'`. `foldRequestHeader(events)` reconstructs the header by selecting the latest snapshot. The event is not a `SurfaceEventType`: it produces no LLM message.
```ts type-equiv
/**
* Logged request state outside derived history: call config, system prompt,
* tools, and prefix. The latest full `request/header` snapshot reconstructs it;
* canonical empty optional fields are absent.
* Logged request state outside derived history: call config, system prompt, and
* tools. The latest full `request/header` snapshot reconstructs it; canonical
* empty optional fields are absent.
*/
interface EpochHeader {
/** The conversation's call configuration (provider, model, and sampling scalars). */
@@ -167,18 +162,10 @@ interface EpochHeader {
system?: string
/** Assembled tool schemas; absent for a tool-less request. */
tools?: ToolSchema[]
/**
* The session prefix: request-only messages sent BEFORE the entire derived
* history (the `agent/session-prefix` waterfall's product, composed once
* per loop instance and reused for every request it sends). Not session
* history — `deriveMessages()` never returns it — so the header is its
* only durable record; absent when the instance composed none.
*/
messagePrefix?: Message[]
}
```
Canonical form: an empty system prompt, an empty tool list, and an empty session prefix are absent fields, matching how requests are built. `messagePrefix` is the durable record of the `agent/session-prefix` waterfall's product (the request is `messagePrefix + derived history`); it is composed once per loop instance and included in every full snapshot that instance records. Legacy v0 logs containing the removed `request/header-delta` event or its full-snapshot `fallback` reason are rejected at seed, append, and persistence-load boundaries rather than replayed incompletely.
Canonical form represents an empty system prompt or tool list as an absent field, matching how requests are built. Legacy v0 logs containing the removed `request/header-delta` event or its full-snapshot `fallback` reason are rejected at seed, append, and persistence-load boundaries rather than replayed incompletely.
## `SessionEvent<T>` — one log entry
@@ -518,11 +505,6 @@ interface TurnEndReasonMap {
disposed: { kind: 'disposed' }
/** At least one step reached its output-token ceiling, even if a plugin continued the turn. */
'max-tokens': { kind: 'max-tokens' }
/**
* Policy blocked the turn's claimed prompt before the first step. The
* zero-step turn still records a balanced durable boundary and veto reason.
*/
rejected: { kind: 'rejected'; reason: string }
/**
* A persistence backend closed a crash-orphaned turn on reload. The loop never
* emits this marker, and the events recorded before the crash remain intact.
@@ -531,7 +513,7 @@ interface TurnEndReasonMap {
}
```
`max-tokens` mirrors the model-call `FinishReason` of the same name: any `max-tokens` step in a turn makes the whole turn end `max-tokens` rather than `completed` (the cut-short fact wins over a later continuation), so a consumer can tell a clean stop from a truncated one — but only over `completed`: the `disposed`/`aborted`/`error` outcomes take precedence. `rejected` is a zero-step turn whose claimed prompt an `agent/prompt-submit` hook blocked (the ACP bridge maps it to `cancelled`). `interrupted` is the one reason no loop emits — it is synthesized by crash recovery (see [persistence.md](persistence.md)). Both maps are merge-extensible.
`max-tokens` mirrors the model-call `FinishReason` of the same name: any `max-tokens` step in a turn makes the whole turn end `max-tokens` rather than `completed` (the cut-short fact wins over a later continuation), so a consumer can tell a clean stop from a truncated one — but only over `completed`: the `disposed`/`aborted`/`error` outcomes take precedence. `interrupted` is the one reason no loop emits — it is synthesized by crash recovery (see [persistence.md](persistence.md)). Both maps are merge-extensible.
## The turn-enclosure invariant
@@ -541,7 +523,7 @@ Every session event lives **inside** a turn (between a `turn/start` and its `tur
A plugin may declaration-merge extra `SessionEventMap` types. These are **log-only**: NOT `SurfaceEventType`s (they carry no `surfaceOp` and contribute nothing to derived history), but, like every event, they must sit inside an open turn. The full per-event enumeration — core and plugin-contributed alike, with payloads and provenance — is the generated [persistence log event catalog](../persistence-catalog.md); the compaction seam's `compact/*` semantics are discussed on [compaction.md](compaction.md).
The hook bridges' `hook/invoked` / `hook/result` provenance pairs (from `@deepseek-ai/dsh-hook-protocol`) correlate by `handlerId`. The mid-turn hook points (`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/`Stop`) fire inside the loop's open turn, so their `hook/*` records are turn-enclosed by construction. `SessionStart` gets no `hook/*` record — its injected `user/message` is the durable evidence — because it has no open turn to enclose one (see [the hook-bridges Agent Note](../../.agents/notes/implemented/feature/2026-06-30-hook-bridges.md)).
The hook bridges' `hook/invoked` / `hook/result` provenance pairs (from `@deepseek-ai/dsh-hook-protocol`) correlate by `handlerId`. The mid-turn hook points (`PreToolUse`/`PostToolUse`/`Stop`) fire inside the loop's open turn, so their `hook/*` records are turn-enclosed by construction. `SessionStart` and the pre-turn `UserPromptSubmit` admission seam get no `hook/*` record because neither has an open turn to enclose one; allowed context is instead evidenced by its sourced `user/message` (see [the hook-bridges Agent Note](../../.agents/notes/implemented/feature/2026-06-30-hook-bridges.md)).
## Durability contract

View File

@@ -58,11 +58,6 @@ interface SessionEventMap {
* injection may append this event between turns without running the model.
*/
'user/message': UserMessageData
/**
* Durable record of a prompt veto and its reason. It is log-only: the blocked
* prompt never enters the model-visible surface, and its turn runs zero steps.
*/
'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string }
/** Raw stream chunk — token-level replay fidelity. */
'assistant/chunk': { turn: number; step: number; chunk: StreamChunk }
/**
@@ -152,13 +147,13 @@ interface TodoItem {
### 请求头事件:`request/header`
请求信封(即 `EpochHeader`:调用配置 + 渲染后的系统提示词 + 已组装的工具 schema + 会话前缀)会作为会话状态写入日志,因此每个对话请求都是日志的纯函数(见可重建性 Agent Note。带有 reason `'initial'` 或 `'resume'` 的完整 `request/header` 快照记录每个 agent loop 实例的边界;之后请求发生变化时,系统会以 reason `'change'` 记录另一份完整快照。`foldRequestHeader(events)` 通过选择最新快照重建请求头。该事件不是 `SurfaceEventType`,不产生 LLM 消息。
请求信封(即 `EpochHeader`:调用配置 + 渲染后的系统提示词 + 已组装的工具 schema会作为会话状态写入日志因此每个对话请求都是日志的纯函数见可重建性 Agent Note。带有 reason `'initial'` 或 `'resume'` 的完整 `request/header` 快照记录每个 agent loop 实例的边界;之后请求发生变化时,系统会以 reason `'change'` 记录另一份完整快照。`foldRequestHeader(events)` 通过选择最新快照重建请求头。该事件不是 `SurfaceEventType`,不产生 LLM 消息。
```ts type-equiv
/**
* Logged request state outside derived history: call config, system prompt,
* tools, and prefix. The latest full `request/header` snapshot reconstructs it;
* canonical empty optional fields are absent.
* Logged request state outside derived history: call config, system prompt, and
* tools. The latest full `request/header` snapshot reconstructs it; canonical
* empty optional fields are absent.
*/
interface EpochHeader {
/** The conversation's call configuration (provider, model, and sampling scalars). */
@@ -167,18 +162,10 @@ interface EpochHeader {
system?: string
/** Assembled tool schemas; absent for a tool-less request. */
tools?: ToolSchema[]
/**
* The session prefix: request-only messages sent BEFORE the entire derived
* history (the `agent/session-prefix` waterfall's product, composed once
* per loop instance and reused for every request it sends). Not session
* history — `deriveMessages()` never returns it — so the header is its
* only durable record; absent when the instance composed none.
*/
messagePrefix?: Message[]
}
```
规范形式空系统提示词空工具列表和空会话前缀都表示为字段缺失,与请求构建方式一致。`messagePrefix` 是 `agent/session-prefix` waterfall瀑布式事件产物的持久记录请求 = `messagePrefix + derived history`);每个 agent loop 实例只组合一次,并包含在该实例记录的每份完整快照中。包含已移除的 `request/header-delta` 事件或完整快照原因为 `fallback` 的旧版 v0 日志,会在 seed、append 和持久化加载边界被拒绝,而不会以不完整方式回放。
规范形式空系统提示词空工具列表表示为字段缺失,与请求构建方式一致。包含已移除的 `request/header-delta` 事件或完整快照原因为 `fallback` 的旧版 v0 日志,会在 seed、append 和持久化加载边界被拒绝,而不会以不完整方式回放。
## `SessionEvent<T>`:一条日志条目
@@ -518,11 +505,6 @@ interface TurnEndReasonMap {
disposed: { kind: 'disposed' }
/** At least one step reached its output-token ceiling, even if a plugin continued the turn. */
'max-tokens': { kind: 'max-tokens' }
/**
* Policy blocked the turn's claimed prompt before the first step. The
* zero-step turn still records a balanced durable boundary and veto reason.
*/
rejected: { kind: 'rejected'; reason: string }
/**
* A persistence backend closed a crash-orphaned turn on reload. The loop never
* emits this marker, and the events recorded before the crash remain intact.
@@ -531,7 +513,7 @@ interface TurnEndReasonMap {
}
```
`max-tokens` 与模型调用中同名的 `FinishReason` 对应:只要轮次内有任何步骤以 `max-tokens` 结束,整个轮次就以 `max-tokens` 而不是 `completed` 结束(即使之后继续执行,截断事实仍优先),让消费方能够区分正常停止和截断停止;但它只优先于 `completed``disposed`/`aborted`/`error` 结果的优先级更高。`rejected` 表示一个零步骤轮次,其已认领的提示词被 `agent/prompt-submit` 钩子阻止ACPAgent Client Protocol桥接层将其映射为 `cancelled`)。`interrupted` 是唯一不会由任何 loop 发出的原因:它由崩溃恢复合成(见 [persistence.md](persistence.md))。两个 map 均可通过合并扩展。
`max-tokens` 与模型调用中同名的 `FinishReason` 对应:只要轮次内有任何步骤以 `max-tokens` 结束,整个轮次就以 `max-tokens` 而不是 `completed` 结束(即使之后继续执行,截断事实仍优先),让消费方能够区分正常停止和截断停止;但它只优先于 `completed``disposed`/`aborted`/`error` 结果的优先级更高。`interrupted` 是唯一不会由任何 loop 发出的原因:它由崩溃恢复合成(见 [persistence.md](persistence.md))。两个 map 均可通过合并扩展。
## 轮次封闭不变式
@@ -541,7 +523,7 @@ interface TurnEndReasonMap {
插件可以通过 declaration merging 添加额外的 `SessionEventMap` 类型。这些是**仅日志**事件:不是 `SurfaceEventType`(不携带 `surfaceOp`,不参与派生历史),但与所有事件一样,必须位于一个打开的轮次内。完整的逐事件枚举(核心与插件贡献的,含 payload 与溯源信息)见生成的[持久化日志事件目录](../persistence-catalog.md);压缩 seam 的 `compact/*` 语义在 [compaction.md](compaction.md) 中讨论。
钩子桥接层的 `hook/invoked` / `hook/result` 溯源对(来自 `@deepseek-ai/dsh-hook-protocol`)通过 `handlerId` 关联。轮次中间的钩子点(`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/`Stop`)在 loop 已打开的轮次内触发,因此其 `hook/*` 记录天然位于轮次之内。`SessionStart` 不生成 `hook/*` 记录:它注入的 `user/message` 已是持久证据,而且当时没有已打开的轮次可容纳该记录(见[钩子桥接 Agent Note](../../.agents/notes/implemented/feature/2026-06-30-hook-bridges.md))。
钩子桥接层的 `hook/invoked` / `hook/result` 溯源对(来自 `@deepseek-ai/dsh-hook-protocol`)通过 `handlerId` 关联。轮次中间的钩子点(`PreToolUse`/`PostToolUse`/`Stop`)在 loop 已打开的轮次内触发,因此其 `hook/*` 记录天然位于轮次之内。`SessionStart` 与轮次前的 `UserPromptSubmit` 接纳 seam 都不生成 `hook/*` 记录,因为当时没有已打开的轮次可容纳记录;获准附加的上下文则由带来源的 `user/message` 作为持久证据(见[钩子桥接 Agent Note](../../.agents/notes/implemented/feature/2026-06-30-hook-bridges.md))。
## 持久性契约

View File

@@ -152,6 +152,6 @@ interface Config {
## Session catalog and tool contract
`dsh-tool-skill` contributes a user-role `<system-reminder>` through `agent/session-prefix`. The catalog contains sorted skill `name` and normalized, XML-escaped `description` only; it omits bodies, paths, sources, providers, and routing hints. Prefix discovery forwards the caller's abort signal through `SkillLookupOptions`. `catalogDescriptionMaxLength` is the consumer config for the description bound, with default `500` and integer minimum `3`. Its request-only, header-logged lifecycle is defined by the [session-prefix Agent Note](../../.agents/notes/implemented/feature/2026-07-07-session-prefix.md).
`dsh-tool-skill` injects a durable user-role `<system-reminder>` at the first `agent/step` of a live session. The catalog contains sorted skill `name` and normalized, XML-escaped `description` only; it omits bodies, paths, sources, providers, and routing hints. Discovery forwards the step's abort signal through `SkillLookupOptions`. `catalogDescriptionMaxLength` is the consumer config for the description bound, with default `500` and integer minimum `3`.
The model-facing `skill({ name })` tool validates the kebab-case name, loads the complete definition for the calling agent cwd, reports an unresolved skill as unknown or no longer available, rejects `disableModelInvocation` skills, and returns a tool result containing `<skill_content name="...">`, `<skill_resources>`, and `<skill_instructions>`. `resourceBase` resolves explicitly referenced scripts, references, and assets only as needed; the loaded result does not enumerate a skill directory. The tool result is the model-visible path for complete instructions.

View File

@@ -213,7 +213,7 @@ interface ToolRunContext extends ToolExecution {
* the agent loop. Contexts retain their individual source and metadata and
* are emitted in call order.
*/
deferContext(context: AdditionalContext): void
deferContext(context: UserMessageData): void
/** Mark a successful final result as terminal for the current agent turn. */
concludeTurn(): void
}
@@ -292,7 +292,7 @@ interface ToolExecutionSuccess {
readonly content: ContentBlock[]
readonly error?: never
readonly meta?: JsonValue
readonly additionalContexts?: AdditionalContext[]
readonly additionalContexts?: UserMessageData[]
/** The agent loop stops after committing this successful result batch. */
readonly concludesTurn?: true
}
@@ -306,7 +306,7 @@ interface ToolExecutionFailure {
readonly value?: never
readonly content: ContentBlock[]
readonly meta?: JsonValue
readonly additionalContexts?: AdditionalContext[]
readonly additionalContexts?: UserMessageData[]
readonly concludesTurn?: never
}
```
@@ -343,9 +343,9 @@ type PreToolDecision =
* next request, or block by turning corrective feedback into an error result.
*/
type PostToolDecision =
| { kind: 'accept'; content?: ContentBlock[]; value?: never; additionalContexts?: AdditionalContext[] }
| { kind: 'accept'; value: JsonValue; content?: never; additionalContexts?: AdditionalContext[] }
| { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: AdditionalContext[] }
| { kind: 'accept'; content?: ContentBlock[]; value?: never; additionalContexts?: UserMessageData[] }
| { kind: 'accept'; value: JsonValue; content?: never; additionalContexts?: UserMessageData[] }
| { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: UserMessageData[] }
```
Call `next()` for the default or return a decision to short-circuit. Pre-policy may deny or ask; only `allowed-once` proceeds, while a non-grant, missing approval channel or service, or agent-less request becomes a denial. Guards may still impose a final denial. Arguments cannot be rewritten because history, audit, UI, and execution must agree.