v0.5.4: sensor node, perceiver model, context budgets, API send
- SensorNode: 5s tick loop with delta-only emissions (clock, idle, memo changes) - Input reframed as perceiver (describes what it heard, not commands) - Output reframed as voice (acts on perception, never echoes it) - Per-node token budgets: Input 2K, Output 4K, Memorizer 3K - fit_context() trims oldest messages to stay within budget - History sliding window: 40 messages max - Facts capped at 20, trace file rotates at 500KB - /api/send + /api/clear endpoints for programmatic testing - test_cog.py test suite - Listener context: physical/social/security awareness Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
569a6022fe
commit
ab661775ef
286
agent.py
286
agent.py
@ -155,22 +155,227 @@ class Command:
|
|||||||
|
|
||||||
# --- Base Node ---
|
# --- Base Node ---
|
||||||
|
|
||||||
|
def estimate_tokens(text: str) -> int:
|
||||||
|
"""Rough token estimate: 1 token ≈ 4 chars."""
|
||||||
|
return len(text) // 4
|
||||||
|
|
||||||
|
|
||||||
|
def fit_context(messages: list[dict], max_tokens: int, protect_last: int = 4) -> list[dict]:
|
||||||
|
"""Trim oldest messages (after system prompt) to fit token budget.
|
||||||
|
Always keeps: system prompt(s) at start + last `protect_last` messages."""
|
||||||
|
if not messages:
|
||||||
|
return messages
|
||||||
|
|
||||||
|
# Split into system prefix, middle (trimmable), and protected tail
|
||||||
|
system_msgs = []
|
||||||
|
rest = []
|
||||||
|
for m in messages:
|
||||||
|
if not rest and m["role"] == "system":
|
||||||
|
system_msgs.append(m)
|
||||||
|
else:
|
||||||
|
rest.append(m)
|
||||||
|
|
||||||
|
protected = rest[-protect_last:] if len(rest) > protect_last else rest
|
||||||
|
middle = rest[:-protect_last] if len(rest) > protect_last else []
|
||||||
|
|
||||||
|
# Count fixed tokens (system + protected tail)
|
||||||
|
fixed_tokens = sum(estimate_tokens(m["content"]) for m in system_msgs + protected)
|
||||||
|
|
||||||
|
if fixed_tokens >= max_tokens:
|
||||||
|
# Even fixed content exceeds budget — truncate protected messages
|
||||||
|
result = system_msgs + protected
|
||||||
|
total = sum(estimate_tokens(m["content"]) for m in result)
|
||||||
|
while total > max_tokens and len(result) > 2:
|
||||||
|
removed = result.pop(1) # remove oldest non-system
|
||||||
|
total -= estimate_tokens(removed["content"])
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Fill remaining budget with middle messages (newest first)
|
||||||
|
remaining = max_tokens - fixed_tokens
|
||||||
|
kept_middle = []
|
||||||
|
for m in reversed(middle):
|
||||||
|
t = estimate_tokens(m["content"])
|
||||||
|
if remaining - t < 0:
|
||||||
|
break
|
||||||
|
kept_middle.insert(0, m)
|
||||||
|
remaining -= t
|
||||||
|
|
||||||
|
return system_msgs + kept_middle + protected
|
||||||
|
|
||||||
|
|
||||||
class Node:
|
class Node:
|
||||||
name: str = "node"
|
name: str = "node"
|
||||||
model: str | None = None
|
model: str | None = None
|
||||||
|
max_context_tokens: int = 4000 # default budget per node
|
||||||
|
|
||||||
def __init__(self, send_hud):
|
def __init__(self, send_hud):
|
||||||
self.send_hud = send_hud # async callable to emit hud events to frontend
|
self.send_hud = send_hud # async callable to emit hud events to frontend
|
||||||
|
self.last_context_tokens = 0
|
||||||
|
|
||||||
async def hud(self, event: str, **data):
|
async def hud(self, event: str, **data):
|
||||||
await self.send_hud({"node": self.name, "event": event, **data})
|
await self.send_hud({"node": self.name, "event": event, **data})
|
||||||
|
|
||||||
|
def trim_context(self, messages: list[dict]) -> list[dict]:
|
||||||
|
"""Fit messages within this node's token budget."""
|
||||||
|
before = len(messages)
|
||||||
|
result = fit_context(messages, self.max_context_tokens)
|
||||||
|
self.last_context_tokens = sum(estimate_tokens(m["content"]) for m in result)
|
||||||
|
self.context_fill_pct = int(100 * self.last_context_tokens / self.max_context_tokens)
|
||||||
|
if before != len(result):
|
||||||
|
log.info(f"[{self.name}] context trimmed: {before} → {len(result)} msgs, {self.context_fill_pct}% fill")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# --- Sensor Node (ticks independently, produces context for other nodes) ---
|
||||||
|
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
|
BERLIN = timezone(timedelta(hours=2)) # CEST
|
||||||
|
|
||||||
|
|
||||||
|
class SensorNode(Node):
|
||||||
|
name = "sensor"
|
||||||
|
|
||||||
|
def __init__(self, send_hud):
|
||||||
|
super().__init__(send_hud)
|
||||||
|
self.tick_count = 0
|
||||||
|
self.running = False
|
||||||
|
self._task: asyncio.Task | None = None
|
||||||
|
self.interval = 5 # seconds
|
||||||
|
# Current sensor readings — each is {value, changed_at, prev}
|
||||||
|
self.readings: dict[str, dict] = {}
|
||||||
|
self._last_user_activity: float = time.time()
|
||||||
|
# Snapshot of memorizer state for change detection
|
||||||
|
self._prev_memo_state: dict = {}
|
||||||
|
|
||||||
|
def _now(self) -> datetime:
|
||||||
|
return datetime.now(BERLIN)
|
||||||
|
|
||||||
|
def _read_clock(self) -> dict:
|
||||||
|
"""Clock sensor — updates when minute changes."""
|
||||||
|
now = self._now()
|
||||||
|
current = now.strftime("%H:%M")
|
||||||
|
prev = self.readings.get("clock", {}).get("value")
|
||||||
|
if current != prev:
|
||||||
|
return {"value": current, "detail": now.strftime("%Y-%m-%d %H:%M:%S %A"), "changed_at": time.time()}
|
||||||
|
return {} # no change
|
||||||
|
|
||||||
|
def _read_idle(self) -> dict:
|
||||||
|
"""Idle sensor — time since last user message."""
|
||||||
|
idle_s = time.time() - self._last_user_activity
|
||||||
|
# Only update on threshold crossings: 30s, 1m, 5m, 10m, 30m
|
||||||
|
thresholds = [30, 60, 300, 600, 1800]
|
||||||
|
prev_idle = self.readings.get("idle", {}).get("_raw", 0)
|
||||||
|
for t in thresholds:
|
||||||
|
if prev_idle < t <= idle_s:
|
||||||
|
if idle_s < 60:
|
||||||
|
label = f"{int(idle_s)}s"
|
||||||
|
else:
|
||||||
|
label = f"{int(idle_s // 60)}m{int(idle_s % 60)}s"
|
||||||
|
return {"value": label, "_raw": idle_s, "changed_at": time.time()}
|
||||||
|
# Update raw but don't flag as changed
|
||||||
|
if "idle" in self.readings:
|
||||||
|
self.readings["idle"]["_raw"] = idle_s
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def _read_memo_changes(self, memo_state: dict) -> dict:
|
||||||
|
"""Detect memorizer state changes."""
|
||||||
|
changes = []
|
||||||
|
for k, v in memo_state.items():
|
||||||
|
prev = self._prev_memo_state.get(k)
|
||||||
|
if v != prev and prev is not None:
|
||||||
|
changes.append(f"{k}: {prev} -> {v}")
|
||||||
|
self._prev_memo_state = dict(memo_state)
|
||||||
|
if changes:
|
||||||
|
return {"value": "; ".join(changes), "changed_at": time.time()}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def note_user_activity(self):
|
||||||
|
"""Called when user sends a message."""
|
||||||
|
self._last_user_activity = time.time()
|
||||||
|
# Reset idle sensor
|
||||||
|
self.readings["idle"] = {"value": "active", "_raw": 0, "changed_at": time.time()}
|
||||||
|
|
||||||
|
async def tick(self, memo_state: dict):
|
||||||
|
"""One tick — read all sensors, emit deltas."""
|
||||||
|
self.tick_count += 1
|
||||||
|
deltas = {}
|
||||||
|
|
||||||
|
# Read each sensor
|
||||||
|
for name, reader in [("clock", self._read_clock),
|
||||||
|
("idle", self._read_idle)]:
|
||||||
|
update = reader()
|
||||||
|
if update:
|
||||||
|
self.readings[name] = {**self.readings.get(name, {}), **update}
|
||||||
|
deltas[name] = update.get("value") or update.get("detail")
|
||||||
|
|
||||||
|
# Memo changes
|
||||||
|
memo_update = self._read_memo_changes(memo_state)
|
||||||
|
if memo_update:
|
||||||
|
self.readings["memo_delta"] = memo_update
|
||||||
|
deltas["memo_delta"] = memo_update["value"]
|
||||||
|
|
||||||
|
# Only emit HUD if something changed
|
||||||
|
if deltas:
|
||||||
|
await self.hud("tick", tick=self.tick_count, deltas=deltas)
|
||||||
|
|
||||||
|
async def _loop(self, get_memo_state):
|
||||||
|
"""Background tick loop."""
|
||||||
|
self.running = True
|
||||||
|
await self.hud("started", interval=self.interval)
|
||||||
|
try:
|
||||||
|
while self.running:
|
||||||
|
await asyncio.sleep(self.interval)
|
||||||
|
try:
|
||||||
|
await self.tick(get_memo_state())
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"[sensor] tick error: {e}")
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
self.running = False
|
||||||
|
await self.hud("stopped")
|
||||||
|
|
||||||
|
def start(self, get_memo_state):
|
||||||
|
"""Start the background tick loop."""
|
||||||
|
if self._task and not self._task.done():
|
||||||
|
return
|
||||||
|
self._task = asyncio.create_task(self._loop(get_memo_state))
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"""Stop the tick loop."""
|
||||||
|
self.running = False
|
||||||
|
if self._task:
|
||||||
|
self._task.cancel()
|
||||||
|
|
||||||
|
def get_context_lines(self) -> list[str]:
|
||||||
|
"""Render current sensor readings for injection into prompts."""
|
||||||
|
if not self.readings:
|
||||||
|
return ["Sensors: (no sensor node running)"]
|
||||||
|
lines = [f"Sensors (tick #{self.tick_count}, {self.interval}s interval):"]
|
||||||
|
for name, r in self.readings.items():
|
||||||
|
if name.startswith("_"):
|
||||||
|
continue
|
||||||
|
val = r.get("value", "?")
|
||||||
|
detail = r.get("detail")
|
||||||
|
age = time.time() - r.get("changed_at", time.time())
|
||||||
|
if age < 10:
|
||||||
|
age_str = "just now"
|
||||||
|
elif age < 60:
|
||||||
|
age_str = f"{int(age)}s ago"
|
||||||
|
else:
|
||||||
|
age_str = f"{int(age // 60)}m ago"
|
||||||
|
line = f"- {name}: {detail or val} [{age_str}]"
|
||||||
|
lines.append(line)
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
# --- Input Node ---
|
# --- Input Node ---
|
||||||
|
|
||||||
class InputNode(Node):
|
class InputNode(Node):
|
||||||
name = "input"
|
name = "input"
|
||||||
model = "google/gemini-2.0-flash-001"
|
model = "google/gemini-2.0-flash-001"
|
||||||
|
max_context_tokens = 2000 # small budget — perception only
|
||||||
|
|
||||||
SYSTEM = """You are the Input node — the ear of this cognitive runtime.
|
SYSTEM = """You are the Input node — the ear of this cognitive runtime.
|
||||||
|
|
||||||
@ -180,8 +385,8 @@ Listener context:
|
|||||||
- Physical: private space, Nico lives with Tina — she may use this session too
|
- Physical: private space, Nico lives with Tina — she may use this session too
|
||||||
- Security: single-user account, shared physical space — other voices are trusted household
|
- Security: single-user account, shared physical space — other voices are trusted household
|
||||||
|
|
||||||
You hear what comes through this channel. Emit ONE instruction sentence telling Output how to respond.
|
Your job: describe what you heard. Who spoke, what they want, what tone, what context matters.
|
||||||
No content, just the command.
|
ONE sentence. No content, no response — just your perception of what came through.
|
||||||
|
|
||||||
{memory_context}"""
|
{memory_context}"""
|
||||||
|
|
||||||
@ -194,14 +399,14 @@ No content, just the command.
|
|||||||
{"role": "system", "content": self.SYSTEM.format(
|
{"role": "system", "content": self.SYSTEM.format(
|
||||||
memory_context=memory_context, identity=identity, channel=channel)},
|
memory_context=memory_context, identity=identity, channel=channel)},
|
||||||
]
|
]
|
||||||
# History already includes current user message — don't add it again
|
|
||||||
for msg in history[-8:]:
|
for msg in history[-8:]:
|
||||||
messages.append(msg)
|
messages.append(msg)
|
||||||
|
messages = self.trim_context(messages)
|
||||||
|
|
||||||
await self.hud("context", messages=messages)
|
await self.hud("context", messages=messages)
|
||||||
instruction = await llm_call(self.model, messages)
|
instruction = await llm_call(self.model, messages)
|
||||||
log.info(f"[input] → command: {instruction}")
|
log.info(f"[input] → command: {instruction}")
|
||||||
await self.hud("decided", instruction=instruction)
|
await self.hud("perceived", instruction=instruction)
|
||||||
return Command(instruction=instruction, source_text=envelope.text)
|
return Command(instruction=instruction, source_text=envelope.text)
|
||||||
|
|
||||||
|
|
||||||
@ -210,11 +415,12 @@ No content, just the command.
|
|||||||
class OutputNode(Node):
|
class OutputNode(Node):
|
||||||
name = "output"
|
name = "output"
|
||||||
model = "google/gemini-2.0-flash-001"
|
model = "google/gemini-2.0-flash-001"
|
||||||
|
max_context_tokens = 4000 # larger — needs history for continuity
|
||||||
|
|
||||||
SYSTEM = """You are the Output node of a cognitive agent runtime.
|
SYSTEM = """You are the Output node — the voice of this cognitive runtime.
|
||||||
You receive a command from the Input node telling you HOW to respond, plus the user's original message.
|
The Input node sends you its perception of what the user said. This is internal context for you — never repeat or echo it.
|
||||||
Follow the command's tone and intent. Be natural, don't mention the command or the runtime architecture.
|
You respond to the USER, not to the Input node. Use the perception to understand intent, then act on it.
|
||||||
Be concise.
|
Be natural. Be concise. If the user asks you to do something, do it — don't describe what you're about to do.
|
||||||
|
|
||||||
{memory_context}"""
|
{memory_context}"""
|
||||||
|
|
||||||
@ -224,11 +430,10 @@ Be concise.
|
|||||||
messages = [
|
messages = [
|
||||||
{"role": "system", "content": self.SYSTEM.format(memory_context=memory_context)},
|
{"role": "system", "content": self.SYSTEM.format(memory_context=memory_context)},
|
||||||
]
|
]
|
||||||
# Conversation history for continuity (already includes current user message)
|
|
||||||
for msg in history[-20:]:
|
for msg in history[-20:]:
|
||||||
messages.append(msg)
|
messages.append(msg)
|
||||||
# Inject command as system guidance after the user message
|
messages.append({"role": "system", "content": f"Input perception: {command.instruction}"})
|
||||||
messages.append({"role": "system", "content": f"Input node command: {command.instruction}"})
|
messages = self.trim_context(messages)
|
||||||
|
|
||||||
await self.hud("context", messages=messages)
|
await self.hud("context", messages=messages)
|
||||||
|
|
||||||
@ -263,6 +468,7 @@ Be concise.
|
|||||||
class MemorizerNode(Node):
|
class MemorizerNode(Node):
|
||||||
name = "memorizer"
|
name = "memorizer"
|
||||||
model = "google/gemini-2.0-flash-001"
|
model = "google/gemini-2.0-flash-001"
|
||||||
|
max_context_tokens = 3000 # needs enough history to distill
|
||||||
|
|
||||||
DISTILL_SYSTEM = """You are the Memorizer node of a cognitive agent runtime.
|
DISTILL_SYSTEM = """You are the Memorizer node of a cognitive agent runtime.
|
||||||
After each exchange you update the shared state that Input and Output nodes read.
|
After each exchange you update the shared state that Input and Output nodes read.
|
||||||
@ -293,9 +499,11 @@ Output ONLY valid JSON. No explanation, no markdown fences."""
|
|||||||
"facts": [],
|
"facts": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_context_block(self) -> str:
|
def get_context_block(self, sensor_lines: list[str] = None) -> str:
|
||||||
"""Returns a formatted string for injection into Input/Output system prompts."""
|
"""Returns a formatted string for injection into Input/Output system prompts."""
|
||||||
lines = ["Shared memory (from Memorizer):"]
|
lines = sensor_lines or ["Sensors: (none)"]
|
||||||
|
lines.append("")
|
||||||
|
lines.append("Shared memory (from Memorizer):")
|
||||||
for k, v in self.state.items():
|
for k, v in self.state.items():
|
||||||
if v:
|
if v:
|
||||||
lines.append(f"- {k}: {v}")
|
lines.append(f"- {k}: {v}")
|
||||||
@ -313,10 +521,10 @@ Output ONLY valid JSON. No explanation, no markdown fences."""
|
|||||||
{"role": "system", "content": self.DISTILL_SYSTEM},
|
{"role": "system", "content": self.DISTILL_SYSTEM},
|
||||||
{"role": "system", "content": f"Current state: {json.dumps(self.state)}"},
|
{"role": "system", "content": f"Current state: {json.dumps(self.state)}"},
|
||||||
]
|
]
|
||||||
# Last few exchanges for distillation
|
|
||||||
for msg in history[-10:]:
|
for msg in history[-10:]:
|
||||||
messages.append(msg)
|
messages.append(msg)
|
||||||
messages.append({"role": "user", "content": "Update the shared state based on this conversation. Output JSON only."})
|
messages.append({"role": "user", "content": "Update the shared state based on this conversation. Output JSON only."})
|
||||||
|
messages = self.trim_context(messages)
|
||||||
|
|
||||||
await self.hud("context", messages=messages)
|
await self.hud("context", messages=messages)
|
||||||
|
|
||||||
@ -336,7 +544,7 @@ Output ONLY valid JSON. No explanation, no markdown fences."""
|
|||||||
# Merge: keep old facts, add new ones
|
# Merge: keep old facts, add new ones
|
||||||
old_facts = set(self.state.get("facts", []))
|
old_facts = set(self.state.get("facts", []))
|
||||||
new_facts = set(new_state.get("facts", []))
|
new_facts = set(new_state.get("facts", []))
|
||||||
new_state["facts"] = list(old_facts | new_facts)
|
new_state["facts"] = list(old_facts | new_facts)[-20:] # cap at 20 facts
|
||||||
# Preserve topic history
|
# Preserve topic history
|
||||||
if self.state.get("topic") and self.state["topic"] != new_state.get("topic"):
|
if self.state.get("topic") and self.state["topic"] != new_state.get("topic"):
|
||||||
hist = new_state.get("topic_history", [])
|
hist = new_state.get("topic_history", [])
|
||||||
@ -362,9 +570,13 @@ class Runtime:
|
|||||||
def __init__(self, ws: WebSocket, user_claims: dict = None, origin: str = ""):
|
def __init__(self, ws: WebSocket, user_claims: dict = None, origin: str = ""):
|
||||||
self.ws = ws
|
self.ws = ws
|
||||||
self.history: list[dict] = []
|
self.history: list[dict] = []
|
||||||
|
self.MAX_HISTORY = 40 # sliding window — oldest messages drop off
|
||||||
self.input_node = InputNode(send_hud=self._send_hud)
|
self.input_node = InputNode(send_hud=self._send_hud)
|
||||||
self.output_node = OutputNode(send_hud=self._send_hud)
|
self.output_node = OutputNode(send_hud=self._send_hud)
|
||||||
self.memorizer = MemorizerNode(send_hud=self._send_hud)
|
self.memorizer = MemorizerNode(send_hud=self._send_hud)
|
||||||
|
self.sensor = SensorNode(send_hud=self._send_hud)
|
||||||
|
# Start sensor tick loop
|
||||||
|
self.sensor.start(get_memo_state=lambda: self.memorizer.state)
|
||||||
# Verified identity from auth — Input and Memorizer use this
|
# Verified identity from auth — Input and Memorizer use this
|
||||||
claims = user_claims or {}
|
claims = user_claims or {}
|
||||||
log.info(f"[runtime] user_claims: {claims}")
|
log.info(f"[runtime] user_claims: {claims}")
|
||||||
@ -383,6 +595,10 @@ class Runtime:
|
|||||||
try:
|
try:
|
||||||
with open(TRACE_FILE, "a", encoding="utf-8") as f:
|
with open(TRACE_FILE, "a", encoding="utf-8") as f:
|
||||||
f.write(json.dumps(trace_entry, ensure_ascii=False) + "\n")
|
f.write(json.dumps(trace_entry, ensure_ascii=False) + "\n")
|
||||||
|
# Rotate trace file at 1000 lines
|
||||||
|
if TRACE_FILE.exists() and TRACE_FILE.stat().st_size > 500_000:
|
||||||
|
lines = TRACE_FILE.read_text(encoding="utf-8").strip().split("\n")
|
||||||
|
TRACE_FILE.write_text("\n".join(lines[-500:]) + "\n", encoding="utf-8")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error(f"trace write error: {e}")
|
log.error(f"trace write error: {e}")
|
||||||
_broadcast_sse(trace_entry)
|
_broadcast_sse(trace_entry)
|
||||||
@ -395,11 +611,15 @@ class Runtime:
|
|||||||
timestamp=time.strftime("%Y-%m-%d %H:%M:%S"),
|
timestamp=time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Note user activity for idle sensor
|
||||||
|
self.sensor.note_user_activity()
|
||||||
|
|
||||||
# Append user message to history FIRST — both nodes see it
|
# Append user message to history FIRST — both nodes see it
|
||||||
self.history.append({"role": "user", "content": text})
|
self.history.append({"role": "user", "content": text})
|
||||||
|
|
||||||
# Get shared memory context for both nodes
|
# Get shared memory + sensor context for both nodes
|
||||||
mem_ctx = self.memorizer.get_context_block()
|
sensor_lines = self.sensor.get_context_lines()
|
||||||
|
mem_ctx = self.memorizer.get_context_block(sensor_lines=sensor_lines)
|
||||||
|
|
||||||
# Input node decides (with memory context + identity + channel)
|
# Input node decides (with memory context + identity + channel)
|
||||||
command = await self.input_node.process(
|
command = await self.input_node.process(
|
||||||
@ -413,12 +633,16 @@ class Runtime:
|
|||||||
# Memorizer updates shared state after each exchange
|
# Memorizer updates shared state after each exchange
|
||||||
await self.memorizer.update(self.history)
|
await self.memorizer.update(self.history)
|
||||||
|
|
||||||
|
# Sliding window — trim oldest messages, keep context in memorizer
|
||||||
|
if len(self.history) > self.MAX_HISTORY:
|
||||||
|
self.history = self.history[-self.MAX_HISTORY:]
|
||||||
|
|
||||||
|
|
||||||
# --- App ---
|
# --- App ---
|
||||||
|
|
||||||
STATIC_DIR = Path(__file__).parent / "static"
|
STATIC_DIR = Path(__file__).parent / "static"
|
||||||
|
|
||||||
app = FastAPI(title="Cognitive Agent Runtime")
|
app = FastAPI(title="cog")
|
||||||
|
|
||||||
# Keep a reference to the active runtime for API access
|
# Keep a reference to the active runtime for API access
|
||||||
_active_runtime: Runtime | None = None
|
_active_runtime: Runtime | None = None
|
||||||
@ -472,6 +696,7 @@ async def ws_endpoint(ws: WebSocket, token: str | None = Query(None), access_tok
|
|||||||
msg = json.loads(data)
|
msg = json.loads(data)
|
||||||
await runtime.handle_message(msg["text"])
|
await runtime.handle_message(msg["text"])
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
|
runtime.sensor.stop()
|
||||||
if _active_runtime is runtime:
|
if _active_runtime is runtime:
|
||||||
_active_runtime = None
|
_active_runtime = None
|
||||||
|
|
||||||
@ -538,6 +763,31 @@ async def poll(since: str = "", user=Depends(require_auth)):
|
|||||||
"last_messages": _active_runtime.history[-3:] if _active_runtime else [],
|
"last_messages": _active_runtime.history[-3:] if _active_runtime else [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@app.post("/api/send")
|
||||||
|
async def api_send(body: dict, user=Depends(require_auth)):
|
||||||
|
"""Send a message as if the user typed it. Requires auth. Returns the response."""
|
||||||
|
if not _active_runtime:
|
||||||
|
raise HTTPException(status_code=409, detail="No active session — someone must be connected via WS first")
|
||||||
|
text = body.get("text", "").strip()
|
||||||
|
if not text:
|
||||||
|
raise HTTPException(status_code=400, detail="Missing 'text' field")
|
||||||
|
await _active_runtime.handle_message(text)
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"response": _active_runtime.history[-1]["content"] if _active_runtime.history else "",
|
||||||
|
"memorizer": _active_runtime.memorizer.state,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/clear")
|
||||||
|
async def api_clear(user=Depends(require_auth)):
|
||||||
|
"""Clear conversation history."""
|
||||||
|
if not _active_runtime:
|
||||||
|
raise HTTPException(status_code=409, detail="No active session")
|
||||||
|
_active_runtime.history.clear()
|
||||||
|
return {"status": "cleared"}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/state")
|
@app.get("/api/state")
|
||||||
async def get_state(user=Depends(require_auth)):
|
async def get_state(user=Depends(require_auth)):
|
||||||
"""Current memorizer state + history length."""
|
"""Current memorizer state + history length."""
|
||||||
|
|||||||
@ -3,13 +3,13 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Cognitive Agent Runtime</title>
|
<title>cog</title>
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<div id="top-bar">
|
<div id="top-bar">
|
||||||
<h1>Cognitive Agent Runtime</h1>
|
<h1>cog</h1>
|
||||||
<div id="status">disconnected</div>
|
<div id="status">disconnected</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
36
test_cog.py
Normal file
36
test_cog.py
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
"""Test script for cog runtime API. Run with: .venv/Scripts/python.exe test_cog.py"""
|
||||||
|
import httpx, sys, time
|
||||||
|
|
||||||
|
API = "https://cog.loop42.de/api"
|
||||||
|
TOKEN = "7Oorb9S3OpwFyWgm4zi_Tq7GeamefbjjTgooPVPWAwPDOf6B4TvgvQlLbhmT4DjsqBS_D1g"
|
||||||
|
HEADERS = {"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"}
|
||||||
|
|
||||||
|
def send(text):
|
||||||
|
r = httpx.post(f"{API}/send", json={"text": text}, headers=HEADERS, timeout=30)
|
||||||
|
d = r.json()
|
||||||
|
return d.get("response", "").strip(), d.get("memorizer", {})
|
||||||
|
|
||||||
|
def clear():
|
||||||
|
httpx.post(f"{API}/clear", headers=HEADERS, timeout=10)
|
||||||
|
|
||||||
|
tests = [
|
||||||
|
("hello!", None),
|
||||||
|
("hey tina hier!", None),
|
||||||
|
("wir gehen gleich in den pub", None),
|
||||||
|
("nico back - schreib mir ein haiku", None),
|
||||||
|
("auf deutsch, mit unseren namen und deinem, dark future tech theme", None),
|
||||||
|
("wie spaet ist es?", None),
|
||||||
|
]
|
||||||
|
|
||||||
|
clear()
|
||||||
|
print("=== COG TEST RUN ===\n")
|
||||||
|
|
||||||
|
for i, (msg, _) in enumerate(tests, 1):
|
||||||
|
print(f"--- {i}. USER: {msg}")
|
||||||
|
resp, memo = send(msg)
|
||||||
|
print(f" COG: {resp}")
|
||||||
|
print(f" MEMO: name={memo.get('user_name')} mood={memo.get('user_mood')} topic={memo.get('topic')}")
|
||||||
|
print()
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
print("=== DONE ===")
|
||||||
Loading…
x
Reference in New Issue
Block a user