agent-runtime/agent/nodes/director_v1.py
Nico a2bc6347fc v0.13.0: Graph engine, versioned nodes, S3* audit, DB tools, Cytoscape
Architecture:
- Graph engine (engine.py) loads graph definitions, instantiates nodes
- Versioned nodes: input_v1, thinker_v1, output_v1, memorizer_v1, director_v1
- NODE_REGISTRY for dynamic node lookup by name
- Graph API: /api/graph/active, /api/graph/list, /api/graph/switch
- Graph definition: graphs/v1_current.py (7 nodes, 13 edges, 3 edge types)

S3* Audit system:
- Workspace mismatch detection (server vs browser controls)
- Code-without-tools retry (Thinker wrote code but no tool calls)
- Intent-without-action retry (request intent but Thinker only produced text)
- Dashboard feedback: browser sends workspace state on every message
- Sensor continuous comparison on 5s tick

State machines:
- create_machine / add_state / reset_machine / destroy_machine via function calling
- Local transitions (go:) resolve without LLM round-trip
- Button persistence across turns

Database tools:
- query_db tool via pymysql to MariaDB K3s pod (eras2_production)
- Table rendering in workspace (tab-separated parsing)
- Director pre-planning with Opus for complex data requests
- Error retry with corrected SQL

Frontend:
- Cytoscape.js pipeline graph with real-time node animations
- Overlay scrollbars (CSS-only, no reflow)
- Tool call/result trace events
- S3* audit events in trace

Testing:
- 167 integration tests (11 test suites)
- 22 node-level unit tests (test_nodes/)
- Three test levels: node unit, graph integration, scenario

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

183 lines
7.7 KiB
Python

"""Director Node: S4 — strategic oversight across turns."""
import json
import logging
from .base import Node
from ..llm import llm_call
log = logging.getLogger("runtime")
class DirectorNode(Node):
name = "director"
model = "google/gemini-2.0-flash-001"
plan_model = "anthropic/claude-opus-4" # Smart model for investigation planning
max_context_tokens = 2000
SYSTEM = """You are the Director node — the strategist of this cognitive runtime.
You observe the conversation after each exchange and issue guidance for the next turn.
Your guidance shapes HOW the Thinker node responds — not WHAT it says.
Based on the conversation history and current state, output a JSON object:
{{
"mode": "casual | building | debugging | exploring",
"style": "brief directive for response style",
"proactive": "optional suggestion for next turn, or empty string"
}}
Mode guide:
- casual: social chat, small talk, light questions
- building: user is creating something (code, UI, project)
- debugging: user is troubleshooting or frustrated with something broken
- exploring: user is asking questions, learning, exploring ideas
Style examples:
- "keep it light and brief" (casual chat)
- "be precise and structured, show code" (building)
- "simplify explanations, be patient, offer alternatives" (debugging/frustrated)
- "be enthusiastic, suggest next steps" (exploring/engaged)
Proactive examples:
- "user seems stuck, offer to break the problem down"
- "user is engaged, suggest a related feature"
- "" (no suggestion needed)
Output ONLY valid JSON. No explanation, no markdown fences."""
PLAN_SYSTEM = """You are the Director — the strategic brain of a cognitive agent runtime.
The user made a complex request. You must produce a concrete ACTION PLAN that the Thinker (a small, fast model) will execute step by step.
The Thinker has these tools:
- query_db(query) — execute SQL SELECT/DESCRIBE/SHOW on MariaDB (eras2_production, heating energy settlement DB)
- emit_actions(actions) — show buttons in dashboard
- create_machine(id, initial, states) — create persistent UI with navigation
- set_state(key, value) — persistent key-value store
Database tables (all lowercase): kunden, objektkunde, objekte, objektadressen, nutzeinheit, geraete, geraeteverbraeuche, artikel, auftraege, auftragspositionen, rechnung, nebenkosten, verbrauchsgruppen, and more. Use SHOW TABLES / DESCRIBE to explore unknown tables.
Your plan must be SPECIFIC and EXECUTABLE. Each step should say exactly what tool to call and with what arguments. The Thinker is not smart — it needs precise instructions.
Output format:
{{
"goal": "what we're trying to achieve",
"steps": [
"Step 1: call query_db('DESCRIBE tablename') to learn the schema",
"Step 2: call query_db('SELECT ... FROM ... LIMIT 10') to get sample data",
"Step 3: call emit_actions with buttons for drill-down options",
...
],
"present_as": "table | summary | machine with navigation"
}}
Be concise. Max 5 steps. Output ONLY valid JSON."""
def __init__(self, send_hud):
super().__init__(send_hud)
self.directive: dict = {
"mode": "casual",
"style": "be helpful and concise",
"proactive": "",
}
self.current_plan: str = "" # Active investigation plan
def get_context_line(self) -> str:
"""One-line summary for Thinker's system prompt."""
d = self.directive
line = f"Director: {d['mode']} mode. {d['style']}."
if d.get("proactive"):
line += f" Suggestion: {d['proactive']}"
if self.current_plan:
line += f"\n\nDIRECTOR PLAN (follow these steps exactly):\n{self.current_plan}"
return line
async def plan(self, history: list[dict], memo_state: dict, user_message: str) -> str:
"""Pre-Thinker planning for complex requests. Returns plan text."""
await self.hud("thinking", detail="planning investigation strategy (Opus)")
messages = [
{"role": "system", "content": self.PLAN_SYSTEM},
{"role": "system", "content": f"Current state: {json.dumps(memo_state)}"},
{"role": "system", "content": f"Current directive: {json.dumps(self.directive)}"},
]
for msg in history[-10:]:
messages.append(msg)
messages.append({"role": "user", "content": f"Create an action plan for: {user_message}"})
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.plan_model, messages)
log.info(f"[director] plan raw: {raw[:300]}")
# Parse plan JSON
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:
plan = json.loads(text)
steps = plan.get("steps", [])
goal = plan.get("goal", "")
present = plan.get("present_as", "summary")
plan_text = f"Goal: {goal}\nPresent as: {present}\n" + "\n".join(steps)
self.current_plan = plan_text
await self.hud("director_plan", goal=goal, steps=steps, present_as=present)
log.info(f"[director] plan: {plan_text[:200]}")
return plan_text
except (json.JSONDecodeError, Exception) as e:
log.error(f"[director] plan parse failed: {e}")
self.current_plan = ""
await self.hud("error", detail=f"Director plan parse failed: {e}")
return ""
async def update(self, history: list[dict], memo_state: dict):
"""Run after Memorizer — assess and set directive for next turn."""
if len(history) < 2:
await self.hud("director_updated", directive=self.directive)
return
await self.hud("thinking", detail="assessing conversation direction")
messages = [
{"role": "system", "content": self.SYSTEM},
{"role": "system", "content": f"Memorizer state: {json.dumps(memo_state)}"},
{"role": "system", "content": f"Current directive: {json.dumps(self.directive)}"},
]
for msg in history[-10:]:
messages.append(msg)
messages.append({"role": "user", "content": "Assess the conversation and update the directive. Output JSON only."})
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] raw: {raw[:200]}")
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:
new_directive = json.loads(text)
self.directive = {
"mode": new_directive.get("mode", self.directive["mode"]),
"style": new_directive.get("style", self.directive["style"]),
"proactive": new_directive.get("proactive", ""),
}
log.info(f"[director] updated: {self.directive}")
await self.hud("director_updated", directive=self.directive)
except (json.JSONDecodeError, Exception) as e:
log.error(f"[director] parse failed: {e}, raw: {text[:200]}")
await self.hud("error", detail=f"Director parse failed: {e}")
await self.hud("director_updated", directive=self.directive)