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>
151 lines
6.3 KiB
Python
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
|