1838 lines
72 KiB
Python
1838 lines
72 KiB
Python
import logging
|
|
import platform
|
|
import subprocess
|
|
import threading
|
|
import time
|
|
|
|
from textual import work
|
|
from textual.app import App, ComposeResult
|
|
from textual.binding import Binding
|
|
from textual.containers import Container, Horizontal
|
|
from textual.widgets import Footer, Label
|
|
|
|
from framework.runtime.event_bus import AgentEvent, EventType
|
|
from framework.tui.widgets.selectable_rich_log import SelectableRichLog
|
|
|
|
# AgentRuntime imported lazily where needed to support runtime=None startup.
|
|
# ChatRepl and GraphOverview are imported lazily in _mount_agent_widgets.
|
|
|
|
|
|
class StatusBar(Container):
|
|
"""Live status bar showing agent execution state."""
|
|
|
|
DEFAULT_CSS = """
|
|
StatusBar {
|
|
dock: top;
|
|
height: 1;
|
|
background: $panel;
|
|
color: $text;
|
|
padding: 0 1;
|
|
}
|
|
StatusBar > Label {
|
|
width: 100%;
|
|
}
|
|
"""
|
|
|
|
def __init__(self, graph_id: str = ""):
|
|
super().__init__()
|
|
self._graph_id = graph_id
|
|
self._state = "idle"
|
|
self._active_node: str | None = None
|
|
self._node_detail: str = ""
|
|
self._start_time: float | None = None
|
|
self._final_elapsed: float | None = None
|
|
|
|
def compose(self) -> ComposeResult:
|
|
yield Label(id="status-content")
|
|
|
|
def on_mount(self) -> None:
|
|
self._refresh()
|
|
self.set_interval(1.0, self._refresh)
|
|
|
|
def _format_elapsed(self, seconds: float) -> str:
|
|
total = int(seconds)
|
|
hours, remainder = divmod(total, 3600)
|
|
mins, secs = divmod(remainder, 60)
|
|
if hours:
|
|
return f"{hours}:{mins:02d}:{secs:02d}"
|
|
return f"{mins}:{secs:02d}"
|
|
|
|
def _refresh(self) -> None:
|
|
parts: list[str] = []
|
|
|
|
if self._graph_id:
|
|
parts.append(f"[bold]{self._graph_id}[/bold]")
|
|
|
|
if self._state == "idle":
|
|
parts.append("[dim]○ idle[/dim]")
|
|
elif self._state == "running":
|
|
parts.append("[bold green]● running[/bold green]")
|
|
elif self._state == "completed":
|
|
parts.append("[green]✓ done[/green]")
|
|
elif self._state == "failed":
|
|
parts.append("[bold red]✗ failed[/bold red]")
|
|
|
|
if self._active_node:
|
|
node_str = f"[cyan]{self._active_node}[/cyan]"
|
|
if self._node_detail:
|
|
node_str += f" [dim]({self._node_detail})[/dim]"
|
|
parts.append(node_str)
|
|
|
|
if self._state == "running" and self._start_time:
|
|
parts.append(f"[dim]{self._format_elapsed(time.time() - self._start_time)}[/dim]")
|
|
elif self._final_elapsed is not None:
|
|
parts.append(f"[dim]{self._format_elapsed(self._final_elapsed)}[/dim]")
|
|
|
|
try:
|
|
label = self.query_one("#status-content", Label)
|
|
label.update(" │ ".join(parts))
|
|
except Exception:
|
|
pass
|
|
|
|
def set_graph_id(self, graph_id: str) -> None:
|
|
self._graph_id = graph_id
|
|
self._refresh()
|
|
|
|
def set_running(self, entry_node: str = "") -> None:
|
|
self._state = "running"
|
|
self._active_node = entry_node or None
|
|
self._node_detail = ""
|
|
self._start_time = time.time()
|
|
self._final_elapsed = None
|
|
self._refresh()
|
|
|
|
def set_completed(self) -> None:
|
|
self._state = "completed"
|
|
if self._start_time:
|
|
self._final_elapsed = time.time() - self._start_time
|
|
self._active_node = None
|
|
self._node_detail = ""
|
|
self._start_time = None
|
|
self._refresh()
|
|
|
|
def set_failed(self, error: str = "") -> None:
|
|
self._state = "failed"
|
|
if self._start_time:
|
|
self._final_elapsed = time.time() - self._start_time
|
|
self._node_detail = error[:40] if error else ""
|
|
self._start_time = None
|
|
self._refresh()
|
|
|
|
def set_active_node(self, node_id: str, detail: str = "") -> None:
|
|
self._active_node = node_id
|
|
self._node_detail = detail
|
|
self._refresh()
|
|
|
|
def set_node_detail(self, detail: str) -> None:
|
|
self._node_detail = detail
|
|
self._refresh()
|
|
|
|
|
|
class AdenTUI(App):
|
|
TITLE = "Aden TUI Dashboard"
|
|
COMMAND_PALETTE_BINDING = "ctrl+o"
|
|
CSS = """
|
|
Screen {
|
|
layout: vertical;
|
|
background: $surface;
|
|
}
|
|
|
|
GraphOverview {
|
|
width: 40%;
|
|
height: 100%;
|
|
background: $panel;
|
|
padding: 0;
|
|
}
|
|
|
|
ChatRepl {
|
|
width: 60%;
|
|
height: 100%;
|
|
background: $panel;
|
|
border-left: tall $primary;
|
|
padding: 0;
|
|
}
|
|
|
|
#agent-workspace {
|
|
height: 1fr;
|
|
}
|
|
|
|
#chat-history {
|
|
height: 1fr;
|
|
width: 100%;
|
|
background: $surface;
|
|
border: none;
|
|
scrollbar-background: $panel;
|
|
scrollbar-color: $primary;
|
|
}
|
|
|
|
RichLog {
|
|
background: $surface;
|
|
border: none;
|
|
scrollbar-background: $panel;
|
|
scrollbar-color: $primary;
|
|
}
|
|
|
|
ChatTextArea {
|
|
background: $surface;
|
|
border: tall $primary;
|
|
margin-top: 1;
|
|
}
|
|
|
|
ChatTextArea:focus {
|
|
border: tall $accent;
|
|
}
|
|
|
|
StatusBar {
|
|
background: $panel;
|
|
color: $text;
|
|
height: 1;
|
|
padding: 0 1;
|
|
}
|
|
|
|
Footer {
|
|
background: $panel;
|
|
color: $text-muted;
|
|
}
|
|
|
|
#empty-workspace {
|
|
align: center middle;
|
|
height: 1fr;
|
|
}
|
|
|
|
#empty-workspace Label {
|
|
text-align: center;
|
|
}
|
|
"""
|
|
|
|
BINDINGS = [
|
|
Binding("q", "quit", "Quit"),
|
|
Binding("ctrl+c", "ctrl_c", "Interrupt", show=False, priority=True),
|
|
Binding("super+c", "ctrl_c", "Copy", show=False, priority=True),
|
|
Binding("ctrl+s", "screenshot", "Screenshot (SVG)", show=True, priority=True),
|
|
Binding("ctrl+l", "toggle_logs", "Toggle Logs", show=True, priority=True),
|
|
Binding("ctrl+z", "pause_execution", "Pause", show=True, priority=True),
|
|
Binding("ctrl+r", "show_sessions", "Sessions", show=True, priority=True),
|
|
Binding("ctrl+a", "show_agent_picker", "Agents", show=True, priority=True),
|
|
Binding("ctrl+e", "escalate_to_coder", "Coder", show=True, priority=True),
|
|
Binding("ctrl+e", "return_from_coder", "← Back", show=True, priority=True),
|
|
Binding("ctrl+q", "connect_to_queen", "Queen", show=True, priority=True),
|
|
Binding("tab", "focus_next", "Next Panel", show=True),
|
|
Binding("shift+tab", "focus_previous", "Previous Panel", show=False),
|
|
]
|
|
|
|
def __init__(
|
|
self,
|
|
runtime=None,
|
|
resume_session: str | None = None,
|
|
resume_checkpoint: str | None = None,
|
|
model: str | None = None,
|
|
):
|
|
super().__init__()
|
|
|
|
self.runtime = runtime
|
|
self._model = model
|
|
self._resume_session = resume_session
|
|
self._resume_checkpoint = resume_checkpoint
|
|
self._runner = None # AgentRunner — needed for cleanup on swap
|
|
|
|
# Escalation stack: stores worker state when coder is in foreground
|
|
self._escalation_stack: list[dict] = []
|
|
|
|
# Health judge + queen monitoring graphs (loaded alongside worker agents)
|
|
self._queen_graph_id: str | None = None
|
|
self._judge_graph_id: str | None = None
|
|
self._judge_task = None # concurrent.futures.Future for the judge loop
|
|
self._queen_task = None # concurrent.futures.Future for the queen loop
|
|
self._queen_executor = None # GraphExecutor for queen input injection
|
|
self._queen_escalation_sub = None # EventBus subscription for queen
|
|
|
|
# Widgets are created lazily when runtime is available
|
|
self.graph_view = None
|
|
self.chat_repl = None
|
|
self.status_bar = StatusBar(graph_id=runtime.graph.id if runtime else "")
|
|
self.is_ready = False
|
|
|
|
def open_url(self, url: str, *, new_tab: bool = True) -> None:
|
|
"""Override to use native `open` for file:// URLs on macOS."""
|
|
if url.startswith("file://") and platform.system() == "Darwin":
|
|
path = url.removeprefix("file://")
|
|
subprocess.Popen(["open", path])
|
|
else:
|
|
super().open_url(url, new_tab=new_tab)
|
|
|
|
def action_ctrl_c(self) -> None:
|
|
# Check if any SelectableRichLog has an active selection to copy
|
|
for widget in self.query(SelectableRichLog):
|
|
if widget.selection is not None:
|
|
text = widget.copy_selection()
|
|
if text:
|
|
widget.clear_selection()
|
|
self.notify("Copied to clipboard", severity="information", timeout=2)
|
|
return
|
|
|
|
self.notify("Press [b]q[/b] to quit", severity="warning", timeout=3)
|
|
|
|
def compose(self) -> ComposeResult:
|
|
yield self.status_bar
|
|
yield Horizontal(id="agent-workspace")
|
|
yield Footer()
|
|
|
|
async def on_mount(self) -> None:
|
|
"""Called when app starts."""
|
|
self.title = "Aden TUI Dashboard"
|
|
self._setup_logging_queue()
|
|
self.is_ready = True
|
|
|
|
if self.runtime is not None:
|
|
# Direct launch with agent already loaded
|
|
self._mount_agent_widgets()
|
|
self.call_later(self._init_runtime_connection)
|
|
|
|
def write_initial_logs():
|
|
logging.info("TUI Dashboard initialized successfully")
|
|
logging.info("Waiting for agent execution to start...")
|
|
|
|
self.set_timer(0.2, write_initial_logs)
|
|
else:
|
|
# No agent — show picker
|
|
self.call_later(self._show_agent_picker_initial)
|
|
|
|
# -- Agent widget lifecycle --
|
|
|
|
def _mount_agent_widgets(self) -> None:
|
|
"""Mount ChatRepl and GraphOverview into #agent-workspace."""
|
|
from framework.tui.widgets.chat_repl import ChatRepl
|
|
from framework.tui.widgets.graph_view import GraphOverview
|
|
|
|
workspace = self.query_one("#agent-workspace", Horizontal)
|
|
|
|
# Remove empty-state placeholder if present
|
|
for child in list(workspace.children):
|
|
child.remove()
|
|
|
|
self.graph_view = GraphOverview(self.runtime)
|
|
self.chat_repl = ChatRepl(
|
|
self.runtime,
|
|
self._resume_session,
|
|
self._resume_checkpoint,
|
|
)
|
|
workspace.mount(self.graph_view)
|
|
workspace.mount(self.chat_repl)
|
|
self.status_bar.set_graph_id(self.runtime.graph.id)
|
|
|
|
def _unmount_agent_widgets(self) -> None:
|
|
"""Remove ChatRepl and GraphOverview from #agent-workspace."""
|
|
# Unsubscribe from events
|
|
if hasattr(self, "_subscription_id"):
|
|
try:
|
|
self.runtime.unsubscribe_from_events(self._subscription_id)
|
|
except Exception:
|
|
pass
|
|
del self._subscription_id
|
|
|
|
workspace = self.query_one("#agent-workspace", Horizontal)
|
|
for child in list(workspace.children):
|
|
child.remove()
|
|
|
|
self.graph_view = None
|
|
self.chat_repl = None
|
|
|
|
async def _load_and_switch_agent(self, agent_path: str) -> None:
|
|
"""Load an agent and replace the current one in the TUI."""
|
|
from pathlib import Path
|
|
|
|
from framework.credentials.models import CredentialError
|
|
from framework.runner import AgentRunner
|
|
|
|
# 1. Tear down old agent
|
|
if self.runtime is not None:
|
|
self._unmount_agent_widgets()
|
|
if self._runner is not None:
|
|
try:
|
|
await self._runner.cleanup_async()
|
|
except Exception:
|
|
pass
|
|
self._runner = None
|
|
self.runtime = None
|
|
|
|
# 2. Show loading state
|
|
agent_name = Path(agent_path).name
|
|
self.status_bar.set_graph_id(f"Loading {agent_name}...")
|
|
self.notify(f"Loading agent: {agent_name}...", timeout=3)
|
|
|
|
# 3. Load new agent (run blocking I/O in thread to avoid freezing the TUI)
|
|
import asyncio
|
|
import functools
|
|
|
|
loop = asyncio.get_event_loop()
|
|
try:
|
|
load_fn = functools.partial(
|
|
AgentRunner.load,
|
|
agent_path,
|
|
model=self._model,
|
|
interactive=False,
|
|
)
|
|
runner = await loop.run_in_executor(None, load_fn)
|
|
except CredentialError as e:
|
|
self.status_bar.set_graph_id("")
|
|
self._show_credential_setup(
|
|
str(agent_path),
|
|
credential_error=e,
|
|
)
|
|
return
|
|
except Exception as e:
|
|
self.status_bar.set_graph_id("")
|
|
self.notify(f"Failed to load agent: {e}", severity="error", timeout=10)
|
|
return
|
|
|
|
# 4. Pre-run account selection (if agent requires it)
|
|
if runner.requires_account_selection and runner._configure_for_account:
|
|
try:
|
|
if runner._list_accounts:
|
|
accounts = await loop.run_in_executor(None, runner._list_accounts)
|
|
else:
|
|
accounts = []
|
|
except Exception as e:
|
|
self.notify(f"Failed to list accounts: {e}", severity="error", timeout=10)
|
|
accounts = []
|
|
if accounts:
|
|
self._show_account_selection(runner, accounts)
|
|
return # Continuation via callback
|
|
|
|
# 5. Complete the load
|
|
await self._finish_agent_load(runner)
|
|
|
|
async def _finish_agent_load(self, runner) -> None:
|
|
"""Complete agent setup and widget mount."""
|
|
import asyncio
|
|
|
|
# Reset health monitoring state from any prior agent load
|
|
self._stop_health_monitoring()
|
|
self._queen_graph_id = None
|
|
self._judge_graph_id = None
|
|
|
|
loop = asyncio.get_event_loop()
|
|
try:
|
|
if runner._agent_runtime is None:
|
|
await loop.run_in_executor(None, runner._setup)
|
|
|
|
self._runner = runner
|
|
self.runtime = runner._agent_runtime
|
|
except Exception as e:
|
|
self.status_bar.set_graph_id("")
|
|
self.notify(f"Failed to load agent: {e}", severity="error", timeout=10)
|
|
return
|
|
|
|
# Mount widgets FIRST — creates the ChatRepl and its dedicated agent
|
|
# event loop on a background thread.
|
|
self._mount_agent_widgets()
|
|
|
|
# Start the runtime on the agent loop so ALL async tasks (timers,
|
|
# event handlers, execution streams) live on the same loop as worker
|
|
# execution. Previously runtime.start() ran on Textual's UI loop,
|
|
# causing timer tasks to be starved by UI rendering.
|
|
if self.runtime and not self.runtime.is_running:
|
|
try:
|
|
agent_loop = self.chat_repl._agent_loop
|
|
future = asyncio.run_coroutine_threadsafe(self.runtime.start(), agent_loop)
|
|
await asyncio.wrap_future(future)
|
|
except Exception as e:
|
|
self.status_bar.set_graph_id("")
|
|
self.notify(f"Failed to start runtime: {e}", severity="error", timeout=10)
|
|
return
|
|
|
|
await self._init_runtime_connection()
|
|
|
|
# Clear resume state for subsequent loads
|
|
self._resume_session = None
|
|
self._resume_checkpoint = None
|
|
|
|
agent_name = runner.agent_path.name
|
|
self.notify(f"Agent loaded: {agent_name}", severity="information", timeout=3)
|
|
|
|
# Load health judge + queen for worker agents (skip for hive_coder itself)
|
|
if agent_name != "hive_coder":
|
|
await self._load_judge_and_queen(runner._storage_path)
|
|
|
|
async def _load_judge_and_queen(self, storage_path) -> None:
|
|
"""Start health judge and interactive queen as independent conversations.
|
|
|
|
Three-conversation architecture:
|
|
- **Queen**: persistent interactive GraphExecutor (user's primary interface)
|
|
- **Judge**: timer-driven background GraphExecutor (silent monitoring)
|
|
- **Worker**: the existing AgentRuntime (unchanged)
|
|
|
|
They share ONLY the EventBus (for communication) and the base
|
|
storage path (so the judge can read worker logs). Nothing else
|
|
is shared — no state manager, no session store, no tool merging
|
|
into the worker runtime. The worker is completely untouched.
|
|
"""
|
|
import asyncio
|
|
import uuid
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
from framework.graph.executor import GraphExecutor
|
|
from framework.monitoring import judge_goal, judge_graph
|
|
from framework.runner.tool_registry import ToolRegistry
|
|
from framework.runtime.core import Runtime
|
|
from framework.runtime.event_bus import EventType as _ET
|
|
from framework.tools.queen_lifecycle_tools import register_queen_lifecycle_tools
|
|
from framework.tools.worker_monitoring_tools import register_worker_monitoring_tools
|
|
|
|
log = logging.getLogger("tui.judge")
|
|
|
|
try:
|
|
storage_path = Path(storage_path)
|
|
event_bus = self.runtime._event_bus
|
|
llm = self.runtime._llm
|
|
agent_loop = self.chat_repl._agent_loop
|
|
|
|
# Generate a shared session ID for queen, judge, and worker.
|
|
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
session_id = f"session_{ts}_{uuid.uuid4().hex[:8]}"
|
|
|
|
# 1. Monitoring tools (health summary, emit ticket, notify operator).
|
|
# Registered on a standalone registry — NOT merged into the worker.
|
|
monitoring_registry = ToolRegistry()
|
|
register_worker_monitoring_tools(
|
|
monitoring_registry,
|
|
event_bus,
|
|
storage_path,
|
|
worker_graph_id=self.runtime._graph_id,
|
|
)
|
|
|
|
# 2. Storage dirs — scoped by session_id so each agent load
|
|
# gets fresh queen/judge conversations.
|
|
judge_dir = storage_path / "graphs" / "judge" / "session" / session_id
|
|
judge_dir.mkdir(parents=True, exist_ok=True)
|
|
queen_dir = storage_path / "graphs" / "queen" / "session" / session_id
|
|
queen_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# ---------------------------------------------------------------
|
|
# 3. Health judge — background task, fires every 2 minutes.
|
|
# ---------------------------------------------------------------
|
|
judge_runtime = Runtime(storage_path / "graphs" / "judge")
|
|
monitoring_tools = list(monitoring_registry.get_tools().values())
|
|
monitoring_executor = monitoring_registry.get_executor()
|
|
|
|
# Scoped event buses — stamp graph_id on every event so
|
|
# downstream routing (queen-primary mode) can distinguish
|
|
# queen/judge/worker events.
|
|
from framework.runtime.execution_stream import GraphScopedEventBus
|
|
|
|
judge_event_bus = GraphScopedEventBus(event_bus, "judge")
|
|
queen_event_bus = GraphScopedEventBus(event_bus, "queen")
|
|
|
|
async def _judge_loop():
|
|
interval = 120 # seconds
|
|
first = True
|
|
while True:
|
|
if not first:
|
|
await asyncio.sleep(interval)
|
|
first = False
|
|
try:
|
|
executor = GraphExecutor(
|
|
runtime=judge_runtime,
|
|
llm=llm,
|
|
tools=monitoring_tools,
|
|
tool_executor=monitoring_executor,
|
|
event_bus=judge_event_bus,
|
|
stream_id="judge",
|
|
storage_path=judge_dir,
|
|
loop_config=judge_graph.loop_config,
|
|
)
|
|
await executor.execute(
|
|
graph=judge_graph,
|
|
goal=judge_goal,
|
|
input_data={
|
|
"event": {"source": "timer", "reason": "scheduled"},
|
|
},
|
|
session_state={"resume_session_id": session_id},
|
|
)
|
|
except Exception:
|
|
log.error("Health judge tick failed", exc_info=True)
|
|
|
|
self._judge_task = asyncio.run_coroutine_threadsafe(
|
|
_judge_loop(),
|
|
agent_loop,
|
|
)
|
|
self._judge_graph_id = "judge"
|
|
|
|
# ---------------------------------------------------------------
|
|
# 4. Queen — persistent interactive conversation.
|
|
# Runs a continuous event_loop node that is the user's
|
|
# primary interface. Has lifecycle tools to control the
|
|
# worker. Escalation tickets from the judge are injected
|
|
# as messages into this conversation.
|
|
# ---------------------------------------------------------------
|
|
import framework.agents.hive_coder as _hive_coder_pkg
|
|
from framework.agents.hive_coder.agent import queen_goal, queen_graph
|
|
|
|
# Queen gets lifecycle tools, monitoring tools, AND coding tools
|
|
# from the hive_coder's coder-tools MCP server. This spawns a
|
|
# separate MCP process so the queen can read/write files, run
|
|
# commands, discover tools, etc. independently of the worker.
|
|
queen_registry = ToolRegistry()
|
|
|
|
# Coding tools from hive_coder's MCP config (coder_tools_server).
|
|
hive_coder_dir = Path(_hive_coder_pkg.__file__).parent
|
|
mcp_config = hive_coder_dir / "mcp_servers.json"
|
|
if mcp_config.exists():
|
|
try:
|
|
queen_registry.load_mcp_config(mcp_config)
|
|
log.info("Queen: loaded MCP config from %s", mcp_config)
|
|
except Exception:
|
|
log.warning("Queen: MCP config failed to load", exc_info=True)
|
|
|
|
register_queen_lifecycle_tools(
|
|
queen_registry,
|
|
worker_runtime=self.runtime,
|
|
event_bus=event_bus,
|
|
storage_path=storage_path,
|
|
session_id=session_id,
|
|
)
|
|
register_worker_monitoring_tools(
|
|
queen_registry,
|
|
event_bus,
|
|
storage_path,
|
|
stream_id="queen",
|
|
worker_graph_id=self.runtime._graph_id,
|
|
)
|
|
queen_tools = list(queen_registry.get_tools().values())
|
|
queen_tool_executor = queen_registry.get_executor()
|
|
|
|
# Build worker identity to inject into the queen's system prompt.
|
|
worker_graph_id = self.runtime._graph_id
|
|
worker_goal_name = getattr(self.runtime.goal, "name", worker_graph_id)
|
|
worker_goal_desc = getattr(self.runtime.goal, "description", "")
|
|
worker_identity = (
|
|
f"\n\n# Current Session\n"
|
|
f"Worker agent: {worker_graph_id}\n"
|
|
f"Goal: {worker_goal_name}\n"
|
|
)
|
|
if worker_goal_desc:
|
|
worker_identity += f"Description: {worker_goal_desc}\n"
|
|
worker_identity += "Status at session start: idle (not started)."
|
|
|
|
# Adjust queen graph: filter tools to what's registered and
|
|
# append worker identity to the system prompt.
|
|
registered_tool_names = set(queen_registry.get_tools().keys())
|
|
_orig_queen_node = queen_graph.nodes[0]
|
|
declared_tools = _orig_queen_node.tools or []
|
|
available_tools = [t for t in declared_tools if t in registered_tool_names]
|
|
|
|
node_updates: dict = {}
|
|
if set(available_tools) != set(declared_tools):
|
|
missing = sorted(set(declared_tools) - registered_tool_names)
|
|
log.warning("Queen: tools not available (MCP may have failed): %s", missing)
|
|
node_updates["tools"] = available_tools
|
|
# Always inject worker identity into system prompt.
|
|
base_prompt = _orig_queen_node.system_prompt or ""
|
|
node_updates["system_prompt"] = base_prompt + worker_identity
|
|
|
|
adjusted_node = _orig_queen_node.model_copy(update=node_updates)
|
|
queen_graph = queen_graph.model_copy(update={"nodes": [adjusted_node]})
|
|
|
|
queen_runtime = Runtime(storage_path / "graphs" / "queen")
|
|
|
|
async def _queen_loop():
|
|
try:
|
|
executor = GraphExecutor(
|
|
runtime=queen_runtime,
|
|
llm=llm,
|
|
tools=queen_tools,
|
|
tool_executor=queen_tool_executor,
|
|
event_bus=queen_event_bus,
|
|
stream_id="queen",
|
|
storage_path=queen_dir,
|
|
loop_config=queen_graph.loop_config,
|
|
)
|
|
self._queen_executor = executor
|
|
log.info(
|
|
"Queen starting with %d tools: %s",
|
|
len(queen_tools),
|
|
[t.name for t in queen_tools],
|
|
)
|
|
# The queen's event_loop node runs forever (continuous mode).
|
|
# It blocks on _await_user_input() after each LLM turn,
|
|
# and input is injected via executor.node_registry["queen"].inject_event().
|
|
result = await executor.execute(
|
|
graph=queen_graph,
|
|
goal=queen_goal,
|
|
input_data={"greeting": "Session started."},
|
|
session_state={"resume_session_id": session_id},
|
|
)
|
|
# Should never reach here — queen is forever-alive.
|
|
log.warning(
|
|
"Queen executor returned (should be forever-alive): %s",
|
|
result,
|
|
)
|
|
except Exception:
|
|
log.error("Queen conversation crashed", exc_info=True)
|
|
finally:
|
|
self._queen_executor = None
|
|
|
|
self._queen_task = asyncio.run_coroutine_threadsafe(
|
|
_queen_loop(),
|
|
agent_loop,
|
|
)
|
|
self._queen_graph_id = "queen"
|
|
|
|
# Wire queen injection callback into ChatRepl so user input
|
|
# is routed to the queen by default.
|
|
async def _inject_queen(content: str) -> bool:
|
|
"""Inject user input into the queen's active node."""
|
|
executor = self._queen_executor
|
|
if executor is None:
|
|
return False
|
|
node = executor.node_registry.get("queen")
|
|
if node is not None and hasattr(node, "inject_event"):
|
|
await node.inject_event(content)
|
|
return True
|
|
return False
|
|
|
|
self.chat_repl._queen_inject_callback = _inject_queen
|
|
|
|
# Judge escalation → inject into queen conversation as a message.
|
|
async def _on_escalation(event):
|
|
ticket = event.data.get("ticket", {})
|
|
executor = self._queen_executor
|
|
if executor is None:
|
|
log.warning("Escalation received but queen executor is None")
|
|
return
|
|
node = executor.node_registry.get("queen")
|
|
if node is not None and hasattr(node, "inject_event"):
|
|
import json as _json
|
|
|
|
msg = "[ESCALATION TICKET from Health Judge]\n" + _json.dumps(
|
|
ticket, indent=2, ensure_ascii=False
|
|
)
|
|
await node.inject_event(msg)
|
|
else:
|
|
log.warning("Escalation received but queen node not ready for injection")
|
|
|
|
self._queen_escalation_sub = event_bus.subscribe(
|
|
event_types=[_ET.WORKER_ESCALATION_TICKET],
|
|
handler=_on_escalation,
|
|
)
|
|
|
|
self.notify(
|
|
"Queen + health judge active",
|
|
severity="information",
|
|
timeout=3,
|
|
)
|
|
except Exception as e:
|
|
log.error("Failed to load health monitoring: %s", e, exc_info=True)
|
|
self.notify(
|
|
f"Health monitoring unavailable: {e}",
|
|
severity="warning",
|
|
timeout=5,
|
|
)
|
|
|
|
def _stop_health_monitoring(self) -> None:
|
|
"""Cancel judge task, queen task, and subscriptions from a prior load."""
|
|
if self._judge_task is not None:
|
|
self._judge_task.cancel()
|
|
self._judge_task = None
|
|
if self._queen_task is not None:
|
|
self._queen_task.cancel()
|
|
self._queen_task = None
|
|
self._queen_executor = None
|
|
if self._queen_escalation_sub is not None:
|
|
try:
|
|
event_bus = self.runtime._event_bus if self.runtime else None
|
|
if event_bus:
|
|
event_bus.unsubscribe(self._queen_escalation_sub)
|
|
except Exception:
|
|
pass
|
|
self._queen_escalation_sub = None
|
|
|
|
def _show_account_selection(self, runner, accounts: list[dict]) -> None:
|
|
"""Show the account selection screen and continue loading on selection."""
|
|
from framework.tui.screens.account_selection import AccountSelectionScreen
|
|
|
|
def _on_selection(selected: dict | None) -> None:
|
|
if selected is None:
|
|
self.status_bar.set_graph_id("")
|
|
self.notify(
|
|
"Account selection cancelled. Agent not loaded.",
|
|
severity="warning",
|
|
timeout=5,
|
|
)
|
|
return
|
|
|
|
# Scope tools to the selected provider
|
|
if runner._configure_for_account:
|
|
runner._configure_for_account(runner, selected)
|
|
|
|
# Continue with the rest of agent loading
|
|
self._do_finish_agent_load(runner)
|
|
|
|
self.push_screen(AccountSelectionScreen(accounts), callback=_on_selection)
|
|
|
|
@work(exclusive=True)
|
|
async def _do_finish_agent_load(self, runner) -> None:
|
|
"""Worker wrapper for _finish_agent_load (used by account selection callback)."""
|
|
await self._finish_agent_load(runner)
|
|
|
|
def _show_credential_setup(
|
|
self,
|
|
agent_path: str,
|
|
on_cancel: object | None = None,
|
|
credential_error: Exception | None = None,
|
|
) -> None:
|
|
"""Show the credential setup screen for an agent with missing credentials.
|
|
|
|
Args:
|
|
agent_path: Path to the agent that needs credentials.
|
|
on_cancel: Callable to invoke if the user skips/cancels setup.
|
|
credential_error: The CredentialError from validation (carries
|
|
``failed_cred_names`` for both missing and invalid creds).
|
|
"""
|
|
from framework.credentials.validation import build_setup_session_from_error
|
|
from framework.tui.screens.credential_setup import CredentialSetupScreen
|
|
|
|
session = build_setup_session_from_error(
|
|
credential_error or Exception("unknown"),
|
|
agent_path=agent_path,
|
|
)
|
|
|
|
if not session.missing:
|
|
self.status_bar.set_graph_id("")
|
|
error_msg = str(credential_error) if credential_error else ""
|
|
if "not connected" in error_msg or "Aden" in error_msg:
|
|
self.notify(
|
|
"ADEN_API_KEY is set but OAuth integrations "
|
|
"are not connected. Visit hive.adenhq.com "
|
|
"to connect them, then reload the agent.",
|
|
severity="warning",
|
|
timeout=15,
|
|
)
|
|
else:
|
|
self.notify(
|
|
"Credential error but no missing credentials "
|
|
"detected. Run 'hive setup-credentials' "
|
|
"from the terminal.",
|
|
severity="error",
|
|
timeout=10,
|
|
)
|
|
if callable(on_cancel):
|
|
on_cancel()
|
|
return
|
|
|
|
def _on_result(result: bool | None) -> None:
|
|
if result is True:
|
|
# Credentials saved — retry loading the agent
|
|
self._do_load_agent(agent_path)
|
|
else:
|
|
self.status_bar.set_graph_id("")
|
|
self.notify(
|
|
"Credential setup skipped. Agent not loaded.",
|
|
severity="warning",
|
|
timeout=5,
|
|
)
|
|
if callable(on_cancel):
|
|
on_cancel()
|
|
|
|
self.push_screen(CredentialSetupScreen(session), callback=_on_result)
|
|
|
|
# -- Agent picker --
|
|
|
|
def _show_agent_picker_initial(self) -> None:
|
|
"""Show the agent picker on initial startup (no agent loaded)."""
|
|
from framework.tui.screens.agent_picker import AgentPickerScreen, discover_agents
|
|
|
|
agents = discover_agents()
|
|
if not agents:
|
|
self.notify("No agents found in exports/ or examples/", severity="error", timeout=5)
|
|
self.set_timer(2.0, self.exit)
|
|
return
|
|
|
|
def _on_initial_pick(result: str | None) -> None:
|
|
if result is None:
|
|
self.exit()
|
|
return
|
|
self._handle_picker_result(result)
|
|
|
|
# Show Get Started tab on initial launch
|
|
self.push_screen(
|
|
AgentPickerScreen(agents, show_get_started=True),
|
|
callback=_on_initial_pick,
|
|
)
|
|
|
|
def _handle_picker_result(self, result: str) -> None:
|
|
"""Handle the result from the agent picker, including Get Started actions."""
|
|
if result.startswith("action:"):
|
|
action = result.removeprefix("action:")
|
|
if action == "run_examples":
|
|
# Switch to Examples tab by re-opening picker focused on examples
|
|
self._show_agent_picker_tab("examples")
|
|
elif action == "run_existing":
|
|
# Switch to Your Agents tab
|
|
self._show_agent_picker_tab("your-agents")
|
|
elif action == "build_edit":
|
|
# Launch agent builder guidance
|
|
self._show_build_edit_message()
|
|
else:
|
|
# Regular agent path - load it
|
|
self._do_load_agent(result)
|
|
|
|
def _show_agent_picker_tab(self, tab_id: str) -> None:
|
|
"""Show the agent picker focused on a specific tab (no Get Started)."""
|
|
from framework.tui.screens.agent_picker import AgentPickerScreen, discover_agents
|
|
|
|
agents = discover_agents()
|
|
if not agents:
|
|
self.notify("No agents found", severity="error", timeout=5)
|
|
return
|
|
|
|
def _on_pick(result: str | None) -> None:
|
|
if result is None:
|
|
self.exit()
|
|
return
|
|
if result.startswith("action:"):
|
|
# Shouldn't happen but handle gracefully
|
|
self._handle_picker_result(result)
|
|
else:
|
|
self._do_load_agent(result)
|
|
|
|
screen = AgentPickerScreen(agents, show_get_started=False)
|
|
|
|
def _focus_tab() -> None:
|
|
try:
|
|
tabbed = screen.query_one(
|
|
"TabbedContent", expect_type=type(screen.query_one("TabbedContent"))
|
|
)
|
|
tabbed.active = tab_id
|
|
except Exception:
|
|
pass
|
|
|
|
self.push_screen(screen, callback=_on_pick)
|
|
self.call_later(_focus_tab)
|
|
|
|
def _show_build_edit_message(self) -> None:
|
|
"""Show guidance for building or editing agents."""
|
|
self.notify(
|
|
"To build or edit agents, use 'hive build' from the terminal "
|
|
"or run Claude Code with the /hive skill.",
|
|
severity="information",
|
|
timeout=10,
|
|
)
|
|
# Re-show picker so user can still select an agent
|
|
self._show_agent_picker_initial()
|
|
|
|
def action_show_agent_picker(self) -> None:
|
|
"""Open the agent picker (Ctrl+A or /agents)."""
|
|
from framework.tui.screens.agent_picker import AgentPickerScreen, discover_agents
|
|
|
|
agents = discover_agents()
|
|
if not agents:
|
|
self.notify("No agents found", severity="error", timeout=5)
|
|
return
|
|
|
|
def _on_pick(result: str | None) -> None:
|
|
if result is not None:
|
|
self._do_load_agent(result)
|
|
|
|
self.push_screen(AgentPickerScreen(agents), callback=_on_pick)
|
|
|
|
@work(exclusive=True)
|
|
async def _do_load_agent(self, agent_path: str) -> None:
|
|
"""Worker wrapper for _load_and_switch_agent."""
|
|
await self._load_and_switch_agent(agent_path)
|
|
|
|
# -- Escalation to Hive Coder --
|
|
|
|
@work(exclusive=True, group="escalation")
|
|
async def _do_escalate_to_coder(
|
|
self,
|
|
reason: str = "",
|
|
context: str = "",
|
|
node_id: str = "",
|
|
) -> None:
|
|
"""Push current agent onto stack and load hive_coder."""
|
|
from pathlib import Path
|
|
|
|
from framework.credentials.models import CredentialError
|
|
from framework.runner import AgentRunner
|
|
from framework.tools.session_graph_tools import register_graph_tools
|
|
|
|
if self.runtime is None:
|
|
self.notify("No active agent to escalate from", severity="error")
|
|
return
|
|
|
|
# 1. Save current state (do NOT cleanup — worker stays alive)
|
|
saved = {
|
|
"runner": self._runner,
|
|
"runtime": self.runtime,
|
|
"blocked_node_id": node_id,
|
|
}
|
|
self._escalation_stack.append(saved)
|
|
|
|
# Unsubscribe from worker events
|
|
if hasattr(self, "_subscription_id"):
|
|
try:
|
|
self.runtime.unsubscribe_from_events(self._subscription_id)
|
|
except Exception:
|
|
pass
|
|
del self._subscription_id
|
|
|
|
# Remember worker agent path for coder context
|
|
worker_path = ""
|
|
if self._runner and hasattr(self._runner, "agent_path"):
|
|
worker_path = str(self._runner.agent_path.resolve())
|
|
|
|
# 2. Remove worker widgets (they get destroyed)
|
|
workspace = self.query_one("#agent-workspace", Horizontal)
|
|
for child in list(workspace.children):
|
|
child.remove()
|
|
self.graph_view = None
|
|
self.chat_repl = None
|
|
|
|
# 3. Show loading state
|
|
self.status_bar.set_graph_id("Loading Hive Coder...")
|
|
self.notify("Escalating to Hive Coder...", timeout=3)
|
|
|
|
# 4. Load hive_coder
|
|
framework_agents_dir = Path(__file__).resolve().parent.parent / "agents"
|
|
hive_coder_path = framework_agents_dir / "hive_coder"
|
|
|
|
import asyncio
|
|
import functools
|
|
|
|
loop = asyncio.get_event_loop()
|
|
try:
|
|
load_fn = functools.partial(
|
|
AgentRunner.load,
|
|
str(hive_coder_path),
|
|
model=self._model,
|
|
interactive=False,
|
|
)
|
|
runner = await loop.run_in_executor(None, load_fn)
|
|
if runner._agent_runtime is None:
|
|
await loop.run_in_executor(None, runner._setup)
|
|
|
|
coder_runtime = runner._agent_runtime
|
|
coder_runtime._graph_id = "hive_coder"
|
|
coder_runtime._active_graph_id = "hive_coder"
|
|
|
|
# Register graph lifecycle tools
|
|
register_graph_tools(runner._tool_registry, coder_runtime)
|
|
coder_runtime._tools = list(runner._tool_registry.get_tools().values())
|
|
coder_runtime._tool_executor = runner._tool_registry.get_executor()
|
|
|
|
self._runner = runner
|
|
self.runtime = coder_runtime
|
|
except CredentialError as e:
|
|
self.status_bar.set_graph_id("")
|
|
self._show_credential_setup(
|
|
str(hive_coder_path),
|
|
on_cancel=self._restore_from_escalation_stack,
|
|
credential_error=e,
|
|
)
|
|
return
|
|
except Exception as e:
|
|
self.status_bar.set_graph_id("")
|
|
self.notify(f"Failed to load coder: {e}", severity="error", timeout=10)
|
|
self._restore_from_escalation_stack()
|
|
return
|
|
|
|
# 5. Mount coder widgets and subscribe
|
|
self._mount_agent_widgets()
|
|
|
|
# Start runtime on the agent loop (same pattern as _finish_agent_load)
|
|
if not coder_runtime.is_running:
|
|
try:
|
|
agent_loop = self.chat_repl._agent_loop
|
|
future = asyncio.run_coroutine_threadsafe(coder_runtime.start(), agent_loop)
|
|
await asyncio.wrap_future(future)
|
|
except Exception as e:
|
|
self.notify(f"Failed to start coder runtime: {e}", severity="error")
|
|
self._restore_from_escalation_stack()
|
|
return
|
|
|
|
await self._init_runtime_connection()
|
|
|
|
self.status_bar.set_graph_id("hive_coder (escalated)")
|
|
|
|
# 6. Auto-trigger coder with escalation context
|
|
escalation_input = self._build_escalation_input(reason, context, worker_path)
|
|
try:
|
|
import asyncio
|
|
|
|
entry_points = self.runtime.get_entry_points()
|
|
if entry_points:
|
|
ep = entry_points[0]
|
|
future = asyncio.run_coroutine_threadsafe(
|
|
self.runtime.trigger(
|
|
entry_point_id=ep.id,
|
|
input_data={"user_request": escalation_input},
|
|
),
|
|
self.chat_repl._agent_loop,
|
|
)
|
|
exec_id = await asyncio.wrap_future(future)
|
|
self.chat_repl._current_exec_id = exec_id
|
|
except Exception as e:
|
|
self.notify(f"Error starting coder: {e}", severity="error")
|
|
|
|
self.notify(
|
|
"Hive Coder loaded. Ctrl+E or /back to return.",
|
|
severity="information",
|
|
timeout=5,
|
|
)
|
|
self.refresh_bindings()
|
|
|
|
def _build_escalation_input(self, reason: str, context: str, worker_path: str) -> str:
|
|
"""Compose the user_request string for hive_coder."""
|
|
parts = []
|
|
if worker_path:
|
|
parts.append(
|
|
f"Modify the agent at: {worker_path}\n"
|
|
f"Do NOT ask which agent to modify — it is the path above."
|
|
)
|
|
if reason:
|
|
parts.append(f"Problem: {reason}")
|
|
if context:
|
|
parts.append(f"Context:\n{context}")
|
|
if not parts:
|
|
parts.append("The user needs help modifying their agent.")
|
|
return "\n\n".join(parts)
|
|
|
|
async def _return_from_escalation(self, summary: str = "") -> None:
|
|
"""Pop escalation stack and restore the worker agent."""
|
|
if not self._escalation_stack:
|
|
self.notify("No escalation to return from", severity="warning")
|
|
return
|
|
|
|
# 1. Tear down coder
|
|
self._unmount_agent_widgets()
|
|
if self._runner is not None:
|
|
try:
|
|
await self._runner.cleanup_async()
|
|
except Exception:
|
|
pass
|
|
|
|
# 2. Restore worker
|
|
saved = self._escalation_stack.pop()
|
|
self._runner = saved["runner"]
|
|
self.runtime = saved["runtime"]
|
|
|
|
# 3. Mount fresh widgets for the worker runtime
|
|
self._mount_agent_widgets()
|
|
await self._init_runtime_connection()
|
|
|
|
graph_id = self.runtime.graph.id if self.runtime else ""
|
|
self.status_bar.set_graph_id(graph_id)
|
|
|
|
# 4. Inject return message to unblock the worker node
|
|
blocked_node_id = saved.get("blocked_node_id", "")
|
|
return_msg = summary or "Coder session completed. Continuing."
|
|
if blocked_node_id:
|
|
try:
|
|
import asyncio
|
|
|
|
future = asyncio.run_coroutine_threadsafe(
|
|
self.runtime.inject_input(blocked_node_id, return_msg),
|
|
self.chat_repl._agent_loop,
|
|
)
|
|
await asyncio.wrap_future(future)
|
|
except Exception as e:
|
|
self.notify(
|
|
f"Could not resume worker: {e}",
|
|
severity="warning",
|
|
timeout=5,
|
|
)
|
|
|
|
# 5. Show return in chat (deferred — widgets need a tick to mount)
|
|
def _show_return():
|
|
if self.chat_repl:
|
|
self.chat_repl._write_history("[bold cyan]Returned from Hive Coder.[/bold cyan]")
|
|
if summary:
|
|
self.chat_repl._write_history(f"[dim]{summary}[/dim]")
|
|
|
|
self.call_later(_show_return)
|
|
self.notify("Returned to worker agent", severity="information", timeout=3)
|
|
self.refresh_bindings()
|
|
|
|
def _restore_from_escalation_stack(self) -> None:
|
|
"""Emergency restore when coder loading fails."""
|
|
if not self._escalation_stack:
|
|
return
|
|
saved = self._escalation_stack.pop()
|
|
self._runner = saved["runner"]
|
|
self.runtime = saved["runtime"]
|
|
self._mount_agent_widgets()
|
|
self.call_later(self._init_runtime_connection)
|
|
|
|
# -- Logging --
|
|
|
|
def _setup_logging_queue(self) -> None:
|
|
"""Setup a thread-safe queue for logs."""
|
|
try:
|
|
import queue
|
|
from logging.handlers import QueueHandler
|
|
|
|
self.log_queue = queue.Queue()
|
|
self.queue_handler = QueueHandler(self.log_queue)
|
|
self.queue_handler.setLevel(logging.INFO)
|
|
|
|
# Get root logger
|
|
root_logger = logging.getLogger()
|
|
|
|
# Remove ALL existing handlers to prevent stdout output
|
|
# This is critical - StreamHandlers cause text to appear in header
|
|
for handler in root_logger.handlers[:]:
|
|
root_logger.removeHandler(handler)
|
|
|
|
# Add ONLY our queue handler
|
|
root_logger.addHandler(self.queue_handler)
|
|
root_logger.setLevel(logging.INFO)
|
|
|
|
# Suppress LiteLLM logging completely
|
|
litellm_logger = logging.getLogger("LiteLLM")
|
|
litellm_logger.setLevel(logging.CRITICAL) # Only show critical errors
|
|
litellm_logger.propagate = False # Don't propagate to root logger
|
|
|
|
# Start polling
|
|
self.set_interval(0.1, self._poll_logs)
|
|
except Exception:
|
|
pass
|
|
|
|
def _poll_logs(self) -> None:
|
|
"""Poll the log queue and update UI."""
|
|
if not self.is_ready or self.chat_repl is None:
|
|
return
|
|
|
|
try:
|
|
while not self.log_queue.empty():
|
|
record = self.log_queue.get_nowait()
|
|
# Filter out framework/library logs
|
|
if record.name.startswith(("textual", "LiteLLM", "litellm")):
|
|
continue
|
|
|
|
self.chat_repl.write_python_log(record)
|
|
except Exception:
|
|
pass
|
|
|
|
# -- Runtime event routing --
|
|
|
|
_EVENT_TYPES = [
|
|
EventType.LLM_TEXT_DELTA,
|
|
EventType.CLIENT_OUTPUT_DELTA,
|
|
EventType.TOOL_CALL_STARTED,
|
|
EventType.TOOL_CALL_COMPLETED,
|
|
EventType.EXECUTION_STARTED,
|
|
EventType.EXECUTION_COMPLETED,
|
|
EventType.EXECUTION_FAILED,
|
|
EventType.NODE_LOOP_STARTED,
|
|
EventType.NODE_LOOP_ITERATION,
|
|
EventType.NODE_LOOP_COMPLETED,
|
|
EventType.CLIENT_INPUT_REQUESTED,
|
|
EventType.NODE_STALLED,
|
|
EventType.GOAL_PROGRESS,
|
|
EventType.GOAL_ACHIEVED,
|
|
EventType.CONSTRAINT_VIOLATION,
|
|
EventType.STATE_CHANGED,
|
|
EventType.NODE_INPUT_BLOCKED,
|
|
EventType.CONTEXT_COMPACTED,
|
|
EventType.NODE_INTERNAL_OUTPUT,
|
|
EventType.JUDGE_VERDICT,
|
|
EventType.OUTPUT_KEY_SET,
|
|
EventType.NODE_RETRY,
|
|
EventType.EDGE_TRAVERSED,
|
|
EventType.EXECUTION_PAUSED,
|
|
EventType.EXECUTION_RESUMED,
|
|
EventType.ESCALATION_REQUESTED,
|
|
EventType.WORKER_ESCALATION_TICKET,
|
|
EventType.QUEEN_INTERVENTION_REQUESTED,
|
|
]
|
|
|
|
_LOG_PANE_EVENTS = frozenset(_EVENT_TYPES) - {
|
|
EventType.LLM_TEXT_DELTA,
|
|
EventType.CLIENT_OUTPUT_DELTA,
|
|
}
|
|
|
|
async def _init_runtime_connection(self) -> None:
|
|
"""Subscribe to runtime events with an async handler."""
|
|
try:
|
|
self._subscription_id = self.runtime.subscribe_to_events(
|
|
event_types=self._EVENT_TYPES,
|
|
handler=self._handle_event,
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
async def _handle_event(self, event: AgentEvent) -> None:
|
|
"""Bridge events to Textual's main thread for UI updates.
|
|
|
|
Events may arrive from the agent-execution thread (normal LLM/tool
|
|
work) or from the Textual thread itself (e.g. webhook server events).
|
|
``call_from_thread`` requires a *different* thread, so we detect
|
|
which thread we're on and act accordingly.
|
|
"""
|
|
try:
|
|
if threading.get_ident() == self._thread_id:
|
|
# Already on Textual's thread — call directly.
|
|
self._route_event(event)
|
|
else:
|
|
# On a different thread — bridge via call_from_thread.
|
|
self.call_from_thread(self._route_event, event)
|
|
except Exception as e:
|
|
logging.getLogger("tui.events").error(
|
|
"call_from_thread failed for %s (node=%s): %s",
|
|
event.type.value,
|
|
event.node_id or "?",
|
|
e,
|
|
)
|
|
|
|
def _route_event(self, event: AgentEvent) -> None:
|
|
"""Route incoming events to widgets. Runs on Textual's main thread."""
|
|
if not self.is_ready or self.chat_repl is None:
|
|
return
|
|
|
|
try:
|
|
et = event.type
|
|
|
|
# --- Judge monitoring filter ---
|
|
# The judge runs as a silent background task. Only surface
|
|
# escalation ticket events on the status bar; everything else
|
|
# (LLM deltas, tool calls, node iterations) goes to logs only.
|
|
if event.stream_id == "judge":
|
|
if et == EventType.WORKER_ESCALATION_TICKET:
|
|
ticket = event.data.get("ticket", {})
|
|
severity = ticket.get("severity", "")
|
|
if severity:
|
|
self.status_bar.set_node_detail(f"judge: {severity} ticket")
|
|
# All judge events → logs only, not displayed.
|
|
return
|
|
|
|
# --- Queen-primary event routing ---
|
|
# When the queen is active, queen events go to chat display
|
|
# and worker events are handled specially.
|
|
_queen_active = self._queen_executor is not None
|
|
|
|
if _queen_active:
|
|
# Queen events (stream_id="queen") → display in chat
|
|
if event.stream_id == "queen":
|
|
if et == EventType.QUEEN_INTERVENTION_REQUESTED:
|
|
self._handle_queen_intervention(event.data)
|
|
return
|
|
# Tag streaming source and active node so labels resolve
|
|
# correctly even when worker events interleave.
|
|
self.chat_repl._streaming_source = "queen"
|
|
if event.node_id:
|
|
self.chat_repl._active_node_id = event.node_id
|
|
# Queen events fall through to the chat handlers below.
|
|
|
|
# Worker events (from AgentRuntime, graph_id set) when queen is primary
|
|
elif event.graph_id is not None:
|
|
if et == EventType.CLIENT_INPUT_REQUESTED:
|
|
# Worker asking for input — set override in ChatRepl
|
|
self.chat_repl.handle_worker_input_requested(
|
|
event.node_id or event.data.get("node_id", ""),
|
|
graph_id=event.graph_id,
|
|
)
|
|
return
|
|
elif et == EventType.EXECUTION_COMPLETED:
|
|
# Inject status into queen conversation
|
|
self._inject_worker_status_into_queen(
|
|
"Worker execution completed successfully."
|
|
)
|
|
return
|
|
elif et == EventType.EXECUTION_FAILED:
|
|
error = event.data.get("error", "Unknown error")[:200]
|
|
self._inject_worker_status_into_queen(f"Worker execution failed: {error}")
|
|
return
|
|
elif et in (
|
|
EventType.LLM_TEXT_DELTA,
|
|
EventType.CLIENT_OUTPUT_DELTA,
|
|
EventType.TOOL_CALL_STARTED,
|
|
EventType.TOOL_CALL_COMPLETED,
|
|
):
|
|
# Let worker client-facing output and tool events
|
|
# through so the user can see what the worker is
|
|
# doing/asking. Clear queen streaming source and
|
|
# update the active node so labels resolve correctly.
|
|
self.chat_repl._streaming_source = None
|
|
if event.node_id:
|
|
self.chat_repl._active_node_id = event.node_id
|
|
# Fall through to the standard chat handlers below.
|
|
else:
|
|
# All other worker events while queen is active → logs only
|
|
return
|
|
|
|
# --- Multi-graph filtering (non-queen mode) ---
|
|
# If the event has a graph_id and it's not the active graph,
|
|
# show a notification for important events and drop the rest.
|
|
if (
|
|
not _queen_active
|
|
and event.graph_id is not None
|
|
and event.graph_id != self.runtime.active_graph_id
|
|
):
|
|
if et == EventType.CLIENT_INPUT_REQUESTED:
|
|
self.notify(
|
|
f"[bold]{event.graph_id}[/bold] is waiting for input",
|
|
severity="warning",
|
|
timeout=10,
|
|
)
|
|
elif et == EventType.EXECUTION_FAILED:
|
|
error = event.data.get("error", "Unknown error")[:60]
|
|
self.notify(
|
|
f"[bold red]{event.graph_id}[/bold red] failed: {error}",
|
|
severity="error",
|
|
timeout=10,
|
|
)
|
|
elif et == EventType.EXECUTION_COMPLETED:
|
|
self.notify(
|
|
f"[bold green]{event.graph_id}[/bold green] completed",
|
|
severity="information",
|
|
timeout=5,
|
|
)
|
|
# All other background events are silently dropped (visible in logs)
|
|
return
|
|
|
|
# --- Chat REPL events ---
|
|
if et in (EventType.LLM_TEXT_DELTA, EventType.CLIENT_OUTPUT_DELTA):
|
|
self.chat_repl.handle_text_delta(
|
|
event.data.get("content", ""),
|
|
event.data.get("snapshot", ""),
|
|
)
|
|
elif et == EventType.TOOL_CALL_STARTED:
|
|
self.chat_repl.handle_tool_started(
|
|
event.data.get("tool_name", "unknown"),
|
|
event.data.get("tool_input", {}),
|
|
)
|
|
elif et == EventType.TOOL_CALL_COMPLETED:
|
|
self.chat_repl.handle_tool_completed(
|
|
event.data.get("tool_name", "unknown"),
|
|
event.data.get("result", ""),
|
|
event.data.get("is_error", False),
|
|
)
|
|
elif et == EventType.EXECUTION_COMPLETED:
|
|
self.chat_repl.handle_execution_completed(event.data.get("output", {}))
|
|
elif et == EventType.EXECUTION_FAILED:
|
|
self.chat_repl.handle_execution_failed(event.data.get("error", "Unknown error"))
|
|
elif et == EventType.CLIENT_INPUT_REQUESTED:
|
|
self.chat_repl.handle_input_requested(
|
|
event.node_id or event.data.get("node_id", ""),
|
|
graph_id=event.graph_id,
|
|
)
|
|
elif et == EventType.ESCALATION_REQUESTED:
|
|
self.chat_repl.handle_escalation_requested(event.data)
|
|
self._do_escalate_to_coder(
|
|
reason=event.data.get("reason", ""),
|
|
context=event.data.get("context", ""),
|
|
node_id=event.node_id or "",
|
|
)
|
|
elif et == EventType.NODE_LOOP_STARTED:
|
|
self.chat_repl.handle_node_started(event.node_id or "")
|
|
elif et == EventType.NODE_LOOP_ITERATION:
|
|
self.chat_repl.handle_loop_iteration(event.data.get("iteration", 0))
|
|
elif et == EventType.NODE_LOOP_COMPLETED:
|
|
self.chat_repl.handle_node_completed(event.node_id or "")
|
|
|
|
# Non-client-facing node output → chat repl
|
|
if et == EventType.NODE_INTERNAL_OUTPUT:
|
|
content = event.data.get("content", "")
|
|
if content.strip():
|
|
self.chat_repl.handle_internal_output(event.node_id or "", content)
|
|
|
|
# Execution paused/resumed → chat repl
|
|
if et == EventType.EXECUTION_PAUSED:
|
|
reason = event.data.get("reason", "")
|
|
self.chat_repl.handle_execution_paused(event.node_id or "", reason)
|
|
elif et == EventType.EXECUTION_RESUMED:
|
|
self.chat_repl.handle_execution_resumed(event.node_id or "")
|
|
|
|
# Goal achieved / constraint violation → chat repl
|
|
if et == EventType.GOAL_ACHIEVED:
|
|
self.chat_repl.handle_goal_achieved(event.data)
|
|
elif et == EventType.CONSTRAINT_VIOLATION:
|
|
self.chat_repl.handle_constraint_violation(event.data)
|
|
|
|
# --- Graph view events ---
|
|
if self.graph_view is not None:
|
|
if et in (
|
|
EventType.EXECUTION_STARTED,
|
|
EventType.EXECUTION_COMPLETED,
|
|
EventType.EXECUTION_FAILED,
|
|
):
|
|
self.graph_view.update_execution(event)
|
|
|
|
if et == EventType.NODE_LOOP_STARTED:
|
|
self.graph_view.handle_node_loop_started(event.node_id or "")
|
|
elif et == EventType.NODE_LOOP_ITERATION:
|
|
self.graph_view.handle_node_loop_iteration(
|
|
event.node_id or "",
|
|
event.data.get("iteration", 0),
|
|
)
|
|
elif et == EventType.NODE_LOOP_COMPLETED:
|
|
self.graph_view.handle_node_loop_completed(event.node_id or "")
|
|
elif et == EventType.NODE_STALLED:
|
|
self.graph_view.handle_stalled(
|
|
event.node_id or "",
|
|
event.data.get("reason", ""),
|
|
)
|
|
|
|
if et == EventType.TOOL_CALL_STARTED:
|
|
self.graph_view.handle_tool_call(
|
|
event.node_id or "",
|
|
event.data.get("tool_name", "unknown"),
|
|
started=True,
|
|
)
|
|
elif et == EventType.TOOL_CALL_COMPLETED:
|
|
self.graph_view.handle_tool_call(
|
|
event.node_id or "",
|
|
event.data.get("tool_name", "unknown"),
|
|
started=False,
|
|
)
|
|
|
|
# Edge traversal → graph view
|
|
if et == EventType.EDGE_TRAVERSED:
|
|
self.graph_view.handle_edge_traversed(
|
|
event.data.get("source_node", ""),
|
|
event.data.get("target_node", ""),
|
|
)
|
|
|
|
# --- Status bar events ---
|
|
# Map of external node IDs (queen, judge) to display names.
|
|
_ext_names = {"queen": "Queen"}
|
|
|
|
if et == EventType.EXECUTION_STARTED:
|
|
entry_node = event.data.get("entry_node") or (
|
|
self.runtime.graph.entry_node if self.runtime else ""
|
|
)
|
|
entry_node = _ext_names.get(entry_node, entry_node)
|
|
self.status_bar.set_running(entry_node)
|
|
elif et == EventType.EXECUTION_COMPLETED:
|
|
self.status_bar.set_completed()
|
|
elif et == EventType.EXECUTION_FAILED:
|
|
self.status_bar.set_failed(event.data.get("error", ""))
|
|
elif et == EventType.NODE_LOOP_STARTED:
|
|
nid = event.node_id or ""
|
|
node = self.runtime.graph.get_node(nid)
|
|
name = node.name if node else _ext_names.get(nid, nid)
|
|
self.status_bar.set_active_node(name, "thinking...")
|
|
elif et == EventType.NODE_LOOP_ITERATION:
|
|
self.status_bar.set_node_detail(f"step {event.data.get('iteration', '?')}")
|
|
elif et == EventType.TOOL_CALL_STARTED:
|
|
self.status_bar.set_node_detail(f"{event.data.get('tool_name', '')}...")
|
|
elif et == EventType.TOOL_CALL_COMPLETED:
|
|
self.status_bar.set_node_detail("thinking...")
|
|
elif et == EventType.NODE_STALLED:
|
|
self.status_bar.set_node_detail(f"stalled: {event.data.get('reason', '')}")
|
|
elif et == EventType.CONTEXT_COMPACTED:
|
|
before = event.data.get("usage_before", "?")
|
|
after = event.data.get("usage_after", "?")
|
|
self.status_bar.set_node_detail(f"compacted: {before}% \u2192 {after}%")
|
|
elif et == EventType.JUDGE_VERDICT:
|
|
action = event.data.get("action", "?")
|
|
self.status_bar.set_node_detail(f"judge: {action}")
|
|
elif et == EventType.OUTPUT_KEY_SET:
|
|
key = event.data.get("key", "?")
|
|
self.status_bar.set_node_detail(f"set: {key}")
|
|
elif et == EventType.NODE_RETRY:
|
|
retry = event.data.get("retry_count", "?")
|
|
max_r = event.data.get("max_retries", "?")
|
|
self.status_bar.set_node_detail(f"retry {retry}/{max_r}")
|
|
elif et == EventType.EXECUTION_PAUSED:
|
|
self.status_bar.set_node_detail("paused")
|
|
elif et == EventType.EXECUTION_RESUMED:
|
|
self.status_bar.set_node_detail("resumed")
|
|
|
|
# --- Log events (inline in chat) ---
|
|
if et in self._LOG_PANE_EVENTS:
|
|
self.chat_repl.write_log_event(event)
|
|
except Exception as e:
|
|
logging.getLogger("tui.events").error(
|
|
"Route failed for %s (node=%s): %s",
|
|
event.type.value,
|
|
event.node_id or "?",
|
|
e,
|
|
exc_info=True,
|
|
)
|
|
|
|
def _handle_queen_intervention(self, data: dict) -> None:
|
|
"""Notify the operator of a queen escalation — non-disruptively.
|
|
|
|
The worker keeps running. The operator can press Ctrl+Q to switch to
|
|
the queen's graph view for a conversation about the issue.
|
|
"""
|
|
severity = data.get("severity", "unknown")
|
|
analysis = data.get("analysis", "(no analysis)")
|
|
|
|
severity_markup = {
|
|
"low": "[dim]low[/dim]",
|
|
"medium": "[yellow]medium[/yellow]",
|
|
"high": "[bold red]high[/bold red]",
|
|
"critical": "[bold red]CRITICAL[/bold red]",
|
|
}
|
|
sev_label = severity_markup.get(severity, severity)
|
|
|
|
msg = f"Queen escalation ({sev_label}): {analysis}"
|
|
if self._queen_graph_id:
|
|
msg += "\nPress [bold]Ctrl+Q[/bold] to chat with queen."
|
|
|
|
textual_severity = "error" if severity in ("high", "critical") else "warning"
|
|
self.notify(msg, severity=textual_severity, timeout=30)
|
|
|
|
def _inject_worker_status_into_queen(self, message: str) -> None:
|
|
"""Inject a worker status update into the queen's conversation."""
|
|
import asyncio as _aio
|
|
|
|
executor = self._queen_executor
|
|
if executor is None:
|
|
return
|
|
node = executor.node_registry.get("queen")
|
|
if node is None or not hasattr(node, "inject_event"):
|
|
return
|
|
|
|
agent_loop = getattr(self.chat_repl, "_agent_loop", None)
|
|
if agent_loop is None:
|
|
return
|
|
|
|
status_msg = f"[WORKER STATUS UPDATE]\n{message}"
|
|
_aio.run_coroutine_threadsafe(node.inject_event(status_msg), agent_loop)
|
|
|
|
# -- Actions --
|
|
|
|
def action_switch_graph(self, graph_id: str) -> None:
|
|
"""Switch the active graph focus in the TUI."""
|
|
if self.runtime is None:
|
|
return
|
|
try:
|
|
self.runtime.active_graph_id = graph_id
|
|
except ValueError:
|
|
self.notify(f"Graph '{graph_id}' not found", severity="error", timeout=3)
|
|
return
|
|
|
|
# Update status bar
|
|
self.status_bar.set_graph_id(graph_id)
|
|
|
|
# Update graph view
|
|
reg = self.runtime.get_graph_registration(graph_id)
|
|
if reg and self.graph_view:
|
|
self.graph_view.switch_graph(reg.graph)
|
|
|
|
# Flush chat streaming state
|
|
if self.chat_repl:
|
|
self.chat_repl.flush_streaming()
|
|
|
|
self.notify(f"Switched to graph: {graph_id}", severity="information", timeout=3)
|
|
|
|
def save_screenshot(self, filename: str | None = None) -> str:
|
|
"""Save a screenshot of the current screen as SVG (viewable in browsers)."""
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
screenshots_dir = Path("screenshots")
|
|
screenshots_dir.mkdir(exist_ok=True)
|
|
|
|
if filename is None:
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
filename = f"tui_screenshot_{timestamp}.svg"
|
|
|
|
if not filename.endswith(".svg"):
|
|
filename += ".svg"
|
|
|
|
filepath = screenshots_dir / filename
|
|
|
|
from framework.tui.widgets.chat_repl import ChatRepl
|
|
|
|
try:
|
|
chat_widget = self.query_one(ChatRepl)
|
|
except Exception:
|
|
# No ChatRepl mounted yet
|
|
svg_data = self.export_screenshot()
|
|
filepath.write_text(svg_data, encoding="utf-8")
|
|
return str(filepath)
|
|
|
|
original_chat_border = chat_widget.styles.border_left
|
|
chat_widget.styles.border_left = ("none", "transparent")
|
|
|
|
input_widgets = self.query("ChatTextArea")
|
|
original_input_borders = []
|
|
for input_widget in input_widgets:
|
|
original_input_borders.append(input_widget.styles.border)
|
|
input_widget.styles.border = ("none", "transparent")
|
|
|
|
try:
|
|
svg_data = self.export_screenshot()
|
|
filepath.write_text(svg_data, encoding="utf-8")
|
|
finally:
|
|
chat_widget.styles.border_left = original_chat_border
|
|
for i, input_widget in enumerate(input_widgets):
|
|
input_widget.styles.border = original_input_borders[i]
|
|
|
|
return str(filepath)
|
|
|
|
def action_screenshot(self) -> None:
|
|
"""Take a screenshot (bound to Ctrl+S)."""
|
|
try:
|
|
filepath = self.save_screenshot()
|
|
self.notify(
|
|
f"Screenshot saved: {filepath} (SVG - open in browser)",
|
|
severity="information",
|
|
timeout=5,
|
|
)
|
|
except Exception as e:
|
|
self.notify(f"Screenshot failed: {e}", severity="error", timeout=5)
|
|
|
|
def action_toggle_logs(self) -> None:
|
|
"""Toggle inline log display in chat (bound to Ctrl+L)."""
|
|
if self.chat_repl is None:
|
|
return
|
|
self.chat_repl.toggle_logs()
|
|
mode = "ON" if self.chat_repl._show_logs else "OFF"
|
|
self.notify(f"Logs {mode}", severity="information", timeout=2)
|
|
|
|
def action_pause_execution(self) -> None:
|
|
"""Immediately pause execution by cancelling task (bound to Ctrl+Z)."""
|
|
if self.chat_repl is None or self.runtime is None:
|
|
return
|
|
try:
|
|
if not self.chat_repl._current_exec_id:
|
|
self.notify(
|
|
"No active execution to pause",
|
|
severity="information",
|
|
timeout=3,
|
|
)
|
|
return
|
|
|
|
task_cancelled = False
|
|
all_streams = []
|
|
active_reg = self.runtime.get_graph_registration(self.runtime.active_graph_id)
|
|
if active_reg:
|
|
all_streams.extend(active_reg.streams.values())
|
|
for gid in self.runtime.list_graphs():
|
|
if gid == self.runtime.active_graph_id:
|
|
continue
|
|
reg = self.runtime.get_graph_registration(gid)
|
|
if reg:
|
|
all_streams.extend(reg.streams.values())
|
|
|
|
for stream in all_streams:
|
|
exec_id = self.chat_repl._current_exec_id
|
|
task = stream._execution_tasks.get(exec_id)
|
|
if task and not task.done():
|
|
task.cancel()
|
|
task_cancelled = True
|
|
self.notify(
|
|
"Execution paused - state saved",
|
|
severity="information",
|
|
timeout=3,
|
|
)
|
|
break
|
|
|
|
if not task_cancelled:
|
|
self.notify(
|
|
"Execution already completed",
|
|
severity="information",
|
|
timeout=2,
|
|
)
|
|
except Exception as e:
|
|
self.notify(
|
|
f"Error pausing execution: {e}",
|
|
severity="error",
|
|
timeout=5,
|
|
)
|
|
|
|
async def action_show_sessions(self) -> None:
|
|
"""Show sessions list (bound to Ctrl+R)."""
|
|
if self.chat_repl is None:
|
|
return
|
|
try:
|
|
await self.chat_repl._submit_input("/sessions")
|
|
except Exception:
|
|
self.notify(
|
|
"Use /sessions command to see all sessions",
|
|
severity="information",
|
|
timeout=3,
|
|
)
|
|
|
|
def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
|
|
"""Control which bindings are shown in the footer.
|
|
|
|
Both escalate_to_coder and return_from_coder are bound to Ctrl+E.
|
|
check_action toggles which one is active based on escalation state,
|
|
so the footer shows "Coder" or "← Back" accordingly.
|
|
connect_to_queen is only shown when a queen monitoring graph is active.
|
|
"""
|
|
if action == "escalate_to_coder":
|
|
return not self._escalation_stack
|
|
if action == "return_from_coder":
|
|
return bool(self._escalation_stack)
|
|
if action == "connect_to_queen":
|
|
return bool(self._queen_graph_id and self.runtime is not None)
|
|
return True
|
|
|
|
def action_connect_to_queen(self) -> None:
|
|
"""Toggle between worker and queen graph views (Ctrl+Q)."""
|
|
if not self._queen_graph_id:
|
|
self.notify("No queen monitoring active", severity="warning", timeout=3)
|
|
return
|
|
# Toggle: if already on queen, switch back to worker
|
|
if self.runtime and self.runtime.active_graph_id == self._queen_graph_id:
|
|
self.action_switch_graph(self.runtime.graph_id)
|
|
else:
|
|
self.action_switch_graph(self._queen_graph_id)
|
|
|
|
def action_escalate_to_coder(self) -> None:
|
|
"""Escalate to Hive Coder (bound to Ctrl+E)."""
|
|
if self.runtime is None:
|
|
self.notify("No active agent to escalate from", severity="error")
|
|
return
|
|
# _do_escalate_to_coder is already @work-decorated; calling it starts the worker.
|
|
self._do_escalate_to_coder(reason="User-initiated escalation")
|
|
|
|
async def action_return_from_coder(self) -> None:
|
|
"""Return from Hive Coder to worker agent (Ctrl+E toggles)."""
|
|
await self._return_from_escalation()
|
|
|
|
async def on_unmount(self) -> None:
|
|
"""Cleanup on app shutdown - cancel execution which will save state."""
|
|
self.is_ready = False
|
|
|
|
# Cancel any active execution
|
|
try:
|
|
import asyncio
|
|
|
|
if self.chat_repl and self.chat_repl._current_exec_id and self.runtime:
|
|
all_streams = []
|
|
for gid in self.runtime.list_graphs():
|
|
reg = self.runtime.get_graph_registration(gid)
|
|
if reg:
|
|
all_streams.extend(reg.streams.values())
|
|
for stream in all_streams:
|
|
exec_id = self.chat_repl._current_exec_id
|
|
task = stream._execution_tasks.get(exec_id)
|
|
if task and not task.done():
|
|
task.cancel()
|
|
try:
|
|
await asyncio.wait_for(task, timeout=5.0)
|
|
except (TimeoutError, asyncio.CancelledError):
|
|
pass
|
|
except Exception:
|
|
pass
|
|
break
|
|
except Exception:
|
|
pass
|
|
|
|
# Stop health monitoring (judge + queen)
|
|
try:
|
|
self._stop_health_monitoring()
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
if hasattr(self, "_subscription_id") and self.runtime:
|
|
self.runtime.unsubscribe_from_events(self._subscription_id)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
if hasattr(self, "queue_handler"):
|
|
logging.getLogger().removeHandler(self.queue_handler)
|
|
except Exception:
|
|
pass
|