Skip to content

Steer messages need a framing axis so callers can control how the model reads them #2812

@trungutt

Description

@trungutt

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:

  1. "Add this to your work, but finish what you're doing first."
    (common interactive case: "also use pytest")

  2. "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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions