Add mid-turn message steering for running agent sessions#2363
Open
trungutt wants to merge 11 commits intodocker:mainfrom
Open
Add mid-turn message steering for running agent sessions#2363trungutt wants to merge 11 commits intodocker:mainfrom
trungutt wants to merge 11 commits intodocker:mainfrom
Conversation
Addresses docker#2223. Allow API clients to inject user messages into an active agent session without waiting for the current turn to finish. This is a common pattern in agentic coding tools where the user can steer or provide follow-up context while the agent is executing tool calls. New API endpoint: POST /sessions/:id/steer Runtime changes: - SteeredMessage type + buffered channel on LocalRuntime - Steer() enqueues, DrainSteeredMessages() batch-drains - Agent loop injects steered messages after tool execution and before the stop-condition check; emits user_message events so clients know when the LLM actually picks them up - Messages wrapped in <system-reminder> tags for clear LLM attribution Server changes: - POST /sessions/:id/steer endpoint (202 Accepted) - SteerSession() on SessionManager with GetLocalRuntime() helper for PersistentRuntime unwrapping - Concurrent stream guard on RunSession (rejects if already streaming) - Proper defer ordering: streaming flag cleared before channel close No behavioral change to the TUI — the existing client-side message queue continues to work as before. The TUI can adopt mid-turn steering in a future change by calling LocalRuntime.Steer() directly.
2bb7953 to
e6f7898
Compare
Introduce a SteerQueue interface (Enqueue/Drain) so that callers can provide their own storage implementation for steered messages. The default InMemorySteerQueue uses a buffered channel and is created automatically. Custom implementations can be injected via the WithSteerQueue option on LocalRuntime.
e6f7898 to
717c63a
Compare
rumpl
reviewed
Apr 9, 2026
Add SteerSession to the RemoteClient interface and implement it on the HTTP Client (POST /sessions/:id/steer). RemoteRuntime.Steer() delegates to the remote server so the TUI works identically regardless of whether the runtime is local or remote. App.Steer() now tries GetLocalRuntime first, then falls back to the Steerer interface so both PersistentRuntime and RemoteRuntime are handled.
rumpl
reviewed
Apr 10, 2026
Implement the two-queue design proposed by rumpl: steering (urgent mid- turn injection) and follow-up (end-of-turn, one-at-a-time processing) are fundamentally different intents that need separate queues. - Rename SteeredMessage → QueuedMessage (shared by both queues) - Replace SteerQueue with MessageQueue interface: adds context.Context to all methods, adds Dequeue (pop one) and Len - Add followUpQueue to LocalRuntime with WithFollowUpQueue option - Split agent loop: steer drains ALL mid-turn, follow-up pops ONE after stop-hooks, then continues the loop for a new turn - Follow-up messages are plain user messages (no system-reminder wrap) - Add POST /sessions/:id/followup endpoint - Add FollowUpSession to RemoteClient, Client, RemoteRuntime, and App
GetLocalRuntime is no longer needed: PersistentRuntime inherits Steer() and FollowUp() from embedded *LocalRuntime, and RemoteRuntime implements them directly. All call sites now use the MessageInjector interface for dispatch, which is cleaner and doesn't require knowledge of concrete runtime wrapper types.
Add Confirm and Cancel methods to the MessageQueue interface so that persistent queue implementations can use a transactional dequeue pattern: Dequeue locks a message (in-flight), Confirm permanently removes it after sess.AddMessage succeeds, Cancel releases it back to the queue on failure. This prevents message loss when the process crashes or the context is cancelled between dequeue and session persistence. The in-memory implementation treats Confirm/Cancel as no-ops since the message is already consumed from the channel on Dequeue. The agent loop now calls Confirm after successfully adding a follow-up message to the session. Drain (used for steer messages) auto-confirms all messages in a batch.
rumpl
reviewed
Apr 10, 2026
rumpl
reviewed
Apr 10, 2026
rumpl
reviewed
Apr 10, 2026
rumpl
reviewed
Apr 10, 2026
…turn error - Move QueuedMessage, MessageQueue interface, and inMemoryMessageQueue to dedicated message_queue.go file - Add Steer() and FollowUp() to the Runtime interface — all runtimes implement them, no need for a separate MessageInjector interface - Return error instead of bool from Steer/FollowUp for richer failure information (queue full, no active session, network errors) - Simplify App and SessionManager: call runtime.Steer/FollowUp directly without type assertions
As rumpl pointed out, calling Confirm after sess.AddMessage does not protect against anything: AddMessage is an in-memory operation, not a store write. If the process dies between Dequeue and AddMessage, the in-memory session is lost regardless. The Confirm/Cancel methods remain on the MessageQueue interface for implementations that integrate with their own persistence layer, but the agent loop does not call them since it has no durable persistence point between dequeue and the next LLM call.
rumpl
reviewed
Apr 10, 2026
rumpl
reviewed
Apr 10, 2026
rumpl
reviewed
Apr 10, 2026
Cleaner and consistent with the follow-up case: explicitly re-enter the loop rather than relying on the reader understanding that setting res.Stopped = false will make the subsequent check pass through.
rumpl
reviewed
Apr 10, 2026
…queueFollowUp Slim down the MessageQueue interface to Enqueue, Dequeue, and Drain — the three methods actually called. Remove wrapper methods on LocalRuntime (DrainSteeredMessages, DequeueFollowUp) and call the queues directly from loop.go.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Addresses #2223.
POST /sessions/:id/steerAPI endpoint that injects user messages into a running agent session mid-turnPOST /sessions/:id/followupAPI endpoint that queues messages for end-of-turn processing (one per turn)user_messageSSE events when messages are picked up, so API clients can confirm delivery to the userRunSessionagainst concurrent streams on the same sessionThis is a common pattern in agentic coding tools where the user can steer or provide follow-up context while the agent is executing tool calls. No behavioral change to the TUI — the existing client-side message queue continues to work as before. The TUI can adopt mid-turn steering in a follow-up PR.
Current flow (before this PR)
Messages sent while the agent is busy are held client-side and only processed after the entire turn finishes. Each queued message starts a completely new turn.
sequenceDiagram participant User participant Client participant Agent Loop User->>Client: "fix the bug" Client->>Agent Loop: POST /sessions/:id/agent (start stream) activate Agent Loop Note over Agent Loop: LLM call → tool calls → LLM call → ... User->>Client: "also update the tests" Note over Client: Message held in<br/>client-side queue User->>Client: "use pytest not unittest" Note over Client: Message held in<br/>client-side queue Agent Loop-->>Client: StreamStopped deactivate Agent Loop Client->>Agent Loop: POST /sessions/:id/agent ("also update the tests") activate Agent Loop Agent Loop-->>Client: StreamStopped deactivate Agent Loop Client->>Agent Loop: POST /sessions/:id/agent ("use pytest not unittest") activate Agent Loop Agent Loop-->>Client: StreamStopped deactivate Agent LoopThe problem
New flow (this PR)
Two distinct mechanisms for two distinct intents:
Steer — urgent mid-turn injection
sequenceDiagram participant User participant Client participant Steer Queue participant Agent Loop User->>Client: "fix the bug" Client->>Agent Loop: POST /sessions/:id/agent (start stream) activate Agent Loop Note over Agent Loop: LLM call → tool calls executing... User->>Client: "use pytest not unittest" Client->>Steer Queue: POST /sessions/:id/steer Note over Steer Queue: Message enqueued Note over Agent Loop: Tool calls finish Steer Queue-->>Agent Loop: Drain all (mid-turn) Note over Agent Loop: Injects message in<br/>system-reminder tags,<br/>forces loop to continue Note over Agent Loop: LLM call (sees steered message) → ... Agent Loop-->>Client: StreamStopped deactivate Agent LoopThe agent sees "use pytest not unittest" before writing any tests — no wasted work.
Follow-up — end-of-turn queue
sequenceDiagram participant User participant Client participant FollowUp Queue participant Agent Loop User->>Client: "fix the bug" Client->>Agent Loop: POST /sessions/:id/agent (start stream) activate Agent Loop User->>Client: "then write a README" Client->>FollowUp Queue: POST /sessions/:id/followup Note over FollowUp Queue: Message enqueued User->>Client: "and add a changelog entry" Client->>FollowUp Queue: POST /sessions/:id/followup Note over FollowUp Queue: Message enqueued Note over Agent Loop: Model stops (bug fixed) FollowUp Queue-->>Agent Loop: Pop one ("write a README") Note over Agent Loop: New turn starts<br/>(plain user message) Note over Agent Loop: Model stops (README written) FollowUp Queue-->>Agent Loop: Pop one ("add a changelog entry") Note over Agent Loop: New turn starts Note over Agent Loop: Model stops (changelog added) Agent Loop-->>Client: StreamStopped deactivate Agent LoopEach follow-up gets a full undivided turn — the agent finishes one task before starting the next.
Steer vs Follow-up — two distinct scenarios
POST /steer)POST /followup)<system-reminder>tags (mid-stream context)res.Stopped = false— loop continuescontinue— starts a new iteration of the loopMessageQueue interface with Lock + Confirm/Cancel
The queue storage is behind a
MessageQueueinterface so callers can provide their own implementation (e.g. persistent/distributed store). The interface uses a Lock + Confirm/Cancel pattern for safe dequeue:Dequeuelocks a message,Confirmpermanently removes it aftersess.AddMessagesucceeds, andCancelreleases it back to the queue on failure. This prevents message loss in persistent implementations when the process crashes between dequeue and session persistence.The default in-memory implementation (backed by a buffered channel) treats
Confirm/Cancelas no-ops. Custom implementations can be injected viaWithSteerQueueandWithFollowUpQueueoptions: