Skip to content

Allow user messages to steer the running agent mid-work instead of queuing until completion #2223

@trungutt

Description

@trungutt

Problem

When the agent is busy (executing tool calls, streaming LLM responses), any user messages are queued and only processed after the entire agent run completes. Each queued message then starts a completely new app.Run() cycle.

This means the user cannot steer or redirect the agent while it is working. For example:

  • The agent starts down the wrong path — the user has to wait for it to finish before correcting it
  • The user wants to add context ("also check the tests directory") — the agent won't see it until its current run ends
  • Follow-up instructions pile up and execute sequentially as separate runs, losing conversational continuity

Ideally, a message sent while the agent is working should be injected into the running conversation so the LLM sees it on its next iteration and can act on it immediately.

Current Architecture (why this is feasible)

The core already has the building blocks:

  1. sess.GetMessages(a) is called on every loop iteration (pkg/runtime/loop.go:265) — if a user message is added to sess.Messages between iterations, the next LLM call will see it automatically.
  2. sess.AddMessage() is mutex-protected (pkg/session/session.go:294-298) — safe to call from another goroutine while the loop is running.
  3. The only barrier is loop.go:366: if res.Stopped { break } — when the model has no tool calls, the loop exits. A mid-work message never gets a chance to override this.

Proposed Approach

1. pkg/session/session.go — Add a pending-message channel

Add a buffered chan *Message field (PendingMessages) to Session with a public InjectMessage() method. Any caller can push a user message into the running session.

2. pkg/runtime/loop.go — Drain pending messages between iterations

After processToolCalls (line 343) and before the res.Stopped check (line 366), non-blocking drain the pending channel:

injected := false
if sess.PendingMessages != nil {
    for {
        select {
        case msg := <-sess.PendingMessages:
            sess.AddMessage(msg)
            events <- UserMessage(...)
            injected = true
        default:
            goto drained
        }
    }
drained:
}

if res.Stopped && !injected {
    r.executeStopHooks(...)
    break
}
// If injected: loop continues, next iteration re-reads messages via GetMessages

When injected == true, the loop does not break even though the model said "stop." The next iteration re-reads messages and the LLM sees the new user message in context.

3. pkg/session/session.go — Mark injected messages

Add an Injected bool field to Message. In GetMessages(), wrap injected user messages with additional context so the LLM understands they arrived mid-work and should be addressed alongside the current task.

4. pkg/app/app.go — Public InjectMessage() API

Add App.InjectMessage(message, attachments) that creates a user message with Injected: true and pushes it into the session channel. This is the entry point for any caller to steer a running agent.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions