Updated tool-calling
This commit is contained in:
+152
-47
@@ -17,6 +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)
|
||||
|
||||
import config
|
||||
import task_store
|
||||
import tools as _tools
|
||||
@@ -80,7 +82,8 @@ class OpenAIClient(ProviderClient):
|
||||
messages=full_messages,
|
||||
extra_body=self.extra_body or None,
|
||||
)
|
||||
return response.choices[0].message.content or ""
|
||||
content = response.choices[0].message.content or ""
|
||||
return _THINK_TAG_RE.sub("", content).strip()
|
||||
|
||||
def call(self, system, messages, model, temperature, max_tokens):
|
||||
return asyncio.run(
|
||||
@@ -100,8 +103,9 @@ class OpenAIClient(ProviderClient):
|
||||
extra_body=self.extra_body or None,
|
||||
)
|
||||
msg = response.choices[0].message
|
||||
content = _THINK_TAG_RE.sub("", msg.content or "").strip()
|
||||
return _ToolMessage(
|
||||
content=msg.content or "",
|
||||
content=content,
|
||||
tool_calls=msg.tool_calls or None,
|
||||
)
|
||||
|
||||
@@ -432,14 +436,24 @@ async def call_agent_async(
|
||||
dry_run: bool = False,
|
||||
stream: bool = True,
|
||||
stream_mode: str = "deliverable",
|
||||
use_tools: bool = True,
|
||||
) -> str:
|
||||
"""
|
||||
Call an agent with a user message and return the response.
|
||||
|
||||
use_tools controls whether the tool-calling loop is engaged.
|
||||
Pass use_tools=False to force a plain streaming/non-streaming call
|
||||
even when the agent has tools assigned — used for synthesis and
|
||||
standing-brief updates where tool dispatch must not occur.
|
||||
"""
|
||||
if dry_run:
|
||||
return f"[DRY RUN] Would call {agent.name} ({config.agent_provider_name(agent.name)}) with: {user_message[:100]}..."
|
||||
|
||||
agent.add_user(user_message)
|
||||
|
||||
# If the agent has tools, use the tool-calling loop instead of streaming directly
|
||||
if agent.tools:
|
||||
# Route through the tool loop only when the agent has tools AND the caller
|
||||
# has not explicitly suppressed tool use for this call.
|
||||
if agent.tools and use_tools:
|
||||
try:
|
||||
return await _run_tool_loop(agent, stream_mode=stream_mode, dry_run=dry_run)
|
||||
except NotImplementedError as e:
|
||||
@@ -478,34 +492,43 @@ async def call_agent_async(
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Task Brief Parser
|
||||
# Lead Brief Formatter
|
||||
# Converts a dispatch_task spec dict into the structured brief sent to a lead.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def parse_task_briefs(orchestrator_response: str, task_id_prefix: str) -> list[Task]:
|
||||
tasks = []
|
||||
blocks = re.findall(
|
||||
r"TASK BRIEF\s*\n(.+?)(?=TASK BRIEF|\Z)",
|
||||
orchestrator_response,
|
||||
re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
for i, block in enumerate(blocks):
|
||||
to_match = re.search(r"To:\s*(.+)", block)
|
||||
if not to_match:
|
||||
continue
|
||||
to_text = to_match.group(1).lower()
|
||||
lead = next(
|
||||
(name for name in config.LEAD_AGENT_NAMES if name in to_text), None
|
||||
)
|
||||
if not lead:
|
||||
continue
|
||||
task_id = f"{task_id_prefix}-{chr(65 + i)}"
|
||||
tasks.append(Task(
|
||||
task_id=task_id,
|
||||
directive=to_text,
|
||||
assigned_to=lead,
|
||||
brief=f"TASK BRIEF\n{block.strip()}",
|
||||
))
|
||||
return tasks
|
||||
def _format_lead_brief(task_id: str, spec: dict) -> str:
|
||||
"""Build the task brief message sent to a lead from a dispatch_task spec."""
|
||||
lead_name = spec["to"]
|
||||
lead_title = config.agent_title(lead_name)
|
||||
lines = [
|
||||
f"TASK {task_id}",
|
||||
f"From: Miranda, Chief of Staff",
|
||||
f"To: {lead_title}",
|
||||
"",
|
||||
f"DIRECTIVE: {spec['directive']}",
|
||||
"",
|
||||
f"SCOPE: {spec['scope']}",
|
||||
]
|
||||
if spec.get("constraints") and spec["constraints"].upper() != "NONE":
|
||||
lines += ["", f"CONSTRAINTS: {spec['constraints']}"]
|
||||
if spec.get("dependencies") and spec["dependencies"].upper() != "NONE":
|
||||
lines += ["", f"DEPENDENCIES: {spec['dependencies']}"]
|
||||
if spec.get("return_format"):
|
||||
lines += ["", f"RETURN FORMAT: {spec['return_format']}"]
|
||||
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 "\n".join(lines)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -615,7 +638,7 @@ async def update_standing_brief(
|
||||
f"log entry at the top of Section 9. Archive anything older than "
|
||||
f"{config.SESSION_LOG_RETENTION} sessions."
|
||||
)
|
||||
return await call_agent_async(orchestrator, update_request, dry_run=dry_run, stream_mode="background")
|
||||
return await call_agent_async(orchestrator, update_request, dry_run=dry_run, stream_mode="background", use_tools=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -641,6 +664,7 @@ class Session:
|
||||
ui.print_system("Loading agents…")
|
||||
self.orchestrator = build_agent(config.ORCHESTRATOR_AGENT)
|
||||
self.auditor = build_agent(config.AUDITOR_AGENT)
|
||||
self.summarizer = build_agent(config.SUMMARIZER_AGENT) if config.SUMMARIZER_AGENT else None
|
||||
self.leads = {
|
||||
name: build_agent(name) for name in config.LEAD_AGENT_NAMES
|
||||
}
|
||||
@@ -662,26 +686,37 @@ class Session:
|
||||
|
||||
ui.print_task_received(task_id, directive)
|
||||
|
||||
# ── Step 1: Orchestrator receives the directive ─────────────────────
|
||||
# Safety: clear any leftover queued tasks from a previous aborted directive
|
||||
_tools.get_pending_tasks()
|
||||
|
||||
# ── Step 1: Miranda analyses the directive, dispatches via tool calls ─
|
||||
cos_prompt = (
|
||||
f"The Principal has issued the following directive. "
|
||||
f"Analyse it, determine what work is needed, and either:\n"
|
||||
f" (a) Respond directly if no Lead work is required, or\n"
|
||||
f" (b) Issue TASK BRIEF blocks for the appropriate Leads.\n\n"
|
||||
f"Analyse it, then either respond directly or use the dispatch_task tool "
|
||||
f"to assign work to the appropriate Leads.\n\n"
|
||||
f"Directive: {directive}"
|
||||
)
|
||||
cos_response = await call_agent_async(
|
||||
self.orchestrator, cos_prompt, dry_run=self.dry_run, stream=True
|
||||
)
|
||||
|
||||
# ── Step 2: Parse any task briefs the orchestrator issued ───────────
|
||||
tasks = parse_task_briefs(cos_response, task_id)
|
||||
# ── Step 2: Drain task specs queued during Miranda's tool loop ───────
|
||||
task_specs = _tools.get_pending_tasks()
|
||||
|
||||
if not tasks:
|
||||
if not task_specs:
|
||||
ui.print_direct_response_notice()
|
||||
deliverable = cos_response
|
||||
else:
|
||||
# ── Step 3: Persist task records then dispatch ──────────────────
|
||||
# ── Step 3: Build Task objects, persist, and dispatch ───────────
|
||||
tasks = [
|
||||
Task(
|
||||
task_id=f"{task_id}-{chr(65 + i)}",
|
||||
directive=directive,
|
||||
assigned_to=spec["to"],
|
||||
brief=_format_lead_brief(f"{task_id}-{chr(65 + i)}", spec),
|
||||
)
|
||||
for i, spec in enumerate(task_specs)
|
||||
]
|
||||
ui.print_task_dispatch_plan(tasks)
|
||||
task_store.record_tasks(tasks, self.session_id, directive)
|
||||
self.all_tasks.extend(tasks)
|
||||
@@ -689,20 +724,21 @@ class Session:
|
||||
self.leads, tasks, dry_run=self.dry_run, session_id=self.session_id,
|
||||
)
|
||||
|
||||
# ── Step 4: Orchestrator synthesizes ────────────────────────────
|
||||
# ── Step 4: Miranda synthesizes lead outputs ────────────────────
|
||||
lead_outputs_text = "\n\n".join(
|
||||
f"--- Output from {t.assigned_to.upper()} [{t.task_id}] ---\n{t.output}"
|
||||
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
|
||||
)
|
||||
synthesis_prompt = (
|
||||
f"All Lead outputs have returned for {task_id}. "
|
||||
f"Please synthesize these into a final deliverable for the Principal.\n\n"
|
||||
f"Synthesize these into your final deliverable for the Principal.\n\n"
|
||||
f"{lead_outputs_text}"
|
||||
)
|
||||
deliverable = await call_agent_async(
|
||||
self.orchestrator, synthesis_prompt, dry_run=self.dry_run, stream=True
|
||||
self.orchestrator, synthesis_prompt, dry_run=self.dry_run,
|
||||
stream=True, use_tools=False,
|
||||
)
|
||||
|
||||
dispatched_leads = sorted({t.assigned_to for t in completed_tasks})
|
||||
@@ -713,19 +749,85 @@ class Session:
|
||||
|
||||
self.last_deliverable = deliverable
|
||||
|
||||
# ── Step 5: Auditor review ──────────────────────────────────────────
|
||||
# ── Step 5: Auditor + Miranda summary in parallel ───────────────────
|
||||
# Vera streams (Rich Live — UI only); Tillie calls the API silently
|
||||
# (stream=False, no Rich output), so they do not conflict visually.
|
||||
# We use FIRST_COMPLETED so Tillie's panel appears as soon as she
|
||||
# resolves, even if Vera is still streaming.
|
||||
audit_memo = ""
|
||||
if self.auto_audit:
|
||||
vera_task = asyncio.create_task(
|
||||
run_auditor(self.auditor, deliverable, task_id, dry_run=self.dry_run)
|
||||
)
|
||||
tillie_task = asyncio.create_task(
|
||||
self._call_summarizer(deliverable)
|
||||
)
|
||||
|
||||
done, pending = await asyncio.wait(
|
||||
{vera_task, tillie_task},
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
|
||||
# If Tillie resolved first, display immediately (Vera may still be streaming)
|
||||
miranda_summary = ""
|
||||
if tillie_task in done:
|
||||
try:
|
||||
miranda_summary = tillie_task.result()
|
||||
if miranda_summary:
|
||||
ui.print_summary(miranda_summary, source_agent=config.ORCHESTRATOR_AGENT)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Ensure Vera finishes
|
||||
try:
|
||||
audit_memo = await run_auditor(
|
||||
self.auditor, deliverable, task_id, dry_run=self.dry_run
|
||||
)
|
||||
audit_memo = await vera_task
|
||||
self.session_log.append(f"Audit complete for {task_id}.")
|
||||
except RuntimeError as e:
|
||||
ui.print_warning(f"Audit skipped — {e}")
|
||||
|
||||
# If Vera resolved first, wait for Tillie now (with spinner) then display
|
||||
if tillie_task in pending:
|
||||
with ui.summary_progress():
|
||||
try:
|
||||
miranda_summary = await tillie_task
|
||||
except Exception:
|
||||
pass
|
||||
if miranda_summary:
|
||||
ui.print_summary(miranda_summary, source_agent=config.ORCHESTRATOR_AGENT)
|
||||
else:
|
||||
await self._summarize(deliverable, source_agent=config.ORCHESTRATOR_AGENT)
|
||||
|
||||
# ── Tillie: summarize Vera's audit memo (sequential — depends on Vera) ─
|
||||
if audit_memo:
|
||||
await self._summarize(audit_memo, source_agent=config.AUDITOR_AGENT)
|
||||
|
||||
return deliverable, audit_memo
|
||||
|
||||
async def _call_summarizer(self, content: str) -> str:
|
||||
"""Call Tillie silently and return the summary text. No UI output.
|
||||
Used when running in parallel with another agent that owns the display."""
|
||||
if not self.summarizer or not content:
|
||||
return ""
|
||||
self.summarizer.history = []
|
||||
return await call_agent_async(
|
||||
self.summarizer,
|
||||
f"Summarize the following:\n\n{content}",
|
||||
dry_run=self.dry_run,
|
||||
stream=False,
|
||||
) or ""
|
||||
|
||||
async def _summarize(self, content: str, source_agent: str = "") -> None:
|
||||
"""Call Tillie with a progress spinner and display the summary panel."""
|
||||
if not self.summarizer or not content:
|
||||
return
|
||||
try:
|
||||
with ui.summary_progress():
|
||||
summary = await self._call_summarizer(content)
|
||||
if summary:
|
||||
ui.print_summary(summary, source_agent=source_agent)
|
||||
except Exception as e:
|
||||
ui.print_warning(f"Summary skipped — {e}")
|
||||
|
||||
async def close(self):
|
||||
summary = "\n".join(self.session_log)
|
||||
updated_brief = await update_standing_brief(
|
||||
@@ -818,6 +920,7 @@ async def cmd_reload(session: Session, args: str) -> bool:
|
||||
# Rebuild all agents with fresh config
|
||||
session.orchestrator = build_agent(config.ORCHESTRATOR_AGENT)
|
||||
session.auditor = build_agent(config.AUDITOR_AGENT)
|
||||
session.summarizer = build_agent(config.SUMMARIZER_AGENT) if config.SUMMARIZER_AGENT else None
|
||||
session.leads = {
|
||||
name: build_agent(name) for name in config.LEAD_AGENT_NAMES
|
||||
}
|
||||
@@ -849,6 +952,8 @@ async def cmd_audit(session: Session, args: str) -> bool:
|
||||
session.last_task_id, dry_run=session.dry_run,
|
||||
)
|
||||
ui.print_audit_memo(memo, task_id=session.last_task_id)
|
||||
if memo:
|
||||
await session._summarize(memo, source_agent=config.AUDITOR_AGENT)
|
||||
return True
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user