godseed / mind /planner.py
AndresCarreon's picture
ONE-SHOT plan generation for ZeroGPU: whole wish in a single @spaces.GPU call (ends the multi-call NVML crash, far faster) + deterministic town fallback so a wish never builds nothing
6ccf62b verified
Raw
History Blame Contribute Delete
15.6 kB
"""The Planner — GODSEED's plan-act-observe loop (Agent protocol, ARCHITECTURE.md).
Flow per wish:
1. Reading — free-text generation streamed token-by-token via
emit({"type": "thought_token", ...}), capped at <=80 tokens.
2. Turn loop — at most 7 grammar-constrained JSON turns. Each turn is
either a tool call (executed through `act`, observation appended to
context) or a done turn carrying the epitaph. Malformed output gets one
re-ask with the parse error in context; if that also fails the turn is
skipped and recorded. Two consecutive skipped turns abort the loop (a
dead backend should not burn 7 turns).
Division of labor for the WishTrace (load-bearing — see ARCHITECTURE.md):
- The planner returns ONLY the fields it can know:
reading, turns, epitaph, ms_total, model, backend
- The queue worker (engine/queue_worker.py, Agent A) merges the rest:
wish_id, text, submitted_at, moderation, feature_ids
Feature ids are assigned by the engine when `act` executes a call; `act`
returns just the observation string, so the worker — not the planner —
tracks which features each wish produced.
- Likewise, emitted events carry no wish_id here; the worker wraps `emit`
and injects it before broadcast.
Events emitted (in order):
thought_token* (the reading, chunk by chunk, then "\n\n")
[per call turn] thought_token*
[done turn] thought_token*
The planner emits ONLY thought_token. tool_call and world_delta are emitted by
the queue worker after the engine accepts a call — the worker's copy carries
the wish_id and the engine's canonical (clamped) args, so a second planner-side
emission would double the god's-gaze camera move in the renderer.
"""
from __future__ import annotations
import time
import zlib
from typing import Awaitable, Callable
from . import prompts
from .backends import make_backend
from .validate import TURN_GRAMMAR, parse_plan, parse_turn
Act = Callable[[dict], Awaitable[str]]
Emit = Callable[[dict], Awaitable[None]]
_FALLBACK_READING = "The god listens, says nothing for a long moment, and begins."
_FALLBACK_EPITAPHS = (
"The god has done what it set out to do. The rest is weather.",
"Enough. The world will finish the sentence on its own.",
"It is made, as far as making goes tonight.",
)
_READING_MAX_CHARS = 700
_TURN_RAW_MAX_CHARS = 4000
_OBSERVATION_MAX_CHARS = 500
_ONESHOT_RAW_MAX_CHARS = 6000
def _fallback_epitaph(wish: str) -> str:
index = zlib.crc32((wish or "").encode("utf-8")) % len(_FALLBACK_EPITAPHS)
return _FALLBACK_EPITAPHS[index]
def _epitaph_tail(reading: str) -> str:
"""The reading's last sentence — a varied epitaph when the model omits one."""
import re as _re
parts = [p.strip() for p in _re.split(r"(?<=[.!?])\s+", str(reading or "")) if p.strip()]
return parts[-1][:120] if parts else ""
def _thought_for(call: dict) -> str:
"""A short liturgical thought for a planned act (the one-shot model gives a
plan, not per-call thoughts, so the god still 'narrates' as each lands)."""
tool = (call or {}).get("tool", "")
a = (call or {}).get("args", {}) or {}
kind = a.get("kind")
phrases = {
"build_district": "A quarter of homes rises.",
"place_structure": f"A {kind} takes its place." if kind else "A landmark rises.",
"place_road": "A road to bind them.",
"place_water": "Water, to soften the stone.",
"spawn_flora": "Green, where there was none.",
"spawn_life": "And life begins to move through it.",
"raise_terrain": "First, ground to stand on.",
"lower_terrain": "The land sinks here.",
"set_sky": "The sky turns.",
"set_weather": "The weather answers.",
"inscribe_wish": "Words for those who come after.",
}
return phrases.get(tool, "It is made.")
def _fallback_town_plan(world_summary: str) -> list[dict]:
"""A deterministic small town near the world's center — the safety net so a
wish NEVER builds nothing, even if the model's JSON was unusable."""
import re as _re
lat, lon = 14.0, 38.0 # genesis monolith / default town seat
m = _re.search(r"near \((-?\d+(?:\.\d+)?),\s*(-?\d+(?:\.\d+)?)\)", str(world_summary or ""))
if m:
lat, lon = float(m.group(1)), float(m.group(2))
return [
{"tool": "build_district", "args": {"lat": lat, "lon": lon, "radius_deg": 7, "density": 0.8, "hue": 40}},
{"tool": "place_structure", "args": {"lat": lat, "lon": lon + 1, "kind": "market", "scale": 1.2, "hue": 45}},
{"tool": "place_structure", "args": {"lat": lat + 1, "lon": lon, "kind": "bank", "scale": 1.1, "hue": 50}},
{"tool": "place_road", "args": {"path": [[lat, lon], [lat, lon + 1], [lat + 1, lon]]}},
{"tool": "spawn_life", "args": {"lat": lat, "lon": lon, "radius_deg": 6, "kind": "carts", "count": 5, "hue": 40}},
]
class Planner:
"""Grants one wish at a time against an injected backend.
The planner owns prompting, parsing, and pacing. It never touches world
state: every effect goes through `act`, every visible word through `emit`.
"""
MAX_TURNS = 7
READING_MAX_TOKENS = 80
TURN_MAX_TOKENS = 220
ONESHOT_MAX_TOKENS = 700 # reading + a full plan JSON + epitaph, one call
MAX_CONSECUTIVE_SKIPS = 2
def __init__(self, backend=None, *, max_turns: int | None = None):
self.backend = backend if backend is not None else make_backend()
self.max_turns = max_turns or self.MAX_TURNS
# ------------------------------------------------------------------ emit
@staticmethod
async def _safe_emit(emit: Emit, event: dict) -> None:
"""Broadcast failures must never kill a grant."""
try:
await emit(event)
except Exception:
pass
async def _emit_text(self, emit: Emit, text: str, *, end: str = "\n") -> None:
"""Stream already-known text (turn thoughts) as small chunks."""
words = text.split()
step = 3
for i in range(0, len(words), step):
chunk = " ".join(words[i : i + step])
if i + step < len(words):
chunk += " "
await self._safe_emit(emit, {"type": "thought_token", "text": chunk})
if end:
await self._safe_emit(emit, {"type": "thought_token", "text": end})
# --------------------------------------------------------------- reading
async def _reading(self, wish: str, world_summary: str, emit: Emit) -> str:
prompt = prompts.build_reading_prompt(wish, world_summary)
parts: list[str] = []
total_chars = 0
token_count = 0
stream = self.backend.generate_stream(
prompt, None, self.READING_MAX_TOKENS
)
try:
async for chunk in stream:
if not chunk:
continue
parts.append(chunk)
total_chars += len(chunk)
token_count += max(1, len(chunk.split()))
await self._safe_emit(emit, {"type": "thought_token", "text": chunk})
if token_count >= self.READING_MAX_TOKENS or total_chars >= _READING_MAX_CHARS:
break
except Exception:
pass # fall through to the fallback reading
finally:
try:
await stream.aclose()
except Exception:
pass
reading = "".join(parts).strip()
if not reading:
reading = _FALLBACK_READING
# The backend gave us nothing; keep the stream alive for watchers.
await self._emit_text(emit, reading, end="")
await self._safe_emit(emit, {"type": "thought_token", "text": "\n\n"})
return reading
# ----------------------------------------------------------------- turns
async def _collect(self, prompt: str, max_tokens: int) -> str:
parts: list[str] = []
total = 0
stream = self.backend.generate_stream(prompt, TURN_GRAMMAR, max_tokens)
try:
async for chunk in stream:
if not chunk:
continue
parts.append(chunk)
total += len(chunk)
if total >= _TURN_RAW_MAX_CHARS:
break
finally:
try:
await stream.aclose()
except Exception:
pass
return "".join(parts)
async def _attempt_turn(self, prompt: str) -> tuple[dict | None, str | None]:
try:
raw = await self._collect(prompt, self.TURN_MAX_TOKENS)
except Exception as exc:
return None, f"backend failure: {type(exc).__name__}"
return parse_turn(raw)
async def _next_turn(
self, wish: str, world_summary: str, reading: str, turns: list[dict]
) -> tuple[dict | None, str | None]:
"""One turn with the single re-ask on malformed output."""
prompt = prompts.build_turn_prompt(
wish, world_summary, reading, turns, self.max_turns
)
obj, err = await self._attempt_turn(prompt)
if err is None:
return obj, None
reask = prompts.build_turn_prompt(
wish, world_summary, reading, turns, self.max_turns, error=err
)
return await self._attempt_turn(reask)
# ------------------------------------------------------------- one-shot
async def _grant_oneshot(
self, wish: str, world_summary: str, act: Act, emit: Emit, started: float
) -> dict:
"""Whole wish in ONE generation: reading + full plan + epitaph. Then the
engine executes the calls (CPU). One GPU call total — ZeroGPU-safe."""
prompt = prompts.build_oneshot_prompt(wish, world_summary)
try:
raw = await self._collect_free(prompt, self.ONESHOT_MAX_TOKENS)
except Exception:
raw = "" # generation failed; the fallback town plan still builds
reading, calls, epitaph = parse_plan(raw)
reading = reading.strip() or _FALLBACK_READING
# Stream the reading so the god still "speaks aloud".
await self._emit_text(emit, reading, end="\n\n")
# Safety net: a wish must never build nothing. If the model gave no
# usable calls, found a small town near the world's center so the
# wisher always sees something rise.
if not calls:
calls = _fallback_town_plan(world_summary)
turns: list[dict] = []
for call in calls[: self.max_turns]:
thought = _thought_for(call)
if thought:
await self._emit_text(emit, thought)
try:
observation = await act(call)
except Exception as exc:
observation = f"error: act failed ({type(exc).__name__})"
turns.append({
"thought": thought,
"call": call,
"observation": str(observation or "")[:_OBSERVATION_MAX_CHARS],
})
epitaph = epitaph.strip() or _epitaph_tail(reading) or _fallback_epitaph(wish)
return {
"reading": reading,
"turns": turns,
"epitaph": epitaph,
"ms_total": int((time.perf_counter() - started) * 1000),
"model": str(getattr(self.backend, "model_id", "unknown")),
"backend": str(getattr(self.backend, "name", "unknown")),
}
async def _collect_free(self, prompt: str, max_tokens: int) -> str:
"""One free-text generation (no grammar), fully collected."""
parts: list[str] = []
total = 0
stream = self.backend.generate_stream(prompt, None, max_tokens)
try:
async for chunk in stream:
if not chunk:
continue
parts.append(chunk)
total += len(chunk)
if total >= _ONESHOT_RAW_MAX_CHARS:
break
finally:
try:
await stream.aclose()
except Exception:
pass
return "".join(parts)
# ----------------------------------------------------------------- grant
async def grant(
self, wish: str, world_summary: str, act: Act, emit: Emit
) -> dict:
"""Grant one wish. Returns the planner's share of the WishTrace.
Returned dict (worker merges wish_id/text/submitted_at/moderation/
feature_ids — see module docstring):
{"reading": str,
"turns": [{"thought": str, "call": dict | None,
"observation": str | None}, ...],
"epitaph": str, "ms_total": int, "model": str, "backend": str}
Turn records: executed calls carry their observation string; the
final done turn has call=None and observation=None; a skipped
(malformed) turn has call=None and an observation starting "error:".
This method never raises for backend/act misbehavior — it always
returns a complete trace.
"""
started = time.perf_counter()
wish = str(wish or "")
world_summary = str(world_summary or "")
# ZeroGPU: a wish must be ONE @spaces.GPU call — the per-turn loop makes
# ~8 and ZeroGPU detaches the GPU between them (NVML crash). One-shot
# backends generate reading + full plan in a single call.
if getattr(self.backend, "oneshot", False):
return await self._grant_oneshot(wish, world_summary, act, emit, started)
reading = await self._reading(wish, world_summary, emit)
turns: list[dict] = []
epitaph: str | None = None
calls_executed = 0
consecutive_skips = 0
for _ in range(self.max_turns):
obj, err = await self._next_turn(wish, world_summary, reading, turns)
if obj is None:
turns.append({
"thought": "",
"call": None,
"observation": f"error: {err or 'malformed output'}; turn skipped",
})
consecutive_skips += 1
if consecutive_skips >= self.MAX_CONSECUTIVE_SKIPS:
break
continue
consecutive_skips = 0
await self._emit_text(emit, obj["thought"])
if obj.get("done"):
epitaph = obj["epitaph"].strip() or _fallback_epitaph(wish)
turns.append({
"thought": obj["thought"],
"call": None,
"observation": None,
})
break
call = obj["call"]
try:
observation = await act(call)
except Exception as exc:
observation = f"error: act failed ({type(exc).__name__})"
observation = str(observation or "")[:_OBSERVATION_MAX_CHARS]
calls_executed += 1
turns.append({
"thought": obj["thought"],
"call": call,
"observation": observation,
})
if epitaph is None:
epitaph = _fallback_epitaph(wish)
return {
"reading": reading,
"turns": turns,
"epitaph": epitaph,
"ms_total": int((time.perf_counter() - started) * 1000),
"model": str(getattr(self.backend, "model_id", "unknown")),
"backend": str(getattr(self.backend, "name", "unknown")),
}