fix(agent-loop): reserve the initial empty system head
Cause: SystemPromptProjection skipped the first empty rendered prompt. The initial admitted user then occupied surface node zero, so a later nonempty prompt appended behind user history. Routes without in-history system support lost the leading system role; pi-ai demotes a non-leading system message to user content. Fix: append the initial system node even when its content is empty. The existing loop commit order reserves node zero before admitted user messages; later prompt text replaces that node. Empty content still derives to no wire message. Keep retained-node replacement, clearing, multi-system handling, and pi-ai conversion unchanged; this addresses only the reviewed PR3476 initial-empty finding, not PR3483. Tests: added initial-empty projection and two-turn loop regressions for empty wire output, reserved surface head, later leading system role, replacement intent, and series header. Negative control failed before the source fix. Focused projection/runtime-context/loop/request-reconstruction/session-surface/pi-ai-context suites passed 176 tests; exact runtime-context.ts coverage is 100% statements, branches, functions, and lines. test:docs passed all 15 gates. Updated README EN/ZH, architecture map and owning architecture note; recorded all three translation pairs. Broad doc-sync/lint stopped at parent request for combined-layer validation. No normalize.ts conflict-comment edit.
This commit is contained in:
1 parent
c29b97ad94
commit
4c9f5efc07
12 files changed
+84
-25
No files matched your search
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md
|
||||
2026-09-02-system-prompt-as-surface-node.md: 996312a7ab7ed2a5fae442dfc4d6251d70356737
|
||||
2026-09-02-system-prompt-as-surface-node.zh.md: 537a39594cfa5e57053e73d74627a7126b301411
|
||||
2026-09-02-system-prompt-as-surface-node.md: 04cc0caf3fb8c4871a71d2bf015a8c3259e6270c
|
||||
2026-09-02-system-prompt-as-surface-node.zh.md: 740b6a8f3a53b20bba40df203ededebc9cddcd3b
|
||||
@@ -24,12 +24,11 @@ The system prompt lives on the surface. It is an ordinary surface event, `system
|
||||
|
||||
| Situation | Surface operation |
|
||||
|---|---|
|
||||
| No `system/message` survives on the surface and the rendered prompt is non-empty | append `system/message`; on the session's first step it is surface node 0, before the first `user/message` of the step |
|
||||
| No `system/message` survives on the surface (including an empty rendered prompt) | append `system/message`; on the session's first step it is surface node 0, before the first `user/message` of the step |
|
||||
| A `system/message` survives and the rendered prompt differs from its text (including a prompt that becomes empty) | replace exactly that node: `surfaceOp: { op: 'replace', start: <seq of the node>, end: <same> }`, `sourceEventSeqs: [<seq of the node>]`; an empty prompt produces an empty-content node that projects to no message |
|
||||
| No `system/message` survives and the rendered prompt is empty | no system node is appended |
|
||||
| The rendered prompt equals the surviving node's text | no operation |
|
||||
|
||||
The append row is exact about position: a prompt that first becomes non-empty after user messages exist appends its node at the surface tail, not at node 0, and the head protection below does not cover it. Replacing node 0 is a head rewrite expressed on the surface: the provider prefix changes from the first token, the log records the shadowed node through `sourceEventSeqs`, and `replaceGeneration` advances as it does for a compaction replacement. The loop's `startsSeries` detection (`requestSurfaceGeneration !== surfaceGeneration`) therefore covers the prompt change without a `system` comparison in `headerEquals`. `request/header` keeps reasons `initial`, `resume`, `change`, and `series`; `change` means config or tools changed, and the unchanged header that follows a prompt replacement logs as `series`.
|
||||
The loop reserves an empty system head before the initial admitted user messages so a prompt that first becomes non-empty later still replaces node 0. Omitting that empty node would append the later prompt behind user history, where pi-ai converts it to a user message rather than its `systemPrompt`. Replacing node 0 is a head rewrite expressed on the surface: the provider prefix changes from the first token, the log records the shadowed node through `sourceEventSeqs`, and `replaceGeneration` advances as it does for a compaction replacement. The loop's `startsSeries` detection (`requestSurfaceGeneration !== surfaceGeneration`) therefore covers the prompt change without a `system` comparison in `headerEquals`. `request/header` keeps reasons `initial`, `resume`, `change`, and `series`; `change` means config or tools changed, and the unchanged header that follows a prompt replacement logs as `series`.
|
||||
|
||||
`packages/core/session/src/surface.ts` enforces the head invariant in `assertSystemHeadRewrite`: a replacement whose range covers surface node 0 while node 0 is a `system/message` is rejected unless the replacing event is itself a `system/message` covering exactly that node. System nodes at later positions carry no such protection; a compaction range may shadow them.
|
||||
|
||||
@@ -74,14 +73,14 @@ In `packages/core/agent-loop/src/agent.ts`, `preStep` renders the prompt with `r
|
||||
- Compaction carries an invariant: node 0 is never compacted. The `dsh-session` surface manager enforces it in the replace operation itself, so a compaction provider other than `compaction-basic` cannot shadow the prompt by anchoring at `surfaceNodes[0]`. Later system nodes are unprotected by design.
|
||||
- `replaceGeneration` advances for a prompt replacement as well as for compaction; a reader that needs to distinguish them inspects the replacement event's type.
|
||||
- A mid-history system node has a surface representation, which is what the [in-history replacement proposal](../../proposed/feature/2026-09-02-in-history-system-prompt-replacement.md) builds on.
|
||||
- A prompt that first becomes non-empty after user messages exist lands at the surface tail rather than node 0, outside the head protection; the loop-owned projection reaches this case only when the session's first step rendered an empty prompt.
|
||||
- An initially empty prompt occupies the protected head without contributing a wire message; a later non-empty prompt replaces it and remains the leading system message.
|
||||
- Recorded snapshot fixtures carry the `system/message` event instead of a header `system` field. The snapshot normalizer tokenizes that event's text to `{{system}}`, the prompt sidecar is harvested from the `system/message` sequence (one section per prompt version, declared as `header.promptChanges`), and `request/header` pins compare config and tools only.
|
||||
|
||||
## Testing
|
||||
|
||||
- `packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts` pins zero post-call surface delta with provider usage through initial, growing, shrinking, and empty prompts, same-step retry replacement, request middleware, and fresh replay.
|
||||
- `packages/core/session/tests/surface.spec.ts` (`system/message surface node` block) pins the leading system-role projection, the empty-content `null` projection, `assertSystemHeadRewrite`'s acceptance and rejection paths, the unprotected later system nodes, and the rejection of a seeded `system/message` with a non-system role or non-plugin source.
|
||||
- `packages/core/agent-loop/tests/system-prompt-projection.spec.ts` pins the append on first render, the no-op on an unchanged prompt, the replacement of the retained node on change, restoration from the log, and the tail append after a replacement shadowed a non-head system node.
|
||||
- `packages/core/agent-loop/tests/system-prompt-projection.spec.ts` pins the append on first render (including empty), the later non-empty prompt at the derived head, the no-op on an unchanged prompt, the replacement of the retained node on change, restoration from the log, and the tail append after a replacement shadowed a non-head system node.
|
||||
- `packages/core/agent-loop/tests/request-reconstruction.spec.ts` (`a system-prompt change replaces surface node 0 and starts a new series under the same header`) pins the `series` header that follows a prompt replacement.
|
||||
- `packages/core/agent-loop/tests/invariant.spec.ts` pins the companion's rejection of a loop request carrying a `system` field and its `messages` equality check against the boundary derivation.
|
||||
- `packages/llm/llm-deepseek/tests/serialize.spec.ts` (`serializes a leading system message byte-for-byte like the same prompt passed as options.system`) pins wire identity. `packages/llm/llm-pi-ai/tests/context.spec.ts` compares both system sources on text and image paths. `packages/compaction/compaction-basic/tests/compaction-basic.spec.ts` pins the derived prefix, routed tools, absent separate `system` option, and protected non-empty or empty head through the region transaction and default summarizer.
|
||||
|
||||
+4
-5
@@ -24,12 +24,11 @@ Status: implemented
|
||||
|
||||
| 情形 | surface 操作 |
|
||||
|---|---|
|
||||
| surface 上没有存活的 `system/message` 且渲染后的提示词非空 | 追加 `system/message`;在会话的首个步骤中它是 surface 第 0 号节点,位于该步骤首条 `user/message` 之前 |
|
||||
| surface 上没有存活的 `system/message`(包括渲染后的提示词为空时) | 追加 `system/message`;在会话的首个步骤中它是 surface 第 0 号节点,位于该步骤首条 `user/message` 之前 |
|
||||
| 有存活的 `system/message` 且渲染后的提示词与其文本不同(包括提示词变为空) | 恰好替换该节点:`surfaceOp: { op: 'replace', start: <该节点的 seq>, end: <同一值> }`,`sourceEventSeqs: [<该节点的 seq>]`;空提示词产生一个投影为无消息的空内容节点 |
|
||||
| 没有存活的 `system/message` 且渲染后的提示词为空 | 不追加系统节点 |
|
||||
| 渲染后的提示词与存活节点的文本相同 | 无操作 |
|
||||
|
||||
追加一行对位置的描述是精确的:在用户消息已存在之后才首次变为非空的提示词,把节点追加到 surface 尾部而不是第 0 号节点,下文的头部保护也不覆盖它。替换第 0 号节点是头部重写在 surface 上的表达:提供方前缀从第一个 token 起改变,日志通过 `sourceEventSeqs` 记录被遮蔽的节点,`replaceGeneration` 与压缩替换时一样推进。因此循环的 `startsSeries` 检测(`requestSurfaceGeneration !== surfaceGeneration`)无需在 `headerEquals` 中比较 `system` 即可覆盖提示词变更。`request/header` 保留 `initial`、`resume`、`change`、`series` 四种 reason;`change` 表示 config 或 tools 变更,提示词替换之后跟随的未变 header 记为 `series`。
|
||||
循环在初始接纳的用户消息之前预留空系统头部,使稍后首次变为非空的提示词仍替换第 0 号节点。省略该空节点会让后来的提示词追加在用户历史之后,pi-ai 会将其转换为用户消息,而不是 `systemPrompt`。替换第 0 号节点是头部重写在 surface 上的表达:提供方前缀从第一个 token 起改变,日志通过 `sourceEventSeqs` 记录被遮蔽的节点,`replaceGeneration` 与压缩替换时一样推进。因此循环的 `startsSeries` 检测(`requestSurfaceGeneration !== surfaceGeneration`)无需在 `headerEquals` 中比较 `system` 即可覆盖提示词变更。`request/header` 保留 `initial`、`resume`、`change`、`series` 四种 reason;`change` 表示 config 或 tools 变更,提示词替换之后跟随的未变 header 记为 `series`。
|
||||
|
||||
`packages/core/session/src/surface.ts` 在 `assertSystemHeadRewrite` 中强制头部不变量:当第 0 号节点是 `system/message` 时,范围覆盖第 0 号节点的替换会被拒绝,除非替换事件本身是恰好覆盖该节点的 `system/message`。位于更后位置的系统节点没有此类保护;压缩范围可以遮蔽它们。
|
||||
|
||||
@@ -74,14 +73,14 @@ Status: implemented
|
||||
- 压缩带有一条不变量:第 0 号节点永不被压缩。`dsh-session` 的 surface 管理器在替换操作本身中强制它,因此除 `compaction-basic` 以外的压缩提供方无法通过锚定在 `surfaceNodes[0]` 来遮蔽提示词。更后位置的系统节点按设计不受保护。
|
||||
- `replaceGeneration` 在提示词替换时和压缩时一样推进;需要区分两者的读取方检查替换事件的类型。
|
||||
- 历史中途的系统节点拥有 surface 表示,这正是[历史内替换提案](../../proposed/feature/2026-09-02-in-history-system-prompt-replacement.zh.md)所依赖的基础。
|
||||
- 在用户消息已存在之后才首次变为非空的提示词落在 surface 尾部而不是第 0 号节点,不在头部保护之内;循环拥有的投影只在会话首个步骤渲染出空提示词时才会到达这种情形。
|
||||
- 初始空提示词占据受保护的头部,但不贡献协议消息;后来的非空提示词替换它,并保持为开头的系统消息。
|
||||
- 录制的快照 fixture 携带 `system/message` 事件而非 header 的 `system` 字段。快照归一化器把该事件的文本标记化为 `{{system}}`,提示词伴随文件从 `system/message` 序列采集(每个提示词版本一节,以 `header.promptChanges` 声明),`request/header` 的 pin 只比较 config 与 tools。
|
||||
|
||||
## Testing
|
||||
|
||||
- `packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts` 钉住提供方用量下调用后的表面增量为零,覆盖初始、增长、缩短与空提示词、同一步骤中的重试替换、请求中间件和全新回放。
|
||||
- `packages/core/session/tests/surface.spec.ts`(`system/message surface node` 块)钉住开头 system 角色的投影、空内容的 `null` 投影、`assertSystemHeadRewrite` 的接受与拒绝路径、更后位置系统节点不受保护,以及对 seed 中非 system 角色或非插件 source 的 `system/message` 的拒绝。
|
||||
- `packages/core/agent-loop/tests/system-prompt-projection.spec.ts` 钉住首次渲染时的追加、提示词未变时的无操作、变更时对所保留节点的替换、从日志恢复,以及替换遮蔽了非头部系统节点之后的尾部追加。
|
||||
- `packages/core/agent-loop/tests/system-prompt-projection.spec.ts` 钉住首次渲染时的追加(包括空提示词)、后来非空提示词位于派生历史头部、提示词未变时的无操作、变更时对所保留节点的替换、从日志恢复,以及替换遮蔽了非头部系统节点之后的尾部追加。
|
||||
- `packages/core/agent-loop/tests/request-reconstruction.spec.ts`(`a system-prompt change replaces surface node 0 and starts a new series under the same header`)钉住提示词替换之后跟随的 `series` header。
|
||||
- `packages/core/agent-loop/tests/invariant.spec.ts` 钉住伴随组件对携带 `system` 字段的循环请求的拒绝,以及其 `messages` 与边界派生结果的相等性检查。
|
||||
- `packages/llm/llm-deepseek/tests/serialize.spec.ts`(`serializes a leading system message byte-for-byte like the same prompt passed as options.system`)钉住协议一致性。 `packages/llm/llm-pi-ai/tests/context.spec.ts` 在文本与图片路径上比较两种系统提示词来源。`packages/compaction/compaction-basic/tests/compaction-basic.spec.ts` 通过区域事务与默认摘要器钉住派生前缀、已路由工具、不携带单独 `system` 选项,以及非空或空头节点的保护。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/architecture.md
|
||||
architecture.md: cafea011ecb1559b0f21292898fc892b914499a4
|
||||
architecture.zh.md: fd6291f2d6ea36eaa5ffcaa16c6acb876a28be1d
|
||||
architecture.md: 563674baca9fa2ee6839e03ce515a93b9a2919d7
|
||||
architecture.zh.md: 20c32bdf3949b101dd237d7e22925e2a4a7964ca
|
||||
@@ -100,7 +100,7 @@ turn/end
|
||||
|
||||
Input reaches the driver through one inbox. Some messages wake it immediately; injected context waits in the inbox until another message does.
|
||||
|
||||
`agent/pre-step` decides what the model sees. Listeners may rewrite the claimed messages or reject them outright; a rejected or empty first claim still closes a durable turn that spent no step, so the log records the attempt. An enter decision may also set `startsRequestSeries` to begin a distinct model-message series: the loop then logs a fresh `request/header` (reason `series`, or `change` carrying `startsSeries: true` when the envelope changed too). A listener that rebuilds a downstream enter decision must spread it (`{ ...decision, messages }`) so the declaration survives. Each step reads the prompt sections and tool schemas that plugins registered. The rendered prompt is surface node 0, a `system/message` event: the first step appends it before its user messages, a later step whose rendered text differs replaces that node, and the request carries no separate `system` field ([decision](../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md)).
|
||||
`agent/pre-step` decides what the model sees. Listeners may rewrite the claimed messages or reject them outright; a rejected or empty first claim still closes a durable turn that spent no step, so the log records the attempt. An enter decision may also set `startsRequestSeries` to begin a distinct model-message series: the loop then logs a fresh `request/header` (reason `series`, or `change` carrying `startsSeries: true` when the envelope changed too). A listener that rebuilds a downstream enter decision must spread it (`{ ...decision, messages }`) so the declaration survives. Each step reads the prompt sections and tool schemas that plugins registered. The rendered prompt is surface node 0, a `system/message` event: the first step appends it before its user messages even when empty (no wire message), a later step whose rendered text differs replaces that node, and the request carries no separate `system` field ([decision](../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md)).
|
||||
|
||||
Details: the [sequence diagram](agent-lifecycle.md), the [tool pipeline](tool-execution-pipeline.md), and [cancellation and error recovery](subsystems/core.md#the-agent-handle).
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ turn/end
|
||||
|
||||
输入通过同一个 inbox 到达驱动器。有些消息会立即唤醒它;注入的上下文会留在 inbox 中,直到另一条消息将其唤醒。
|
||||
|
||||
`agent/pre-step` 决定模型看到什么。监听器可以改写已领取的消息,也可以直接拒绝它们;首次领取被拒绝或被改写为空时,仍会关闭一个不含步骤的持久轮次,因此日志会记录这次尝试。enter 决策还可以设置 `startsRequestSeries` 来开启独立的模型消息序列:loop 会随之记录一个新的 `request/header`(原因为 `series`,或在封装同时变化时为携带 `startsSeries: true` 的 `change`)。重建下游 enter 决策的监听器必须展开它(`{ ...decision, messages }`),该声明才能存活。每个步骤读取插件注册的提示词片段和工具 schema。渲染后的提示词是 surface 第 0 号节点,即一个 `system/message` 事件:首个步骤在其用户消息之前追加它,渲染文本不同的后续步骤替换该节点,请求不携带单独的 `system` 字段([决策](../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md))。
|
||||
`agent/pre-step` 决定模型看到什么。监听器可以改写已领取的消息,也可以直接拒绝它们;首次领取被拒绝或被改写为空时,仍会关闭一个不含步骤的持久轮次,因此日志会记录这次尝试。enter 决策还可以设置 `startsRequestSeries` 来开启独立的模型消息序列:loop 会随之记录一个新的 `request/header`(原因为 `series`,或在封装同时变化时为携带 `startsSeries: true` 的 `change`)。重建下游 enter 决策的监听器必须展开它(`{ ...decision, messages }`),该声明才能存活。每个步骤读取插件注册的提示词片段和工具 schema。渲染后的提示词是 surface 第 0 号节点,即一个 `system/message` 事件:首个步骤在其用户消息之前追加它,即使提示词为空(不产生协议消息),渲染文本不同的后续步骤替换该节点,请求不携带单独的 `system` 字段([决策](../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md))。
|
||||
|
||||
详情见[时序图](agent-lifecycle.zh.md)、[工具流水线](tool-execution-pipeline.zh.md)和[取消与错误恢复](subsystems/core.zh.md#the-agent-handle)。
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/core/agent-loop/README.md
|
||||
README.md: f32fb71e076c5b991312303ea60c340592b138f0
|
||||
README.zh.md: 27cb00bc8176103564df8f1c5dcddb7d4051b330
|
||||
README.md: b338fc561d317507586dc884311a30242aeeebf0
|
||||
README.zh.md: 31822f8f6e242a62fff4adfd71cf7095a39c3835
|
||||
@@ -111,7 +111,7 @@ The loop is the production acquisition point for session write handles. When `ct
|
||||
|
||||
### Turn and step flow
|
||||
|
||||
The driver owns one agent for its lifetime and runs inside `ctx.agents.withInitiator(agent, ...)`. At a turn boundary it opens the durable turn, then atomically claims pending next-step input plus one queued prompt; between steps it claims only next-step input. Before `agent/pre-step`, the driver assembles the prompt, renders it, and projects the text against the surviving `system/message` node (`SystemPromptProjection` in `runtime-context.ts`): no surviving node yields an append, a differing text yields a replacement of exactly that node, and an unchanged text yields nothing. `agent/pre-step` decides what enters the step. An entered decision appends the pending `system/message` right after `step/start` and then its complete `user/message` batch before the driver can claim again, so log order is wire order; a rejected decision appends none. The request is `header.config`, `deriveMessages()`, and `header.tools`; the request carries no `system` field. Each model attempt emits one process-local `start`, emits every `chunk` only after the matching durable assistant-frame settlement, and emits exactly one terminal `end`; final assembly or message-append failure settles it as `aborted`, while `committed` follows the durable `assistant/message`. Each successful model call appends one message anchor, and a cancelled stream appends an `interrupted: true` anchor with the delivered prefix so the next request contains what the user saw. Within a step, exclusive calls form barriers and parallel-safe calls use the bounded rolling pool; policy, durable results, and result context remain model-ordered.
|
||||
The driver owns one agent for its lifetime and runs inside `ctx.agents.withInitiator(agent, ...)`. At a turn boundary it opens the durable turn, then atomically claims pending next-step input plus one queued prompt; between steps it claims only next-step input. Before `agent/pre-step`, the driver assembles the prompt, renders it, and projects the text against the surviving `system/message` node (`SystemPromptProjection` in `runtime-context.ts`): no surviving node yields an append even for an empty prompt (reserving node 0 without a wire message), a differing text yields a replacement of exactly that node, and an unchanged text yields nothing. `agent/pre-step` decides what enters the step. An entered decision appends the pending `system/message` right after `step/start` and then its complete `user/message` batch before the driver can claim again, so log order is wire order; a rejected decision appends none. The request is `header.config`, `deriveMessages()`, and `header.tools`; the request carries no `system` field. Each model attempt emits one process-local `start`, emits every `chunk` only after the matching durable assistant-frame settlement, and emits exactly one terminal `end`; final assembly or message-append failure settles it as `aborted`, while `committed` follows the durable `assistant/message`. Each successful model call appends one message anchor, and a cancelled stream appends an `interrupted: true` anchor with the delivered prefix so the next request contains what the user saw. Within a step, exclusive calls form barriers and parallel-safe calls use the bounded rolling pool; policy, durable results, and result context remain model-ordered.
|
||||
|
||||
### Failure and cancellation
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ const handle = await ctx.agents.create({
|
||||
|
||||
### 轮次与步骤流程
|
||||
|
||||
驱动器在其整个生命周期内拥有一个 agent,并在 `ctx.agents.withInitiator(agent, ...)` 内运行。在轮次边界,它先打开持久轮次,再原子领取待处理的 next-step 输入与一条排队提示词;在步骤之间则只领取 next-step 输入。在 `agent/pre-step` 之前,驱动器组装并渲染提示词,再把渲染文本与存活的 `system/message` 节点比对投影(`runtime-context.ts` 中的 `SystemPromptProjection`):没有存活节点时追加,文本不同时恰好替换该节点,文本未变时不产生任何事件。`agent/pre-step` 决定什么进入该步骤。进入步骤的决定会紧接 `step/start` 之后追加待提交的 `system/message`,随后在驱动器再次领取消息前追加完整的 `user/message` 批次,因此日志顺序即协议顺序;被拒绝的决定则不追加任何消息。请求由 `header.config`、`deriveMessages()` 与 `header.tools` 构成;请求不携带 `system` 字段。每次模型尝试会发出一个进程本地 `start`,仅在匹配的持久 assistant-frame 结算之后发出各个 `chunk`,并恰好发出一个终态 `end`;最终组装或消息追加失败时以 `aborted` 结算,`committed` 则出现在持久 `assistant/message` 之后。每次成功的模型调用都恰好追加一个 message 锚点,被取消的流则追加带 `interrupted: true` 的锚点并携带已交付前缀,使下一次请求包含用户看到的内容。在步骤内,独占调用形成屏障,并行安全调用使用有界滚动池;策略、持久结果与结果上下文保持模型顺序。
|
||||
驱动器在其整个生命周期内拥有一个 agent,并在 `ctx.agents.withInitiator(agent, ...)` 内运行。在轮次边界,它先打开持久轮次,再原子领取待处理的 next-step 输入与一条排队提示词;在步骤之间则只领取 next-step 输入。在 `agent/pre-step` 之前,驱动器组装并渲染提示词,再把渲染文本与存活的 `system/message` 节点比对投影(`runtime-context.ts` 中的 `SystemPromptProjection`):没有存活节点时即使提示词为空也追加(预留第 0 号节点,但不产生协议消息),文本不同时恰好替换该节点,文本未变时不产生任何事件。`agent/pre-step` 决定什么进入该步骤。进入步骤的决定会紧接 `step/start` 之后追加待提交的 `system/message`,随后在驱动器再次领取消息前追加完整的 `user/message` 批次,因此日志顺序即协议顺序;被拒绝的决定则不追加任何消息。请求由 `header.config`、`deriveMessages()` 与 `header.tools` 构成;请求不携带 `system` 字段。每次模型尝试会发出一个进程本地 `start`,仅在匹配的持久 assistant-frame 结算之后发出各个 `chunk`,并恰好发出一个终态 `end`;最终组装或消息追加失败时以 `aborted` 结算,`committed` 则出现在持久 `assistant/message` 之后。每次成功的模型调用都恰好追加一个 message 锚点,被取消的流则追加带 `interrupted: true` 的锚点并携带已交付前缀,使下一次请求包含用户看到的内容。在步骤内,独占调用形成屏障,并行安全调用使用有界滚动池;策略、持久结果与结果上下文保持模型顺序。
|
||||
|
||||
### 失败与取消
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ function eventsNewestFirst(session: Session): readonly SessionEvent[] {
|
||||
|
||||
/**
|
||||
* Tracks the retained `system/message` surface node without owning its commit.
|
||||
* The first rendered prompt appends surface node 0; every later change
|
||||
* The first rendered prompt, even empty, reserves surface node 0; every later change
|
||||
* replaces the retained node in place, so the model-visible head of the
|
||||
* request is derived history like every other message.
|
||||
*/
|
||||
@@ -72,13 +72,12 @@ export class SystemPromptProjection {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an uncommitted system node only when the retained prompt differs.
|
||||
* Create an uncommitted system node when absent, even for an empty prompt, or changed.
|
||||
* @param rendered - the fully rendered system prompt; `''` when none is active.
|
||||
* @returns the message and its surface intent, or `undefined` when no update is needed.
|
||||
*/
|
||||
project(rendered: string): SystemPromptCommit | undefined {
|
||||
if (this.retained === undefined) {
|
||||
if (rendered.length === 0) return
|
||||
return { message: createSystemMessage(rendered, SOURCE), intent: { surfaceOp: 'append' } }
|
||||
}
|
||||
if (this.retained.text === rendered) return
|
||||
|
||||
@@ -630,7 +630,43 @@ describe('agent loop', () => {
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect('system' in adapter.requests[0]!).toBe(false)
|
||||
expect(adapter.requests[0]!.messages.map(message => message.role)).toEqual(['user'])
|
||||
expect(agent.session.snapshotEvents().some(event => event.type === 'system/message')).toBe(false)
|
||||
expect(agent.session.snapshotEvents().filter(event => event.type === 'system/message')).toMatchObject([
|
||||
{ data: { message: { role: 'system', content: [] } }, surfaceOp: 'append' },
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps a later nonempty prompt ahead of history after an initially empty assembly', async () => {
|
||||
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
|
||||
const ctx = await harness(adapter)
|
||||
try {
|
||||
const emptyAssembly = ctx.on('system-prompt/assemble', async () => ({ sections: [], contexts: [], tools: [], variables: {} }))
|
||||
const agent = await ctx.agentLoop.create(SessionId('a-empty-system-head'), { provider: 'mock', model: 'mock' })
|
||||
const firstIdle = waitForIdle(ctx, agent)
|
||||
send(agent, 'first')
|
||||
await firstIdle
|
||||
const head = agent.session.snapshotEvents().find(event => event.type === 'system/message')
|
||||
expect(adapter.requests[0]?.messages.map(message => message.role)).toEqual(['user'])
|
||||
expect(head).toMatchObject({ data: { message: { content: [] } }, surfaceOp: 'append' })
|
||||
expect(agent.session.surface.nodes[0]).toBe(head?.seq)
|
||||
|
||||
emptyAssembly()
|
||||
const secondIdle = waitForIdle(ctx, agent)
|
||||
send(agent, 'second')
|
||||
await secondIdle
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(systemOf(adapter.requests[1])).toBe('You are an AI agent powered by DeepSeek Harness.')
|
||||
expect(adapter.requests[1]?.messages.map(message => message.role)).toEqual(['system', 'user', 'assistant', 'user'])
|
||||
const replacement = agent.session.snapshotEvents().findLast(event => event.type === 'system/message')
|
||||
expect(replacement).toMatchObject({
|
||||
surfaceOp: { op: 'replace', start: head?.seq, end: head?.seq },
|
||||
sourceEventSeqs: [head?.seq],
|
||||
})
|
||||
expect(agent.session.surface.nodes[0]).toBe(replacement?.seq)
|
||||
expect(agent.session.snapshotEvents().flatMap(event =>
|
||||
event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial', 'series'])
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('materializes changed runtime context at the history tail without rewriting the system header', async () => {
|
||||
|
||||
@@ -26,7 +26,6 @@ describe('SystemPromptProjection', () => {
|
||||
const session = ctx.sessions.create(SessionId('system-prompt-fresh'))
|
||||
const projection = new SystemPromptProjection(ctx, session)
|
||||
|
||||
expect(projection.project('')).toBeUndefined()
|
||||
const first = projection.project('v1')
|
||||
expect(first?.intent).toEqual({ surfaceOp: 'append' })
|
||||
expect(first?.message.role).toBe('system')
|
||||
@@ -53,6 +52,33 @@ describe('SystemPromptProjection', () => {
|
||||
expect(projection.project('v3')?.intent).toMatchObject({ surfaceOp: { op: 'replace' } })
|
||||
})
|
||||
|
||||
it('reserves an empty head before user history and replaces it when a prompt appears', async () => {
|
||||
const ctx = await sessionStore()
|
||||
try {
|
||||
const session = ctx.sessions.create(SessionId('system-prompt-empty-head'))
|
||||
const projection = new SystemPromptProjection(ctx, session)
|
||||
const first = projection.project('')
|
||||
expect(first?.intent).toEqual({ surfaceOp: 'append' })
|
||||
expect(first?.message.content).toEqual([])
|
||||
const head = session.append('system/message', { turn: 1, step: 1, message: first!.message }, first!.intent)
|
||||
const user = appendUser(session, 'hello')
|
||||
expect(session.surface.nodes).toEqual([head.seq, user.seq])
|
||||
expect(session.deriveMessages().map(message => message.role)).toEqual(['user'])
|
||||
expect(projection.project('')).toBeUndefined()
|
||||
|
||||
const next = projection.project('Follow this guidance.')
|
||||
expect(next?.intent).toEqual({
|
||||
surfaceOp: { op: 'replace', start: head.seq, end: head.seq },
|
||||
sourceEventSeqs: [head.seq],
|
||||
})
|
||||
const replacement = session.append('system/message', { turn: 2, step: 1, message: next!.message }, next!.intent)
|
||||
expect(session.surface.nodes).toEqual([replacement.seq, user.seq])
|
||||
expect(session.deriveMessages()).toEqual([next!.message, user.data])
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('restores the surviving system node from the log and ignores other sessions', async () => {
|
||||
const ctx = await sessionStore()
|
||||
const session = ctx.sessions.create(SessionId('system-prompt-replay'))
|
||||
|
||||
Reference in new issue
Block a user