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

@@ -87,7 +87,7 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata)
#### What the model sees
The model receives projections of `user/message`, `assistant/message`, and `tool/result` surface entries verbatim. A `context/message` is a user-role message containing exactly `<context source="<source-kind>">`, its content blocks, and `</context>`; `steering/message` uses the identical `<steering source="<source-kind>">` / `</steering>` wrapper. Tool calls live inside assistant messages. Chunks, boundaries, usage, hook records, todo records, and other log-only events add no message.
The model receives projections of `user/message`, `assistant/message`, `tool/result`, and `steering/message` surface entries verbatim. A `context/message` is a user-role message containing exactly `<context source="<source-kind>">`, its content blocks, and `</context>`. Tool calls live inside assistant messages. Chunks, boundaries, usage, hook records, todo records, and other log-only events add no message.
#### Token effect

View File

@@ -85,13 +85,11 @@ declare module 'cordis' {
* canonical session vocabulary provider-neutral. Adapter-specific exceptions
* belong in the adapter.
*/
function renderTagged(tag: string, content: ContentBlock[], source: MessageSource): ContentBlock[] {
const open = `<${tag} source=${JSON.stringify(source.kind)}>`
const close = `</${tag}>`
function renderContextEnvelope(content: ContentBlock[], source: MessageSource): ContentBlock[] {
return [
{ type: 'text', text: open },
{ type: 'text', text: `<context source=${JSON.stringify(source.kind)}>` },
...content,
{ type: 'text', text: close },
{ type: 'text', text: '</context>' },
]
}
@@ -241,7 +239,7 @@ export function renderContextContent(
envelope: ContextEnvelope = 'context',
): ContentBlock[] {
const cloned = structuredClone(content)
return envelope === 'raw' ? cloned : renderTagged('context', cloned, source)
return envelope === 'raw' ? cloned : renderContextEnvelope(cloned, source)
}
/**
@@ -531,8 +529,7 @@ export class Session {
return { role: 'user', content: renderContextContent(content, source, envelope) }
}
case 'steering/message': {
const { content, source } = event.data
return { role: 'user', content: renderTagged('steering', content, source) }
return { role: 'user', content: event.data.content }
}
default:
// A non-surface event (boundary, chunk, log-only record) projects to

View File

@@ -48,7 +48,7 @@ describe('Session', () => {
expect(structuredClone(turnEnd.data.reason)).toEqual({ kind: 'max-tokens' })
})
it('renders context and steering messages as tagged synthetic user content', () => {
it('renders context messages tagged and steering messages as plain user content', () => {
const session = new Session(SessionId('s2'))
session.append('context/message', {
content: [{ type: 'text', text: 'file changed: a.ts' }],
@@ -64,7 +64,8 @@ describe('Session', () => {
expect(contextMessage!.role).toBe('user')
expect(contextMessage!.content[0]).toMatchObject({ type: 'text', text: '<context source="plugin">' })
expect(contextMessage!.content.at(-1)).toMatchObject({ type: 'text', text: '</context>' })
expect(steeringMessage!.content[0]).toMatchObject({ type: 'text', text: '<steering source="user">' })
expect(steeringMessage!.role).toBe('user')
expect(steeringMessage!.content).toEqual([{ type: 'text', text: 'focus on tests' }])
})
it('renders raw context without a generic envelope while preserving structured metadata', () => {

View File

@@ -370,7 +370,7 @@ describe('deriveMessages with surface', () => {
const messages = s.deriveMessages()
expect(messages).toHaveLength(2)
expect(messages[0]!.content[0]).toMatchObject({ type: 'text', text: '<context source="plugin">' })
expect(messages[1]!.content[0]).toMatchObject({ type: 'text', text: '<steering source="user">' })
expect(messages[1]!.content).toEqual([{ type: 'text', text: 'focus' }])
})
})