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>
183 lines
7.7 KiB
Python
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)
|