Skip to content

kiro-cli v3: a sub-agent replays steering it was never sent (root cause + tested patch) #11240

Description

@cplieger

Note

Working patch: kiro-cli-steer-scope.py, made for kiro-cli 2.21.0 (@kiro/agent 0.54.8). Kept current.

This is not official AWS code and comes with no guarantees; review the script before applying it. --check reports the state and writes nothing, --dry-run validates without writing, --restore removes the patch.

Before opening, please confirm:

Operating System

Debian 13 (trixie), x86_64. The mechanism is OS-independent: it is a per-execution cursor that is always initialised to zero and is never seeded from the shared buffer's current length, in the agent runtime's own JavaScript.

Kiro Version

kiro-cli 2.21.0, @kiro/agent 0.54.8, engine v3 (KAS). Not a v2 report: the Rust in-process agent loop never loads acp-server.js, so it cannot reach this path.

Bug Description

What happens

Scope: this is a kiro-cli v3 (KAS) bug, in node_modules/@kiro/agent/dist/server/acp-server.js. A steering message sent in Ctrl+S steer mode is delivered to every sub-agent that reaches another model call before it finishes, and to every sub-agent spawned later in the same turn, not only to the agent it was addressed to.

I typed a correction while a long task was running, and it landed in three helper agents that had nothing to do with it. One of them stopped its own work and started answering me instead.

Impact:

  • A sub-agent acts on an instruction addressed to the agent that dispatched it. The injected prompt tells it to adjust its approach "as the currently active agent" and to report back on how it handled the message, so a single-purpose helper is told to treat the message as its own guidance. In my session one of them stopped its own work and answered me instead.
  • Every steering message still buffered for the turn is replayed into each new sub-agent, which contaminates that sub-agent's prompt and pays for the message in input tokens once per sub-agent. In one of my sessions four messages, all four already delivered to the main agent, were injected into two sub-agents spawned after them, thirteen minutes after the first and a hundred seconds after the last.
  • The runtime's own memory-extraction execution receives user steering text. It is spawned with skipHooks: true, so it is already treated as not a participant in the conversation; nothing exempts it from steering.
  • A message arriving late enough to trigger the end-of-turn continuation costs one extra model round trip in each live sub-agent, because the continuation loops that execution back through MODEL_INVOKE to deliver it.

What the docs promise

  • Queue steering: "Your message is sent to the agent", "The agent picks it up mid-turn at the next safe point", "The agent acknowledges how it handled your input".
  • Invoking as sub-agents lists what a sub-agent shares with the main agent and what stays isolated. Conversation history is in the isolated column; neither page defines the audience of a mid-turn steer while a child is running.

Related existing reports

  • #10263 is the same subsystem with a different reported symptom: there a steering message wrongly cancels the in-flight turn, here it is delivered to the wrong audience.

Technical details

The steering buffer belongs to the SESSION, the read cursor belongs to the EXECUTION, and an ordinary newly spawned execution starts at zero. Three shipped facts, in the order they combine:

// the cursor, a class field on every AgentExecution
steeringReaderIndex=0;drainRetryCount=0

// a sub-agent is constructed with the parent's services, which carry the parent's buffer
sessionServices:r.execution.sessionServices,promptContext:r.execution.promptContext,signal:r.execution.abortController.signal,subExecutionDepth:w+1,agentName:f});

// and the drain runs from the MODEL_INVOKE node for EVERY execution, parent and sub
await pki(r.execution),r=await Hut(r),r.execution.state=r;

The buffer accumulates for the turn and is cleared at successful turn end; a cancel, a prompt-processing error, a model-settle failure and an explicit _session/steer/clear also clear it. None of those runs between an ordinary root delivery and a later same-turn sub-agent spawn, so a sub-agent spawned mid-turn starts at index 0 and reads everything, including messages the main agent consumed minutes earlier.

The seeding mechanism already exists and only the root continuation uses it, in the prompt loop's own re-run of the ROOT execution:

O&&D.setSteeringReaderIndex(N),n.eventAdapter.setOwnedExecutionId(D.executionId)

A sub-agent spawn gets no equivalent. Nothing dedupes on delivery either: the buffer carries an acknowledgment predicate that no reader calls, so a message the main agent already handled is offered to the next reader regardless. The drain reads the handler set only to choose which prompt to wrap the message in, never to skip delivery.

isHandled(t){let r=this.messageById.get(t);return r!==void 0&&r.handlers.size>0}

Delivery to a sub-agent that is ALREADY RUNNING looks deliberate: there is a prompt builder for the recap the parent gets afterwards, listing each <handled_by agent="...">. The replay into a sub-agent spawned after the message was delivered does not, and neither does the delivery to the memory-extraction execution.

Here is the runtime's own log for one turn, trimmed to the message id, the receiving agent and the timestamp, with my two helper agents renamed. All four messages reach the main agent first and are then replayed into both sub-agents, which did not exist when they were sent:

22:06:28 injected steering steer-01711ac6 into chat-agent (493e8efb) (handlers: 0)
22:11:05 injected steering steer-1d478a3e into chat-agent (493e8efb) (handlers: 0)
22:17:29 injected steering steer-1d58fc0a into chat-agent (493e8efb) (handlers: 0)
22:17:29 injected steering steer-811178a0 into chat-agent (493e8efb) (handlers: 0)
22:19:09 injected steering steer-01711ac6 into sub-agent-a (62e7e2ab) (handlers: 0)
22:19:09 injected steering steer-1d478a3e into sub-agent-a (62e7e2ab) (handlers: 0)
22:19:09 injected steering steer-1d58fc0a into sub-agent-a (62e7e2ab) (handlers: 0)
22:19:09 injected steering steer-811178a0 into sub-agent-a (62e7e2ab) (handlers: 0)
22:19:09 injected steering steer-01711ac6 into sub-agent-b (df92de03) (handlers: 0)
22:19:09 injected steering steer-1d478a3e into sub-agent-b (df92de03) (handlers: 0)
22:19:09 injected steering steer-1d58fc0a into sub-agent-b (df92de03) (handlers: 0)
22:19:09 injected steering steer-811178a0 into sub-agent-b (df92de03) (handlers: 0)

handlers is acknowledgment metadata and not a gate: the drain never consults isHandled, so replay happens whatever that count says. It does decide the wrapper, and that is where the replay becomes visibly wrong. The prompt dispatcher picks the recap variant for any execution whose id differs from a handler's, so a sub-agent spawned after a message was acknowledged is handed the PARENT's recap. In another turn in the same log set, steer-8e99ed00 was injected into five later sub-agents at (handlers: 2), which means each freshly spawned child was told "While you delegated work to subagent(s), the user sent the following message. The subagent(s) saw it and responded as noted." A child addressed as the delegating parent is the clearest signal that this path was not designed. A third log line shows a message reaching the memory-extraction execution at (handlers: 3).

Suggested fix

Two changes, and the first one alone removes the replay class:

  1. Seed a new execution's steering cursor at spawn, the way the root continuation already does: setSteeringReaderIndex(<buffer length>) when the execution is created, so an execution can only ever see messages that arrive after it starts. That is one line at each spawn site, next to the subExecutionDepth: <depth> + 1 already there.
  2. Decide the audience for a user steering message explicitly. If only the agent the user is talking to should get it, gate the drain on subExecutionDepth === 0, which is the discriminator the runtime already uses for its own top-level gates. If delivery to a live sub-agent is intended, the prompt is the thing to change: telling a single-purpose helper to adjust course "as the currently active agent" is what makes it abandon its task. Either way the memory-extraction execution wants excluding on its own terms, the way skipHooks already excludes it from hooks. Workflow notifications are a separate case and read correctly today, since their own text says informational only.

Try it: patch script + instructions

Made for kiro-cli 2.21.0 (@kiro/agent 0.54.8): kiro-cli-steer-scope.py. It is a stopgap, not the fix above, and it is deliberately broader than the reported defect: it seeds no cursors, and instead withholds a kind === "user" message from every execution at subExecutionDepth > 0, so it removes steering of a LIVE sub-agent as well as the replay. That is a choice I made for my own use, not something the defect compels. It advances the cursor past every message it reads, so the end-of-turn drain check cannot loop on withheld work, and workflow notifications still reach sub-agents unchanged. The cost is that nothing acts on a message sent during a fan-out until the parent's next model call, where stock had a live child pick it up at its own next safe point.

Anchors resolve every identifier from the bundle instead of pinning minified names, each insertion records its own bytes in its marker so --restore strips it byte-exactly, and the result is checked with node --check before an atomic replace.

python3 kiro-cli-steer-scope.py --check      # report state, write nothing
python3 kiro-cli-steer-scope.py --dry-run    # validate an apply, write nothing
python3 kiro-cli-steer-scope.py              # apply
python3 kiro-cli-steer-scope.py --restore    # remove it

The KAS bundle path is discovered automatically and version-matched against the installed kiro-cli; --target overrides it. Start a new session afterwards, because a running one holds the old bundle in memory, and re-run it after a kiro-cli upgrade, which unpacks a fresh unpatched bundle.

Steps to Reproduce

  1. Start kiro-cli chat on the v3 engine and confirm steer mode is active (Ctrl+S toggles it; the input tab reads "Steer").
  2. Send a prompt that dispatches sub-agents, so several executions are alive at once.
  3. While it is working, type a message and press Enter. It is delivered to the main agent.
  4. Let the turn continue until it dispatches more sub-agents.
  5. Read ~/.kiro/logs/<newest>/kiro.log for [LiveSteeringHook] injected steering. The message from step 3 appears again, once per sub-agent spawned in step 4, with each sub-agent's name.

Expected Behavior

An execution never receives a steering message that was sent before it existed. Whether a live sub-agent should receive one at all is a product decision I cannot make from outside, but the current prompt addresses whoever reads it as the agent the user is talking to, so if the answer is yes then the prompt needs to say what a sub-agent is supposed to do with a message aimed at its orchestrator.

Conversation ID

Omitted intentionally; available on request.

Additional Context

The behaviour proof is a harness that slices the drain and the end-of-turn check out of the real bundle and runs them under node against both the stock and the patched bytes, over fourteen cases; happy to share it. I have not read the IDE's embedded copy of this runtime, so I cannot say whether the IDE surface is affected, and I have only measured 2.21.0, since that is the only KAS bundle this machine has unpacked.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions