refactor(agent): unify sourced message delivery
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
```
|
||||
|
||||
|
||||
@@ -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 Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)。
|
||||
循环从已记录状态构建每个请求。`EpochHeader` 通过完整的 `request/header` 快照记录调用配置、渲染后的提示词和权威返回工具顺序(由 `toolOrder` 配置;未配置时按字典序)。结合派生历史,请求便可由会话日志重建。见 [session.md](session.md#the-request-header-event-requestheader) 与[可重建性 Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)。
|
||||
|
||||
`agent/request` 接收冻结的调用配置种子,并可返回替代值以切换提供方、模型或采样参数。`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 }
|
||||
```
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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` 钩子阻止(ACP(Agent Client Protocol)桥接层将其映射为 `cancelled`)。`interrupted` 是唯一不会由任何 loop 发出的原因:它由崩溃恢复合成(见 [persistence.md](persistence.md))。两个 map 均可通过合并扩展。
|
||||
`max-tokens` 与模型调用中同名的 `FinishReason` 对应:只要轮次内有任何步骤以 `max-tokens` 结束,整个轮次就以 `max-tokens` 而不是 `completed` 结束(即使之后继续执行,截断事实仍优先),让消费方能够区分正常停止和截断停止;但它只优先于 `completed`,`disposed`/`aborted`/`error` 结果的优先级更高。`interrupted` 是唯一不会由任何 loop 发出的原因:它由崩溃恢复合成(见 [persistence.md](persistence.md))。两个 map 均可通过合并扩展。
|
||||
|
||||
## 轮次封闭不变式
|
||||
|
||||
@@ -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))。
|
||||
|
||||
## 持久性契约
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user