agent-runtime/agent/nodes/input_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

106 lines
4.2 KiB
Python

"""Input Node: structured analyst — classifies user input."""
import json
import logging
from .base import Node
from ..llm import llm_call
from ..types import Envelope, Command, InputAnalysis
log = logging.getLogger("runtime")
class InputNode(Node):
name = "input"
model = "google/gemini-2.0-flash-001"
max_context_tokens = 2000
SYSTEM = """You are the Input node — the analyst of this cognitive runtime.
Listener: {identity} on {channel}
YOUR ONLY JOB: Analyze the user's message and return a JSON classification.
Output ONLY valid JSON, nothing else. No markdown fences, no explanation.
Schema:
{{
"who": "name or unknown",
"language": "en | de | mixed",
"intent": "question | request | social | action | feedback",
"topic": "short topic string",
"tone": "casual | frustrated | playful | urgent",
"complexity": "trivial | simple | complex",
"context": "brief situational note or empty string"
}}
Classification guide:
- intent "social": greetings, thanks, goodbye, acknowledgments (hi, ok, thanks, bye, cool)
- intent "question": asking for information (what, how, when, why, who)
- intent "request": asking to do/create/build something
- intent "action": clicking a button or triggering a UI action
- intent "feedback": commenting on results, correcting, expressing satisfaction/dissatisfaction
- complexity "trivial": one-word or very short social messages that need no reasoning
- complexity "simple": clear single-step requests or questions
- complexity "complex": multi-step, ambiguous, or requires deep reasoning
- tone "frustrated": complaints, anger, exasperation
- tone "urgent": time pressure, critical issues
- tone "playful": jokes, teasing, lighthearted
- tone "casual": neutral everyday conversation
{memory_context}"""
async def process(self, envelope: Envelope, history: list[dict], memory_context: str = "",
identity: str = "unknown", channel: str = "unknown") -> Command:
await self.hud("thinking", detail="analyzing input")
log.info(f"[input] user said: {envelope.text}")
messages = [
{"role": "system", "content": self.SYSTEM.format(
memory_context=memory_context, identity=identity, channel=channel)},
]
for msg in history[-8:]:
messages.append(msg)
messages.append({"role": "user", "content": f"Classify this message: {envelope.text}"})
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"[input] raw: {raw[:300]}")
analysis = self._parse_analysis(raw, identity)
log.info(f"[input] analysis: {analysis}")
await self.hud("perceived", analysis=self._to_dict(analysis))
return Command(analysis=analysis, source_text=envelope.text)
def _parse_analysis(self, raw: str, identity: str = "unknown") -> InputAnalysis:
"""Parse LLM JSON response into InputAnalysis, with fallback defaults."""
text = raw.strip()
# Strip markdown fences if present
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)
return InputAnalysis(
who=data.get("who", identity) or identity,
language=data.get("language", "en"),
intent=data.get("intent", "request"),
topic=data.get("topic", ""),
tone=data.get("tone", "casual"),
complexity=data.get("complexity", "simple"),
context=data.get("context", ""),
)
except (json.JSONDecodeError, Exception) as e:
log.error(f"[input] JSON parse failed: {e}, raw: {text[:200]}")
# Fallback: best-effort from raw text
return InputAnalysis(who=identity, topic=text[:50])
@staticmethod
def _to_dict(analysis: InputAnalysis) -> dict:
from dataclasses import asdict
return asdict(analysis)