|
| 1 | +""" |
| 2 | +Agent collaboration / pipeline view for the RLM Code TUI. |
| 3 | +
|
| 4 | +Visualizes multi-step RLM runs, delegation chains, and agent state |
| 5 | +as a Rich renderable that can be written into a RichLog. |
| 6 | +
|
| 7 | +Based on SuperQode's agent_collab.py (predefined agent roles with icons/colors, |
| 8 | +animated active state, issue tracking, ASCII art boxes) and Toad's conversation |
| 9 | +widget patterns. |
| 10 | +""" |
| 11 | + |
| 12 | +from __future__ import annotations |
| 13 | + |
| 14 | +from dataclasses import dataclass, field |
| 15 | +from enum import Enum |
| 16 | +from time import time |
| 17 | +from typing import Sequence |
| 18 | + |
| 19 | +from rich.console import Console, ConsoleOptions, RenderResult |
| 20 | +from rich.panel import Panel |
| 21 | +from rich.table import Table |
| 22 | +from rich.text import Text |
| 23 | + |
| 24 | +from .design_system import PALETTE, ICONS, SPINNER_FRAMES |
| 25 | + |
| 26 | + |
| 27 | +class AgentState(Enum): |
| 28 | + """Lifecycle state of an agent node in the pipeline.""" |
| 29 | + |
| 30 | + IDLE = "idle" |
| 31 | + ACTIVE = "active" |
| 32 | + COMPLETE = "complete" |
| 33 | + ERROR = "error" |
| 34 | + PENDING = "pending" |
| 35 | + |
| 36 | + |
| 37 | +# (symbol, color) per state. |
| 38 | +STATE_SYMBOLS: dict[AgentState, tuple[str, str]] = { |
| 39 | + AgentState.IDLE: (ICONS["idle"], PALETTE.text_disabled), |
| 40 | + AgentState.ACTIVE: (ICONS["active"], PALETTE.info_bright), |
| 41 | + AgentState.COMPLETE: (ICONS["complete"], PALETTE.success), |
| 42 | + AgentState.ERROR: (ICONS["error"], PALETTE.error), |
| 43 | + AgentState.PENDING: (ICONS["pending"], PALETTE.warning), |
| 44 | +} |
| 45 | + |
| 46 | + |
| 47 | +# Predefined agent roles (from SuperQode). |
| 48 | +@dataclass(frozen=True) |
| 49 | +class AgentRole: |
| 50 | + """A predefined role with icon and color.""" |
| 51 | + icon: str |
| 52 | + label: str |
| 53 | + color: str |
| 54 | + |
| 55 | + |
| 56 | +AGENT_ROLES: dict[str, AgentRole] = { |
| 57 | + "scout": AgentRole(icon=ICONS["scout"], label="Scout", color="#f59e0b"), |
| 58 | + "verifier": AgentRole(icon=ICONS["verifier"], label="Verifier", color="#3b82f6"), |
| 59 | + "reviewer": AgentRole(icon=ICONS["reviewer"], label="Reviewer", color="#8b5cf6"), |
| 60 | + "fixer": AgentRole(icon=ICONS["fixer"], label="Fixer", color="#22c55e"), |
| 61 | + "tester": AgentRole(icon=ICONS["tester"], label="Tester", color="#06b6d4"), |
| 62 | + "guardian": AgentRole(icon=ICONS["guardian"], label="Guardian", color="#ef4444"), |
| 63 | + "runner": AgentRole(icon=ICONS["agent"], label="Runner", color=PALETTE.primary), |
| 64 | + "planner": AgentRole(icon="\U0001f4cb", label="Planner", color=PALETTE.info_bright), |
| 65 | + "coder": AgentRole(icon="\U0001f4bb", label="Coder", color=PALETTE.success), |
| 66 | + "delegate": AgentRole(icon=ICONS["arrow_right"], label="Delegate", color=PALETTE.text_muted), |
| 67 | +} |
| 68 | + |
| 69 | + |
| 70 | +@dataclass |
| 71 | +class AgentNode: |
| 72 | + """A single agent in the collaboration pipeline.""" |
| 73 | + |
| 74 | + name: str |
| 75 | + role: str = "" |
| 76 | + state: AgentState = AgentState.IDLE |
| 77 | + current_task: str = "" |
| 78 | + step: int = 0 |
| 79 | + total_steps: int = 0 |
| 80 | + reward: float = 0.0 |
| 81 | + started_at: float = 0.0 |
| 82 | + finished_at: float = 0.0 |
| 83 | + error_message: str = "" |
| 84 | + issues_found: int = 0 |
| 85 | + issues_verified: int = 0 |
| 86 | + |
| 87 | + @property |
| 88 | + def elapsed(self) -> float: |
| 89 | + end = self.finished_at if self.finished_at else time() |
| 90 | + return end - self.started_at if self.started_at else 0.0 |
| 91 | + |
| 92 | + @property |
| 93 | + def role_info(self) -> AgentRole: |
| 94 | + """Return the role metadata, falling back to a default.""" |
| 95 | + return AGENT_ROLES.get(self.role.lower(), AgentRole( |
| 96 | + icon=ICONS["agent"], label=self.role or "Agent", color=PALETTE.text_muted |
| 97 | + )) |
| 98 | + |
| 99 | + |
| 100 | +@dataclass |
| 101 | +class Handoff: |
| 102 | + """A message passed between two agents.""" |
| 103 | + |
| 104 | + from_agent: str |
| 105 | + to_agent: str |
| 106 | + message: str = "" |
| 107 | + issue_count: int = 0 |
| 108 | + timestamp: float = field(default_factory=time) |
| 109 | + |
| 110 | + |
| 111 | +class AgentPipeline: |
| 112 | + """Manages an ordered list of agents and their handoffs.""" |
| 113 | + |
| 114 | + def __init__(self) -> None: |
| 115 | + self.nodes: list[AgentNode] = [] |
| 116 | + self.handoffs: list[Handoff] = [] |
| 117 | + self._node_map: dict[str, AgentNode] = {} |
| 118 | + self._frame_index: int = 0 |
| 119 | + |
| 120 | + def add_agent(self, name: str, role: str = "") -> AgentNode: |
| 121 | + node = AgentNode(name=name, role=role) |
| 122 | + self.nodes.append(node) |
| 123 | + self._node_map[name] = node |
| 124 | + return node |
| 125 | + |
| 126 | + def get_agent(self, name: str) -> AgentNode | None: |
| 127 | + return self._node_map.get(name) |
| 128 | + |
| 129 | + def set_state(self, name: str, state: AgentState, task: str = "") -> None: |
| 130 | + node = self._node_map.get(name) |
| 131 | + if not node: |
| 132 | + return |
| 133 | + node.state = state |
| 134 | + if task: |
| 135 | + node.current_task = task |
| 136 | + if state == AgentState.ACTIVE and not node.started_at: |
| 137 | + node.started_at = time() |
| 138 | + if state in (AgentState.COMPLETE, AgentState.ERROR): |
| 139 | + node.finished_at = time() |
| 140 | + |
| 141 | + def add_handoff( |
| 142 | + self, |
| 143 | + from_agent: str, |
| 144 | + to_agent: str, |
| 145 | + message: str = "", |
| 146 | + issue_count: int = 0, |
| 147 | + ) -> None: |
| 148 | + self.handoffs.append(Handoff(from_agent, to_agent, message, issue_count)) |
| 149 | + |
| 150 | + def update_progress(self, name: str, step: int, total: int, reward: float = 0.0) -> None: |
| 151 | + node = self._node_map.get(name) |
| 152 | + if not node: |
| 153 | + return |
| 154 | + node.step = step |
| 155 | + node.total_steps = total |
| 156 | + node.reward = reward |
| 157 | + |
| 158 | + def update_issues(self, name: str, found: int = 0, verified: int = 0) -> None: |
| 159 | + """Update issue tracking for an agent.""" |
| 160 | + node = self._node_map.get(name) |
| 161 | + if not node: |
| 162 | + return |
| 163 | + node.issues_found = found |
| 164 | + node.issues_verified = verified |
| 165 | + |
| 166 | + def set_status(self, message: str) -> None: |
| 167 | + """Set a status message on the currently active agent.""" |
| 168 | + for node in self.nodes: |
| 169 | + if node.state == AgentState.ACTIVE: |
| 170 | + node.current_task = message |
| 171 | + break |
| 172 | + |
| 173 | + def clear(self) -> None: |
| 174 | + """Reset the pipeline.""" |
| 175 | + self.nodes.clear() |
| 176 | + self.handoffs.clear() |
| 177 | + self._node_map.clear() |
| 178 | + self._frame_index = 0 |
| 179 | + |
| 180 | + @property |
| 181 | + def has_active(self) -> bool: |
| 182 | + return any(n.state == AgentState.ACTIVE for n in self.nodes) |
| 183 | + |
| 184 | + def tick_animation(self) -> None: |
| 185 | + """Advance the animation frame counter.""" |
| 186 | + self._frame_index += 1 |
| 187 | + |
| 188 | + @property |
| 189 | + def active_symbol(self) -> str: |
| 190 | + """Animated symbol for ACTIVE state.""" |
| 191 | + return SPINNER_FRAMES[self._frame_index % len(SPINNER_FRAMES)] |
| 192 | + |
| 193 | + |
| 194 | +class PipelineRenderable: |
| 195 | + """Rich renderable showing the agent pipeline as a vertical flow. |
| 196 | +
|
| 197 | + Usage: ``chat_log.write(PipelineRenderable(pipeline))`` |
| 198 | + """ |
| 199 | + |
| 200 | + def __init__(self, pipeline: AgentPipeline, title: str = "Agent Pipeline") -> None: |
| 201 | + self.pipeline = pipeline |
| 202 | + self.title = title |
| 203 | + |
| 204 | + def __rich_console__( |
| 205 | + self, console: Console, options: ConsoleOptions |
| 206 | + ) -> RenderResult: |
| 207 | + table = Table( |
| 208 | + show_header=True, |
| 209 | + header_style=f"bold {PALETTE.primary_lighter}", |
| 210 | + border_style=PALETTE.border_default, |
| 211 | + expand=True, |
| 212 | + padding=(0, 1), |
| 213 | + ) |
| 214 | + table.add_column("", width=3, justify="center") |
| 215 | + table.add_column("Agent", min_width=12) |
| 216 | + table.add_column("Role", min_width=10) |
| 217 | + table.add_column("Task", min_width=14) |
| 218 | + table.add_column("Progress", min_width=10, justify="center") |
| 219 | + table.add_column("Reward", min_width=8, justify="right") |
| 220 | + table.add_column("Time", min_width=8, justify="right") |
| 221 | + |
| 222 | + for node in self.pipeline.nodes: |
| 223 | + if node.state == AgentState.ACTIVE: |
| 224 | + symbol = self.pipeline.active_symbol |
| 225 | + color = PALETTE.info_bright |
| 226 | + else: |
| 227 | + symbol, color = STATE_SYMBOLS[node.state] |
| 228 | + status_text = Text(symbol, style=f"bold {color}") |
| 229 | + |
| 230 | + role_info = node.role_info |
| 231 | + role_display = Text() |
| 232 | + role_display.append(f"{role_info.icon} ", style=role_info.color) |
| 233 | + role_display.append(role_info.label, style=role_info.color) |
| 234 | + |
| 235 | + progress = "" |
| 236 | + if node.total_steps > 0: |
| 237 | + progress = f"{node.step}/{node.total_steps}" |
| 238 | + |
| 239 | + elapsed = "" |
| 240 | + if node.elapsed > 0: |
| 241 | + secs = node.elapsed |
| 242 | + if secs >= 60: |
| 243 | + elapsed = f"{int(secs // 60)}m {int(secs % 60)}s" |
| 244 | + else: |
| 245 | + elapsed = f"{secs:.1f}s" |
| 246 | + |
| 247 | + reward_text = Text() |
| 248 | + if node.state in (AgentState.ACTIVE, AgentState.COMPLETE): |
| 249 | + rcolor = PALETTE.success if node.reward >= 0.5 else PALETTE.warning |
| 250 | + reward_text = Text(f"{node.reward:.2f}", style=rcolor) |
| 251 | + |
| 252 | + task_text = Text() |
| 253 | + if node.current_task: |
| 254 | + truncated = node.current_task[:20] + "..." if len(node.current_task) > 23 else node.current_task |
| 255 | + task_text = Text(truncated, style=PALETTE.text_hint) |
| 256 | + |
| 257 | + table.add_row( |
| 258 | + status_text, |
| 259 | + Text(node.name, style=f"bold {PALETTE.text_body}"), |
| 260 | + role_display, |
| 261 | + task_text, |
| 262 | + Text(progress, style=PALETTE.text_secondary), |
| 263 | + reward_text, |
| 264 | + Text(elapsed, style=PALETTE.text_dim), |
| 265 | + ) |
| 266 | + |
| 267 | + # Show handoffs below the table if any. |
| 268 | + content = Text() |
| 269 | + if self.pipeline.handoffs: |
| 270 | + content.append("\n") |
| 271 | + for ho in self.pipeline.handoffs[-5:]: # last 5 handoffs |
| 272 | + content.append(f" {ho.from_agent}", style=f"bold {PALETTE.info_bright}") |
| 273 | + content.append(f" {ICONS['arrow_right']} ", style=PALETTE.text_dim) |
| 274 | + content.append(f"{ho.to_agent}", style=f"bold {PALETTE.accent_light}") |
| 275 | + if ho.issue_count: |
| 276 | + content.append(f" [{ho.issue_count} issues]", style=PALETTE.warning) |
| 277 | + if ho.message: |
| 278 | + content.append(f" {ho.message}", style=PALETTE.text_hint) |
| 279 | + content.append("\n") |
| 280 | + |
| 281 | + yield Panel( |
| 282 | + table, |
| 283 | + title=f"[{PALETTE.primary_lighter}]{self.title}[/]", |
| 284 | + border_style=PALETTE.border_primary, |
| 285 | + padding=(0, 0), |
| 286 | + ) |
| 287 | + if self.pipeline.handoffs: |
| 288 | + yield content |
| 289 | + |
| 290 | + |
| 291 | +def create_pipeline_from_events(events: list[dict]) -> AgentPipeline: |
| 292 | + """Build an AgentPipeline from a list of RLM event dicts. |
| 293 | +
|
| 294 | + Expected event dict keys: event_type, agent_name, step, total_steps, |
| 295 | + reward, from_agent, to_agent, message. |
| 296 | + """ |
| 297 | + pipeline = AgentPipeline() |
| 298 | + |
| 299 | + for ev in events: |
| 300 | + event_type = ev.get("event_type", "") |
| 301 | + name = ev.get("agent_name", "") |
| 302 | + |
| 303 | + if event_type == "RUN_START": |
| 304 | + node = pipeline.add_agent(name, role=ev.get("role", "runner")) |
| 305 | + node.state = AgentState.ACTIVE |
| 306 | + node.started_at = ev.get("timestamp", time()) |
| 307 | + |
| 308 | + elif event_type == "ITERATION_END": |
| 309 | + pipeline.update_progress( |
| 310 | + name, |
| 311 | + step=ev.get("step", 0), |
| 312 | + total=ev.get("total_steps", 0), |
| 313 | + reward=ev.get("reward", 0.0), |
| 314 | + ) |
| 315 | + |
| 316 | + elif event_type == "RUN_END": |
| 317 | + pipeline.set_state(name, AgentState.COMPLETE) |
| 318 | + |
| 319 | + elif event_type == "RUN_ERROR": |
| 320 | + node = pipeline.get_agent(name) |
| 321 | + if node: |
| 322 | + node.error_message = ev.get("error", "") |
| 323 | + pipeline.set_state(name, AgentState.ERROR) |
| 324 | + |
| 325 | + elif event_type == "DELEGATE": |
| 326 | + from_name = ev.get("from_agent", name) |
| 327 | + to_name = ev.get("to_agent", "") |
| 328 | + if to_name: |
| 329 | + if not pipeline.get_agent(to_name): |
| 330 | + pipeline.add_agent(to_name, role="delegate") |
| 331 | + pipeline.set_state(to_name, AgentState.PENDING) |
| 332 | + pipeline.add_handoff( |
| 333 | + from_name, to_name, |
| 334 | + ev.get("message", ""), |
| 335 | + ev.get("issue_count", 0), |
| 336 | + ) |
| 337 | + |
| 338 | + return pipeline |
0 commit comments