fix(session): project steering messages as plain user content

steering/message previously rendered inside a <steering source="…">
envelope like context/message. No model is trained on a <steering> tag,
so the framing is arbitrary markup the model was never taught to read;
recorded transcripts show it treating the instruction as third-party
metadata and refusing it. Framing also does not belong on the session
surface — a caller that wants a frame formats its own content. It now
projects verbatim as a plain user-role message; the <context> envelope is
untouched and renderTagged becomes context-only.

Agent Note: .agents/notes/implemented/simplification/2026-07-20-unwrap-steering-message-projection.md
This commit is contained in:
Turtle
2026-07-20 11:00:36 +08:00
parent 023bed1a83
commit b25b78fd02
15 changed files with 227 additions and 421 deletions

View File

@@ -10,7 +10,7 @@ The harness needs one internal language for messages that the loop, session log,
Own the vocabulary: messages are arrays of typed content blocks (`text`, `reasoning`, `tool-call`, `tool-result`), with the union derived from the merge-extensible `ContentBlockMap` so plugins add block types via declaration merging. The same merge-extensible-map pattern types every "stringly" field (`MessageSource`, `FinishReason`, `TurnTrigger`, `TurnEndReason`). Streaming is a raw chunk protocol; `BlockAssembler` is the single shared assembly implementation. Adapters translate to provider wire formats — mapping cost lives in adapters, where it belongs.
In-session context injection (`context/message`, `steering/message`) renders as tagged user-role envelopes (the system-reminder pattern) rather than a new role, so adapters carry zero burden. Live-adapter validation confirms this rendering for current DeepSeek behavior; a future provider-specific mismatch belongs in that adapter rather than a new canonical role.
In-session context injection (`context/message`) renders as a tagged user-role envelope (the system-reminder pattern) rather than a new role, so adapters carry zero burden. Live-adapter validation confirms this rendering for current DeepSeek behavior; a future provider-specific mismatch belongs in that adapter rather than a new canonical role. `steering/message` originally shared the envelope but now projects as plain user content; see [the steering-unwrap Agent Note](../simplification/2026-07-20-unwrap-steering-message-projection.md).
## Alternatives considered

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-20-unwrap-steering-message-projection.md: 93be7191a556b6438a0ad265d3cc9bde29f5d0ef
2026-07-20-unwrap-steering-message-projection.zh.md: 8e3fcd0fbf25c393e7429b0d24bd9a1e38d61e48

View File

@@ -0,0 +1,26 @@
# Agent Note: Project steering messages as plain user content
Status: implemented
English | [中文](2026-07-20-unwrap-steering-message-projection.zh.md)
## Problem
`Session.deriveEventMessage` rendered `steering/message` inside a `<steering source="…">…</steering>` envelope, mirroring the `context/message` framing. But the two events differ in kind: context injection is ambient, non-conversational material (file-change notices, workspace instructions) where the envelope tells the model "this is not the user speaking", while steering *is* the user (or a plugin acting for the user) speaking mid-turn — "also reply with SECOND", "focus on tests". Wrapping that direction in an XML label distances the model from an instruction it should treat as a first-class user message; recorded transcripts show models reasoning about whether to obey "the steering input" as if it were third-party metadata.
## Decision
`steering/message` projects to a plain user-role message carrying its content blocks verbatim — identical to `user/message` projection. The `<context>` envelope on `context/message` (with its `raw` opt-out) is untouched. The former `renderTagged` helper in `packages/core/session/src/index.ts` is now the context-only `renderContextEnvelope` with no tag parameter. The compaction renderer's `[Steering: …]` label is unaffected: that is a summarization-input format, not model-visible history.
The `source` attribution that the envelope carried is not lost — it remains on the durable `steering/message` event; it just no longer renders into the model transcript.
## Alternatives considered
- **Keep the envelope for plugin-sourced steering only** — splits one projection into two on `source.kind` for no observed benefit; a plugin steering the agent (hook-bridge continuation reasons) also wants the instruction followed, not attributed.
- **Move the unwrapping into adapters** — the canonical projection is the model-visible contract ("model-visible ⟺ logged"); per-adapter divergence on framing would make the derived transcript adapter-dependent.
## Consequences
- Mid-turn steering reaches the model with the same weight as an ordinary user prompt.
- The transcript no longer distinguishes a steering injection from a user message; consumers that need the distinction read the durable event log, which keeps `steering/message` and its `source` intact.
- The [content-block-vocabulary Agent Note](../architecture/2026-06-11-content-block-vocabulary.md)'s tagged-envelope clause now covers `context/message` only and is amended to point here.

View File

@@ -0,0 +1,26 @@
# Agent Note: steering 消息投影为普通用户内容
Status: implemented
[English](2026-07-20-unwrap-steering-message-projection.md) | 中文
## 问题
`Session.deriveEventMessage` 曾把 `steering/message` 包在 `<steering source="…">…</steering>` 封套里渲染,与 `context/message` 的框架保持一致。但这两类事件性质不同:上下文注入是环境性的、非对话性的材料(文件变更通知、工作区指令),封套告诉模型「这不是用户在说话」;而 steering中途引导恰恰**是**用户(或代表用户的插件)在轮次中途发言——「再回复 SECOND」「专注于测试」。把这种指令包进 XML 标签会让模型把本应作为一等用户消息对待的指令当成第三方元数据;已录制的 transcript文本记录显示模型会推理是否要服从「那条 steering 输入」,仿佛它是旁观者的附注。
## 决策
`steering/message` 投影为普通的 user 角色消息,逐字携带其内容块——与 `user/message` 的投影完全相同。`context/message` 上的 `<context>` 封套(及其 `raw` 退出选项)保持不变。`packages/core/session/src/index.ts` 中原来的 `renderTagged` 辅助函数现在是只服务于 context 的 `renderContextEnvelope`不再接受标签参数。压缩compaction渲染器的 `[Steering: …]` 标注不受影响:那是摘要输入格式,不是模型可见的历史。
封套曾携带的 `source` 归属并未丢失——它仍保留在持久的 `steering/message` 事件上;只是不再渲染进模型 transcript。
## 备选方案
- **仅对插件来源的 steering 保留封套** —— 会按 `source.kind` 把一条投影拆成两条,却没有观察到任何收益;插件引导 agent智能体钩子桥接器的轮次续行原因同样希望指令被遵从而不是被归因。
- **把去封套的逻辑移入适配器** —— 规范投影就是模型可见契约(「模型可见 ⟺ 已记录」);让各适配器在框架上各行其是,会使派生的 transcript 依赖于适配器。
## 影响
- 中途引导以与普通用户提示相同的权重到达模型。
- transcript 不再区分 steering 注入与用户消息;需要这一区分的消费方读取持久事件日志,其中 `steering/message` 及其 `source` 完整保留。
- [内容块词汇表 Agent Note](../architecture/2026-06-11-content-block-vocabulary.md) 中关于带标签封套的条款现在只覆盖 `context/message`,并已修订为指向本文。