update cli

This commit is contained in:
X-iZhang
2026-02-01 20:52:08 +00:00
parent 068d6e135d
commit 3993029466
2 changed files with 703 additions and 175 deletions
+447 -175
View File
@@ -74,41 +74,166 @@ async def stream_agent_events(agent: Any, message: str, thread_id: str) -> Async
"""
config = {"configurable": {"thread_id": thread_id}}
emitter = StreamEventEmitter()
tracker = ToolCallTracker()
main_tracker = ToolCallTracker()
full_response = ""
# Track sub-agent names by root namespace element
_subagent_names: dict[str, str] = {} # root_ns_element → display name
# Track which task tool_call_ids have been announced
_announced_tasks: set[str] = set()
# Track sub-agent names
_key_to_name: dict[str, str] = {} # subagent_key → display name (cache)
_announced_names: list[str] = [] # ordered queue of announced task names
_assigned_names: set[str] = set() # names already assigned to a namespace
_announced_task_ids: list[str] = [] # ordered task tool_call_ids
_task_id_to_name: dict[str, str] = {} # tool_call_id → sub-agent name
_subagent_trackers: dict[str, ToolCallTracker] = {} # namespace_key → tracker
def _get_subagent_name(namespace: tuple) -> str | None:
"""Get sub-agent name from namespace, or None if main agent.
def _register_task_tool_call(tc_data: dict) -> str | None:
"""Register or update a task tool call, return subagent name if started/updated."""
tool_id = tc_data.get("id", "")
if not tool_id:
return None
args = tc_data.get("args", {}) or {}
desc = str(args.get("description", "")).strip()
sa_name = str(args.get("subagent_type", "")).strip()
if not sa_name:
# Fallback to description snippet (may be empty during streaming)
sa_name = desc[:30] + "..." if len(desc) > 30 else desc
if not sa_name:
sa_name = "sub-agent"
Any non-empty namespace is a sub-agent. Name is resolved by checking
all registered names for a prefix match against namespace elements.
if tool_id not in _announced_task_ids:
_announced_task_ids.append(tool_id)
_announced_names.append(sa_name)
_task_id_to_name[tool_id] = sa_name
return sa_name
# Update mapping if we learned a better name later
current = _task_id_to_name.get(tool_id, "sub-agent")
if sa_name != "sub-agent" and current != sa_name:
_task_id_to_name[tool_id] = sa_name
try:
idx = _announced_task_ids.index(tool_id)
if idx < len(_announced_names):
_announced_names[idx] = sa_name
except ValueError:
pass
return sa_name
return None
def _extract_task_id(namespace: tuple) -> tuple[str | None, str | None]:
"""Extract task tool_call_id from namespace if present.
Returns (task_id, task_ns_element) or (None, None).
"""
for part in namespace:
part_str = str(part)
if "task:" in part_str:
tail = part_str.split("task:", 1)[1]
task_id = tail.split(":", 1)[0] if tail else ""
if task_id:
return task_id, part_str
return None, None
def _next_announced_name() -> str | None:
"""Get next announced name that hasn't been assigned yet."""
for announced in _announced_names:
if announced not in _assigned_names:
_assigned_names.add(announced)
return announced
return None
def _find_task_id_from_metadata(metadata: dict | None) -> str | None:
"""Try to find a task tool_call_id in metadata."""
if not metadata:
return None
candidates = (
"tool_call_id",
"task_id",
"parent_run_id",
"root_run_id",
"run_id",
)
for key in candidates:
val = metadata.get(key)
if val and val in _task_id_to_name:
return val
return None
def _get_subagent_key(namespace: tuple, metadata: dict | None) -> str | None:
"""Stable key for tracker/mapping per sub-agent namespace."""
if not namespace:
return None
task_id, task_ns = _extract_task_id(namespace)
if task_ns:
return task_ns
meta_task_id = _find_task_id_from_metadata(metadata)
if meta_task_id:
return f"task:{meta_task_id}"
if metadata:
for key in ("parent_run_id", "root_run_id", "run_id", "graph_id", "node_id"):
val = metadata.get(key)
if val:
return f"{key}:{val}"
return str(namespace)
def _get_subagent_name(namespace: tuple, metadata: dict | None) -> str | None:
"""Resolve sub-agent name from namespace, or None if main agent.
Priority:
0) metadata["lc_agent_name"] — most reliable, set by DeepAgents framework.
1) Match task_id embedded in namespace to announced tool_call_id.
2) Use cached key mapping (only real names, never "sub-agent").
3) Queue-based: assign next announced name to this key.
4) Fallback: return "sub-agent" WITHOUT caching.
"""
if not namespace:
return None
root = str(namespace[0]) if namespace else ""
# Exact match
if root in _subagent_names:
return _subagent_names[root]
# Prefix match: namespace root might be "task:abc123" and we
# registered "task:call_xyz" — check if any registered key
# appears as a substring of the root or vice versa
for key, name in _subagent_names.items():
if key in root or root in key:
_subagent_names[root] = name # cache for next lookup
key = _get_subagent_key(namespace, metadata) or str(namespace)
# 0) lc_agent_name from metadata — the REAL sub-agent name
# set by the DeepAgents framework on every namespace event.
if metadata:
lc_name = metadata.get("lc_agent_name", "")
if isinstance(lc_name, str):
lc_name = lc_name.strip()
# Filter out generic/framework names
if lc_name and lc_name not in (
"sub-agent", "agent", "tools", "EvoScientist",
"LangGraph", "",
):
_key_to_name[key] = lc_name
return lc_name
# 1) Resolve by task_id if present in namespace
task_id, _task_ns = _extract_task_id(namespace)
if task_id and task_id in _task_id_to_name:
name = _task_id_to_name[task_id]
if name and name != "sub-agent":
_assigned_names.add(name)
_key_to_name[key] = name
return name
# Auto-register: infer from namespace string
if ":" in root:
inferred = root.split(":")[0]
else:
inferred = root
name = inferred or "sub-agent"
_subagent_names[root] = name
return name
meta_task_id = _find_task_id_from_metadata(metadata)
if meta_task_id and meta_task_id in _task_id_to_name:
name = _task_id_to_name[meta_task_id]
if name and name != "sub-agent":
_assigned_names.add(name)
_key_to_name[key] = name
return name
# 2) Cached real name for this key (skip if it's "sub-agent")
cached = _key_to_name.get(key)
if cached and cached != "sub-agent":
return cached
# 3) Assign next announced name from queue (skip "sub-agent" entries)
for announced in _announced_names:
if announced not in _assigned_names and announced != "sub-agent":
_assigned_names.add(announced)
_key_to_name[key] = announced
return announced
# 4) No real names available yet — return generic WITHOUT caching
return "sub-agent"
try:
async for chunk in agent.astream(
@@ -131,20 +256,26 @@ async def stream_agent_events(agent: Any, message: str, thread_id: str) -> Async
# (message, metadata) — no namespace
data = chunk
# Unpack message from data
# Unpack message + metadata from data
msg: Any
metadata: dict = {}
if isinstance(data, tuple) and len(data) >= 2:
msg = data[0]
metadata = data[1] or {}
else:
msg = data
subagent = _get_subagent_name(namespace)
subagent = _get_subagent_name(namespace, metadata)
subagent_tracker = None
if subagent:
tracker_key = _get_subagent_key(namespace, metadata) or str(namespace)
subagent_tracker = _subagent_trackers.setdefault(tracker_key, ToolCallTracker())
# Process AIMessageChunk / AIMessage
if isinstance(msg, (AIMessageChunk, AIMessage)):
if subagent:
# Sub-agent content — emit sub-agent events
for ev in _process_chunk_content(msg, emitter, tracker):
for ev in _process_chunk_content(msg, emitter, subagent_tracker):
if ev.type == "tool_call":
yield emitter.subagent_tool_call(
subagent, ev.data["name"], ev.data["args"], ev.data.get("id", "")
@@ -164,50 +295,54 @@ async def stream_agent_events(agent: Any, message: str, thread_id: str) -> Async
).data
else:
# Main agent content
for ev in _process_chunk_content(msg, emitter, tracker):
for ev in _process_chunk_content(msg, emitter, main_tracker):
if ev.type == "text":
full_response += ev.data.get("content", "")
yield ev.data
if hasattr(msg, "tool_calls") and msg.tool_calls:
for ev in _process_tool_calls(msg.tool_calls, emitter, tracker):
for ev in _process_tool_calls(msg.tool_calls, emitter, main_tracker):
yield ev.data
# Detect task tool calls → announce sub-agent
tc_data = ev.data
if tc_data.get("name") == "task":
tool_id = tc_data.get("id", "")
if tool_id and tool_id not in _announced_tasks:
_announced_tasks.add(tool_id)
args = tc_data.get("args", {})
sa_name = args.get("subagent_type", "").strip()
desc = args.get("description", "").strip()
# Use subagent_type as name; fall back to description snippet
if not sa_name:
sa_name = desc[:30] + "..." if len(desc) > 30 else desc
if not sa_name:
sa_name = "sub-agent"
# Pre-register name so namespace lookup finds it
_subagent_names[f"task:{tool_id}"] = sa_name
yield emitter.subagent_start(sa_name, desc).data
started_name = _register_task_tool_call(tc_data)
if started_name:
desc = str(tc_data.get("args", {}).get("description", "")).strip()
yield emitter.subagent_start(started_name, desc).data
# Process ToolMessage (tool execution result)
elif hasattr(msg, "type") and msg.type == "tool":
if subagent:
if subagent_tracker:
subagent_tracker.finalize_all()
for info in subagent_tracker.emit_all_pending():
yield emitter.subagent_tool_call(
subagent,
info.name,
info.args,
info.id,
).data
name = getattr(msg, "name", "unknown")
raw_content = str(getattr(msg, "content", ""))
content = raw_content[:DisplayLimits.TOOL_RESULT_MAX]
success = is_success(content)
yield emitter.subagent_tool_result(subagent, name, content, success).data
else:
for ev in _process_tool_result(msg, emitter, tracker):
for ev in _process_tool_result(msg, emitter, main_tracker):
yield ev.data
# Tool result can re-emit tool_call with full args; update task mapping
if ev.type == "tool_call" and ev.data.get("name") == "task":
started_name = _register_task_tool_call(ev.data)
if started_name:
desc = str(ev.data.get("args", {}).get("description", "")).strip()
yield emitter.subagent_start(started_name, desc).data
# Check if this is a task result → sub-agent ended
name = getattr(msg, "name", "")
if name == "task":
tool_call_id = getattr(msg, "tool_call_id", "")
# Find the sub-agent name for this task
sa_key = f"task:{tool_call_id}"
sa_name = _subagent_names.get(sa_key, "sub-agent")
# Find the sub-agent name via tool_call_id map
sa_name = _task_id_to_name.get(tool_call_id, "sub-agent")
yield emitter.subagent_end(sa_name).data
except Exception as e:
@@ -403,12 +538,14 @@ class StreamState:
# Sub-agent tracking
self.subagents: list[SubAgentState] = []
self._subagent_map: dict[str, SubAgentState] = {} # name → state
# Todo list tracking
self.todo_items: list[dict] = []
# Latest text segment (reset on each tool_call)
self.latest_text = ""
def _get_or_create_subagent(self, name: str, description: str = "") -> SubAgentState:
if name not in self._subagent_map:
# Check if there's a generic "sub-agent" entry that should be merged
# This happens when namespace events arrive before the task tool call
# registers the proper name
# Case 1: real name arrives, "sub-agent" entry exists → rename it
if name != "sub-agent" and "sub-agent" in self._subagent_map:
old_sa = self._subagent_map.pop("sub-agent")
old_sa.name = name
@@ -416,13 +553,41 @@ class StreamState:
old_sa.description = description
self._subagent_map[name] = old_sa
return old_sa
# Case 2: "sub-agent" arrives but a pre-registered real-name entry
# exists with no tool calls → merge into it
if name == "sub-agent":
active_named = [
sa for sa in self.subagents
if sa.is_active and sa.name != "sub-agent"
]
if len(active_named) == 1 and not active_named[0].tool_calls:
self._subagent_map[name] = active_named[0]
return active_named[0]
sa = SubAgentState(name, description)
self.subagents.append(sa)
self._subagent_map[name] = sa
elif description and not self._subagent_map[name].description:
self._subagent_map[name].description = description
else:
existing = self._subagent_map[name]
if description and not existing.description:
existing.description = description
# If this entry was created as "sub-agent" placeholder and the
# actual name is different, update.
if name != "sub-agent" and existing.name == "sub-agent":
existing.name = name
return self._subagent_map[name]
def _resolve_subagent_name(self, name: str) -> str:
"""Resolve "sub-agent" to the single active named sub-agent when possible."""
if name != "sub-agent":
return name
active_named = [
sa.name for sa in self.subagents
if sa.is_active and sa.name != "sub-agent"
]
if len(active_named) == 1:
return active_named[0]
return name
def handle_event(self, event: dict) -> str:
"""Process a single stream event, update internal state, return event type."""
event_type: str = event.get("type", "")
@@ -437,18 +602,23 @@ class StreamState:
self.is_thinking = False
self.is_responding = True
self.is_processing = False
self.response_text += event.get("content", "")
text_content = event.get("content", "")
self.response_text += text_content
self.latest_text += text_content
elif event_type == "tool_call":
self.is_thinking = False
self.is_responding = False
self.is_processing = False
self.latest_text = "" # Reset — next text segment is a new message
tool_id = event.get("id", "")
tool_name = event.get("name", "unknown")
tool_args = event.get("args", {})
tc_data = {
"id": tool_id,
"name": event.get("name", "unknown"),
"args": event.get("args", {}),
"name": tool_name,
"args": tool_args,
}
if tool_id:
@@ -463,12 +633,25 @@ class StreamState:
else:
self.tool_calls.append(tc_data)
# Capture todo items from write_todos args (most reliable source)
if tool_name == "write_todos":
todos = tool_args.get("todos", [])
if isinstance(todos, list) and todos:
self.todo_items = todos
elif event_type == "tool_result":
self.is_processing = True
result_name = event.get("name", "unknown")
result_content = event.get("content", "")
self.tool_results.append({
"name": event.get("name", "unknown"),
"content": event.get("content", ""),
"name": result_name,
"content": result_content,
})
# Update todo list from write_todos / read_todos results (fallback)
if result_name in ("write_todos", "read_todos"):
parsed = _parse_todo_items(result_content)
if parsed:
self.todo_items = parsed
elif event_type == "subagent_start":
name = event.get("name", "sub-agent")
@@ -477,7 +660,7 @@ class StreamState:
sa.is_active = True
elif event_type == "subagent_tool_call":
sa_name = event.get("subagent", "sub-agent")
sa_name = self._resolve_subagent_name(event.get("subagent", "sub-agent"))
sa = self._get_or_create_subagent(sa_name)
sa.add_tool_call(
event.get("name", "unknown"),
@@ -486,7 +669,7 @@ class StreamState:
)
elif event_type == "subagent_tool_result":
sa_name = event.get("subagent", "sub-agent")
sa_name = self._resolve_subagent_name(event.get("subagent", "sub-agent"))
sa = self._get_or_create_subagent(sa_name)
sa.add_tool_result(
event.get("name", "unknown"),
@@ -495,9 +678,15 @@ class StreamState:
)
elif event_type == "subagent_end":
name = event.get("name", "sub-agent")
name = self._resolve_subagent_name(event.get("name", "sub-agent"))
if name in self._subagent_map:
self._subagent_map[name].is_active = False
elif name == "sub-agent":
# Couldn't resolve — deactivate the oldest active sub-agent
for sa in self.subagents:
if sa.is_active:
sa.is_active = False
break
elif event_type == "done":
self.is_processing = False
@@ -518,12 +707,14 @@ class StreamState:
return {
"thinking_text": self.thinking_text,
"response_text": self.response_text,
"latest_text": self.latest_text,
"tool_calls": self.tool_calls,
"tool_results": self.tool_results,
"is_thinking": self.is_thinking,
"is_responding": self.is_responding,
"is_processing": self.is_processing,
"subagents": self.subagents,
"todo_items": self.todo_items,
}
@@ -536,43 +727,58 @@ def _parse_todo_items(content: str) -> list[dict] | None:
Attempts to extract a list of dicts with 'status' and 'content' keys
from the tool result string. Returns None if parsing fails.
Handles formats like:
- Raw JSON/Python list: [{"content": "...", "status": "..."}]
- Prefixed: "Updated todo list to [{'content': '...', ...}]"
"""
import ast
import json
content = content.strip()
# Try JSON first
try:
data = json.loads(content)
if isinstance(data, list) and data and isinstance(data[0], dict):
return data
except (json.JSONDecodeError, ValueError):
pass
def _try_parse(text: str) -> list[dict] | None:
"""Try JSON then Python literal parsing."""
text = text.strip()
try:
data = json.loads(text)
if isinstance(data, list) and data and isinstance(data[0], dict):
return data
except (json.JSONDecodeError, ValueError):
pass
try:
data = ast.literal_eval(text)
if isinstance(data, list) and data and isinstance(data[0], dict):
return data
except (ValueError, SyntaxError):
pass
return None
# Try Python literal
try:
data = ast.literal_eval(content)
if isinstance(data, list) and data and isinstance(data[0], dict):
return data
except (ValueError, SyntaxError):
pass
# Try the full content directly
result = _try_parse(content)
if result:
return result
# Try to find a list embedded in the output
# Extract embedded [...] from content (e.g. "Updated todo list to [{...}]")
bracket_start = content.find("[")
if bracket_start != -1:
bracket_end = content.rfind("]")
if bracket_end > bracket_start:
embedded = content[bracket_start:bracket_end + 1]
result = _try_parse(embedded)
if result:
return result
# Try line-by-line scan
for line in content.split("\n"):
line = line.strip()
if line.startswith("[") and line.endswith("]"):
try:
data = json.loads(line)
if isinstance(data, list):
return data
except (json.JSONDecodeError, ValueError):
try:
data = ast.literal_eval(line)
if isinstance(data, list):
return data
except (ValueError, SyntaxError):
pass
if "[" in line:
start = line.find("[")
end = line.rfind("]")
if end > start:
result = _try_parse(line[start:end + 1])
if result:
return result
return None
@@ -701,14 +907,14 @@ def _render_tool_call_line(tc: dict, tr: dict | None) -> Text:
def _render_subagent_section(sa: 'SubAgentState', compact: bool = False) -> list:
"""Render a sub-agent's activity as a compact indented section.
"""Render a sub-agent's activity as a bordered section.
Args:
sa: Sub-agent state to render
compact: If True, render minimal 1-2 line summary (for final display)
compact: If True, render minimal 1-line summary (completed sub-agents)
Completed tools are collapsed into a summary line.
Only the currently running tool is shown expanded.
Header uses "Cooking with {name}" style matching task tool format.
Active sub-agents show bordered tool list; completed ones collapse to 1 line.
"""
elements = []
BORDER = "dim cyan" if sa.is_active else "dim"
@@ -729,59 +935,49 @@ def _render_subagent_section(sa: 'SubAgentState', compact: bool = False) -> list
succeeded = sum(1 for _, tr in completed if tr.get("success", True))
failed = len(completed) - succeeded
# --- Compact mode: 1-2 line summary for final display ---
# Build display name
display_name = f"Cooking with {sa.name}"
if sa.description:
desc = sa.description[:50] + "..." if len(sa.description) > 50 else sa.description
display_name += f" \u2014 {desc}"
# --- Compact mode: 1-line summary for completed sub-agents ---
if compact:
line = Text()
if not sa.is_active:
line.append(" \u2713 ", style="green")
line.append(sa.name, style="bold green")
line.append("\u2713 ", style="green")
line.append(display_name, style="green dim")
total = len(valid_calls)
line.append(f" ({total} tools)", style="dim")
else:
line.append(" \u25b6 ", style="cyan")
line.append(sa.name, style="bold cyan")
if sa.description:
desc = sa.description[:50] + "..." if len(sa.description) > 50 else sa.description
line.append(f" \u2014 {desc}", style="dim")
line.append("\u25b6 ", style="cyan")
line.append(display_name, style="bold cyan")
elements.append(line)
# Stats line
if valid_calls:
stats = Text(" ")
stats.append(f"{succeeded} completed", style="dim green")
if failed > 0:
stats.append(f" \u00b7 {failed} failed", style="dim red")
if pending:
stats.append(f" \u00b7 {len(pending)} running", style="dim yellow")
elements.append(stats)
return elements
# --- Full mode: bordered section for Live streaming ---
# Shows every tool call individually with status indicators
# Header
header = Text()
header.append(" \u250c ", style=BORDER)
header.append("\u250c ", style=BORDER)
if sa.is_active:
header.append(sa.name, style="bold cyan")
header.append(f"\u25b6 {display_name}", style="bold cyan")
else:
header.append(sa.name, style="bold green")
header.append(" \u2713", style="green")
if sa.description:
desc = sa.description[:55] + "..." if len(sa.description) > 55 else sa.description
header.append(f" \u2014 {desc}", style="dim")
header.append(f"\u2713 {display_name}", style="bold green")
elements.append(header)
# Show every tool call with its status
for tc, tr in completed:
tc_line = Text(" \u2502 ", style=BORDER)
tc_line = Text("\u2502 ", style=BORDER)
tc_name = format_tool_compact(tc["name"], tc.get("args"))
if tr.get("success", True):
tc_line.append(f"\u2713 {tc_name}", style="green")
else:
tc_line.append(f"\u2717 {tc_name}", style="red")
# Show first line of error
content = tr.get("content", "")
first_line = content.strip().split("\n")[0][:70]
if first_line:
err_line = Text(" \u2502 ", style=BORDER)
err_line = Text("\u2502 ", style=BORDER)
err_line.append(f"\u2514 {first_line}", style="red dim")
elements.append(tc_line)
elements.append(err_line)
@@ -790,29 +986,64 @@ def _render_subagent_section(sa: 'SubAgentState', compact: bool = False) -> list
# Pending/running tools
for tc in pending:
tc_line = Text(" \u2502 ", style=BORDER)
tc_line = Text("\u2502 ", style=BORDER)
tc_name = format_tool_compact(tc["name"], tc.get("args"))
tc_line.append(f"\u25cf {tc_name}", style="bold yellow")
elements.append(tc_line)
spinner_line = Text(" \u2502 ", style=BORDER)
spinner_line = Text("\u2502 ", style=BORDER)
spinner_line.append("\u21bb running...", style="yellow dim")
elements.append(spinner_line)
# Footer
if not sa.is_active:
total = len(valid_calls)
footer = Text(f" \u2514 done ({total} tools)", style="dim green")
footer = Text(f"\u2514 done ({total} tools)", style="dim green")
elements.append(footer)
elif valid_calls:
footer = Text(" \u2514 running...", style="dim cyan")
footer = Text("\u2514 running...", style="dim cyan")
elements.append(footer)
return elements
def _render_todo_panel(todo_items: list[dict]) -> Panel:
"""Render a bordered Task List panel from todo items.
Matches the style: cyan border, status icons per item.
"""
lines = Text()
for i, item in enumerate(todo_items):
if i > 0:
lines.append("\n")
status = str(item.get("status", "todo")).lower()
content_text = str(item.get("content", item.get("task", item.get("title", ""))))
if status in ("done", "completed", "complete"):
symbol = "\u2713" # ✓
style = "green dim"
elif status in ("active", "in_progress", "in-progress", "working"):
symbol = "\u23f3" # ⏳
style = "yellow"
else:
symbol = "\u25a1" # □
style = "dim"
lines.append(f"{symbol} ", style=style)
lines.append(content_text, style=style)
return Panel(
lines,
title="Task List",
title_align="center",
border_style="cyan",
padding=(0, 1),
)
def create_streaming_display(
thinking_text: str = "",
response_text: str = "",
latest_text: str = "",
tool_calls: list | None = None,
tool_results: list | None = None,
is_thinking: bool = False,
@@ -821,6 +1052,7 @@ def create_streaming_display(
is_processing: bool = False,
show_thinking: bool = True,
subagents: list | None = None,
todo_items: list | None = None,
) -> Any:
"""Create Rich display layout for streaming output.
@@ -855,61 +1087,95 @@ def create_streaming_display(
# Tool calls and results paired display
# Collapse older completed tools to prevent overflow in Live mode
# Task tool calls are ALWAYS visible (they represent sub-agent delegations)
MAX_VISIBLE_TOOLS = 4
MAX_VISIBLE_RUNNING = 3
if tool_calls:
# Split into completed and pending/running
completed_tools = []
recent_tools = [] # last few completed + all pending
# Split into categories
completed_regular = [] # completed non-task tools
task_tools = [] # task tools (always visible)
running_regular = [] # running non-task tools
for i, tc in enumerate(tool_calls):
has_result = i < len(tool_results)
tr = tool_results[i] if has_result else None
if has_result:
completed_tools.append((tc, tr))
is_task = tc.get('name') == 'task'
if is_task:
# Skip task calls with empty args (still streaming)
if tc.get('args'):
task_tools.append((tc, tr))
elif has_result:
completed_regular.append((tc, tr))
else:
recent_tools.append((tc, None))
running_regular.append((tc, None))
# Determine how many completed tools to show
# Keep the last few completed + all pending within MAX_VISIBLE_TOOLS
slots_for_completed = max(0, MAX_VISIBLE_TOOLS - len(recent_tools))
hidden_completed = completed_tools[:-slots_for_completed] if slots_for_completed and len(completed_tools) > slots_for_completed else (completed_tools if not slots_for_completed else [])
visible_completed = completed_tools[-slots_for_completed:] if slots_for_completed else []
# --- Completed regular tools (collapsible) ---
slots = max(0, MAX_VISIBLE_TOOLS - len(running_regular))
hidden = completed_regular[:-slots] if slots and len(completed_regular) > slots else (completed_regular if not slots else [])
visible = completed_regular[-slots:] if slots else []
# Summary line for hidden completed tools
if hidden_completed:
ok = sum(1 for _, tr in hidden_completed if is_success(tr.get('content', '')))
fail = len(hidden_completed) - ok
if hidden:
ok = sum(1 for _, tr in hidden if is_success(tr.get('content', '')))
fail = len(hidden) - ok
summary = Text()
summary.append(f"\u2713 {ok} completed", style="dim green")
if fail > 0:
summary.append(f" | {fail} failed", style="dim red")
elements.append(summary)
# Render visible completed tools (compact: 1 line each, no result expansion)
for tc, tr in visible_completed:
for tc, tr in visible:
elements.append(_render_tool_call_line(tc, tr))
# Only expand result for write_todos (useful) or errors
content = tr.get('content', '') if tr else ''
if tc.get('name') == 'write_todos' or (tr and not is_success(content)):
if tr and not is_success(content):
result_elements = format_tool_result_compact(
tr['name'],
content,
max_lines=5,
tr['name'], content, max_lines=5,
)
elements.extend(result_elements)
# Render pending/running tools (expanded with spinner)
for tc, tr in recent_tools:
# --- Running regular tools (limit visible) ---
hidden_running = len(running_regular) - MAX_VISIBLE_RUNNING
if hidden_running > 0:
summary = Text()
summary.append(f"\u25cf {hidden_running} more running...", style="dim yellow")
elements.append(summary)
running_regular = running_regular[-MAX_VISIBLE_RUNNING:]
for tc, tr in running_regular:
elements.append(_render_tool_call_line(tc, tr))
if tc.get('name') != 'task':
spinner = Spinner("dots", text=" Running...", style="yellow")
elements.append(spinner)
spinner = Spinner("dots", text=" Running...", style="yellow")
elements.append(spinner)
# Task tool calls are rendered as part of sub-agent sections below
# Response text handling
has_pending_tools = len(tool_calls) > len(tool_results)
any_active_subagent = any(sa.is_active for sa in subagents)
has_used_tools = len(tool_calls) > 0
all_done = not has_pending_tools and not any_active_subagent and not is_processing
# Intermediate narration (tools still running) — dim italic above Task List
if latest_text and has_used_tools and not all_done:
preview = latest_text.strip()
if preview:
last_line = preview.split("\n")[-1].strip()
if last_line:
if len(last_line) > 80:
last_line = last_line[:77] + "..."
elements.append(Text(f" {last_line}", style="dim italic"))
# Task List panel (persistent, updates on write_todos / read_todos)
todo_items = todo_items or []
if todo_items:
elements.append(Text("")) # blank separator
elements.append(_render_todo_panel(todo_items))
# Sub-agent activity sections
# Active: full bordered view; Completed: compact 1-line summary
for sa in subagents:
if sa.tool_calls or sa.is_active:
elements.extend(_render_subagent_section(sa))
elements.extend(_render_subagent_section(sa, compact=not sa.is_active))
# Processing state after tool execution
if is_processing and not is_thinking and not is_responding and not response_text:
@@ -919,25 +1185,10 @@ def create_streaming_display(
spinner = Spinner("dots", text=" Analyzing results...", style="cyan")
elements.append(spinner)
# Response text display logic
has_pending_tools = len(tool_calls) > len(tool_results)
any_active_subagent = any(sa.is_active for sa in subagents)
has_used_tools = len(tool_calls) > 0
if response_text and not has_pending_tools and not any_active_subagent:
if has_used_tools:
# Tools were used — treat all text as intermediate during Live streaming.
# Final rendering is handled by display_final_results().
preview = response_text
if len(preview) > 200:
preview = "..." + preview[-197:]
for line in preview.strip().split("\n")[-3:]:
if line.strip():
elements.append(Text(f" {line.strip()}", style="dim italic"))
else:
# Pure text response (no tools used) — render as Markdown
elements.append(Text("")) # blank separator
elements.append(Markdown(response_text))
# Final response — render as Markdown when all work is done
if response_text and all_done:
elements.append(Text("")) # blank separator
elements.append(Markdown(response_text))
elif is_responding and not thinking_text and not has_pending_tools:
elements.append(Text("Generating response...", style="dim"))
@@ -1006,6 +1257,11 @@ def display_final_results(
console.print()
# Task List panel in final output
if state.todo_items:
console.print(_render_todo_panel(state.todo_items))
console.print()
if state.response_text:
console.print()
console.print(Markdown(state.response_text))
@@ -1126,15 +1382,26 @@ def cmd_interactive(agent: Any, show_thinking: bool = True, workspace_dir: str |
enable_history_search=True,
)
def _print_separator():
"""Print a horizontal separator line spanning the terminal width."""
width = console.size.width
console.print(Text("\u2500" * width, style="dim"))
_print_separator()
while True:
try:
user_input = session.prompt(
HTML('<ansigreen><b>You:</b></ansigreen> ')
HTML('<ansiblue><b>&gt;</b></ansiblue> ')
).strip()
if not user_input:
# Erase the empty prompt line so it looks like nothing happened
sys.stdout.write("\033[A\033[2K\r")
sys.stdout.flush()
continue
_print_separator()
# Special commands
if user_input.lower() in ("/exit", "/quit", "/q"):
console.print("[dim]Goodbye![/dim]")
@@ -1160,6 +1427,7 @@ def cmd_interactive(agent: Any, show_thinking: bool = True, workspace_dir: str |
# Stream agent response
console.print()
_run_streaming(agent, user_input, thread_id, show_thinking, interactive=True)
_print_separator()
except KeyboardInterrupt:
console.print("\n[dim]Goodbye![/dim]")
@@ -1180,7 +1448,11 @@ def cmd_run(agent: Any, prompt: str, thread_id: str | None = None, show_thinking
"""
thread_id = thread_id or str(uuid.uuid4())
console.print(Panel(f"[bold cyan]Query:[/bold cyan]\n{prompt}"))
width = console.size.width
sep = Text("\u2500" * width, style="dim")
console.print(sep)
console.print(Text(f"> {prompt}"))
console.print(sep)
console.print(f"[dim]Thread: {thread_id}[/dim]")
if workspace_dir:
console.print(f"[dim]Workspace: {workspace_dir}[/dim]")
+256
View File
@@ -228,6 +228,37 @@ class TestNameMerging:
assert len(state.subagents) == 2
class TestSubagentNameResolution:
def test_subagent_tool_call_resolves_single_active(self):
state = StreamState()
state.handle_event({"type": "subagent_start", "name": "code-agent", "description": ""})
state.handle_event({
"type": "subagent_tool_call",
"subagent": "sub-agent",
"name": "execute",
"args": {},
"id": "tc1",
})
assert len(state.subagents) == 1
assert state.subagents[0].name == "code-agent"
assert len(state.subagents[0].tool_calls) == 1
def test_subagent_tool_call_does_not_resolve_when_multiple_active(self):
state = StreamState()
state.handle_event({"type": "subagent_start", "name": "code-agent", "description": ""})
state.handle_event({"type": "subagent_start", "name": "research-agent", "description": ""})
state.handle_event({
"type": "subagent_tool_call",
"subagent": "sub-agent",
"name": "execute",
"args": {},
"id": "tc1",
})
# Should stay as "sub-agent" because more than one named subagent is active
assert len(state.subagents) == 3
assert state.subagents[-1].name == "sub-agent"
# === _parse_todo_items ===
class TestParseTodoItems:
@@ -283,3 +314,228 @@ class TestBuildTodoStats:
def test_empty_items(self):
result = _build_todo_stats([])
assert "0 items" in result
# === _resolve_subagent_name ===
class TestResolveSubagentName:
def test_real_name_passthrough(self):
state = StreamState()
assert state._resolve_subagent_name("code-agent") == "code-agent"
def test_resolve_single_active(self):
"""When exactly one named active sub-agent exists, 'sub-agent' resolves to it."""
state = StreamState()
state.handle_event({"type": "subagent_start", "name": "code-agent", "description": ""})
assert state._resolve_subagent_name("sub-agent") == "code-agent"
def test_no_resolve_multiple_active(self):
"""With multiple active named sub-agents, 'sub-agent' stays generic."""
state = StreamState()
state.handle_event({"type": "subagent_start", "name": "code-agent", "description": ""})
state.handle_event({"type": "subagent_start", "name": "research-agent", "description": ""})
assert state._resolve_subagent_name("sub-agent") == "sub-agent"
def test_no_resolve_no_active(self):
state = StreamState()
assert state._resolve_subagent_name("sub-agent") == "sub-agent"
# === subagent_end fallback ===
class TestSubagentEndFallback:
def test_end_resolves_via_name(self):
"""subagent_end with exact name deactivates sub-agent."""
state = StreamState()
state.handle_event({"type": "subagent_start", "name": "code-agent", "description": ""})
state.handle_event({"type": "subagent_end", "name": "code-agent"})
assert state.subagents[0].is_active is False
def test_end_resolves_generic_to_single_active(self):
"""subagent_end with 'sub-agent' resolves to the only active named sub-agent."""
state = StreamState()
state.handle_event({"type": "subagent_start", "name": "research-agent", "description": ""})
state.handle_event({"type": "subagent_end", "name": "sub-agent"})
assert state.subagents[0].is_active is False
def test_end_fallback_deactivates_oldest(self):
"""When name can't be resolved, deactivates oldest active sub-agent."""
state = StreamState()
state.handle_event({"type": "subagent_start", "name": "a-agent", "description": ""})
state.handle_event({"type": "subagent_start", "name": "b-agent", "description": ""})
# "sub-agent" can't resolve (2 active), falls back to oldest
state.handle_event({"type": "subagent_end", "name": "sub-agent"})
assert state.subagents[0].is_active is False # a-agent deactivated
assert state.subagents[1].is_active is True # b-agent still active
# === Todo capture from write_todos args ===
class TestTodoCaptureFromArgs:
def test_capture_from_tool_call_args(self):
"""write_todos tool_call args should update todo_items."""
state = StreamState()
todos = [{"status": "todo", "content": "Task A"}, {"status": "active", "content": "Task B"}]
state.handle_event({
"type": "tool_call",
"id": "tc1",
"name": "write_todos",
"args": {"todos": todos},
})
assert state.todo_items == todos
def test_capture_from_tool_result_fallback(self):
"""write_todos tool_result should also update todo_items."""
import json
state = StreamState()
items = [{"status": "done", "content": "Finished"}]
state.handle_event({
"type": "tool_result",
"name": "write_todos",
"content": json.dumps(items),
})
assert len(state.todo_items) == 1
assert state.todo_items[0]["status"] == "done"
def test_args_capture_takes_priority(self):
"""Args capture (structured) should override result parse."""
import json
state = StreamState()
args_todos = [{"status": "active", "content": "From args"}]
result_todos = [{"status": "todo", "content": "From result"}]
state.handle_event({
"type": "tool_call",
"id": "tc1",
"name": "write_todos",
"args": {"todos": args_todos},
})
assert state.todo_items[0]["content"] == "From args"
# Result arrives but args already captured — result updates too
state.handle_event({
"type": "tool_result",
"name": "write_todos",
"content": json.dumps(result_todos),
})
# Result overwrites (both sources are valid, last write wins)
assert state.todo_items[0]["content"] == "From result"
def test_read_todos_result_updates(self):
"""read_todos tool_result should also update todo_items."""
import json
state = StreamState()
items = [{"status": "todo", "content": "Read item"}]
state.handle_event({
"type": "tool_result",
"name": "read_todos",
"content": json.dumps(items),
})
assert len(state.todo_items) == 1
def test_non_todo_tool_does_not_capture(self):
state = StreamState()
state.handle_event({
"type": "tool_call",
"id": "tc1",
"name": "execute",
"args": {"command": "ls"},
})
assert state.todo_items == []
# === latest_text reset on tool_call ===
class TestLatestTextReset:
def test_latest_text_accumulates(self):
state = StreamState()
state.handle_event({"type": "text", "content": "a"})
state.handle_event({"type": "text", "content": "b"})
assert state.latest_text == "ab"
def test_latest_text_resets_on_tool_call(self):
state = StreamState()
state.handle_event({"type": "text", "content": "first segment"})
state.handle_event({"type": "tool_call", "id": "tc1", "name": "execute", "args": {}})
assert state.latest_text == ""
state.handle_event({"type": "text", "content": "second segment"})
assert state.latest_text == "second segment"
# response_text still has everything
assert state.response_text == "first segmentsecond segment"
# === Name merging edge cases ===
class TestNameMergingAdvanced:
def test_subagent_merges_into_preregistered(self):
"""'sub-agent' event merges into pre-registered real-name entry with no tools."""
state = StreamState()
state.handle_event({"type": "subagent_start", "name": "code-agent", "description": "code"})
assert len(state.subagents) == 1
# Now sub-agent tool call arrives — should merge into code-agent
state.handle_event({
"type": "subagent_tool_call",
"subagent": "sub-agent",
"name": "execute",
"args": {},
"id": "tc1",
})
# _resolve_subagent_name should resolve "sub-agent" → "code-agent" (single active)
assert len(state.subagents) == 1
assert state.subagents[0].name == "code-agent"
assert len(state.subagents[0].tool_calls) == 1
def test_multiple_subagents_no_cross_merge(self):
"""Two named sub-agents should not merge with each other."""
state = StreamState()
state.handle_event({"type": "subagent_start", "name": "code-agent", "description": ""})
state.handle_event({"type": "subagent_start", "name": "research-agent", "description": ""})
state.handle_event({
"type": "subagent_tool_call",
"subagent": "code-agent",
"name": "execute",
"args": {},
"id": "tc1",
})
state.handle_event({
"type": "subagent_tool_call",
"subagent": "research-agent",
"name": "tavily_search",
"args": {},
"id": "tc2",
})
assert len(state.subagents) == 2
code_sa = state._subagent_map["code-agent"]
research_sa = state._subagent_map["research-agent"]
assert len(code_sa.tool_calls) == 1
assert code_sa.tool_calls[0]["name"] == "execute"
assert len(research_sa.tool_calls) == 1
assert research_sa.tool_calls[0]["name"] == "tavily_search"
# === _parse_todo_items edge cases ===
class TestParseTodoItemsAdvanced:
def test_prefixed_with_update_message(self):
"""'Updated todo list to [...]' format from write_todos result."""
text = "Updated todo list to [{'status': 'todo', 'content': 'Do X'}]"
result = _parse_todo_items(text)
assert result is not None
assert len(result) == 1
assert result[0]["content"] == "Do X"
def test_multiple_items(self):
import json
items = [
{"status": "done", "content": "A"},
{"status": "active", "content": "B"},
{"status": "todo", "content": "C"},
]
result = _parse_todo_items(json.dumps(items))
assert len(result) == 3
def test_non_list_json(self):
"""JSON object (not list) should return None."""
assert _parse_todo_items('{"status": "todo"}') is None
def test_list_of_non_dicts(self):
"""List of strings should return None."""
assert _parse_todo_items('["a", "b"]') is None