Architecture: - director_v2: always-on brain, produces DirectorPlan with tool_sequence - thinker_v2: pure executor, runs tools from DirectorPlan - interpreter_v1: factual result summarizer, no hallucination - v2_director_drives graph: Input -> Director -> Thinker -> Output Infrastructure: - Split into 3 pods: cog-frontend (nginx), cog-runtime (FastAPI), cog-mcp (SSE proxy) - MCP survives runtime restarts (separate pod, proxies via HTTP) - Async send pipeline: /api/send/check -> /api/send -> /api/result with progress - Zero-downtime rolling updates (maxUnavailable: 0) - Dynamic graph visualization (fetched from API, not hardcoded) Tests: 22 new mocked unit tests (director_v2: 7, thinker_v2: 8, interpreter_v1: 7) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
148 lines
5.9 KiB
Python
148 lines
5.9 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-sonnet-4"
|
|
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
|
|
- add_state / reset_machine / destroy_machine — machine lifecycle
|
|
|
|
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
|