UI Improvements
This commit is contained in:
+233
-103
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user