daydream / agents /base.py
kaiser-data's picture
Per-agent retry budgets: narrator waits, Keeper degrades fast
ee730c2 verified
Raw
History Blame Contribute Delete
7.1 kB
"""Minimal agent abstraction for a fleet of small models.
A small framework for small models — no LangChain. An Agent is a persona bound
to a backend role; it can stream prose (for the dream) or return structured JSON
(for Hobbes' choices and the world-state Keeper).
Set DAYDREAM_MOCK=1 to run the whole app with no inference backend — agents emit
templated placeholder text so the UI is fully playable offline during dev.
"""
from __future__ import annotations
import os
import re
import json
import time
import random
from dataclasses import dataclass, field
from typing import Iterator
from .registry import get_client
MOCK = bool(os.environ.get("DAYDREAM_MOCK"))
# A scaled-down Modal endpoint answers its first requests with a FAST 503
# ("loading model"), not a slow block — so the retry WINDOW must span the whole
# cold start (~48s cached, longer on a true cold boot), or we degrade to the
# "dream wavers" fallback right when we should be patiently waiting. 6×15s ≈ 90s
# of recovery, matching the UI's "first turn ~90s" promise. Tune via env.
RETRIES = int(os.environ.get("DAYDREAM_RETRIES", "6"))
RETRY_WAIT = float(os.environ.get("DAYDREAM_RETRY_WAIT", "15"))
# Both 2026 small models reason by default, which pollutes prose with "Thinking
# Process:" text and adds seconds per turn. Two levers, applied to every call:
# - chat_template_kwargs enable_thinking=False -> disables Qwen3.5 thinking
# (vLLM honors it; llama.cpp tolerates it harmlessly)
# - "/no_think" in the system prompt -> disables MiniCPM thinking
# (Qwen ignores the token)
NO_THINK = "/no_think"
EXTRA_BODY = {"chat_template_kwargs": {"enable_thinking": False}}
@dataclass
class LLMConfig:
temperature: float = 0.8
max_tokens: int = 300
top_p: float = 0.95
def _mock_line(name: str, user: str) -> str:
banks = {
"Dreamweaver": [
"The air thickens to syrup; the path ahead folds like wet paper and opens onto somewhere new.",
"A sound you can almost see ripples across the ground, and the world leans in to listen.",
],
"Mischief": ["(A door appears where there was none, breathing softly.)",
"(Your shadow waves at you. It is not your shadow.)"],
"Hobbes": ['{"reaction": "Okay, I have a bad feeling AND a good feeling.", '
'"choices": ["Open the breathing door", "Follow the hum deeper", "Ask Hobbes to scout"]}'],
"Keeper": ['{"location":"a stranger clearing","add_items":["a humming pebble"],'
'"progress_delta":15,"mission_complete":false,"note":"crept deeper in"}'],
}
return random.choice(banks.get(name, ["..."]))
def loose_json(text: str) -> dict:
"""Best-effort extract the first valid JSON object from a model reply.
Small models often wrap JSON in prose or fences, and a greedy ``{.*}`` match
breaks as soon as the reply contains more than one object. Use the JSON
decoder directly from each candidate ``{`` so braces inside strings and
trailing chatter are handled by the parser instead of regex.
"""
decoder = json.JSONDecoder()
for match in re.finditer(r"\{", text or ""):
try:
obj, _ = decoder.raw_decode(text[match.start():])
except json.JSONDecodeError:
continue
if isinstance(obj, dict):
return obj
return {}
@dataclass
class Agent:
name: str # display name, e.g. "Hobbes"
role: str # registry key: "specialist" | "router"
system: str # persona / instructions
cfg: LLMConfig = field(default_factory=LLMConfig)
# Per-agent retry budget. Essential agents (the narrator) inherit the wide
# default so they patiently wait out a cold start; presentational agents (the
# Keeper) override to a SHORT budget so a cold endpoint degrades fast instead
# of holding the whole turn hostage.
retries: int = RETRIES
retry_wait: float = RETRY_WAIT
def _messages(self, user: str, history: list[dict] | None) -> list[dict]:
return [{"role": "system", "content": f"{self.system} {NO_THINK}"}, *(history or []),
{"role": "user", "content": user}]
def stream(self, user: str, history: list[dict] | None = None) -> Iterator[str]:
if MOCK:
for tok in _mock_line(self.name, user).split(" "):
yield tok + " "
return
client, model = get_client(self.role)
# Open the stream with cold-start retries; once streaming we don't restart
# mid-sentence — a stream that breaks just ends the (already-narrated) beat.
last_err: Exception | None = None
for attempt in range(self.retries):
try:
s = client.chat.completions.create(
model=model, messages=self._messages(user, history),
temperature=self.cfg.temperature, max_tokens=self.cfg.max_tokens,
top_p=self.cfg.top_p, stream=True, extra_body=EXTRA_BODY,
)
for chunk in s:
delta = chunk.choices[0].delta.content
if delta:
yield delta
return
except Exception as e: # transient: cold endpoint, timeout, blip
last_err = e
if attempt < self.retries - 1:
time.sleep(self.retry_wait)
# Degraded but never crashing: keep the dream moving with a soft beat.
yield "The dream wavers for a moment, then steadies."
_ = last_err
def say(self, user: str, history: list[dict] | None = None) -> str:
return "".join(self.stream(user, history))
def json(self, user: str, history: list[dict] | None = None) -> dict:
"""Non-streaming structured call; tolerant of small-model JSON wobble.
Returns {} on any failure (cold endpoint, timeout, unparseable) — every
caller already treats {} as "no update / use defaults", so the turn
survives a wobbly small model rather than crashing.
"""
if MOCK:
return loose_json(_mock_line(self.name, user)) or {}
client, model = get_client(self.role)
for attempt in range(self.retries):
try:
r = client.chat.completions.create(
model=model, messages=self._messages(user, history),
temperature=min(self.cfg.temperature, 0.4), max_tokens=self.cfg.max_tokens,
extra_body=EXTRA_BODY,
)
msg = r.choices[0].message
# Reasoning models (e.g. MiniCPM5) split thinking into
# reasoning_content; the JSON we want is in content. Fall back to
# reasoning_content only if content is empty.
text = msg.content or getattr(msg, "reasoning_content", "") or ""
return loose_json(text)
except Exception:
if attempt < self.retries - 1:
time.sleep(self.retry_wait)
return {}