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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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?
|
||||
|
||||
Reference in New Issue
Block a user