OpenSpace/openspace/core/background_runtime.py
2026-07-17 11:43:42 +08:00

711 lines
26 KiB
Python

"""TUI-facing background runtime adapter.
The manager binds a real background runtime/orchestrator and converts its
events into UI-friendly snapshots.
"""
from __future__ import annotations
import copy
import inspect
import time
import uuid
from typing import Any, Callable
from openspace.core.agent_runtime_events import (
AgentRuntimeEvent,
RUNTIME_AGENT_EVENT_TYPES,
RUNTIME_EVENT_TYPES,
coerce_runtime_event,
)
from openspace.utils.logging import Logger
logger = Logger.get_logger(__name__)
SnapshotCallback = Callable[[str, dict[str, Any]], Any]
SUPPORTED_BACKGROUND_RUNTIME_EVENTS = RUNTIME_EVENT_TYPES | {
"agent_list",
"agent_event",
"agent_transcript",
"status_update",
}
TASK_UPDATE_EVENT_STATUSES: dict[str, str] = {
"agent_task_complete": "completed",
"task_started": "running",
"task_completed": "completed",
"task_failed": "failed",
"task_stopped": "killed",
}
class BackgroundRuntimeManager:
"""Track one background session and emit UI-friendly snapshots."""
def __init__(
self,
emit_callback: SnapshotCallback | None = None,
*,
session_title: str = "Background session",
primary_agent_id: str = "primary",
primary_agent_name: str = "Primary agent",
) -> None:
self._emit_callback = emit_callback
self._runtime: Any | None = None
self._session_title = session_title.strip() or "Background session"
self._primary_agent_id = primary_agent_id.strip() or "primary"
self._primary_agent_name = (
primary_agent_name.strip() or "Primary agent"
)
self._session_id = uuid.uuid4().hex
self._run_index = 0
self._status = "idle"
self._created_at = _utc_now()
self._started_at: str | None = None
self._finished_at: str | None = None
self._metadata: dict[str, Any] = {}
self._active_agent_id = self._primary_agent_id
self._last_input: dict[str, Any] | None = None
self._last_control: dict[str, Any] | None = None
self._last_event_kind: str | None = None
self._agents: dict[str, dict[str, Any]] = {
self._primary_agent_id: self._make_agent(
self._primary_agent_id,
self._primary_agent_name,
)
}
self._events: list[dict[str, Any]] = []
self._transcript: list[dict[str, Any]] = []
@property
def session_id(self) -> str:
return self._session_id
@property
def status(self) -> str:
return self._status
@property
def is_running(self) -> bool:
return self._status == "running"
@property
def is_paused(self) -> bool:
return self._status == "paused"
@property
def is_idle(self) -> bool:
return self._status == "idle"
@property
def is_stopped(self) -> bool:
return self._status == "stopped"
def set_event_callback(self, callback: SnapshotCallback | None) -> None:
self._emit_callback = callback
def bind_runtime(self, runtime: Any) -> Any:
"""Attach a runtime/orchestrator that will receive delegated commands."""
if runtime is None:
raise RuntimeError("BackgroundRuntimeManager requires a bound runtime")
self._runtime = runtime
for attr in ("register_event_sink", "set_event_sink", "bind_event_sink"):
sink = getattr(runtime, attr, None)
if callable(sink):
try:
sink(self.ingest_runtime_event)
break
except Exception:
logger.exception("Failed to bind runtime event sink via %s", attr)
return runtime
async def dispatch(
self,
event_type: str,
data: dict[str, Any] | str | None = None,
) -> dict[str, Any]:
if event_type == "agent_input":
return await self.handle_agent_input(data)
if event_type == "background_control":
return await self.handle_background_control(data)
raise ValueError(f"Unsupported background runtime event type: {event_type}")
async def handle_agent_input(
self,
data: dict[str, Any] | str | None,
) -> dict[str, Any]:
payload = self._normalize_input(data)
result = await self._invoke_runtime("handle_agent_input", payload)
self._apply_runtime_result(result)
return self.snapshot()
async def handle_background_control(
self,
data: dict[str, Any] | str | None,
) -> dict[str, Any]:
payload = self._normalize_control(data)
result = await self._invoke_runtime("handle_background_control", payload)
self._apply_runtime_result(result)
return self.snapshot()
async def ingest_runtime_event(
self,
event_type: str | AgentRuntimeEvent | dict[str, Any],
payload: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Ingest a normalized runtime event emitted by a bound executor."""
runtime_event = coerce_runtime_event(event_type, payload)
etype = runtime_event.event_type
event_payload = runtime_event.payload
if etype not in SUPPORTED_BACKGROUND_RUNTIME_EVENTS:
logger.debug("Ignoring non-background runtime event: %s", etype)
return self.snapshot()
if etype == "agent_list":
self._sync_agent_list_from_snapshot(event_payload)
await self._emit_state(
None,
include_agent_list=True,
emit_agent_event=False,
emit_transcript=False,
)
return self.snapshot()
if etype == "agent_event":
self._sync_events_from_snapshot(event_payload)
await self._emit_state(
None,
include_agent_list=True,
emit_agent_event=False,
emit_transcript=False,
)
return self.snapshot()
if etype == "agent_transcript":
self._sync_transcript_from_snapshot(event_payload)
await self._emit_state(
None,
include_agent_list=False,
emit_agent_event=False,
emit_transcript=False,
)
return self.snapshot()
if etype == "status_update":
event_payload.setdefault("session_id", self._session_id)
await self._emit("status_update", event_payload)
return self.snapshot()
if etype == "background_session_update":
self._sync_session_from_runtime(event_payload)
event = self._append_event(
etype,
agent_id=str(
event_payload.get("active_agent_id")
or event_payload.get("agent_id")
or self._active_agent_id
),
payload=copy.deepcopy(event_payload),
source="runtime",
)
await self._emit_state(event, include_agent_list=True)
return self.snapshot()
if etype in TASK_UPDATE_EVENT_STATUSES:
event_payload.setdefault("status", TASK_UPDATE_EVENT_STATUSES[etype])
etype = "agent_task_update"
if etype == "team_update":
team_name = event_payload.get("team_name")
if team_name:
self._metadata["team_name"] = str(team_name)
team_status = event_payload.get("status")
if team_status:
self._metadata["team_status"] = str(team_status)
event = self._append_event(
etype,
agent_id=str(event_payload.get("agent_id") or self._primary_agent_id),
payload=copy.deepcopy(event_payload),
source="runtime",
)
await self._emit_state(event, include_agent_list=True)
return self.snapshot()
if etype == "todo_update":
event = self._append_event(
etype,
agent_id=str(event_payload.get("agent_id") or self._primary_agent_id),
payload=copy.deepcopy(event_payload),
source="runtime",
)
await self._emit_state(event, include_agent_list=False)
return self.snapshot()
if etype in RUNTIME_AGENT_EVENT_TYPES:
agent = self._upsert_agent_from_runtime(event_payload)
if etype in {"agent_start", "agent_spawn"}:
self._status = "running"
self._active_agent_id = agent["agent_id"]
self._last_control = {
"action": (
"runtime_agent_start"
if etype == "agent_start"
else "runtime_agent_spawn"
),
"payload": copy.deepcopy(event_payload),
}
elif etype in {"agent_progress", "agent_task_update"}:
self._status = "running"
self._active_agent_id = agent["agent_id"]
elif etype == "agent_output":
agent["last_output_at"] = _utc_now()
elif etype == "agent_error":
agent["last_error_at"] = _utc_now()
elif etype == "agent_complete":
agent["last_completed_at"] = _utc_now()
if etype in {"agent_output", "agent_error"}:
self._append_transcript_from_runtime(runtime_event)
event = self._append_event(
etype,
agent_id=agent["agent_id"],
payload=copy.deepcopy(event_payload),
source="runtime",
)
await self._emit_state(event, include_agent_list=True)
return self.snapshot()
logger.debug("Ignoring unsupported background runtime event: %s", etype)
return self.snapshot()
def snapshot(self) -> dict[str, Any]:
return {
"session": self._session_snapshot(),
"agents": self._agent_list_snapshot(),
"events": copy.deepcopy(self._events),
"transcript": copy.deepcopy(self._transcript),
}
async def _invoke_runtime(self, method_name: str, payload: dict[str, Any]) -> Any:
runtime = self._runtime
if runtime is None:
raise RuntimeError("BackgroundRuntimeManager has no bound runtime")
method = getattr(runtime, method_name, None)
if method is None and hasattr(runtime, "dispatch"):
if method_name == "handle_agent_input":
method = lambda data: runtime.dispatch("agent_input", data)
elif method_name == "handle_background_control":
method = lambda data: runtime.dispatch("background_control", data)
if method is None:
raise RuntimeError(f"Bound runtime does not implement {method_name}")
result = method(payload)
if inspect.isawaitable(result):
result = await result
return result
def _apply_runtime_result(self, result: Any) -> None:
if not isinstance(result, dict):
return
if "session" in result and isinstance(result["session"], dict):
self._sync_session_from_runtime(result["session"])
if "agents" in result and isinstance(result["agents"], dict):
self._sync_agent_list_from_snapshot(result["agents"])
if "events" in result and isinstance(result["events"], list):
self._events = copy.deepcopy(result["events"])
if "transcript" in result and isinstance(result["transcript"], list):
self._transcript = copy.deepcopy(result["transcript"])
def _reset_session_state(
self,
*,
session_id: str | None = None,
run_index: int | None = None,
) -> None:
self._session_id = session_id or uuid.uuid4().hex
self._run_index = run_index if run_index is not None else self._run_index + 1
self._status = "idle"
self._created_at = _utc_now()
self._started_at = None
self._finished_at = None
self._metadata = {}
self._active_agent_id = self._primary_agent_id
self._last_input = None
self._last_control = None
self._last_event_kind = None
self._events = []
self._transcript = []
self._agents = {
self._primary_agent_id: self._make_agent(
self._primary_agent_id,
self._primary_agent_name,
)
}
def _sync_session_from_runtime(self, payload: dict[str, Any]) -> None:
session_id = payload.get("session_id")
if session_id and session_id != self._session_id:
self._reset_session_state(
session_id=str(session_id),
run_index=_optional_int(payload.get("run_index")),
)
run_index = _optional_int(payload.get("run_index"))
if run_index is not None:
self._run_index = run_index
title = payload.get("title")
if isinstance(title, str) and title.strip():
self._session_title = title.strip()
status = payload.get("status")
if isinstance(status, str) and status.strip():
self._status = status.strip().lower()
created_at = payload.get("created_at")
if isinstance(created_at, str) and created_at:
self._created_at = created_at
started_at = payload.get("started_at")
if isinstance(started_at, str) and started_at:
self._started_at = started_at
finished_at = payload.get("finished_at")
if isinstance(finished_at, str) and finished_at:
self._finished_at = finished_at
active_agent_id = payload.get("active_agent_id")
if isinstance(active_agent_id, str) and active_agent_id.strip():
self._active_agent_id = active_agent_id.strip()
metadata = payload.get("metadata")
if isinstance(metadata, dict):
self._metadata.update(copy.deepcopy(metadata))
agents = payload.get("agents")
if isinstance(agents, list):
self._sync_agent_list_from_iterable(agents)
self._last_control = copy.deepcopy(payload)
def _sync_agent_list_from_snapshot(self, payload: dict[str, Any]) -> None:
session_id = payload.get("session_id")
if session_id and session_id != self._session_id:
self._reset_session_state(
session_id=str(session_id),
run_index=_optional_int(payload.get("run_index")),
)
run_index = _optional_int(payload.get("run_index"))
if run_index is not None:
self._run_index = run_index
status = payload.get("status")
if isinstance(status, str) and status.strip():
self._status = status.strip().lower()
active_agent_id = payload.get("active_agent_id")
if isinstance(active_agent_id, str) and active_agent_id.strip():
self._active_agent_id = active_agent_id.strip()
agents = payload.get("agents")
if isinstance(agents, list):
self._sync_agent_list_from_iterable(agents)
def _sync_agent_list_from_iterable(self, agents: list[dict[str, Any]]) -> None:
normalized: dict[str, dict[str, Any]] = {}
for raw_agent in agents:
if not isinstance(raw_agent, dict):
continue
agent = self._upsert_agent_from_runtime(raw_agent)
normalized[agent["agent_id"]] = agent
if self._primary_agent_id not in normalized:
normalized[self._primary_agent_id] = self._make_agent(
self._primary_agent_id,
self._primary_agent_name,
)
self._agents = normalized
def _sync_events_from_snapshot(self, payload: dict[str, Any]) -> None:
events = payload.get("events")
if isinstance(events, list):
self._events = copy.deepcopy(events)
latest_event = payload.get("latest_event")
if isinstance(latest_event, dict):
self._last_event_kind = str(
latest_event.get("kind")
or latest_event.get("event_type")
or self._last_event_kind
)
def _sync_transcript_from_snapshot(self, payload: dict[str, Any]) -> None:
transcript = payload.get("transcript")
if isinstance(transcript, list):
self._transcript = copy.deepcopy(transcript)
def _append_transcript_from_runtime(
self,
runtime_event: AgentRuntimeEvent,
) -> dict[str, Any]:
payload = runtime_event.payload
agent_id = runtime_event.agent_id or str(
payload.get("agent_id") or self._active_agent_id
)
agent = self._agents.get(agent_id) or self._upsert_agent_from_runtime(
{
"agent_id": agent_id,
"name": payload.get("name") or payload.get("agent_name") or agent_id,
}
)
content = self._extract_content(payload)
entry = {
"entry_id": uuid.uuid4().hex,
"kind": runtime_event.event_type,
"role": str(
payload.get("role")
or ("system" if runtime_event.event_type == "agent_error" else "assistant")
),
"agent_id": agent_id,
"agent_name": agent["name"],
"content": copy.deepcopy(content),
"payload": copy.deepcopy(payload),
"timestamp": _utc_now(),
}
self._transcript.append(entry)
return entry
def _upsert_agent_from_runtime(self, payload: dict[str, Any]) -> dict[str, Any]:
agent_id = str(payload.get("agent_id") or payload.get("id") or self._primary_agent_id)
agent_name = str(
payload.get("name")
or payload.get("agent_name")
or self._agents.get(agent_id, {}).get("name")
or agent_id
)
agent = self._agents.get(agent_id)
if agent is None:
agent = self._make_agent(
agent_id,
agent_name,
is_primary=agent_id == self._primary_agent_id,
)
self._agents[agent_id] = agent
else:
agent["name"] = agent_name
for key in ("status", "model", "task_id", "summary"):
value = payload.get(key)
if value is not None:
agent[key] = value
metadata = payload.get("metadata")
if isinstance(metadata, dict):
agent["metadata"].update(copy.deepcopy(metadata))
return agent
def _ensure_agent(self, agent_id: str, agent_name: str) -> dict[str, Any]:
agent = self._agents.get(agent_id)
if agent is None:
agent = self._make_agent(agent_id, agent_name, is_primary=False)
self._agents[agent_id] = agent
elif agent_name and agent["name"] != agent_name:
agent["name"] = agent_name
return agent
def _make_agent(
self,
agent_id: str,
agent_name: str,
*,
is_primary: bool = True,
) -> dict[str, Any]:
return {
"agent_id": agent_id,
"name": agent_name,
"kind": "primary" if is_primary else "auxiliary",
"status": "idle",
"is_primary": is_primary,
"input_count": 0,
"event_count": 0,
"last_event_kind": None,
"last_input_at": None,
"last_output_at": None,
"last_error_at": None,
"last_completed_at": None,
"model": None,
"task_id": None,
"summary": None,
"metadata": {},
}
def _append_event(
self,
kind: str,
*,
agent_id: str,
payload: dict[str, Any],
source: str = "runtime",
) -> dict[str, Any]:
event = {
"event_id": uuid.uuid4().hex,
"kind": kind,
"agent_id": agent_id,
"session_id": self._session_id,
"run_index": self._run_index,
"timestamp": _utc_now(),
"source": source,
"payload": copy.deepcopy(payload),
}
self._events.append(event)
agent = self._ensure_agent(agent_id, self._agents.get(agent_id, {}).get("name", agent_id))
agent["event_count"] += 1
agent["last_event_kind"] = kind
self._last_event_kind = kind
return event
async def _emit_state(
self,
event: dict[str, Any] | None,
*,
include_agent_list: bool = False,
emit_agent_event: bool = True,
emit_transcript: bool = True,
) -> None:
snapshots: list[tuple[str, dict[str, Any]]] = []
if include_agent_list:
snapshots.append(("agent_list", self._agent_list_snapshot()))
if emit_agent_event and event is not None:
snapshots.append(("agent_event", self._agent_event_snapshot(event)))
if emit_transcript:
snapshots.append(("agent_transcript", self._transcript_snapshot()))
snapshots.append(("background_session_update", self._session_snapshot()))
for event_type, payload in snapshots:
await self._emit(event_type, payload)
async def _emit(self, event_type: str, data: dict[str, Any]) -> None:
callback = self._emit_callback
if callback is None:
return
try:
result = callback(event_type, copy.deepcopy(data))
if inspect.isawaitable(result):
await result
except Exception:
logger.exception("Background runtime callback failed for %s", event_type)
def _apply_runtime_result(self, result: Any) -> None:
if not isinstance(result, dict):
return
if "session" in result and isinstance(result["session"], dict):
self._sync_session_from_runtime(result["session"])
if "agents" in result and isinstance(result["agents"], dict):
self._sync_agent_list_from_snapshot(result["agents"])
if "events" in result and isinstance(result["events"], list):
self._events = copy.deepcopy(result["events"])
if "transcript" in result and isinstance(result["transcript"], list):
self._transcript = copy.deepcopy(result["transcript"])
def _agent_list_snapshot(self) -> dict[str, Any]:
agents = [copy.deepcopy(agent) for agent in self._agents.values()]
agents.sort(key=lambda agent: (not agent.get("is_primary", False), agent["agent_id"]))
return {
"session_id": self._session_id,
"run_index": self._run_index,
"status": self._status,
"primary_agent_id": self._primary_agent_id,
"active_agent_id": self._active_agent_id,
"agents": agents,
}
def _agent_event_snapshot(self, event: dict[str, Any]) -> dict[str, Any]:
return {
"session_id": self._session_id,
"run_index": self._run_index,
"event_count": len(self._events),
"latest_event": copy.deepcopy(event),
"events": copy.deepcopy(self._events),
}
def _transcript_snapshot(self) -> dict[str, Any]:
return {
"session_id": self._session_id,
"run_index": self._run_index,
"transcript_count": len(self._transcript),
"transcript": copy.deepcopy(self._transcript),
}
def _session_snapshot(self) -> dict[str, Any]:
return {
"session_id": self._session_id,
"run_index": self._run_index,
"title": self._session_title,
"status": self._status,
"created_at": self._created_at,
"started_at": self._started_at,
"finished_at": self._finished_at,
"updated_at": _utc_now(),
"primary_agent_id": self._primary_agent_id,
"active_agent_id": self._active_agent_id,
"agent_count": len(self._agents),
"event_count": len(self._events),
"transcript_count": len(self._transcript),
"last_input": copy.deepcopy(self._last_input),
"last_control": copy.deepcopy(self._last_control),
"last_event_kind": self._last_event_kind,
"metadata": copy.deepcopy(self._metadata),
}
@staticmethod
def _normalize_input(data: dict[str, Any] | str | None) -> dict[str, Any]:
if data is None:
return {}
if isinstance(data, str):
return {"content": data}
if isinstance(data, dict):
return copy.deepcopy(data)
raise TypeError(f"Unsupported agent input payload: {type(data)!r}")
@staticmethod
def _normalize_control(data: dict[str, Any] | str | None) -> dict[str, Any]:
if data is None:
raise ValueError("background_control requires an action")
if isinstance(data, str):
return {"action": data.strip().lower()}
if not isinstance(data, dict):
raise TypeError(f"Unsupported background control payload: {type(data)!r}")
payload = copy.deepcopy(data)
action = str(payload.get("action") or payload.get("command") or "").strip().lower()
if not action:
raise ValueError("background_control requires an action")
payload["action"] = action
return payload
@staticmethod
def _extract_content(payload: dict[str, Any]) -> Any:
for key in ("content", "text", "message", "output", "response", "instruction"):
if key in payload:
return payload[key]
return payload
def _optional_int(value: Any) -> int | None:
try:
if value is None:
return None
return int(value)
except (TypeError, ValueError):
return None
def _utc_now() -> str:
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())