feat(api): steer active runs

Adds POST /v1/runs/{run_id}/steer and bridges Browser-Extension/WebUI
session chat streams into the active run registry so live runs on those
surfaces are steerable too.

- steer accepted only while run status is exactly 'running'; stop/stopping/
  terminal states return 409 run_not_accepting_steer even while cooperative
  shutdown retains the agent reference
- session SSE disconnect/cancellation interrupts and drains the executor-
  backed run instead of cancelling only the async wrapper; control refs stay
  registered until the turn actually exits
- undelivered steer text (accepted after the final response) is preserved as
  pending_steer on the terminal run.completed event/status so clients can
  replay it as the next user turn instead of losing it
- docs for the endpoint, next-tool-boundary delivery, acceptance-vs-delivery
  semantics

Salvaged from PR #54466 by @abundantbeing.
This commit is contained in:
Jon Komet
2026-08-13 03:26:46 -07:00
committed by Teknium
parent 1fd21cbd0f
commit 001bcb908e
4 changed files with 462 additions and 9 deletions
+154 -7
View File
@@ -18,6 +18,7 @@ Exposes an HTTP server with endpoints:
- GET /v1/runs/{run_id} — retrieve current run status
- GET /v1/runs/{run_id}/events — SSE stream of structured lifecycle events
- POST /v1/runs/{run_id}/approval — resolve a pending run approval
- POST /v1/runs/{run_id}/steer — inject guidance into a running agent
- POST /v1/runs/{run_id}/stop — interrupt a running agent
- GET /health — health check
- GET /health/detailed — rich status for cross-container dashboard probing
@@ -45,7 +46,7 @@ import hashlib
import hmac
import itertools
import json
from contextlib import contextmanager, nullcontext
from contextlib import contextmanager, nullcontext, suppress
from contextvars import ContextVar
from functools import wraps
import logging
@@ -2091,6 +2092,7 @@ class APIServerAdapter(BasePlatformAdapter):
("GET", "/v1/runs/{run_id}", self._handle_get_run),
("GET", "/v1/runs/{run_id}/events", self._handle_run_events),
("POST", "/v1/runs/{run_id}/approval", self._handle_run_approval),
("POST", "/v1/runs/{run_id}/steer", self._handle_steer_run),
("POST", "/v1/runs/{run_id}/stop", self._handle_stop_run),
]
if _CRON_AVAILABLE:
@@ -3127,6 +3129,7 @@ class APIServerAdapter(BasePlatformAdapter):
"run_status": True,
"run_events_sse": True,
"run_stop": True,
"run_steer": True,
"run_approval_response": True,
"tool_progress_events": True,
"approval_events": True,
@@ -3157,6 +3160,7 @@ class APIServerAdapter(BasePlatformAdapter):
"run_status": {"method": "GET", "path": "/v1/runs/{run_id}"},
"run_events": {"method": "GET", "path": "/v1/runs/{run_id}/events"},
"run_approval": {"method": "POST", "path": "/v1/runs/{run_id}/approval"},
"run_steer": {"method": "POST", "path": "/v1/runs/{run_id}/steer"},
"run_stop": {"method": "POST", "path": "/v1/runs/{run_id}/stop"},
"skills": {"method": "GET", "path": "/v1/skills"},
"toolsets": {"method": "GET", "path": "/v1/toolsets"},
@@ -3844,6 +3848,12 @@ class APIServerAdapter(BasePlatformAdapter):
queue: "asyncio.Queue[Optional[tuple[str, Dict[str, Any]]]]" = asyncio.Queue()
message_id = f"msg_{uuid.uuid4().hex}"
run_id = f"run_{uuid.uuid4().hex}"
self._set_run_status(
run_id,
"queued",
session_id=session_id,
model=body.get("model", self._model_name),
)
seq = 0
def _event_payload(name: str, payload: Dict[str, Any]) -> tuple[str, Dict[str, Any]]:
@@ -3886,6 +3896,7 @@ class APIServerAdapter(BasePlatformAdapter):
"user_message": {"role": "user", "content": user_message},
"runtime": runtime_meta,
}))
self._set_run_status(run_id, "running", last_event="run.started")
await queue.put(_event_payload("message.started", {"message": {"id": message_id, "role": "assistant"}}))
history = await self._conversation_history_for_session(session_id)
result, usage = await self._run_agent(
@@ -3895,6 +3906,7 @@ class APIServerAdapter(BasePlatformAdapter):
session_id=session_id,
stream_delta_callback=_delta,
tool_progress_callback=_tool_progress,
active_run_id=run_id,
gateway_session_key=gateway_session_key,
route=route,
session_model=session_model,
@@ -3932,22 +3944,50 @@ class APIServerAdapter(BasePlatformAdapter):
"interrupted": False,
"runtime": effective_runtime,
}))
await queue.put(_event_payload("run.completed", {
# A steer accepted after the final assistant response is drained
# into result["pending_steer"] by the turn finalizer instead of
# being consumed; surface it so clients can replay it as the
# next user turn rather than silently losing it.
pending_steer = result.get("pending_steer") if isinstance(result, dict) else None
completed_payload = {
"session_id": effective_session_id,
"message_id": message_id,
"completed": True,
"messages": turn_messages,
"usage": usage,
"runtime": effective_runtime,
}))
}
if pending_steer:
completed_payload["pending_steer"] = pending_steer
await queue.put(_event_payload("run.completed", completed_payload))
self._set_run_status(
run_id,
"completed",
session_id=effective_session_id,
usage=usage,
last_event="run.completed",
**({"pending_steer": pending_steer} if pending_steer else {}),
)
except asyncio.CancelledError:
self._set_run_status(run_id, "cancelled", last_event="run.cancelled")
raise
except Exception as exc:
logger.exception("[api_server] session chat stream failed")
self._set_run_status(
run_id,
"failed",
error=_redact_api_error_text(exc),
last_event="run.failed",
)
await queue.put(_event_payload("error", {"message": _redact_api_error_text(exc)}))
finally:
self._active_run_agents.pop(run_id, None)
self._active_run_tasks.pop(run_id, None)
await queue.put(_event_payload("done", {}))
await queue.put(None)
task = asyncio.create_task(_run_and_signal())
self._active_run_tasks[run_id] = task
try:
self._background_tasks.add(task)
except TypeError:
@@ -3976,13 +4016,43 @@ class APIServerAdapter(BasePlatformAdapter):
break
name, payload = item
await response.write(_sse_frame(payload, event=name, ensure_ascii=False))
except (asyncio.CancelledError, ConnectionResetError):
task.cancel()
except (ConnectionResetError, ConnectionAbortedError, BrokenPipeError, OSError):
await self._drain_session_stream_task_on_disconnect(
run_id, task, interrupt_message="SSE client disconnected", shield_wait=False
)
logger.info("Session SSE client disconnected; interrupted live run %s", run_id)
except asyncio.CancelledError:
await self._drain_session_stream_task_on_disconnect(
run_id, task, interrupt_message="SSE task cancelled", shield_wait=True
)
logger.info("Session SSE task cancelled; drained live run %s", run_id)
raise
except Exception as exc:
logger.debug("[api_server] session SSE stream error: %s", exc)
return response
async def _drain_session_stream_task_on_disconnect(
self,
run_id: str,
task: "asyncio.Task",
*,
interrupt_message: str,
shield_wait: bool,
) -> None:
"""Preserve live run control refs until the executor-backed turn actually exits."""
agent = self._active_run_agents.get(run_id)
if agent is None:
if not task.done():
task.cancel()
with suppress(Exception):
await task
return
with suppress(Exception):
agent.interrupt(interrupt_message)
if not task.done():
with suppress(Exception):
await (asyncio.shield(task) if shield_wait else task)
async def _handle_session_model_lock(self, request: "web.Request") -> "web.Response":
"""POST /api/sessions/{session_id}/model — backend-ack a Browser model lock."""
auth_err = self._check_auth(request)
@@ -6136,6 +6206,7 @@ class APIServerAdapter(BasePlatformAdapter):
tool_start_callback=None,
tool_complete_callback=None,
agent_ref: Optional[list] = None,
active_run_id: Optional[str] = None,
gateway_session_key: Optional[str] = None,
requested_model: Optional[str] = None,
requested_provider: Optional[str] = None,
@@ -6170,6 +6241,10 @@ class APIServerAdapter(BasePlatformAdapter):
at ``agent_ref[0]`` before ``run_conversation`` begins. This allows
callers (e.g. the SSE writer) to call ``agent.interrupt()`` from
another thread to stop in-progress LLM calls.
If *active_run_id* is supplied, the same live agent is registered in
``_active_run_agents`` while the turn is running so API clients can
call run-scoped control endpoints such as ``/v1/runs/{run_id}/steer``.
"""
loop = asyncio.get_running_loop()
# Capture before hopping to the executor — ContextVars do not follow
@@ -6205,6 +6280,8 @@ class APIServerAdapter(BasePlatformAdapter):
)
if agent_ref is not None:
agent_ref[0] = agent
if active_run_id:
self._active_run_agents[active_run_id] = agent
effective_task_id = session_id or str(uuid.uuid4())
# Baseline for selective background-process reaping on
# SSE client disconnect — mirrors gateway/run.py's
@@ -6346,6 +6423,8 @@ class APIServerAdapter(BasePlatformAdapter):
# point can't reap background work this turn left
# running on purpose. Mirrors the same race-window guard
# in gateway/run.py's _run_sync_with_timeout_lifecycle.
if active_run_id:
self._active_run_agents.pop(active_run_id, None)
if agent is not None:
_clear_turn_process_ownership(agent)
# Symmetric with the registration above: the turn is
@@ -6773,19 +6852,27 @@ class APIServerAdapter(BasePlatformAdapter):
)
else:
final_response = result.get("final_response", "") if isinstance(result, dict) else ""
_put_event_if_active({
# Undelivered steer text (accepted after the final response;
# see turn_finalizer) rides on the terminal event/status so
# the client can replay it as the next user turn.
pending_steer = result.get("pending_steer") if isinstance(result, dict) else None
completed_event = {
"event": "run.completed",
"run_id": run_id,
"timestamp": time.time(),
"output": final_response,
"usage": usage,
})
}
if pending_steer:
completed_event["pending_steer"] = pending_steer
_put_event_if_active(completed_event)
self._set_run_status(
run_id,
"completed",
output=final_response,
usage=usage,
last_event="run.completed",
**({"pending_steer": pending_steer} if pending_steer else {}),
)
except asyncio.CancelledError:
self._set_run_status(
@@ -7040,6 +7127,66 @@ class APIServerAdapter(BasePlatformAdapter):
"resolved": resolved,
})
async def _handle_steer_run(self, request: "web.Request") -> "web.Response":
"""POST /v1/runs/{run_id}/steer — inject guidance into a running agent."""
auth_err = self._check_auth(request)
if auth_err:
return auth_err
run_id = request.match_info["run_id"]
status = self._run_statuses.get(run_id)
if status is None:
return web.json_response(_openai_error(f"Run not found: {run_id}", code="run_not_found"), status=404)
# Only genuinely running runs are steerable. /stop retains agent/task
# refs during cooperative shutdown, so the status gate (not the mere
# presence of an agent ref) is what rejects stop-then-steer.
agent = self._active_run_agents.get(run_id)
if status.get("status") != "running" or not hasattr(agent, "steer"):
return web.json_response(
_openai_error(
f"Run is not currently accepting steer input: {run_id}",
code="run_not_accepting_steer",
),
status=409,
)
body, err = await self._read_json_body(request)
if err:
return err
raw_text = body.get("input") or body.get("message") or body.get("text") or ""
steer_text = _normalize_chat_content(raw_text).strip()
if not steer_text:
return web.json_response(
_openai_error(
"Missing non-empty steer text; expected 'input', 'message', or 'text'.",
code="invalid_steer_input",
),
status=400,
)
try:
accepted = bool(agent.steer(steer_text))
except Exception as exc:
logger.exception("[api_server] steer failed for run %s", run_id)
return web.json_response(_openai_error(_redact_api_error_text(exc), code="steer_failed"), status=500)
if not accepted:
return web.json_response(
_openai_error(f"Run did not accept steer text: {run_id}", code="steer_not_accepted"),
status=409,
)
self._set_run_status(run_id, "running", last_event="run.steered")
q = self._run_streams.get(run_id)
if q is not None:
with suppress(Exception):
q.put_nowait({
"event": "run.steered",
"run_id": run_id,
"timestamp": time.time(),
"accepted": True,
})
return web.json_response({"object": "hermes.run.steer", "run_id": run_id, "accepted": True})
async def _handle_stop_run(self, request: "web.Request") -> "web.Response":
"""POST /v1/runs/{run_id}/stop — interrupt a running agent."""
auth_err = self._check_auth(request)
+164 -1
View File
@@ -1,9 +1,10 @@
"""Tests for /v1/runs endpoints: start, status, events, and stop.
"""Tests for /v1/runs endpoints: start, status, events, steer, and stop.
Covers:
- POST /v1/runs — start a run (202)
- GET /v1/runs/{run_id} — poll run status
- GET /v1/runs/{run_id}/events — SSE event stream
- POST /v1/runs/{run_id}/steer — inject guidance into a running agent
- POST /v1/runs/{run_id}/stop — interrupt a running agent
- Auth, error handling, and cleanup
"""
@@ -69,6 +70,7 @@ def _create_runs_app(adapter: APIServerAdapter) -> web.Application:
app.router.add_get("/v1/runs/{run_id}", adapter._handle_get_run)
app.router.add_get("/v1/runs/{run_id}/events", adapter._handle_run_events)
app.router.add_post("/v1/runs/{run_id}/approval", adapter._handle_run_approval)
app.router.add_post("/v1/runs/{run_id}/steer", adapter._handle_steer_run)
app.router.add_post("/v1/runs/{run_id}/stop", adapter._handle_stop_run)
return app
@@ -403,6 +405,167 @@ class TestRunEvents:
attacker_interrupted.set()
# ---------------------------------------------------------------------------
# POST /v1/runs/{run_id}/steer — steer a running agent
# ---------------------------------------------------------------------------
class TestSteerRun:
@pytest.mark.asyncio
async def test_steer_running_agent(self, adapter):
app = _create_runs_app(adapter)
agent = MagicMock()
agent.steer.return_value = True
queue = asyncio.Queue()
adapter._active_run_agents["run_123"] = agent
adapter._run_streams["run_123"] = queue
adapter._set_run_status("run_123", "running")
async with TestClient(TestServer(app)) as cli:
resp = await cli.post("/v1/runs/run_123/steer", json={"input": "tighten the ending"})
payload = await resp.json()
assert resp.status == 200
assert payload == {
"object": "hermes.run.steer",
"run_id": "run_123",
"accepted": True,
}
agent.steer.assert_called_once_with("tighten the ending")
assert adapter._run_statuses["run_123"]["last_event"] == "run.steered"
event = queue.get_nowait()
assert event["event"] == "run.steered"
assert event["run_id"] == "run_123"
assert event["accepted"] is True
@pytest.mark.asyncio
async def test_steer_nonexistent_run_returns_404(self, adapter):
app = _create_runs_app(adapter)
async with TestClient(TestServer(app)) as cli:
resp = await cli.post("/v1/runs/run_missing/steer", json={"input": "hello"})
payload = await resp.json()
assert resp.status == 404
assert payload["error"]["code"] == "run_not_found"
@pytest.mark.asyncio
async def test_steer_inactive_run_returns_409(self, adapter):
app = _create_runs_app(adapter)
adapter._set_run_status("run_done", "completed")
async with TestClient(TestServer(app)) as cli:
resp = await cli.post("/v1/runs/run_done/steer", json={"input": "hello"})
payload = await resp.json()
assert resp.status == 409
assert payload["error"]["code"] == "run_not_accepting_steer"
@pytest.mark.asyncio
async def test_steer_missing_input_returns_400(self, adapter):
app = _create_runs_app(adapter)
agent = MagicMock()
agent.steer.return_value = True
adapter._active_run_agents["run_123"] = agent
adapter._set_run_status("run_123", "running")
async with TestClient(TestServer(app)) as cli:
resp = await cli.post("/v1/runs/run_123/steer", json={"input": ""})
payload = await resp.json()
assert resp.status == 400
assert payload["error"]["code"] == "invalid_steer_input"
agent.steer.assert_not_called()
@pytest.mark.asyncio
async def test_stop_then_steer_rejects_retained_agent_ref(self, adapter):
"""Steer must reject a stopping run even if the executor thread is still live."""
app = _create_runs_app(adapter)
run_can_finish = threading.Event()
run_started = threading.Event()
async with TestClient(TestServer(app)) as cli:
with patch.object(adapter, "_create_agent") as mock_create:
mock_agent = MagicMock()
mock_agent.session_prompt_tokens = 0
mock_agent.session_completion_tokens = 0
mock_agent.session_total_tokens = 0
mock_agent.steer = MagicMock(return_value=True)
def _interrupt(_message=None):
return None
def _run_conversation(*_args, **_kwargs):
run_started.set()
run_can_finish.wait(timeout=5)
return {"final_response": "late result"}
mock_agent.interrupt = MagicMock(side_effect=_interrupt)
mock_agent.run_conversation.side_effect = _run_conversation
mock_create.return_value = mock_agent
start_resp = await cli.post("/v1/runs", json={"input": "hello"})
run_id = (await start_resp.json())["run_id"]
assert run_started.wait(timeout=3.0)
stop_resp = await cli.post(f"/v1/runs/{run_id}/stop")
assert stop_resp.status == 200
assert run_id in adapter._active_run_agents
steer_resp = await cli.post(
f"/v1/runs/{run_id}/steer",
json={"input": "tighten the ending"},
)
steer_data = await steer_resp.json()
assert steer_resp.status == 409
assert steer_data["error"]["code"] == "run_not_accepting_steer"
mock_agent.steer.assert_not_called()
run_can_finish.set()
for _ in range(40):
if run_id not in adapter._active_run_tasks:
break
await asyncio.sleep(0.05)
@pytest.mark.asyncio
async def test_pending_steer_preserved_on_run_completed(self, adapter):
"""A steer drained by the turn finalizer (accepted after the final
response) must surface as pending_steer on the terminal run status
instead of being silently dropped."""
app = _create_runs_app(adapter)
async with TestClient(TestServer(app)) as cli:
with patch.object(adapter, "_create_agent") as mock_create:
mock_agent = MagicMock()
mock_agent.session_prompt_tokens = 0
mock_agent.session_completion_tokens = 0
mock_agent.session_total_tokens = 0
mock_agent.run_conversation.return_value = {
"final_response": "done",
"pending_steer": "tighten the ending",
}
mock_create.return_value = mock_agent
start_resp = await cli.post("/v1/runs", json={"input": "hello"})
run_id = (await start_resp.json())["run_id"]
for _ in range(40):
status = adapter._run_statuses.get(run_id, {})
if status.get("status") == "completed":
break
await asyncio.sleep(0.05)
assert adapter._run_statuses[run_id]["status"] == "completed"
assert adapter._run_statuses[run_id]["pending_steer"] == "tighten the ending"
@pytest.mark.asyncio
async def test_steer_requires_auth(self, auth_adapter):
app = _create_runs_app(auth_adapter)
async with TestClient(TestServer(app)) as cli:
resp = await cli.post("/v1/runs/run_any/steer", json={"input": "hello"})
assert resp.status == 401
# ---------------------------------------------------------------------------
# Run lifecycle TTL sweeping
# ---------------------------------------------------------------------------
+137 -1
View File
@@ -1,6 +1,8 @@
"""Focused tests for API server session-control endpoints."""
from unittest.mock import AsyncMock, patch
import asyncio
import threading
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from aiohttp import web
@@ -64,6 +66,7 @@ async def test_capabilities_advertises_session_control_surface(adapter):
assert features["session_chat"] is True
assert features["session_chat_streaming"] is True
assert features["session_fork"] is True
assert features["run_steer"] is True
assert features["admin_config_rw"] is False
assert features["memory_write_api"] is False
assert features["skills_api"] is True
@@ -73,6 +76,10 @@ async def test_capabilities_advertises_session_control_surface(adapter):
"method": "POST",
"path": "/api/sessions/{session_id}/chat/stream",
}
assert data["endpoints"]["run_steer"] == {
"method": "POST",
"path": "/v1/runs/{run_id}/steer",
}
@pytest.mark.asyncio
@@ -160,6 +167,135 @@ async def test_run_agent_binds_api_session_context_for_tool_env(adapter, monkeyp
}
@pytest.mark.asyncio
async def test_run_agent_registers_active_run_id_for_steering(adapter, monkeypatch):
observed = {}
class FakeAgent:
session_prompt_tokens = 0
session_completion_tokens = 0
session_total_tokens = 0
def __init__(self, session_id: str):
self.session_id = session_id
def steer(self, text: str) -> bool:
observed["steer_text"] = text
return True
def run_conversation(self, user_message, conversation_history, task_id):
observed["registered"] = adapter._active_run_agents.get("run_steer_test") is self
observed["task_id"] = task_id
return {"final_response": "ok"}
def fake_create_agent(**kwargs):
return FakeAgent(kwargs["session_id"])
monkeypatch.setattr(adapter, "_create_agent", fake_create_agent)
result, usage = await adapter._run_agent(
user_message="hello",
conversation_history=[],
session_id="request-session",
active_run_id="run_steer_test",
)
assert result["session_id"] == "request-session"
assert usage == {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}
assert observed == {"registered": True, "task_id": "request-session"}
assert "run_steer_test" not in adapter._active_run_agents
@pytest.mark.asyncio
async def test_session_chat_stream_disconnect_keeps_control_refs_until_executor_finishes(
adapter, session_db
):
"""Disconnects must interrupt the live run without dropping its control refs early."""
session_id = session_db.create_session("disconnect-stream-session", "api_server")
run_started = threading.Event()
interrupt_called = threading.Event()
allow_finish = threading.Event()
write_calls = {"count": 0}
class FakeAgent:
session_prompt_tokens = 0
session_completion_tokens = 0
session_total_tokens = 0
def __init__(self, stream_delta_callback):
self._stream_delta_callback = stream_delta_callback
self.session_id = session_id
def interrupt(self, _message=None):
interrupt_called.set()
def run_conversation(self, user_message, conversation_history, task_id):
del user_message, conversation_history, task_id
run_started.set()
self._stream_delta_callback("hello")
allow_finish.wait(timeout=5)
return {"final_response": "done", "session_id": session_id}
class DisconnectingStreamResponse:
async def prepare(self, request):
del request
async def write(self, payload):
del payload
write_calls["count"] += 1
if write_calls["count"] >= 3:
raise ConnectionResetError("simulated client disconnect")
request = MagicMock()
request.headers = {}
request.match_info = {"session_id": session_id}
def _create_agent(**kwargs):
return FakeAgent(kwargs["stream_delta_callback"])
with patch.object(
adapter,
"_get_existing_session_or_404",
return_value=({"id": session_id}, None),
), patch.object(
adapter,
"_read_json_body",
return_value=({"message": "stream please"}, None),
), patch.object(
adapter,
"_create_agent",
side_effect=_create_agent,
), patch(
"gateway.platforms.api_server.web.StreamResponse",
return_value=DisconnectingStreamResponse(),
):
handler_task = asyncio.create_task(adapter._handle_session_chat_stream(request))
for _ in range(60):
if run_started.is_set():
break
await asyncio.sleep(0.05)
assert run_started.is_set()
run_id = next(iter(adapter._run_statuses))
for _ in range(40):
if interrupt_called.is_set():
break
await asyncio.sleep(0.05)
assert interrupt_called.is_set()
assert run_id in adapter._active_run_agents
assert run_id in adapter._active_run_tasks
assert not handler_task.done()
allow_finish.set()
await handler_task
assert run_id not in adapter._active_run_agents
assert run_id not in adapter._active_run_tasks
@pytest.mark.asyncio
async def test_session_chat_stream_run_completed_carries_turn_transcript(adapter, session_db):
"""run.completed must include the full interleaved turn transcript so a
@@ -109,6 +109,7 @@ POST /v1/runs Start a run, returns run_id (202)
GET /v1/runs/{id} Run status
GET /v1/runs/{id}/events SSE stream of lifecycle events
POST /v1/runs/{id}/approval Resolve a pending approval
POST /v1/runs/{id}/steer Inject mid-run guidance at the next tool boundary
POST /v1/runs/{id}/stop Interrupt the run
GET /v1/capabilities Machine-readable feature flags
GET /v1/models Lists hermes-agent
@@ -142,6 +143,12 @@ probe policy:
Use `/v1/models` for OpenAI-client compatibility. Use `/api/model/options` or
`model.options` when you are building a Hermes-aware model picker.
`POST /v1/runs/{id}/steer` is the HTTP equivalent of Hermes `/steer`: it does not create a new user turn or immediately rewrite the assistant output already in flight. Instead, the text is appended to the live run and becomes visible to the agent after the next tool boundary, so it can course-correct without discarding the current tool-calling loop.
`/v1/runs/{id}/steer` is only accepted while the run status is `running`. Queued, approval-paused, stopping, cancelled, failed, and completed runs return `409 run_not_accepting_steer`, even if the server still retains internal agent references during cooperative shutdown.
A `200` (and the `run.steered` event) means the text was **queued**, not that the agent consumed it. If a steer lands after the agent's final response — with no later tool boundary to deliver it at — the undelivered text is returned as `pending_steer` on the terminal `run.completed` event and run status, so the client can replay it as the next user turn instead of losing it.
---
## Which one should I use?