UI Improvements

This commit is contained in:
vh
2026-04-04 19:13:58 -07:00
parent 7472654587
commit 62510fd198
74 changed files with 1113 additions and 268 deletions
+233 -103
View File
@@ -17,7 +17,8 @@ import re
from dataclasses import dataclass, field
from datetime import datetime
_THINK_TAG_RE = re.compile(r"<think>.*?</think>", re.DOTALL | re.IGNORECASE)
_THINK_TAG_RE = re.compile(r"<think>.*?</think>", re.DOTALL | re.IGNORECASE)
_TOOL_CALL_TAG_RE = re.compile(r"<tool_call>\s*(.*?)\s*</tool_call>", re.DOTALL)
import config
import task_store
@@ -104,10 +105,15 @@ class OpenAIClient(ProviderClient):
)
msg = response.choices[0].message
content = _THINK_TAG_RE.sub("", msg.content or "").strip()
return _ToolMessage(
content=content,
tool_calls=msg.tool_calls or None,
)
tool_calls = msg.tool_calls or None
# Fallback: if the model embedded tool calls as <tool_call> XML in
# the content (common with Qwen3 when the server lacks --tool-call-parser),
# parse them out and strip the tags from the displayed content.
if tool_calls is None and content:
content, tool_calls = _extract_content_tool_calls(content)
return _ToolMessage(content=content, tool_calls=tool_calls)
async def call_async_streaming(self, system, messages, model, temperature, max_tokens):
"""Stream via the OpenAI-compatible API, yielding thinking and content chunks.
@@ -225,6 +231,60 @@ class _ToolMessage:
tool_calls: list | None # list of openai ToolCall objects, or None
# ---------------------------------------------------------------------------
# Content-based tool call fallback
# Qwen3 (and some other models) emit tool calls as <tool_call>…</tool_call>
# XML blocks in the content when the server isn't configured to return
# structured tool_calls (e.g. vLLM without --tool-call-parser).
# We parse these and synthesise objects that _run_tool_loop can consume.
# ---------------------------------------------------------------------------
import json as _json
@dataclass
class _SyntheticFunction:
name: str
arguments: str # JSON string, same shape as openai ToolCall.function.arguments
@dataclass
class _SyntheticToolCall:
id: str
function: _SyntheticFunction
def _extract_content_tool_calls(content: str) -> tuple[str, list[_SyntheticToolCall] | None]:
"""
Scan *content* for <tool_call>…</tool_call> blocks.
Returns (cleaned_content, tool_calls_list) where cleaned_content has the
blocks removed, and tool_calls_list is None if nothing was found.
"""
matches = _TOOL_CALL_TAG_RE.findall(content)
if not matches:
return content, None
calls = []
for i, raw in enumerate(matches):
try:
parsed = _json.loads(raw)
except _json.JSONDecodeError:
continue
name = parsed.get("name") or parsed.get("function") or ""
args = parsed.get("arguments") or parsed.get("parameters") or {}
if isinstance(args, dict):
args = _json.dumps(args)
calls.append(_SyntheticToolCall(
id=f"call_{i}",
function=_SyntheticFunction(name=name, arguments=args),
))
if not calls:
return content, None
cleaned = _TOOL_CALL_TAG_RE.sub("", content).strip()
return cleaned, calls
@dataclass
class AgentState:
"""Runtime state for one named agent during a session."""
@@ -356,10 +416,10 @@ async def _run_tool_loop(
"""
Agentic loop for tool-enabled agents.
Calls the agent with its tool schemas until the model stops issuing
tool_calls. Each tool invocation is displayed via ui.print_tool_call().
The final text response is streamed via stream_agent_output (so the caller
gets the same visual treatment as a regular streaming call).
Opens a transient height-capped Live panel (like the thinking panel) that
accumulates tool call summaries as each tool completes. When the model
stops issuing tool_calls the panel closes, a persistent one-liner summary
is printed, and the final content is handed off to stream_agent_output.
The agent's history is updated with assistant tool-call messages and
tool result messages so that multi-turn tool use works correctly.
@@ -370,60 +430,73 @@ async def _run_tool_loop(
return f"[DRY RUN] Would run tool loop for {agent.name}"
schemas = _tools.get_schemas(agent.tools)
show_panel = stream_mode != "background"
# Iterate until the model stops calling tools
while True:
msg: _ToolMessage = await agent.client.call_async_with_tools(
system=agent.system_prompt,
messages=agent.messages_for_call(),
model=agent.model,
temperature=agent.temperature,
max_tokens=agent.max_tokens,
tools=schemas,
)
if show_panel:
ui.start_tool_panel(agent.name)
if not msg.tool_calls:
# No more tool calls — stream final content
final_content = msg.content
try:
# Iterate until the model stops calling tools
while True:
msg: _ToolMessage = await agent.client.call_async_with_tools(
system=agent.system_prompt,
messages=agent.messages_for_call(),
model=agent.model,
temperature=agent.temperature,
max_tokens=agent.max_tokens,
tools=schemas,
)
async def _emit_final():
yield ('content', final_content)
if not msg.tool_calls:
# No more tool calls — close panel, then stream final content
if show_panel:
ui.end_tool_panel(agent.name)
response = await ui.stream_agent_output(agent.name, _emit_final(), mode=stream_mode)
agent.add_assistant(response)
return response
final_content = msg.content
# Append the assistant message with tool_calls to history
# Build a serialisable representation of the tool-calls message
tool_calls_repr = [
{
"id": tc.id,
"type": "function",
"function": {
"name": tc.function.name,
"arguments": tc.function.arguments,
},
}
for tc in msg.tool_calls
]
agent.history.append({
"role": "assistant",
"content": msg.content or "",
"tool_calls": tool_calls_repr,
})
async def _emit_final():
yield ('content', final_content)
# Execute each tool call and append results
for tc in msg.tool_calls:
tool_name = tc.function.name
args_json = tc.function.arguments
result_json = _tools.call(tool_name, args_json)
ui.print_tool_call(agent.name, tool_name, args_json, result_json)
response = await ui.stream_agent_output(agent.name, _emit_final(), mode=stream_mode)
agent.add_assistant(response)
return response
# Append the assistant message with tool_calls to history
tool_calls_repr = [
{
"id": tc.id,
"type": "function",
"function": {
"name": tc.function.name,
"arguments": tc.function.arguments,
},
}
for tc in msg.tool_calls
]
agent.history.append({
"role": "tool",
"tool_call_id": tc.id,
"content": result_json,
"role": "assistant",
"content": msg.content or "",
"tool_calls": tool_calls_repr,
})
# Execute each tool call and update the panel
for tc in msg.tool_calls:
tool_name = tc.function.name
args_json = tc.function.arguments
result_json = _tools.call(tool_name, args_json)
if show_panel:
ui.record_tool_call(agent.name, tool_name, args_json, result_json)
agent.history.append({
"role": "tool",
"tool_call_id": tc.id,
"content": result_json,
})
except Exception:
if show_panel:
ui.end_tool_panel(agent.name)
raise
# ---------------------------------------------------------------------------
# Core Agent Caller
@@ -491,6 +564,82 @@ async def call_agent_async(
return response
# ---------------------------------------------------------------------------
# Lead Output Parser
# Extracts standard STATUS / SUMMARY / FINDINGS / OPEN ITEMS sections from
# a lead's response. Falls back to the raw text if no sections are found.
# ---------------------------------------------------------------------------
def _parse_lead_output(text: str) -> dict:
"""
Parse a lead's response for the four standard sections.
Returns a dict with keys: status, summary, findings, open_items, raw.
Any section not found is an empty string; raw always holds the full text.
"""
result = {"status": "", "summary": "", "findings": "", "open_items": "", "raw": text}
# STATUS: single line
m = re.search(r"^STATUS:\s*(.+)$", text, re.MULTILINE | re.IGNORECASE)
if m:
result["status"] = m.group(1).strip()
# SUMMARY: everything after the label until the next known section or EOF
m = re.search(
r"^SUMMARY:\s*\n?(.*?)(?=\n^(?:FINDINGS:|OPEN ITEMS:)|\Z)",
text, re.MULTILINE | re.DOTALL | re.IGNORECASE,
)
if m:
result["summary"] = m.group(1).strip()
# FINDINGS: everything after the label until OPEN ITEMS: or EOF
m = re.search(
r"^FINDINGS:\s*\n(.*?)(?=\n^OPEN ITEMS:|\Z)",
text, re.MULTILINE | re.DOTALL | re.IGNORECASE,
)
if m:
result["findings"] = m.group(1).strip()
# OPEN ITEMS: everything after the label to EOF
m = re.search(
r"^OPEN ITEMS:\s*\n?(.*?)$",
text, re.MULTILINE | re.DOTALL | re.IGNORECASE,
)
if m:
result["open_items"] = m.group(1).strip()
return result
def _format_lead_output_for_synthesis(task: "Task") -> str:
"""
Format a completed task for inclusion in Miranda's synthesis prompt.
Uses parsed sections when present; falls back to raw output.
"""
lead_title = config.agent_title(task.assigned_to)
header = f"=== {lead_title} [{task.task_id}] ==="
if task.status != "complete":
return f"{header}\nSTATUS: Failed\nERROR: {task.error}"
p = _parse_lead_output(task.output)
structured = any([p["status"], p["summary"], p["findings"]])
if not structured:
# Lead didn't follow the format — pass raw output with a label
return f"{header}\n{task.output}"
parts = [header]
if p["status"]:
parts.append(f"STATUS: {p['status']}")
if p["summary"]:
parts.append(f"\nSUMMARY:\n{p['summary']}")
if p["findings"]:
parts.append(f"\nFINDINGS:\n{p['findings']}")
if p["open_items"]:
parts.append(f"\nOPEN ITEMS:\n{p['open_items']}")
return "\n".join(parts)
# ---------------------------------------------------------------------------
# Lead Brief Formatter
# Converts a dispatch_task spec dict into the structured brief sent to a lead.
@@ -518,15 +667,7 @@ def _format_lead_brief(task_id: str, spec: dict) -> str:
lines += [
"",
"---",
"Return your response using this exact structure:",
"",
"STATUS: Complete | Partial | Blocked",
"SUMMARY: [2–5 sentence executive summary of your findings]",
"",
"FINDINGS:",
"[Your full output here]",
"",
"OPEN ITEMS: [Blockers, unresolved questions, or follow-up work — or NONE]",
"Return your output using your standard STATUS / SUMMARY / FINDINGS / OPEN ITEMS format.",
]
return "\n".join(lines)
@@ -538,32 +679,31 @@ def _format_lead_brief(task_id: str, spec: dict) -> str:
async def dispatch_to_lead(
lead: AgentState,
task: Task,
progress=None,
progress_tasks: dict = None,
dry_run: bool = False,
session_id: str = "",
) -> Task:
task.status = "in_progress"
if progress is not None and progress_tasks is not None:
ui.mark_task_running(progress, progress_tasks, task.task_id, lead.name)
if session_id:
task_store.update_task(task, session_id)
ui.print_lead_dispatch(lead.name, task.task_id)
try:
output = await call_agent_async(lead, task.brief, dry_run=dry_run, stream=False)
output = await call_agent_async(
lead, task.brief, dry_run=dry_run,
stream=True, stream_mode="lead",
)
task.output = output
task.status = "complete"
ui.record_lead_output(lead.name, task.task_id, output)
ui.print_lead_completion(lead.name, task.task_id, len(output))
except Exception as e:
task.error = str(e)
task.status = "error"
ui.print_error(f"{lead.name} failed on {task.task_id}: {e}")
ui.print_lead_completion(lead.name, task.task_id, 0, error=True)
finally:
if session_id:
task_store.update_task(task, session_id)
if progress is not None and progress_tasks is not None:
ui.mark_task_done(
progress, progress_tasks, task.task_id, lead.name,
error=(task.status == "error"),
)
return task
@@ -573,31 +713,18 @@ async def dispatch_all_leads(
dry_run: bool = False,
session_id: str = "",
) -> list[Task]:
by_lead: dict[str, list[Task]] = {}
# Run leads sequentially so each can display its own transient Live panel
# (Rich does not support nested Live displays).
results = []
for task in tasks:
by_lead.setdefault(task.assigned_to, []).append(task)
with ui.lead_dispatch_progress(tasks) as (progress, prog_tasks):
async def run_lead_tasks(lead_name: str, lead_tasks: list[Task]):
lead = leads[lead_name]
results = []
for task in lead_tasks:
result = await dispatch_to_lead(
lead, task,
progress=progress, progress_tasks=prog_tasks,
dry_run=dry_run,
session_id=session_id,
)
results.append(result)
return results
coroutines = [
run_lead_tasks(lead_name, lead_tasks)
for lead_name, lead_tasks in by_lead.items()
]
grouped_results = await asyncio.gather(*coroutines)
return [task for group in grouped_results for task in group]
lead = leads[task.assigned_to]
result = await dispatch_to_lead(
lead, task,
dry_run=dry_run,
session_id=session_id,
)
results.append(result)
return results
# ---------------------------------------------------------------------------
@@ -726,14 +853,11 @@ class Session:
# ── Step 4: Miranda synthesizes lead outputs ────────────────────
lead_outputs_text = "\n\n".join(
f"--- {t.assigned_to.upper()} [{t.task_id}] ---\n{t.output}"
if t.status == "complete"
else f"--- {t.assigned_to.upper()} [{t.task_id}] FAILED: {t.error} ---"
for t in completed_tasks
_format_lead_output_for_synthesis(t) for t in completed_tasks
)
synthesis_prompt = (
f"All Lead outputs have returned for {task_id}. "
f"Synthesize these into your final deliverable for the Principal.\n\n"
f"All Lead outputs for {task_id} are in. "
f"Synthesize them into your SUMMARY / DETAIL / OPEN ITEMS deliverable for the Principal.\n\n"
f"{lead_outputs_text}"
)
deliverable = await call_agent_async(
@@ -941,6 +1065,12 @@ async def cmd_thinking(session: Session, args: str) -> bool:
return True
@command(["/leads"], "Browse completed lead outputs from this session")
async def cmd_leads(session: Session, args: str) -> bool:
await ui.show_lead_output_picker()
return True
@command(["/audit"], "Re-run Vera's audit on the last deliverable")
async def cmd_audit(session: Session, args: str) -> bool:
if not session.last_deliverable: