agent-runtime/agent/nodes/director_v2.py
Nico 4c412d3c4b v0.14.4: Interpreter wired in v2, tool_call convention, Haiku models, UI fix
- Wire Interpreter into v2 pipeline (after Thinker tool_output, before Output)
- Rename tool_exec -> tool_call everywhere (consistent convention across v1/v2)
- Switch Director v1+v2 to anthropic/claude-haiku-4.5 (was opus, reserved)
- Fix UI apply_machine_ops crash when states are strings instead of dicts
- Fix runtime_test.py async poll to match on message ID (prevent stale results)
- Add traceback to pipeline error logging

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 06:06:13 +02:00

148 lines
5.9 KiB
Python

"""Director Node v2: always-on brain — decides what Thinker should execute."""
import json
import logging
from .base import Node
from ..llm import llm_call
from ..types import Command, DirectorPlan
log = logging.getLogger("runtime")
class DirectorV2Node(Node):
name = "director_v2"
model = "anthropic/claude-haiku-4.5"
max_context_tokens = 4000
SYSTEM = """You are the Director — the brain of this cognitive agent runtime.
You receive the user's message (already classified by Input) and conversation history.
Your job: decide WHAT to do and HOW, then produce an action plan for the Thinker (a fast executor).
The Thinker has these tools:
- query_db(query, database) — SQL SELECT/DESCRIBE/SHOW on MariaDB
Databases: eras2_production (heating/energy, 693 customers), plankiste_test (Kita planning)
Tables are lowercase: kunden, objekte, geraete, nutzeinheit, geraeteverbraeuche, etc.
- emit_actions(actions) — show buttons [{label, action, payload?}]
- set_state(key, value) — persistent key-value store
- emit_display(items) — per-response formatted data [{type, label, value?, style?}]
- create_machine(id, initial, states) — persistent interactive UI with navigation
- add_state / reset_machine / destroy_machine — machine lifecycle
Your output is a JSON plan:
{{
"goal": "what we're trying to achieve",
"steps": ["Step 1: ...", "Step 2: ..."],
"present_as": "table | summary | machine",
"tool_sequence": [
{{"tool": "query_db", "args": {{"query": "SELECT ...", "database": "eras2_production"}}}},
{{"tool": "emit_display", "args": {{"items": [...]}}}}
],
"reasoning": "why this approach",
"response_hint": "how Thinker should phrase the response (if no tools needed)",
"mode": "casual | building | debugging | exploring",
"style": "brief directive for response style"
}}
Rules:
- NEVER guess column or table names. If you don't know the schema, your FIRST step MUST be DESCRIBE or SHOW TABLES. Only write SELECT queries using columns you have seen in a prior DESCRIBE result or in conversation history.
- For simple social/greeting: empty tool_sequence, set response_hint instead.
- For data questions: plan the SQL queries. Be specific — the Thinker is not smart.
- For UI requests: plan the exact tool calls with full args.
- Max 5 tools in sequence. Keep it focused.
- mode/style guide the Output node's voice.
Output ONLY valid JSON. No markdown fences, no explanation."""
def __init__(self, send_hud):
super().__init__(send_hud)
self.directive: dict = {
"mode": "casual",
"style": "be helpful and concise",
}
def get_context_line(self) -> str:
d = self.directive
return f"Director: {d['mode']} mode. {d['style']}."
async def decide(self, command: Command, history: list[dict],
memory_context: str = "") -> DirectorPlan:
"""Analyze input and produce an action plan for Thinker v2."""
await self.hud("thinking", detail="deciding action plan")
a = command.analysis
messages = [
{"role": "system", "content": self.SYSTEM},
]
if memory_context:
messages.append({"role": "system", "content": memory_context})
for msg in history[-12:]:
messages.append(msg)
input_ctx = (
f"User message analysis:\n"
f"- Who: {a.who} | Intent: {a.intent} | Complexity: {a.complexity}\n"
f"- Topic: {a.topic} | Tone: {a.tone} | Language: {a.language}\n"
f"- Context: {a.context}\n"
f"- Original: {command.source_text}"
)
messages.append({"role": "user", "content": input_ctx})
messages = self.trim_context(messages)
await self.hud("context", messages=messages, tokens=self.last_context_tokens,
max_tokens=self.max_context_tokens, fill_pct=self.context_fill_pct)
raw = await llm_call(self.model, messages)
log.info(f"[director_v2] raw: {raw[:300]}")
plan = self._parse_plan(raw, command)
# Update style directive (for Output node)
if hasattr(plan, '_raw') and plan._raw:
raw_data = plan._raw
if raw_data.get("mode"):
self.directive["mode"] = raw_data["mode"]
if raw_data.get("style"):
self.directive["style"] = raw_data["style"]
await self.hud("decided", goal=plan.goal, tools=len(plan.tool_sequence),
direct=plan.is_direct_response)
return plan
def _parse_plan(self, raw: str, command: Command) -> DirectorPlan:
"""Parse LLM output into DirectorPlan, with fallback."""
text = raw.strip()
if text.startswith("```"):
text = text.split("\n", 1)[1] if "\n" in text else text[3:]
if text.endswith("```"):
text = text[:-3]
text = text.strip()
try:
data = json.loads(text)
plan = DirectorPlan(
goal=data.get("goal", ""),
steps=data.get("steps", []),
present_as=data.get("present_as", "summary"),
tool_sequence=data.get("tool_sequence", []),
reasoning=data.get("reasoning", ""),
response_hint=data.get("response_hint", ""),
)
# Stash raw for directive extraction
plan._raw = data
return plan
except (json.JSONDecodeError, Exception) as e:
log.error(f"[director_v2] parse failed: {e}, raw: {text[:200]}")
# Fallback: direct response
plan = DirectorPlan(
goal=f"respond to: {command.source_text[:50]}",
steps=[],
present_as="summary",
tool_sequence=[],
reasoning=f"parse failed: {e}",
response_hint=f"Respond naturally to: {command.source_text}",
)
plan._raw = {}
return plan