"""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-haiku-4.5" # 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)