614 lines
27 KiB
Python
614 lines
27 KiB
Python
"""Stateful OPCEvent → VisualEvent translator.
|
|
|
|
Tracks per-agent animation state to synthesize paired start/done events
|
|
that the office-UI frontend expects.
|
|
|
|
OPC emits linear transitions: thinking → executing → thinking → idle
|
|
UI expects paired events: reflect_start → reflect_done → tool_start → tool_done → waiting
|
|
|
|
The EventAdapter bridges this gap by maintaining a state machine per agent.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
import uuid
|
|
from dataclasses import dataclass, field
|
|
from enum import Enum
|
|
from typing import Any
|
|
|
|
|
|
# ── OPC tool name → UI tool name mapping ──────────────────────────────────
|
|
|
|
TOOL_MAP: dict[str, str] = {
|
|
"file_read": "read",
|
|
"file_search": "search",
|
|
"list_dir": "list",
|
|
"web_search": "web_search",
|
|
"web_fetch": "fetch",
|
|
"todo_read": "read",
|
|
"file_write": "write",
|
|
"file_edit": "edit",
|
|
"apply_patch": "edit",
|
|
"shell_exec": "shell",
|
|
"python_exec": "shell",
|
|
"git_status": "shell",
|
|
"git_commit": "shell",
|
|
"git_diff": "shell",
|
|
"todo_write": "write",
|
|
"agent_spawn": "reflect",
|
|
"browser_navigate": "web_search",
|
|
"browser_snapshot": "read",
|
|
"browser_click": "shell",
|
|
"browser_type": "write",
|
|
"browser_take_screenshot": "read",
|
|
"browser_close": "shell",
|
|
"browser_wait_for": "shell",
|
|
"browser_scroll": "shell",
|
|
"browser_select_option": "write",
|
|
"browser_navigate_back": "web_search",
|
|
}
|
|
|
|
# Collaboration tools whose visual effects are driven by downstream semantic
|
|
# events (meeting_started → collab_started, agent_message_sent → message_out).
|
|
# Emitting tool_start for these would conflict: the agent walks to the desk
|
|
# first, then immediately redirects to the meeting room when the real event
|
|
# arrives. Skipping tool_start/tool_done avoids this visual stutter.
|
|
COLLAB_SKIP_TOOLS: frozenset[str] = frozenset({
|
|
"start_meeting",
|
|
"respond_meeting",
|
|
"send_dm",
|
|
"reply_message",
|
|
"broadcast_issue",
|
|
})
|
|
|
|
# Collaboration tools that have NO downstream semantic event.
|
|
# We emit a lightweight visual event directly instead of tool_start.
|
|
COLLAB_DIRECT_MAP: dict[str, str] = {
|
|
"inbox": "message_in",
|
|
"read_inbox": "message_in",
|
|
}
|
|
|
|
|
|
# ── Per-agent animation state ─────────────────────────────────────────────
|
|
|
|
class AgentAnimState(Enum):
|
|
IDLE = "idle"
|
|
REFLECTING = "reflecting"
|
|
TOOL_ACTIVE = "tool_active"
|
|
|
|
|
|
@dataclass
|
|
class AgentTracker:
|
|
state: AgentAnimState = AgentAnimState.IDLE
|
|
current_tool: str | None = None
|
|
task_id: str | None = None
|
|
|
|
|
|
# ── Helper ─────────────────────────────────────────────────────────────────
|
|
|
|
def _ve(agent_id: str, event_type: str, data: dict[str, Any]) -> dict[str, Any]:
|
|
"""Build a VisualEvent dict."""
|
|
return {
|
|
"event_id": str(uuid.uuid4()),
|
|
"type": event_type,
|
|
"agent_id": agent_id,
|
|
"data": data,
|
|
"timestamp": time.time(),
|
|
}
|
|
|
|
|
|
def _add_execution_turn_aliases(data: dict[str, Any], task_id: Any | None = None) -> dict[str, Any]:
|
|
runtime_task_id = str(
|
|
task_id
|
|
or data.get("runtime_task_id")
|
|
or data.get("execution_turn_id")
|
|
or data.get("task_id")
|
|
or ""
|
|
).strip()
|
|
if runtime_task_id:
|
|
data.setdefault("runtime_task_id", runtime_task_id)
|
|
data.setdefault("execution_turn_id", runtime_task_id)
|
|
return data
|
|
|
|
|
|
# ── EventAdapter ───────────────────────────────────────────────────────────
|
|
|
|
class EventAdapter:
|
|
"""Stateful OPC → Visual event translator.
|
|
|
|
Maintains per-agent state machines to emit correct transition events.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self._trackers: dict[str, AgentTracker] = {}
|
|
# Maps task_id → agent_id (populated from agent_status_changed events)
|
|
self._task_agent_map: dict[str, str] = {}
|
|
self._task_display_counter: int = 0
|
|
# Maps task_id → display number (populated from task_created events)
|
|
self._task_display_map: dict[str, int] = {}
|
|
# Maps opc_role_id → UI agent_id (synced from agent_store)
|
|
self._role_agent_map: dict[str, str] = {}
|
|
|
|
def update_role_map(self, role_agent_map: dict[str, str]) -> None:
|
|
"""Sync the opc_role_id → UI agent_id mapping from agent_store."""
|
|
self._role_agent_map = dict(role_agent_map)
|
|
|
|
def _resolve_role_to_agent(self, role_id: str) -> str:
|
|
"""Map OPC role_id to UI agent_id. Falls back to role_id itself."""
|
|
return self._role_agent_map.get(role_id, role_id)
|
|
|
|
def _get_tracker(self, agent_id: str) -> AgentTracker:
|
|
if agent_id not in self._trackers:
|
|
self._trackers[agent_id] = AgentTracker()
|
|
return self._trackers[agent_id]
|
|
|
|
def _resolve_agent_from_task(self, task_id: str) -> str | None:
|
|
"""Resolve task_id → agent_id using the task→agent map."""
|
|
return self._task_agent_map.get(task_id)
|
|
|
|
def _close_previous_state(self, agent_id: str, tracker: AgentTracker) -> list[dict[str, Any]]:
|
|
"""Emit closing events for the agent's current animation state.
|
|
|
|
REFLECTING → emit reflect_done
|
|
TOOL_ACTIVE → emit tool_done(current_tool)
|
|
IDLE → nothing
|
|
"""
|
|
events: list[dict[str, Any]] = []
|
|
if tracker.state == AgentAnimState.REFLECTING:
|
|
events.append(_ve(agent_id, "reflect_done", {}))
|
|
tracker.state = AgentAnimState.IDLE
|
|
elif tracker.state == AgentAnimState.TOOL_ACTIVE:
|
|
tool_name = TOOL_MAP.get(tracker.current_tool or "", tracker.current_tool or "unknown")
|
|
events.append(_ve(agent_id, "tool_done", {"tool_name": tool_name}))
|
|
tracker.current_tool = None
|
|
tracker.state = AgentAnimState.IDLE
|
|
return events
|
|
|
|
def _emit_runtime_update(
|
|
self,
|
|
agent_id: str,
|
|
tracker: AgentTracker,
|
|
iteration: int | None = None,
|
|
extras: dict[str, Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Build an agent_runtime_update event for the current tracker state."""
|
|
data = {
|
|
"agent_id": agent_id,
|
|
"status": tracker.state.value,
|
|
"current_tool": tracker.current_tool,
|
|
"task_id": tracker.task_id,
|
|
"iteration": iteration,
|
|
**dict(extras or {}),
|
|
}
|
|
return _ve(agent_id, "agent_runtime_update", _add_execution_turn_aliases(data))
|
|
|
|
def translate(self, event: Any) -> list[dict[str, Any]]:
|
|
"""Translate a single OPCEvent into zero or more VisualEvents.
|
|
|
|
Args:
|
|
event: An OPCEvent (has .event_type, .payload, .timestamp, .event_id)
|
|
|
|
Returns:
|
|
List of VisualEvent dicts ready to send to the frontend.
|
|
"""
|
|
results: list[dict[str, Any]] = []
|
|
etype: str = event.event_type
|
|
p: dict[str, Any] = event.payload or {}
|
|
|
|
if etype == "runtime_event":
|
|
runtime_type = str(p.get("type", "runtime_event") or "runtime_event")
|
|
role_id = str(p.get("role_id", "") or "").strip()
|
|
agent_id = (
|
|
self._resolve_role_to_agent(role_id)
|
|
if role_id
|
|
else str(p.get("agent_id") or p.get("task_id") or "runtime")
|
|
)
|
|
results.append(_ve(agent_id, runtime_type, dict(p)))
|
|
if runtime_type in {
|
|
"turn_started",
|
|
"tool_started",
|
|
"tool_progress",
|
|
"tool_completed",
|
|
"status_snapshot",
|
|
"permission_requested",
|
|
"permission_resolved",
|
|
"subagent_started",
|
|
"subagent_updated",
|
|
"subagent_completed",
|
|
"verification_started",
|
|
"verification_completed",
|
|
}:
|
|
tracker = self._get_tracker(agent_id)
|
|
tracker.task_id = str(p.get("task_id", "") or tracker.task_id or "")
|
|
if runtime_type == "tool_started":
|
|
tracker.current_tool = str(p.get("tool_name", "") or "")
|
|
tracker.state = AgentAnimState.TOOL_ACTIVE
|
|
elif runtime_type == "tool_progress":
|
|
tracker.current_tool = str(p.get("tool_name", "") or tracker.current_tool or "")
|
|
tracker.state = AgentAnimState.TOOL_ACTIVE
|
|
elif runtime_type == "status_snapshot":
|
|
tracker.current_tool = str(p.get("current_tool", "") or "") or None
|
|
tracker.state = AgentAnimState.TOOL_ACTIVE if tracker.current_tool else AgentAnimState.REFLECTING
|
|
elif runtime_type in {"turn_started", "permission_requested", "permission_resolved", "subagent_started", "subagent_updated", "verification_started"}:
|
|
tracker.state = AgentAnimState.REFLECTING
|
|
elif runtime_type in {"tool_completed", "subagent_completed", "verification_completed"}:
|
|
tracker.current_tool = None
|
|
tracker.state = AgentAnimState.IDLE
|
|
extras: dict[str, Any] = {}
|
|
for key in (
|
|
"tool_elapsed_ms",
|
|
"last_tool_summary",
|
|
"context_tokens",
|
|
"context_window",
|
|
"context_remaining_pct",
|
|
"input_tokens",
|
|
"output_tokens",
|
|
"total_tokens",
|
|
"turn_cost_usd",
|
|
"session_cost_usd",
|
|
"pending_permission_count",
|
|
"drain_mode",
|
|
):
|
|
if key in p:
|
|
extras[key] = p.get(key)
|
|
if runtime_type in {"tool_started", "tool_progress", "tool_completed"} and "elapsed_ms" in p:
|
|
extras.setdefault("tool_elapsed_ms", p.get("elapsed_ms"))
|
|
if runtime_type == "tool_completed":
|
|
extras.setdefault("last_tool_summary", p.get("result_summary"))
|
|
results.append(self._emit_runtime_update(agent_id, tracker, p.get("iteration"), extras))
|
|
return results
|
|
|
|
# ── agent_log {status:"thinking"} ──────────────────────────
|
|
if etype == "agent_log" and p.get("status") == "thinking":
|
|
task_id = p.get("task_id", "")
|
|
agent_id = self._resolve_agent_from_task(task_id)
|
|
if not agent_id:
|
|
return []
|
|
tracker = self._get_tracker(agent_id)
|
|
|
|
# Close previous state (emits tool_done or reflect_done)
|
|
results.extend(self._close_previous_state(agent_id, tracker))
|
|
|
|
iteration = p.get("iteration", 1)
|
|
if iteration == 1:
|
|
# First iteration = new task received
|
|
results.append(_ve(agent_id, "message_in", {
|
|
"content_preview": f"Task started (iter {iteration})",
|
|
}))
|
|
|
|
# Enter reflecting state
|
|
results.append(_ve(agent_id, "reflect_start", {}))
|
|
tracker.state = AgentAnimState.REFLECTING
|
|
results.append(self._emit_runtime_update(agent_id, tracker, iteration))
|
|
|
|
# ── agent_log {tool:X, status:"executing"} ─────────────────
|
|
elif etype == "agent_log" and p.get("status") == "executing":
|
|
task_id = p.get("task_id", "")
|
|
agent_id = self._resolve_agent_from_task(task_id)
|
|
if not agent_id:
|
|
return []
|
|
tracker = self._get_tracker(agent_id)
|
|
tool = p.get("tool", "unknown")
|
|
|
|
# Close previous state (emits reflect_done if was reflecting)
|
|
results.extend(self._close_previous_state(agent_id, tracker))
|
|
|
|
# Special: agent_spawn is treated as subagent spawn visual.
|
|
if tool == "agent_spawn":
|
|
results.append(_ve(agent_id, "subagent_spawn", {}))
|
|
|
|
# A-class collab tools: skip tool_start entirely.
|
|
# Downstream semantic events (meeting_started, agent_message_sent)
|
|
# will drive the correct frontend animations (collab_started,
|
|
# message_out, etc.) without the conflicting desk-walk.
|
|
if tool in COLLAB_SKIP_TOOLS:
|
|
pass # stay IDLE — no TOOL_ACTIVE, no tool_start
|
|
|
|
# B-class collab tools: emit a direct visual event.
|
|
# These have no downstream semantic event to rely on.
|
|
elif tool in COLLAB_DIRECT_MAP:
|
|
results.append(_ve(agent_id, COLLAB_DIRECT_MAP[tool], {
|
|
"content_preview": tool.replace("_", " ").title(),
|
|
}))
|
|
# stay IDLE — these are instant operations
|
|
|
|
# Regular tools: normal tool_start / TOOL_ACTIVE path
|
|
else:
|
|
mapped = TOOL_MAP.get(tool, tool)
|
|
results.append(_ve(agent_id, "tool_start", {"tool_name": mapped}))
|
|
tracker.state = AgentAnimState.TOOL_ACTIVE
|
|
tracker.current_tool = tool
|
|
|
|
# Emit runtime update for kanban
|
|
results.append(self._emit_runtime_update(agent_id, tracker))
|
|
|
|
# ── agent_status_changed {status:"running"} ────────────────
|
|
elif etype == "agent_status_changed" and p.get("status") == "running":
|
|
role_id = p.get("role_id", "")
|
|
if not role_id:
|
|
return []
|
|
agent_id = self._resolve_role_to_agent(role_id)
|
|
tracker = self._get_tracker(agent_id)
|
|
task_id = p.get("task_id", "")
|
|
if task_id:
|
|
tracker.task_id = task_id
|
|
self._task_agent_map[task_id] = agent_id
|
|
results.append(_ve(agent_id, "agent_active", {}))
|
|
results.append(self._emit_runtime_update(agent_id, tracker))
|
|
|
|
# ── agent_status_changed {status:"idle"} ───────────────────
|
|
elif etype == "agent_status_changed" and p.get("status") == "idle":
|
|
role_id = p.get("role_id", "")
|
|
if not role_id:
|
|
return []
|
|
agent_id = self._resolve_role_to_agent(role_id)
|
|
tracker = self._get_tracker(agent_id)
|
|
|
|
# Close previous state (emits tool_done or reflect_done)
|
|
results.extend(self._close_previous_state(agent_id, tracker))
|
|
|
|
# Save task_id before clearing so the runtime_update
|
|
# reaches the correct frontend session.
|
|
old_task_id = tracker.task_id
|
|
|
|
# Clean up task mapping
|
|
if tracker.task_id and tracker.task_id in self._task_agent_map:
|
|
del self._task_agent_map[tracker.task_id]
|
|
tracker.task_id = None
|
|
results.append(_ve(agent_id, "waiting", {}))
|
|
update_evt = self._emit_runtime_update(agent_id, tracker)
|
|
update_evt["data"]["task_id"] = old_task_id
|
|
results.append(update_evt)
|
|
|
|
# ── task_created ───────────────────────────────────────────
|
|
elif etype == "task_created":
|
|
self._task_display_counter += 1
|
|
task_id = p.get("task_id", "")
|
|
if task_id:
|
|
self._task_display_map[task_id] = self._task_display_counter
|
|
results.append(_ve("system", "task_routed", {
|
|
"method": "auto",
|
|
"task_preview": p.get("title", ""),
|
|
}))
|
|
|
|
# ── child_session_created ─────────────────────────────────
|
|
elif etype == "child_session_created":
|
|
raw_agent = p.get("agent_id", "system")
|
|
ui_agent = self._resolve_role_to_agent(raw_agent) if raw_agent != "system" else "system"
|
|
results.append({
|
|
"event_id": str(uuid.uuid4()),
|
|
"type": "child_session_created",
|
|
"agent_id": ui_agent,
|
|
"data": {
|
|
"session_id": p.get("session_id"),
|
|
"parent_session_id": p.get("parent_session_id"),
|
|
"task_id": p.get("task_id"),
|
|
**_add_execution_turn_aliases({}, p.get("task_id")),
|
|
"title": p.get("title", ""),
|
|
"agent_id": ui_agent,
|
|
},
|
|
"timestamp": time.time(),
|
|
})
|
|
|
|
# ── task_status_changed ────────────────────────────────────
|
|
elif etype == "task_status_changed":
|
|
from opc.plugins.office_ui.snapshot_builder import STATUS_TO_COLUMN
|
|
task_id = p.get("task_id")
|
|
new_status = p.get("status")
|
|
if task_id and new_status:
|
|
column = STATUS_TO_COLUMN.get(new_status)
|
|
if column:
|
|
results.append({
|
|
"event_id": str(uuid.uuid4()),
|
|
"type": "board_task_status_changed",
|
|
"agent_id": "system",
|
|
"data": {
|
|
"task_id": task_id,
|
|
**_add_execution_turn_aliases({}, task_id),
|
|
"column_id": column,
|
|
"status": new_status,
|
|
},
|
|
"timestamp": time.time(),
|
|
})
|
|
|
|
# ── meeting_started ────────────────────────────────────────
|
|
elif etype == "meeting_started":
|
|
for participant in p.get("participants", []):
|
|
ui_id = self._resolve_role_to_agent(participant)
|
|
results.append(_ve(ui_id, "collab_started", {}))
|
|
|
|
# ── meeting_ended ──────────────────────────────────────────
|
|
elif etype == "meeting_ended":
|
|
# Emit collab_ended for all agents currently in reflecting state
|
|
for aid, tracker in self._trackers.items():
|
|
if tracker.state == AgentAnimState.REFLECTING:
|
|
results.append(_ve(aid, "collab_ended", {}))
|
|
|
|
# ── agent_message_sent ─────────────────────────────────────
|
|
elif etype == "agent_message_sent":
|
|
from_role = p.get("from", "")
|
|
preview = (p.get("subject", "") or p.get("body", "") or "Message")[:30]
|
|
if from_role:
|
|
from_agent = self._resolve_role_to_agent(from_role)
|
|
results.append(_ve(from_agent, "message_out", {
|
|
"content_preview": preview,
|
|
}))
|
|
# Notify each recipient so they show a receive-bubble
|
|
for to_role in p.get("to", []):
|
|
to_agent = self._resolve_role_to_agent(to_role)
|
|
results.append(_ve(to_agent, "message_in", {
|
|
"content_preview": preview,
|
|
}))
|
|
|
|
# ── agent_message_replied ──────────────────────────────────
|
|
elif etype == "agent_message_replied":
|
|
task_id = p.get("task_id", "")
|
|
agent_id = self._resolve_agent_from_task(task_id) if task_id else None
|
|
if agent_id:
|
|
results.append(_ve(agent_id, "message_out", {
|
|
"content_preview": "Reply sent",
|
|
}))
|
|
|
|
# ── escalation_created ─────────────────────────────────────
|
|
elif etype == "escalation_created":
|
|
msg = p.get("message", "Escalation")
|
|
results.append(_ve("system", "message_out", {
|
|
"content_preview": msg[:30],
|
|
}))
|
|
|
|
# ── execution_mode_resolved ──────────────────────────────
|
|
elif etype == "execution_mode_resolved":
|
|
results.append(_ve("system", "execution_mode_resolved", p))
|
|
|
|
# ── escalation_resolved / escalation_timeout ───────────────
|
|
elif etype in ("escalation_resolved", "escalation_timeout"):
|
|
pass # No visual effect needed
|
|
|
|
return results
|
|
|
|
def _resolve_progress_agent(
|
|
self,
|
|
*,
|
|
task_id: str | None = None,
|
|
agent_role_id: str | None = None,
|
|
) -> str:
|
|
"""Resolve progress updates to the most likely UI agent."""
|
|
role_id = str(agent_role_id or "").strip()
|
|
if role_id:
|
|
return self._resolve_role_to_agent(role_id)
|
|
|
|
resolved_task_id = str(task_id or "").strip()
|
|
if resolved_task_id:
|
|
agent_id = self._resolve_agent_from_task(resolved_task_id)
|
|
if agent_id:
|
|
return agent_id
|
|
|
|
for aid, tracker in self._trackers.items():
|
|
if tracker.state != AgentAnimState.IDLE:
|
|
return aid
|
|
return "system"
|
|
|
|
def parse_progress(
|
|
self,
|
|
text: str,
|
|
*,
|
|
task_id: str | None = None,
|
|
agent_role_id: str | None = None,
|
|
) -> list[dict[str, Any]]:
|
|
"""Parse on_progress callback strings into VisualEvents.
|
|
|
|
Progress updates can originate from native or external execution.
|
|
Prefer the explicitly scoped task/role identity so company-mode child
|
|
work items render on the correct role card instead of the last active one.
|
|
"""
|
|
active_agent = self._resolve_progress_agent(
|
|
task_id=task_id,
|
|
agent_role_id=agent_role_id,
|
|
)
|
|
|
|
# P1: "[Tool: X] ..." — SKIP (redundant with agent_log events)
|
|
if text.startswith("[Tool:"):
|
|
return []
|
|
|
|
# External agent monitoring status — show a lightweight role-scoped
|
|
# bubble so company-mode work items remain visibly active.
|
|
if text.startswith("[External status]"):
|
|
detail = text[text.find("]") + 1:].strip() if "]" in text else text
|
|
return [_ve(active_agent, "message_out", {
|
|
"content_preview": detail[:30] if detail else "External status",
|
|
})]
|
|
|
|
# P3: "[Company:projection] starting title"
|
|
if text.startswith("[Company:") and "] starting" in text:
|
|
bracket_end = text.find("]")
|
|
target = text[9:bracket_end] if bracket_end > 9 else "projection"
|
|
return [_ve(active_agent, "task_delegated", {"target": target})]
|
|
|
|
# P4: "[Company:projection] gate passed" / "completed"
|
|
if text.startswith("[Company:") and ("gate passed" in text or " completed" in text):
|
|
bracket_end = text.find("]")
|
|
target = text[9:bracket_end] if bracket_end > 9 else "projection"
|
|
return [_ve(active_agent, "delegation_done", {"target": target})]
|
|
|
|
# P5: "[Company:projection] rejected; reworking"
|
|
if text.startswith("[Company:") and "reworking" in text:
|
|
bracket_end = text.find("]")
|
|
preview = text[bracket_end + 2:bracket_end + 52] if bracket_end >= 0 else text[:50]
|
|
return [_ve(active_agent, "message_out", {
|
|
"content_preview": "Rework: " + preview,
|
|
})]
|
|
|
|
# P6-P9: Other [Company:...] messages
|
|
if text.startswith("[Company:"):
|
|
bracket_end = text.find("]")
|
|
preview = text[bracket_end + 2:bracket_end + 32] if bracket_end >= 0 else text[:30]
|
|
return [_ve(active_agent, "message_out", {"content_preview": preview})]
|
|
|
|
# P10: "[Delegating to agent]"
|
|
if text.startswith("[Delegating to"):
|
|
bracket_end = text.find("]")
|
|
target = text[15:bracket_end] if bracket_end > 15 else "agent"
|
|
return [_ve(active_agent, "task_delegated", {"target": target})]
|
|
|
|
# External broker stdout/stderr lines. Keep the office preview short;
|
|
# the detailed content remains available in the session progress log.
|
|
if text.startswith("[External:"):
|
|
bracket_end = text.find("]")
|
|
header = text[1:bracket_end] if bracket_end > 1 else "External"
|
|
detail = text[bracket_end + 1:].strip() if bracket_end > 0 else ""
|
|
parts = header.split(":")
|
|
stream = parts[2] if len(parts) > 2 else ""
|
|
preview = detail or stream or header
|
|
if stream and detail:
|
|
preview = f"{stream}: {detail}"
|
|
return [_ve(active_agent, "message_out", {"content_preview": preview[:30]})]
|
|
|
|
if text.startswith("[External approval]"):
|
|
detail = text[text.find("]") + 1:].strip() if "]" in text else text
|
|
return [_ve(active_agent, "message_out", {
|
|
"content_preview": detail[:30] if detail else "Approval requested",
|
|
})]
|
|
|
|
# P11-P12: "[External agent ...]"
|
|
if text.startswith("[External agent") or text.startswith("[External agents"):
|
|
return [_ve(active_agent, "message_out", {"content_preview": text[:30]})]
|
|
|
|
# P13-P14: "[CapabilityRecovery]"
|
|
if text.startswith("[CapabilityRecovery]"):
|
|
skill = text.split(":")[-1].strip()[:20] if ":" in text else "recovery"
|
|
return [_ve(active_agent, "skill_adopted", {"skill_name": skill})]
|
|
|
|
# P2: Raw LLM final response (long text without bracket prefix)
|
|
if len(text) > 20 and not text.startswith("["):
|
|
return [_ve(active_agent, "message_out", {"content_preview": text[:30]})]
|
|
|
|
return []
|
|
|
|
def reset(self) -> None:
|
|
"""Clear all tracker state (used on mode switch)."""
|
|
self._trackers.clear()
|
|
self._task_agent_map.clear()
|
|
self._task_display_map.clear()
|
|
self._task_display_counter = 0
|
|
|
|
def get_tracker(self, agent_id: str) -> AgentTracker | None:
|
|
"""Return a copy of the tracker for the given agent, or None."""
|
|
tracker = self._trackers.get(agent_id)
|
|
if tracker is None:
|
|
return None
|
|
# Return a shallow copy to avoid external mutation
|
|
return AgentTracker(
|
|
state=tracker.state,
|
|
current_tool=tracker.current_tool,
|
|
task_id=tracker.task_id,
|
|
)
|
|
|
|
@property
|
|
def task_display_counter(self) -> int:
|
|
return self._task_display_counter
|
|
|
|
def get_task_display_num(self, task_id: str) -> int:
|
|
"""Return the display number for a task_id, or current counter as fallback."""
|
|
return self._task_display_map.get(task_id, self._task_display_counter)
|