Spaces:
Running
Running
File size: 7,095 Bytes
125f9c0 f66ff49 125f9c0 f66ff49 125f9c0 ee730c2 125f9c0 ee730c2 125f9c0 ee730c2 125f9c0 ee730c2 125f9c0 ee730c2 125f9c0 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 | """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 {}
|