Spaces:
Running on Zero
Running on Zero
agharsallah
feat(commentary): refine rafters-critic persona and improve commentary prompt for humor
26bc5b9 | """The Commentator β a universal "color commentary" observer. | |
| It is scenario-agnostic by design: it summarises only the public ledger, so it drops | |
| into *any* cast (a debate, a mystery, a guessing game, a living scene) with no engine | |
| edits and no per-scenario flavour. Most agents are pure declarative config; the | |
| commentator needs a handler for two things the generic turn cannot express: | |
| 1. **Cadence, measured in rounds.** It holds its tongue until a configurable number | |
| of speaking *rounds* have passed since its last remark β where one round is | |
| approximated as "every known speaker has spoken once" (one beat per distinct cast | |
| speaker it has seen). The knob is ``commentary.rounds`` in the manifest (default 1), | |
| overridable at runtime via ``MAL_COMMENTATOR_ROUNDS``; the legacy | |
| ``MAL_COMMENTATOR_EVERY`` still pins an *absolute* beat count when set. It is polled | |
| every turn (``schedule.tick_every: 1``) and ABSTAINS (returns ``None``) until the | |
| threshold accrues, then delivers exactly one beat. The threshold is a *count* of | |
| beats, not a per-speaker quorum, so a stalled or errored speaker can never wedge the | |
| cadence (the illustrated/spoken media beat always eventually fires). | |
| 2. **Media.** When it does speak it draws an image of the beat and says the line | |
| aloud, folding both onto its event β the :class:`FortuneTeller` tool pattern, | |
| for the ``image.render`` / ``tts.speak`` capabilities. Media is a garnish: a | |
| missing tool (before media is wired) or a failed call degrades the beat to text, | |
| never breaking the turn. | |
| It never calls a peer and never reads another mind β it summarises only the public | |
| ledger, exactly like every other agent. Drop ``rafters-critic`` into a scenario's | |
| ``cast`` to switch it on; remove it and the engine never knows it existed (ADR-0011). | |
| """ | |
| from __future__ import annotations | |
| import os | |
| from src import observability as obs | |
| from src.agents.base import ManifestAgent | |
| from src.core.events import Event | |
| from src.core.projections import StageProjection | |
| from src.core.registry import register_handler | |
| # Public, ledger-visible "a cast member said something" kinds β mirrors | |
| # ``base._SPEECH_KINDS``. The commentator's own ``commentary.posted`` is deliberately | |
| # absent, so a remark never counts toward the next quorum (self-trigger guard #2; guard | |
| # #1 is ``subscribes_to: []`` in the manifest, so it is never event-woken at all). | |
| _SPEECH_KINDS = frozenset({"agent.spoke", "agent.thought", "oracle.spoke", "world.observed"}) | |
| _COMMENTARY_KIND = "commentary.posted" | |
| _DEFAULT_ROUNDS = 1 | |
| def _env_int(name: str) -> int | None: | |
| """A floored-at-1 positive int from env var *name*, or None if unset/garbage.""" | |
| raw = os.getenv(name) | |
| if raw is None: | |
| return None | |
| try: | |
| return max(1, int(raw)) | |
| except ValueError: | |
| return None | |
| class Commentator(ManifestAgent): | |
| """Universal color commentary on a round-paced beat counter, with an illustrated, spoken beat.""" | |
| # ββ cadence βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _rounds(self) -> int: | |
| """How many speaking rounds must pass before the next remark (default 1). | |
| Manifest ``commentary.rounds`` is the declared default; ``MAL_COMMENTATOR_ROUNDS`` | |
| overrides it at runtime (the user-facing knob). Floored at 1 so a bad value can't | |
| wedge the cadence.""" | |
| env = _env_int("MAL_COMMENTATOR_ROUNDS") | |
| if env is not None: | |
| return env | |
| cfg = self.manifest.commentary | |
| return max(1, cfg.rounds) if cfg else _DEFAULT_ROUNDS | |
| def _round_size(self, events: tuple[Event, ...]) -> int: | |
| """Distinct cast speakers (never self) seen so far β one round's worth of beats. | |
| Self-calibrating: it counts only cast members who have actually spoken, so silent | |
| observers and the critic itself don't inflate the round, and a scenario with three | |
| speakers needs three beats per round where one with five needs five.""" | |
| cast = set(self.cast_names) | |
| speakers = {e.actor for e in events if e.kind in _SPEECH_KINDS and e.actor in cast and e.actor != self.name} | |
| return len(speakers) | |
| def _every(self, events: tuple[Event, ...]) -> int: | |
| """How many public speech beats must land before the next remark. | |
| Legacy ``MAL_COMMENTATOR_EVERY`` pins an *absolute* beat count when set (back-compat); | |
| otherwise it is ``rounds Γ round_size`` β "this many rounds of everyone-speaks-once". | |
| A plain count, not a per-speaker quorum: a stalled or errored speaker can never wedge | |
| the cadence (the old quorum required *every* speaker who ever spoke to keep speaking, | |
| so one silent agent blocked commentary forever β and starved the media beat with it). | |
| Floored at 1.""" | |
| absolute = _env_int("MAL_COMMENTATOR_EVERY") | |
| if absolute is not None: | |
| return absolute | |
| return max(1, self._rounds() * self._round_size(events)) | |
| def _window_since_last(self, events: tuple[Event, ...]) -> tuple[Event, ...]: | |
| """Events after this agent's most recent remark β its counter resets each beat.""" | |
| last = -1 | |
| for i, event in enumerate(events): | |
| if event.kind == _COMMENTARY_KIND and event.actor == self.name: | |
| last = i | |
| return events[last + 1 :] | |
| def _beats_since_last(self, events: tuple[Event, ...]) -> int: | |
| """Count cast speech beats (never self) since this critic's last remark.""" | |
| cast = set(self.cast_names) | |
| return sum( | |
| 1 | |
| for e in self._window_since_last(events) | |
| if e.kind in _SPEECH_KINDS and e.actor in cast and e.actor != self.name | |
| ) | |
| def _ready(self, events: tuple[Event, ...]) -> bool: | |
| """True once enough fresh speech has landed since the last beat to chime in.""" | |
| return self._beats_since_last(events) >= self._every(events) | |
| # ββ prompt steering βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _build_extra_prompt(self, projection: StageProjection, recent_events: tuple[Event, ...]) -> str: | |
| """Steer the model toward a genuinely funny one-line heckle of the beat. | |
| Small models can't be funny on the word "funny" alone β they default to | |
| cheerful narration. So we hand them a comedian's recipe: latch onto one | |
| concrete detail, then break it with a twist (absurd comparison, deadpan | |
| undercut, or mock-serious overreaction). Specific + surprising = the laugh.""" | |
| return ( | |
| "YOUR JOB\n" | |
| "Heckle the beat above with ONE short, funny line β the kind that gets a laugh, " | |
| "not a polite nod. Work the bit like this:\n" | |
| "- Grab ONE specific thing the cast just did β a prop, a word, a choice β and make " | |
| "THAT the target. Never a vague 'well, that happened'.\n" | |
| "- Then break it: an absurd comparison, a deadpan undercut, or a mock-serious " | |
| "overreaction. The twist is where the laugh lives β surprise beats cleverness.\n" | |
| "- Punch up at the drama, never down at a person. Affectionate, never cruel.\n" | |
| "- ONE sentence. No narration, no stage directions, no quotation marks, no lists, " | |
| "no emoji, no setup-then-punchline. Just the line, like you shouted it from the rafters." | |
| ) | |
| # ββ turn ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def act( | |
| self, | |
| run_id: str, | |
| turn: int, | |
| projection: StageProjection, | |
| recent_events: tuple[Event, ...], | |
| ) -> Event | None: | |
| # Hold until enough fresh speech beats have landed since the last remark. | |
| if not self._ready(recent_events): | |
| return None | |
| # The generic turn writes the funny line (offline β the curated stub keyed on | |
| # this agent's name); kind is constrained to ``commentary.posted`` by may_emit. | |
| event = super().act(run_id, turn, projection, recent_events) | |
| summary = str(event.payload.get("text", "")).strip() | |
| if not summary: | |
| return event | |
| # Draw + voice the beat. Best-effort: a missing/failed tool leaves the beat as | |
| # text, exactly like a media-less offline run. The slug keys the file under the | |
| # run so the hybrid transport can serve it (or inline a data: URI offline). | |
| slug = f"{turn:03d}-{event.id[:8]}" | |
| image = self._media_ref("image.render", prompt=summary, run_id=run_id, slug=f"{slug}-img") | |
| if image: | |
| event.payload["image"] = {"src": image["src"], "alt": summary[:120]} | |
| audio = self._media_ref("tts.speak", text=summary, run_id=run_id, slug=f"{slug}-tts") | |
| if audio: | |
| event.payload["audio"] = {"src": audio["src"], "mime": audio.get("mime", "")} | |
| return event | |
| def _media_ref(self, tool: str, **params) -> dict | None: | |
| """Best-effort media via a capability-checked tool; ``None`` on absence or failure. | |
| Returns the tool's ref dict (``{"src", "mime", ...}``) only when it carries a | |
| usable ``src``. A tool that isn't registered (before media is wired) or a failed | |
| generation degrades the beat to text β it must never drop the turn.""" | |
| if self.tools is None or tool not in self.manifest.tools or not self.tools.has(tool): | |
| return None | |
| try: | |
| result = self.call_tool(tool, **params) | |
| except Exception as exc: # noqa: BLE001 β media is garnish; a failure must not drop the beat | |
| obs.log("commentator.media_skip", level="warning", agent=self.name, tool=tool, error=str(exc)) | |
| return None | |
| return result if (result or {}).get("src") else None | |