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.
/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
| File | Function | Issue |
|---|---|---|
agent/agent_runtime_helpers.py:1986 | apply_pending_steer_to_tool_results() | Returns None — callers can't detect if steer was consumed |
agent/tool_executor.py | execute_tool_calls_sequential() | No break after per-tool steer drain; remaining tools still run |
agent/tool_executor.py | execute_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:
-
agent/agent_runtime_helpers.py:2029—apply_pending_steer_to_tool_results: Appends\n\nUser guidance: {text}to the last tool result's content. -
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. -
Persistence (
run_agent.py:1163_persist_session): Themessageslist (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 aspending_steer.api/streaming.py:4650emits apending_steer_leftoverSSE event.- The frontend (
static/messages.js:1621) catches this event and callsqueueSessionMessage(), 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
| Scenario | Steer visible? |
|---|---|
| During the same turn (before next API call) | Yes — embedded in tool result content |
| After session persisted, conversation re-loaded | No — buried in tool result as \n\nUser guidance: ... |
| Forked from pre-steer checkpoint | No — 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 return → return 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:
- Creates a mock agent with steer-capable methods
- Calls
execute_tool_calls_sequentialwith multiple tool calls - Injects steer via
agent.steer()after the first tool completes - Asserts remaining tool calls were skipped with cancellation messages
The bug spans the agent's concurrent and sequential tool execution core, requiring careful changes to steer state management and tool-call interruption semantics.
- agent/tool_executor.py
- agent/session.py
- api/routes.py
- static/js/chat.js
- tests/test_agent.py