Engineering
Mid-turn steering: how Hermes Agent's ACP adapter handles /steer and /queue
How /steer commands route through the Hermes ACP adapter: three execution paths and why the distinction matters.
One of the more interesting design problems in the Hermes Agent ACP adapter is what to do when a user sends /steer at the wrong moment.
Hermes is an open-source AI agent. Molecules maintains a fork that wraps it with an acp_adapter/ module — this is what lets a Hermes process run as a workspace on the Molecule AI platform. The ACP (Agent Control Protocol) side of that adapter handles prompts from the editor (Zed, by default) as structured messages over a persistent session.
/steer and /queue are two slash commands the adapter added specifically for interactive control of a running session. This post explains what they do, why the implementation is more involved than it looks, and what we learned shipping the fix that closed issue #18258.
What the commands do
Both commands were added in commit e27b0b7651 in the feat(acp): add steer and queue slash commands changeset. The intent is simple:
/queue <prompt> schedules a prompt to run after the current agent turn finishes. If the agent is mid-turn, it waits. If the agent is idle, it runs immediately on the next cycle. The implementation is twelve lines:
def _cmd_queue(self, args: str, state: SessionState) -> str:
queued_text = args.strip()
if not queued_text:
return "Usage: /queue <prompt>"
with state.runtime_lock:
state.queued_prompts.append(queued_text)
depth = len(state.queued_prompts)
return f"Queued for the next turn. ({depth} queued)"
/steer <guidance> is where it gets interesting. The intent is to inject guidance into an actively running agent turn — to nudge the agent mid-flight rather than wait for it to finish and then send a follow-up. But “actively running” is a state the adapter only knows about under a lock.
The three-path problem
The original implementation only handled the active case. If you sent /steer when the agent wasn’t running, it silently appended to state.queued_prompts and replied “No active turn — queued for the next turn”. That looks identical to /queue output even though the user typed a different command — a confusing UX documented in the issue.
The fix (41fa1f1b5c, “fix(acp): run /steer as a regular prompt on idle sessions”) partitions the idle case into two sub-paths. The comment in acp_adapter/server.py explains the design:
# /steer on an idle session has no in-flight tool call to inject into.
# Rewrite it so the payload runs as a normal user prompt, matching the
# gateway's behavior (gateway/run.py ~L4898). Two sub-cases:
# 1. Zed-interrupt salvage — a prior prompt was cancelled by the
# client right before /steer arrived; replay it with the steer
# text attached as explicit correction/guidance so the user's
# in-flight work isn't lost.
# 2. Plain idle — no prior work to salvage; just run the steer
# payload as a regular prompt. Without this, _cmd_steer would
# silently append to state.queued_prompts and respond with
# "No active turn — queued for the next turn", which looks like
# /queue even though the user never typed /queue.
So there are three paths depending on session state at the moment /steer arrives:
Path 1 — agent is running. The steer payload is injected as in-flight guidance. The agent receives it mid-turn and can adjust its output without restarting.
Path 2 — Zed-interrupt salvage. The client cancelled a prompt (state.interrupted_prompt_text is non-empty) and then immediately sent /steer. The prior prompt is replayed with the steer text appended as “User correction/guidance after interrupt”, so nothing the agent had started is silently discarded:
if interrupted_prompt:
user_text = (
f"{interrupted_prompt}\n\n"
f"User correction/guidance after interrupt: {steer_text}"
)
user_content = user_text
Path 3 — plain idle. No running turn, no interrupted prompt. The steer payload is rewritten as a regular prompt and sent directly. The user gets the expected behavior — “give guidance, agent responds” — rather than the confusing queued-but-nothing-running state.
Why this matters in an ACP context
The ACP adapter is stateful in a way that the gateway’s Telegram/Discord/Slack delivery path is not. In the gateway, each message comes in, the agent processes it, and the result goes out. In an ACP session, the editor keeps the connection open, can cancel mid-turn (state.cancel_event), and can send follow-up messages while a turn is in flight.
That means the adapter has to correctly serialize concurrent inputs against state.runtime_lock — including distinguishing between the “just cancelled” state and the “never started” state, which are both “not running” but have different correct behaviors. The fix in 78886365c2 (“fix(acp): replay interrupted prompts for steer”) tightened this further to ensure the interrupted-prompt salvage path works correctly under the session locking contract.
The broader pattern
This is a small example of the class of problems that show up when you take an agent designed for one delivery channel (Hermes was built for Telegram/Discord/Slack/Matrix) and wrap it for a different one (an IDE’s ACP session).
The per-prompt semantics are the same — user sends text, agent responds — but the session lifecycle is different enough to produce edge cases at every state transition. The Molecules fork collects these fixes in acp_adapter/ so they don’t need to be upstreamed to NousResearch’s Hermes; they’re specific to the Molecule AI platform’s session contract.
The steer/queue commands are now live in the Hermes workspace runtime. If you have a Molecule AI workspace backed by the Hermes adapter and an ACP-capable editor, /steer <guidance> works the way you’d expect it to in all three session states.