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 {}