Background
Today Runtime.Steer (and POST /api/sessions/:id/steer) drains queued
messages into sess.Messages as plain user messages — no wrapping,
no envelope. This was locked in by #2492 and #2518, both of which solved
real scheduling and tokenisation bugs. Those fixes should stay.
But the storage choice — plain text — was bundled in alongside the
scheduling fix, and it is the only "shape" available to callers. That
single shape collapses two very different user intents into the same
on-the-wire representation, and the model can't tell them apart.
The problem
When a steered message arrives mid-turn, the user (or programmatic
caller) is trying to express one of two things:
-
"Add this to your work, but finish what you're doing first."
(common interactive case: "also use pytest")
-
"Drop what you're doing and do this instead."
(explicit pivot: "stop, the bug is in db.ts, not api.ts")
Both arrive at the model as bare text appended to the session. The model
has no signal to distinguish them, so it picks one — and empirically it
picks "drop what you're doing" most of the time, because a fresh
user turn after a tool batch reads as a new instruction. The "finish
first, then address" interpretation is essentially unreachable today.
A third axis exists for programmatic callers (chains, channel bridges,
AHP sessions) where neither preamble is wanted — they really do want a
plain user message. So the answer is not "always wrap" — it's
"let the caller pick".
Illustration of the current behavior
Setup: agent is editing api.ts. User types "also update the tests".
┌──────────┐ ┌─────────┐
│ edit │ ─── steer arrives ──────────▶│ LLM #3 │
│ api.ts │ drained, plain text │ │
│ ✓ done │ │ │
└──────────┘ └─────────┘
▲
model sees this as the
last user turn:
┌─────────────────────────┐
│ also update the tests │
└─────────────────────────┘
What happens: model pivots to "update the tests", abandoning the
partly-finished api.ts work. The user expected it
to finish the bug fix first.
There is no API path today to get the "finish first" interpretation,
even though it's the more common interactive intent.
Proposed change
Add an explicit framing field to QueuedMessage and to the
/steer HTTP payload. Framing is orthogonal to scheduling — the
three drain sites from #2492 and the newline behavior from #2518 are
unchanged.
┌─────────────────────────────────────────────────────────────────┐
│ SCHEDULING layer — drain sites (unchanged, locked by #2492) │
│ • top of every iteration │
│ • after each tool batch │
│ • after res.Stopped, before break │
│ invariant: never stranded. │
└─────────────────────────────────────────────────────────────────┘
│
▼ drained messages
┌─────────────────────────────────────────────────────────────────┐
│ RENDERING layer — framing (NEW) │
│ per-message choice: plain | instruction | replacement │
│ determines how the model reads the injected text. │
└─────────────────────────────────────────────────────────────────┘
The three framings
plain (today's behavior — kept as an explicit opt-in)
─────────────────────────────────────────────────────────────────
model sees:
┌─────────────────────────────────────┐
│ <message text> │
└─────────────────────────────────────┘
read as: a fresh user turn.
use for: programmatic callers (chains, channels, bridges)
that don't want any meta-instruction.
instruction (new default for /steer; restores the "finish
───────────── first" UX)
model sees:
┌─────────────────────────────────────┐
│ <system-reminder> │
│ The user sent a new message while │
│ you were working: │
│ <message text> │
│ │
│ IMPORTANT: finish your current task │
│ first, then address this. Do not │
│ abandon what you're doing. │
│ </system-reminder> │
└─────────────────────────────────────┘
read as: meta-instruction layered on top of the current task.
use for: interactive course corrections ("also do Y", "btw, Z").
replacement (new — explicit pivot semantics)
─────────────────────────────────────────────────────────────────
model sees:
┌─────────────────────────────────────┐
│ <system-reminder> │
│ The user has changed direction: │
│ <message text> │
│ │
│ Abandon your current task and │
│ address this instead. │
│ </system-reminder> │
└─────────────────────────────────────┘
read as: explicit pivot.
use for: interactive "stop, do X instead" cases that today
require the user to cancel and re-prompt.
Wire-level shape
POST /api/sessions/:id/steer
{
"messages": [{ "content": "use pytest, not unittest" }],
"framing": "instruction" // optional; defaults below
}
Defaults preserve every existing caller:
| Route |
Default framing |
POST /steer |
instruction |
POST /followup |
plain (unchanged) |
Runtime.Steer(QueuedMessage{}) (zero value) |
instruction |
Runtime.FollowUp stays as it is — semantically equivalent to a
steer with end-of-turn scheduling and plain framing.
Worked examples
Same setup in every case: agent is editing api.ts, plans to run
tests after.
Example A — framing: "instruction" (new default)
User types "also use pytest, not unittest" while the agent is working.
Model sees:
<system-reminder>
The user sent a new message while you were working:
also use pytest, not unittest
IMPORTANT: finish your current task first, then address this.
</system-reminder>
Agent behavior:
1. finish editing api.ts ← current task preserved
2. run the tests, using pytest ← new instruction folded in
3. report done
Example B — framing: "plain" (programmatic callers)
Channel bridge POSTs additional context mid-turn:
{ "framing": "plain",
"messages": [{"content": "the file is in src/, not root"}] }
Model sees:
the file is in src/, not root
Agent behavior:
reads it as a conversational hint; corrects the path;
continues. No "finish first" or "abandon" pressure.
This is the right default for any non-human caller — a pre-baked
preamble would just clutter the context window of every chain step.
Example C — framing: "replacement"
User types "actually skip the bug fix, just write a reproducer test".
Model sees:
<system-reminder>
The user has changed direction:
actually skip the bug fix, just write a reproducer test
Abandon your current task and address this instead.
</system-reminder>
Agent behavior:
1. drops the api.ts work
2. writes a failing test that reproduces the bug
3. reports — does NOT continue the abandoned plan
Today this case requires the user to cancel the turn and re-prompt,
because plain framing reliably triggers an abandon — but with no
acknowledgement that the previous work is being dropped on purpose.
Replacement framing makes the intent explicit.
References
Background
Today
Runtime.Steer(andPOST /api/sessions/:id/steer) drains queuedmessages into
sess.Messagesas plain user messages — no wrapping,no envelope. This was locked in by #2492 and #2518, both of which solved
real scheduling and tokenisation bugs. Those fixes should stay.
But the storage choice — plain text — was bundled in alongside the
scheduling fix, and it is the only "shape" available to callers. That
single shape collapses two very different user intents into the same
on-the-wire representation, and the model can't tell them apart.
The problem
When a steered message arrives mid-turn, the user (or programmatic
caller) is trying to express one of two things:
"Add this to your work, but finish what you're doing first."
(common interactive case: "also use pytest")
"Drop what you're doing and do this instead."
(explicit pivot: "stop, the bug is in db.ts, not api.ts")
Both arrive at the model as bare text appended to the session. The model
has no signal to distinguish them, so it picks one — and empirically it
picks "drop what you're doing" most of the time, because a fresh
user turn after a tool batch reads as a new instruction. The "finish
first, then address" interpretation is essentially unreachable today.
A third axis exists for programmatic callers (chains, channel bridges,
AHP sessions) where neither preamble is wanted — they really do want a
plain user message. So the answer is not "always wrap" — it's
"let the caller pick".
Illustration of the current behavior
There is no API path today to get the "finish first" interpretation,
even though it's the more common interactive intent.
Proposed change
Add an explicit framing field to
QueuedMessageand to the/steerHTTP payload. Framing is orthogonal to scheduling — thethree drain sites from #2492 and the newline behavior from #2518 are
unchanged.
The three framings
Wire-level shape
Defaults preserve every existing caller:
framingPOST /steerinstructionPOST /followupplain(unchanged)Runtime.Steer(QueuedMessage{})(zero value)instructionRuntime.FollowUpstays as it is — semantically equivalent to asteer with end-of-turn scheduling and
plainframing.Worked examples
Same setup in every case: agent is editing
api.ts, plans to runtests after.
Example A —
framing: "instruction"(new default)Example B —
framing: "plain"(programmatic callers)This is the right default for any non-human caller — a pre-baked
preamble would just clutter the context window of every chain step.
Example C —
framing: "replacement"Today this case requires the user to cancel the turn and re-prompt,
because plain framing reliably triggers an abandon — but with no
acknowledgement that the previous work is being dropped on purpose.
Replacement framing makes the intent explicit.
References