github →

New request

#2555: [Steer] Tool calls continue after steer consumed & steer not preserved as distinct entry

We'll provision a sandbox, run an agent against the issue, and open a draft PR. You can pull the branch and iterate from there.

Issue
bugupstream-changestreaming

/steer Bugs and Gaps

Bug 1: Remaining tool calls still execute after steer is consumed

Description

When a user sends /steer <text> during mid-turn tool execution, the steer text IS consumed and injected into the most recent tool result (by the per-tool steer drain). However, the remaining queued tool calls in the same batch still execute before the model ever sees the steer. The next API call — where the steer-modified messages reach the LLM — only happens after _execute_tool_calls() fully returns.

The user-visible behavior: the steer sits "until the entire turn ends" instead of being acted upon "after the next tool call."

Technical Root Cause

Sequential path (execute_tool_calls_sequential in agent/tool_executor.py)

After each tool call, the per-tool steer drain runs:

self._apply_pending_steer_to_tool_results(messages, 1)

This consumes any pending steer and appends it to the last tool result's content (e.g. \n\nUser guidance: use Python instead). But the loop continues — the next tool call in the batch starts executing immediately. There is no check after the per-tool drain that says "if steer was consumed, break out and let the model respond."

The only exit from the loop is _interrupt_requested (explicit /stop or new message), which is a different mechanism.

Concurrent path (execute_tool_calls_concurrent in agent/tool_executor.py)

All tool calls are submitted to a ThreadPoolExecutor simultaneously. The main thread waits for ALL of them via concurrent.futures.wait(). Steer that arrives during execution sits unconsumed in _pending_steer until the post-execution result-collection loop runs — by which point all tools have already completed.

Why _apply_pending_steer_to_tool_results doesn't help

The function at agent/agent_runtime_helpers.py:1986 returns None. Callers have no way to know whether steer was actually consumed. Even if they did, the tool execution loops (execute_tool_calls_sequential and execute_tool_calls_concurrent) don't have a break/return path for steer.

Affected files

FileFunctionIssue
agent/agent_runtime_helpers.py:1986apply_pending_steer_to_tool_results()Returns None — callers can't detect if steer was consumed
agent/tool_executor.pyexecute_tool_calls_sequential()No break after per-tool steer drain; remaining tools still run
agent/tool_executor.pyexecute_tool_calls_concurrent()Wait loop doesn't check for pending steer; no way to cancel remaining futures

Gap 2: Steer text is not preserved as a distinct conversation entry

Description

When steer is consumed (injected into a tool result), it is appended to a tool result's content string with the marker \n\nUser guidance: <text> and the modified content is persisted to the session database. The steer text is NOT preserved as its own message with role "user" — it's embedded inside an existing role: "tool" message.

This means:

  • No traceability — there is no record that "the user sent a steer at this point in the conversation." The steer is invisible to anyone reviewing the transcript.
  • No recovery — if the conversation is forked from a point before the steer was sent, the steer text is lost (it was baked into a tool result in the original branch, not a separate user message).
  • Confusing on re-load — when the conversation history is re-loaded in a new session, the LLM sees \n\nUser guidance: ... as part of a tool's output, not as user-authored instruction. The semantic meaning ("the user redirected the agent here") is degraded.
  • Collision risk — if a tool naturally outputs text containing "User guidance:", the steer marker blends in indistinguishably.

Why it happens

The design intentionally avoids inserting a new user-role message to preserve role alternation (user → assistant → tool → assistant). Both injection points do the same thing:

  1. agent/agent_runtime_helpers.py:2029apply_pending_steer_to_tool_results: Appends \n\nUser guidance: {text} to the last tool result's content.

  2. agent/conversation_loop.py:611 — The pre-API-call steer drain: Same marker, same injection pattern. Falls back to re-stashing the steer if no tool message exists.

  3. Persistence (run_agent.py:1163 _persist_session): The messages list (with steer baked into tool results) is saved directly. No steer- specific metadata is recorded.

Contrast with leftover steer

When steer arrives and there are NO tool results to inject into (agent finished its turn), the leftover path handles it properly:

  • agent.run_agent._leftover_steer (line ~13799) drains the pending steer and returns it in the result dict as pending_steer.
  • api/streaming.py:4650 emits a pending_steer_leftover SSE event.
  • The frontend (static/messages.js:1621) catches this event and calls queueSessionMessage(), which creates a proper user-role message for the next turn.

So the leftover path does preserve steer as a distinct entity — but only when steer was never consumed. Once steer is consumed by the per-tool drain, it's gone as a separate entity and can never be recovered from the session data.

Impact

ScenarioSteer visible?
During the same turn (before next API call)Yes — embedded in tool result content
After session persisted, conversation re-loadedNo — buried in tool result as \n\nUser guidance: ...
Forked from pre-steer checkpointNo — irrevocably lost
Steer that arrived after turn ended (leftover)Yes — queued as proper user message

Recommended fixes

Fix 1: Break out of tool execution after steer consumed

1a. apply_pending_steer_to_tool_results → return bool

def apply_pending_steer_to_tool_results(agent, messages: list, num_tool_msgs: int) -> bool:

Change all returnreturn False on early-exit paths. Add return True after the successful injection at the end.

1b. Sequential path: break after steer consumption

In execute_tool_calls_sequential, after the per-tool steer drain:

_steer_applied = apply_pending_steer_to_tool_results(agent, messages, 1)

if _steer_applied and i < len(assistant_message.tool_calls):
    remaining = len(assistant_message.tool_calls) - i
    agent._vprint(f"{agent.log_prefix}⚡ Steer: skipping {remaining} remaining tool call(s)", force=True)
    for skipped_tc in assistant_message.tool_calls[i:]:
        skip_msg = {
            "role": "tool",
            "content": f"[Tool execution skipped — {skipped_tc.function.name} was not started. User sent new guidance]",
            "tool_call_id": skipped_tc.id,
        }
        messages.append(skip_msg)
    break

Place this before the interrupt check so steer takes priority.

1c. Concurrent path: steer check in wait loop

In execute_tool_calls_concurrent, in the concurrent.futures.wait() poll loop:

if not agent._interrupt_requested and getattr(agent, '_pending_steer', None) is not None:
    if not _conc_steer_logged:
        _conc_steer_logged = True
        agent._vprint(...)
    for f in not_done:
        f.cancel()
    concurrent.futures.wait(not_done, timeout=3.0)
    break

1d. Forwarder in run_agent.py

def _apply_pending_steer_to_tool_results(self, messages: list, num_tool_msgs: int) -> bool:
    from agent.agent_runtime_helpers import apply_pending_steer_to_tool_results
    return apply_pending_steer_to_tool_results(self, messages, num_tool_msgs)

Fix 2: Preserve steer as a separate conversation entry (design change)

This is a larger design decision that needs discussion, but here are options:

Option A: Insert a synthetic user message

Instead of modifying the tool result content, insert a proper role: "user" message after the tool results but before the next assistant turn. This breaks role alternation (tool → user → assistant) which some APIs may reject.

Option B: Store steer metadata on the tool result

Add a non-API-sent field to the tool result dict (e.g. _steer_applied: "<text>") that the persistence layer can use to display steer separately in the UI. Strip it before sending to the API. This preserves the current API behavior while adding traceability.

Option C: Use a dedicated steer message with role alternation repair

Insert a role: "user" message for the steer, but before sending to the API, merge it back into the preceding tool result (current behavior). On session persistence, keep the separate user message. On re-load, the user message re-appears as steer context. This gives the best of both worlds — API sees the current format, but persistence preserves the steer semantics.


Test Impact

Existing tests in tests/test_real_steer.py and tests/test_1062_busy_input_modes.py focus on the API endpoint and frontend wiring. They do not exercise the agent's internal steer consumption in execute_tool_calls_sequential/concurrent, so the fixes for Bug 1 should not break them.

A new test should be added that:

  1. Creates a mock agent with steer-capable methods
  2. Calls execute_tool_calls_sequential with multiple tool calls
  3. Injects steer via agent.steer() after the first tool completes
  4. Asserts remaining tool calls were skipped with cancellation messages
Assessmentadvisory
bug●●● hard85% confidence

The bug spans the agent's concurrent and sequential tool execution core, requiring careful changes to steer state management and tool-call interruption semantics.

Likely files
  • agent/tool_executor.py
  • agent/session.py
  • api/routes.py
  • static/js/chat.js
  • tests/test_agent.py
Create the request

This opens a fresh agent run and a draft PR for issue #2555.

Cancel