agent-runtime/agent/nodes/director_v2.py
Nico 1000411eb2 v0.15.0: Frame engine (v3), PA + Expert architecture (v4-eras), live test streaming
Frame Engine (v3-framed):
- Tick-based deterministic pipeline: frames advance on completion, not timers
- FrameRecord/FrameTrace dataclasses for structured per-message tracing
- /api/frames endpoint: queryable frame trace history (last 20 messages)
- frame_trace HUD event with full pipeline visibility
- Reflex=2F, Director=4F, Director+Interpreter=5F deterministic frame counts

Expert Architecture (v4-eras):
- PA node (pa_v1): routes to domain experts, holds user context
- ExpertNode base: stateless executor with plan+execute two-LLM-call pattern
- ErasExpertNode: eras2_production DB specialist with DESCRIBE-first discipline
- Schema caching: DESCRIBE results reused across queries within session
- Progress streaming: PA streams thinking message, expert streams per-tool progress
- PARouting type for structured routing decisions

UI Controls Split:
- Separate thinker_controls from machine controls (current_controls is now a property)
- Machine buttons persist across Thinker responses
- Machine state parser handles both dict and list formats from Director
- Normalized button format with go/payload field mapping

WebSocket Architecture:
- /ws/test: dedicated debug socket for test runner progress
- /ws/trace: dedicated debug socket for HUD/frame trace events
- /ws (chat): cleaned up, only deltas/controls/done/cleared
- WS survives graph switch (re-attaches to new runtime)
- Pipeline result reset on clear

Test Infrastructure:
- Live test streaming: on_result callback fires per check during execution
- Frontend polling fallback (500ms) for proxy-buffered WS
- frame_trace-first trace assertion (fixes stale perceived event bug)
- action_match supports "or" patterns and multi-pattern matching
- Trace window increased to 40 events
- Graph-agnostic assertions (has X or Y)

Test Suites:
- smoketest.md: 12 steps covering all categories (~2min)
- fast.md: 10 quick checks (~1min)
- fast_v4.md: 10 v4-eras specific checks
- expert_eras.md: eras domain tests (routing, DB, schema, errors)
- expert_progress.md: progress streaming tests

Other:
- Shared db.py extracted from thinker_v2 (reused by experts)
- InputNode prompt: few-shot examples, history as context summary
- Director prompt: full tool signatures for add_state/reset_machine/destroy_machine
- nginx no-cache headers for static files during development
- Cache-busted static file references

Scores: v3 smoketest 39/40, v4-eras fast 28/28, expert_eras 23/23

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

151 lines
6.3 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
states is a dict: {{"state_name": {{"actions": [{{"label":"Go","action":"go","payload":"target","go":"target"}}], "display": [{{"type":"text","label":"Title","value":"Content"}}]}}}}
- add_state(id, state, buttons, content) — add a state to existing machine. id=machine name, state=new state name, buttons=[{{label,action,go}}], content=["text"]
- reset_machine(id) — reset machine to initial state. id=machine name
- destroy_machine(id) — remove machine. id=machine name
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