Files
openswarm/openswarm-runner/runner/workflow_run.py
T

225 lines
7.9 KiB
Python

"""Drive one workflow through the backend's own HTTP surface and collect its result.
Deliberately no shortcuts into agent_manager: the cloud run fires the same route the
Run button fires, so the MCP gate, action filtering, provider routing and history all
behave exactly as they do on a laptop.
"""
import json
import time
from typing import Any, Callable, Dict, List, Optional
import httpx
from pydantic import BaseModel, ConfigDict, Field
from typeguard import typechecked
from runner.backend_process import BackendProcess
TERMINAL_STATUSES = ("success", "failure", "ran_late", "skipped")
POLL_INTERVAL_SECONDS = 1.0
TRANSCRIPT_MAX_CHARS = 14000
class WorkflowRunFailed(RuntimeError):
"""The backend refused to start the run at all."""
class RunProgress(BaseModel):
model_config = ConfigDict(validate_assignment=True)
run_id: str
status: str
active_step_idx: Optional[int] = None
last_tool_label: Optional[str] = None
class RunOutcome(BaseModel):
model_config = ConfigDict(validate_assignment=True)
run_id: str
status: str
error: Optional[str] = None
cost_usd: float = 0.0
session_id: Optional[str] = None
transcript: str = ""
answer: str = ""
system_notices: List[str] = Field(default_factory=list)
@typechecked
def p_block_text(block: Dict[str, Any]) -> str:
kind = block.get("type")
if kind == "text":
return str(block.get("text") or "")
if kind == "tool_use":
return f"[tool {block.get('name')}] {json.dumps(block.get('input') or {})[:300]}"
if kind == "tool_result":
inner = block.get("content")
return f"[result] {inner if isinstance(inner, str) else json.dumps(inner)[:300]}"
return ""
@typechecked
def p_message_text(message: Dict[str, Any]) -> str:
content = message.get("content")
if isinstance(content, str):
return content
if isinstance(content, list):
parts = [p_block_text(block) for block in content if isinstance(block, dict)]
return "\n".join(part for part in parts if part)
return ""
@typechecked
def render_transcript(messages: List[Dict[str, Any]]) -> str:
"""Role-tagged flatten, tail-biased so the end of a long run always survives the cap."""
lines: List[str] = []
for message in messages:
if message.get("hidden"):
continue
text = p_message_text(message).strip()
if text:
lines.append(f"{str(message.get('role') or '?').upper()}: {text}")
joined = "\n\n".join(lines)
if len(joined) > TRANSCRIPT_MAX_CHARS:
return "...(earlier turns trimmed)...\n\n" + joined[-TRANSCRIPT_MAX_CHARS:]
return joined
@typechecked
def final_answer(messages: List[Dict[str, Any]]) -> str:
"""Last visible assistant text: the thing a user actually asked the workflow for."""
for message in reversed(messages):
if message.get("hidden") or message.get("role") != "assistant":
continue
text = p_message_text(message).strip()
if text:
return text
return ""
@typechecked
def system_notices(messages: List[Dict[str, Any]]) -> List[str]:
"""Every system-role bubble in the session.
The backend appends a system message only when something went wrong (a dead
provider token, a run error, a blocked tool), and it does NOT fail the run for
those, so a workflow whose credential was rejected still comes back "success".
Keyed on the typed role, not on the prose, and reported rather than judged: the
control plane decides what a notice means for billing and retries.
"""
notices: List[str] = []
for message in messages:
if message.get("role") != "system" or message.get("hidden"):
continue
text = p_message_text(message).strip()
if text:
notices.append(text)
return notices
@typechecked
def p_get_json(client: httpx.Client, backend: BackendProcess, path: str) -> Dict[str, Any]:
response = client.get(f"{backend.base_url}{path}", headers=backend.headers())
response.raise_for_status()
payload = response.json()
return payload if isinstance(payload, dict) else {}
@typechecked
def trigger_run(client: httpx.Client, backend: BackendProcess, workflow_id: str) -> str:
response = client.post(
f"{backend.base_url}/api/workflows/{workflow_id}/run",
headers=backend.headers(),
json={},
)
response.raise_for_status()
body = response.json()
run_id = str(body.get("run_id") or "")
if not run_id:
raise WorkflowRunFailed(
f"backend accepted the trigger but never created a run for workflow {workflow_id}"
)
if body.get("status") == "failure":
raise WorkflowRunFailed(str(body.get("error") or "run failed immediately"))
return run_id
@typechecked
def p_find_run(client: httpx.Client, backend: BackendProcess, workflow_id: str, run_id: str) -> Dict[str, Any]:
body = p_get_json(client, backend, f"/api/workflows/{workflow_id}/runs?limit=50")
for record in body.get("runs") or []:
if isinstance(record, dict) and record.get("id") == run_id:
return record
return {}
@typechecked
def p_stop_run(client: httpx.Client, backend: BackendProcess, run_id: str) -> None:
try:
client.post(f"{backend.base_url}/api/workflows/runs/{run_id}/stop", headers=backend.headers())
except httpx.HTTPError:
pass
@typechecked
def p_collect_session(client: httpx.Client, backend: BackendProcess, session_id: str) -> List[Dict[str, Any]]:
try:
body = p_get_json(client, backend, f"/api/agents/sessions/{session_id}")
except httpx.HTTPError:
return []
messages = body.get("messages")
return [m for m in messages if isinstance(m, dict)] if isinstance(messages, list) else []
@typechecked
def execute_workflow(
backend: BackendProcess,
workflow_id: str,
deadline: float,
on_progress: Optional[Callable[[RunProgress], None]] = None,
) -> RunOutcome:
"""Fire the workflow, poll it to a terminal state, and pull the transcript back.
Blowing the deadline stops the run and reports `timed_out`; the caller still gets
whatever the agent produced before the wall came down.
"""
with httpx.Client(timeout=30.0) as client:
run_id = trigger_run(client, backend, workflow_id)
record: Dict[str, Any] = {}
timed_out = False
while True:
record = p_find_run(client, backend, workflow_id, run_id) or record
status = str(record.get("status") or "running")
if on_progress is not None:
on_progress(RunProgress(
run_id=run_id,
status=status,
active_step_idx=record.get("active_step_idx"),
last_tool_label=record.get("last_tool_label"),
))
if status in TERMINAL_STATUSES:
break
if not backend.is_alive():
raise WorkflowRunFailed("backend died while the workflow was running")
if time.monotonic() >= deadline:
timed_out = True
p_stop_run(client, backend, run_id)
record = p_find_run(client, backend, workflow_id, run_id) or record
break
time.sleep(POLL_INTERVAL_SECONDS)
session_id = record.get("session_id")
messages = p_collect_session(client, backend, str(session_id)) if session_id else []
return RunOutcome(
run_id=run_id,
status="timed_out" if timed_out else str(record.get("status") or "failure"),
error=("wall-clock cap reached before the workflow finished" if timed_out else record.get("error")),
cost_usd=float(record.get("cost_usd") or 0.0),
session_id=str(session_id) if session_id else None,
transcript=render_transcript(messages),
answer=final_answer(messages),
system_notices=system_notices(messages),
)