agharsallah commited on
Commit Β·
11f4e9a
1
Parent(s): 8400d8c
feat(commentator): update cadence logic to use a fixed count instead of per-speaker quorum
Browse files- config/agents/rafters-critic.yaml +5 -4
- src/agents/base.py +21 -4
- src/agents/commentator.py +33 -34
- tests/test_commentator.py +25 -9
config/agents/rafters-critic.yaml
CHANGED
|
@@ -10,10 +10,11 @@ persona: >
|
|
| 10 |
to what just happened, and nothing else.
|
| 11 |
# Never event-woken (subscribes_to: []) so it can never re-trigger on its own remark;
|
| 12 |
# instead it is POLLED every turn (schedule.tick_every: 1) and the `commentator` handler
|
| 13 |
-
# decides whether to speak β it holds its tongue until
|
| 14 |
-
#
|
| 15 |
-
# then drops one funny, illustrated, spoken-aloud beat.
|
| 16 |
-
#
|
|
|
|
| 17 |
subscribes_to: []
|
| 18 |
may_emit:
|
| 19 |
- commentary.posted
|
|
|
|
| 10 |
to what just happened, and nothing else.
|
| 11 |
# Never event-woken (subscribes_to: []) so it can never re-trigger on its own remark;
|
| 12 |
# instead it is POLLED every turn (schedule.tick_every: 1) and the `commentator` handler
|
| 13 |
+
# decides whether to speak β it holds its tongue until a few public speech beats have
|
| 14 |
+
# landed since its last remark (a simple count, default 4, tunable via MAL_COMMENTATOR_EVERY),
|
| 15 |
+
# then drops one funny, illustrated, spoken-aloud beat. A plain count (not a per-speaker
|
| 16 |
+
# quorum) means a stalled speaker can never wedge it, so the media beat always fires.
|
| 17 |
+
# Remove this name from a scenario's cast and the engine never knows it existed (ADR-0011).
|
| 18 |
subscribes_to: []
|
| 19 |
may_emit:
|
| 20 |
- commentary.posted
|
src/agents/base.py
CHANGED
|
@@ -51,6 +51,17 @@ _ctx = ContextBuilder()
|
|
| 51 |
# domain `may_emit` grant β reflection compacts memory, it is not a world action.
|
| 52 |
_REFLECTION_KIND = "agent.reflected"
|
| 53 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
# Live fallback when structured output fails: ask for a plain spoken line, NOT JSON.
|
| 55 |
# Weak/reasoning models echo a JSON schema (and its example) and leak their reasoning;
|
| 56 |
# asking for prose gives a clean line we can strip and ship.
|
|
@@ -170,8 +181,11 @@ class ManifestAgent(Agent):
|
|
| 170 |
|
| 171 |
parsed = self._resolve_payload(self.manifest.name, base_prompt, allowed, extra_fields)
|
| 172 |
|
| 173 |
-
# Don't echo the table: if this line near-duplicates a recent spoken one,
|
| 174 |
-
#
|
|
|
|
|
|
|
|
|
|
| 175 |
# Live only β the offline stub's curated catalogue is reproducible by design, and
|
| 176 |
# de-duplicating its small set of lines would starve demos and tests of events.
|
| 177 |
if (
|
|
@@ -179,8 +193,11 @@ class ManifestAgent(Agent):
|
|
| 179 |
and parsed.get("kind") in _SPEECH_KINDS
|
| 180 |
and self._is_repeat(parsed.get("text", ""), recent_events)
|
| 181 |
):
|
| 182 |
-
obs.log("agent.
|
| 183 |
-
|
|
|
|
|
|
|
|
|
|
| 184 |
|
| 185 |
obs.log("agent.acted", agent=self.manifest.name, kind=parsed["kind"], text=str(parsed.get("text", ""))[:160])
|
| 186 |
return Event(
|
|
|
|
| 51 |
# domain `may_emit` grant β reflection compacts memory, it is not a world action.
|
| 52 |
_REFLECTION_KIND = "agent.reflected"
|
| 53 |
|
| 54 |
+
# Anti-loop corrective re-ask: when the whole cast shares one backend, every agent
|
| 55 |
+
# samples the same model and they drift toward the same line. Rather than silently
|
| 56 |
+
# DROP a near-duplicate turn (which sidelines that agent from the round β one model
|
| 57 |
+
# repeating made it look like a single agent monopolised the show), we nudge once for
|
| 58 |
+
# something new and only skip if it still repeats. Keeps the round collaborative.
|
| 59 |
+
_ANTI_REPEAT_NUDGE = (
|
| 60 |
+
"\n\nIMPORTANT: another voice just said something almost identical to your draft. "
|
| 61 |
+
"Say something DIFFERENT this turn β a fresh image, a new beat, your own angle. "
|
| 62 |
+
"Do not echo or paraphrase a line that was already spoken."
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
# Live fallback when structured output fails: ask for a plain spoken line, NOT JSON.
|
| 66 |
# Weak/reasoning models echo a JSON schema (and its example) and leak their reasoning;
|
| 67 |
# asking for prose gives a clean line we can strip and ship.
|
|
|
|
| 181 |
|
| 182 |
parsed = self._resolve_payload(self.manifest.name, base_prompt, allowed, extra_fields)
|
| 183 |
|
| 184 |
+
# Don't echo the table: if this line near-duplicates a recent spoken one, give the
|
| 185 |
+
# agent ONE corrective re-ask for something new before skipping. A shared backend
|
| 186 |
+
# makes the whole cast drift toward the same line, so dropping every duplicate
|
| 187 |
+
# silently sidelines agents from the round; the nudge keeps each one talking. Only
|
| 188 |
+
# if the re-ask still repeats do we skip the turn so the conversation advances.
|
| 189 |
# Live only β the offline stub's curated catalogue is reproducible by design, and
|
| 190 |
# de-duplicating its small set of lines would starve demos and tests of events.
|
| 191 |
if (
|
|
|
|
| 193 |
and parsed.get("kind") in _SPEECH_KINDS
|
| 194 |
and self._is_repeat(parsed.get("text", ""), recent_events)
|
| 195 |
):
|
| 196 |
+
obs.log("agent.repeat_retry", agent=self.manifest.name, text=str(parsed.get("text", ""))[:120])
|
| 197 |
+
parsed = self._resolve_payload(self.manifest.name, base_prompt + _ANTI_REPEAT_NUDGE, allowed, extra_fields)
|
| 198 |
+
if parsed.get("kind") in _SPEECH_KINDS and self._is_repeat(parsed.get("text", ""), recent_events):
|
| 199 |
+
obs.log("agent.repeat_skip", agent=self.manifest.name, text=str(parsed.get("text", ""))[:120])
|
| 200 |
+
raise AgentOutputError(f"{self.manifest.name}: repeated a recent line β skipped to keep it moving")
|
| 201 |
|
| 202 |
obs.log("agent.acted", agent=self.manifest.name, kind=parsed["kind"], text=str(parsed.get("text", ""))[:160])
|
| 203 |
return Event(
|
src/agents/commentator.py
CHANGED
|
@@ -3,10 +3,12 @@
|
|
| 3 |
Most agents are pure declarative config; the commentator needs a handler for two
|
| 4 |
things the generic turn cannot express:
|
| 5 |
|
| 6 |
-
1. **Cadence.** It holds its tongue until
|
| 7 |
-
|
| 8 |
-
It is polled every turn (``schedule.tick_every: 1``)
|
| 9 |
-
``None``) until
|
|
|
|
|
|
|
| 10 |
|
| 11 |
2. **Media.** When it does speak it draws an image of the beat and says the line
|
| 12 |
aloud, folding both onto its event β the :class:`FortuneTeller` tool pattern,
|
|
@@ -36,53 +38,50 @@ from src.core.registry import register_handler
|
|
| 36 |
_SPEECH_KINDS = frozenset({"agent.spoke", "agent.thought", "oracle.spoke", "world.observed"})
|
| 37 |
|
| 38 |
_COMMENTARY_KIND = "commentary.posted"
|
| 39 |
-
|
| 40 |
|
| 41 |
|
| 42 |
@register_handler("commentator")
|
| 43 |
class Commentator(ManifestAgent):
|
| 44 |
-
"""Color commentary on a
|
| 45 |
|
| 46 |
# ββ cadence βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 47 |
|
| 48 |
-
def
|
| 49 |
-
"""How many
|
| 50 |
|
| 51 |
-
A
|
| 52 |
-
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
try:
|
| 55 |
-
return max(1, int(os.getenv("MAL_COMMENTATOR_EVERY", str(
|
| 56 |
except ValueError:
|
| 57 |
-
return
|
| 58 |
|
| 59 |
def _window_since_last(self, events: tuple[Event, ...]) -> tuple[Event, ...]:
|
| 60 |
-
"""Events after this agent's most recent remark β its
|
| 61 |
last = -1
|
| 62 |
for i, event in enumerate(events):
|
| 63 |
if event.kind == _COMMENTARY_KIND and event.actor == self.name:
|
| 64 |
last = i
|
| 65 |
return events[last + 1 :]
|
| 66 |
|
| 67 |
-
def
|
| 68 |
-
"""
|
| 69 |
-
|
| 70 |
-
Judges β whose verdicts are not speech kinds β and the commentator itself fall
|
| 71 |
-
out naturally, so the quorum tracks only the talking cast."""
|
| 72 |
cast = set(self.cast_names)
|
| 73 |
-
return
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
for event in self._window_since_last(events):
|
| 83 |
-
if event.kind in _SPEECH_KINDS and event.actor in counts:
|
| 84 |
-
counts[event.actor] += 1
|
| 85 |
-
return all(counts[name] >= need for name in speakers)
|
| 86 |
|
| 87 |
# ββ prompt steering βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 88 |
|
|
@@ -104,8 +103,8 @@ class Commentator(ManifestAgent):
|
|
| 104 |
projection: StageProjection,
|
| 105 |
recent_events: tuple[Event, ...],
|
| 106 |
) -> Event | None:
|
| 107 |
-
# Hold until
|
| 108 |
-
if not self.
|
| 109 |
return None
|
| 110 |
# The generic turn writes the funny line (offline β the curated stub keyed on
|
| 111 |
# this agent's name); kind is constrained to ``commentary.posted`` by may_emit.
|
|
|
|
| 3 |
Most agents are pure declarative config; the commentator needs a handler for two
|
| 4 |
things the generic turn cannot express:
|
| 5 |
|
| 6 |
+
1. **Cadence.** It holds its tongue until a few public speech beats have landed
|
| 7 |
+
since its last remark β a simple count (``MAL_COMMENTATOR_EVERY``, default 4),
|
| 8 |
+
not a per-speaker quorum. It is polled every turn (``schedule.tick_every: 1``)
|
| 9 |
+
and ABSTAINS (returns ``None``) until that many beats accrue, then delivers
|
| 10 |
+
exactly one beat. A plain count means a stalled or errored speaker can never
|
| 11 |
+
wedge the cadence (so the illustrated/spoken media beat always eventually fires).
|
| 12 |
|
| 13 |
2. **Media.** When it does speak it draws an image of the beat and says the line
|
| 14 |
aloud, folding both onto its event β the :class:`FortuneTeller` tool pattern,
|
|
|
|
| 38 |
_SPEECH_KINDS = frozenset({"agent.spoke", "agent.thought", "oracle.spoke", "world.observed"})
|
| 39 |
|
| 40 |
_COMMENTARY_KIND = "commentary.posted"
|
| 41 |
+
_DEFAULT_EVERY = 1
|
| 42 |
|
| 43 |
|
| 44 |
@register_handler("commentator")
|
| 45 |
class Commentator(ManifestAgent):
|
| 46 |
+
"""Color commentary on a fixed-cadence beat counter, with an illustrated, spoken beat."""
|
| 47 |
|
| 48 |
# ββ cadence βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 49 |
|
| 50 |
+
def _every(self) -> int:
|
| 51 |
+
"""How many public speech beats must land before the next remark (default 4).
|
| 52 |
|
| 53 |
+
A simple count, not a per-speaker quorum: a stalled or errored speaker can never
|
| 54 |
+
wedge the cadence (the old quorum required *every* speaker who ever spoke to keep
|
| 55 |
+
speaking, so one silent agent blocked commentary forever β and starved the media
|
| 56 |
+
beat with it). A handler constant with a ``MAL_COMMENTATOR_EVERY`` env override β
|
| 57 |
+
the manifest is ``extra='forbid'`` so this cannot be a YAML field yet (a typed
|
| 58 |
+
CommentaryConfig sub-schema is the clean follow-up). Floored at 1 so a bad value
|
| 59 |
+
can't wedge it."""
|
| 60 |
try:
|
| 61 |
+
return max(1, int(os.getenv("MAL_COMMENTATOR_EVERY", str(_DEFAULT_EVERY))))
|
| 62 |
except ValueError:
|
| 63 |
+
return _DEFAULT_EVERY
|
| 64 |
|
| 65 |
def _window_since_last(self, events: tuple[Event, ...]) -> tuple[Event, ...]:
|
| 66 |
+
"""Events after this agent's most recent remark β its counter resets each beat."""
|
| 67 |
last = -1
|
| 68 |
for i, event in enumerate(events):
|
| 69 |
if event.kind == _COMMENTARY_KIND and event.actor == self.name:
|
| 70 |
last = i
|
| 71 |
return events[last + 1 :]
|
| 72 |
|
| 73 |
+
def _beats_since_last(self, events: tuple[Event, ...]) -> int:
|
| 74 |
+
"""Count cast speech beats (never self) since this critic's last remark."""
|
|
|
|
|
|
|
|
|
|
| 75 |
cast = set(self.cast_names)
|
| 76 |
+
return sum(
|
| 77 |
+
1
|
| 78 |
+
for e in self._window_since_last(events)
|
| 79 |
+
if e.kind in _SPEECH_KINDS and e.actor in cast and e.actor != self.name
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
def _ready(self, events: tuple[Event, ...]) -> bool:
|
| 83 |
+
"""True once enough fresh speech has landed since the last beat to chime in."""
|
| 84 |
+
return self._beats_since_last(events) >= self._every()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
|
| 86 |
# ββ prompt steering βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 87 |
|
|
|
|
| 103 |
projection: StageProjection,
|
| 104 |
recent_events: tuple[Event, ...],
|
| 105 |
) -> Event | None:
|
| 106 |
+
# Hold until enough fresh speech beats have landed since the last remark.
|
| 107 |
+
if not self._ready(recent_events):
|
| 108 |
return None
|
| 109 |
# The generic turn writes the funny line (offline β the curated stub keyed on
|
| 110 |
# this agent's name); kind is constrained to ``commentary.posted`` by may_emit.
|
tests/test_commentator.py
CHANGED
|
@@ -37,19 +37,20 @@ def _critic(cast_names: list[str]):
|
|
| 37 |
|
| 38 |
|
| 39 |
class TestCadence:
|
| 40 |
-
def
|
| 41 |
-
monkeypatch.setenv("MAL_COMMENTATOR_EVERY", "
|
| 42 |
critic = _critic(["scene-whisperer", "pocket-actor", "rafters-critic"])
|
| 43 |
-
#
|
| 44 |
events = (
|
| 45 |
_ev("world.observed", "scene-whisperer", text="the wood hums"),
|
| 46 |
_ev("agent.spoke", "pocket-actor", text="I want the moon"),
|
| 47 |
)
|
| 48 |
assert critic.act("r", 1, _projection(), events) is None
|
| 49 |
|
| 50 |
-
def
|
| 51 |
-
monkeypatch.setenv("MAL_COMMENTATOR_EVERY", "
|
| 52 |
critic = _critic(["scene-whisperer", "pocket-actor", "rafters-critic"])
|
|
|
|
| 53 |
events = (
|
| 54 |
_ev("world.observed", "scene-whisperer", text="a"),
|
| 55 |
_ev("agent.spoke", "pocket-actor", text="b"),
|
|
@@ -61,7 +62,22 @@ class TestCadence:
|
|
| 61 |
assert event.kind == "commentary.posted"
|
| 62 |
assert event.payload.get("text")
|
| 63 |
|
| 64 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
monkeypatch.setenv("MAL_COMMENTATOR_EVERY", "1")
|
| 66 |
critic = _critic(["scene-whisperer", "pocket-actor", "rafters-critic"])
|
| 67 |
# Only the critic's own / non-speech events exist β nobody to comment on.
|
|
@@ -69,8 +85,8 @@ class TestCadence:
|
|
| 69 |
assert critic.act("r", 1, _projection(), events) is None
|
| 70 |
|
| 71 |
def test_window_resets_after_a_remark(self, monkeypatch):
|
| 72 |
-
"""The self-trigger guard: a posted beat resets the
|
| 73 |
-
monkeypatch.setenv("MAL_COMMENTATOR_EVERY", "
|
| 74 |
critic = _critic(["scene-whisperer", "pocket-actor", "rafters-critic"])
|
| 75 |
events = (
|
| 76 |
_ev("world.observed", "scene-whisperer", text="a"),
|
|
@@ -101,7 +117,7 @@ class TestModularity:
|
|
| 101 |
scenario = reg.build_scenario("thousand-token-wood", tools=default_tool_registry())
|
| 102 |
conductor = Conductor(scenario, governor=reg.governor_for("thousand-token-wood"), ledger=Ledger())
|
| 103 |
conductor.reset("a village of stage props wakes up")
|
| 104 |
-
conductor.step(8) # default
|
| 105 |
kinds = [e.kind for e in conductor.ledger.events_for_run(conductor.run_id)]
|
| 106 |
assert "commentary.posted" in kinds
|
| 107 |
|
|
|
|
| 37 |
|
| 38 |
|
| 39 |
class TestCadence:
|
| 40 |
+
def test_abstains_below_count(self, monkeypatch):
|
| 41 |
+
monkeypatch.setenv("MAL_COMMENTATOR_EVERY", "4")
|
| 42 |
critic = _critic(["scene-whisperer", "pocket-actor", "rafters-critic"])
|
| 43 |
+
# Only 2 speech beats so far β the cadence (4) is not met yet.
|
| 44 |
events = (
|
| 45 |
_ev("world.observed", "scene-whisperer", text="the wood hums"),
|
| 46 |
_ev("agent.spoke", "pocket-actor", text="I want the moon"),
|
| 47 |
)
|
| 48 |
assert critic.act("r", 1, _projection(), events) is None
|
| 49 |
|
| 50 |
+
def test_emits_one_beat_at_count(self, monkeypatch):
|
| 51 |
+
monkeypatch.setenv("MAL_COMMENTATOR_EVERY", "4")
|
| 52 |
critic = _critic(["scene-whisperer", "pocket-actor", "rafters-critic"])
|
| 53 |
+
# 4 speech beats since the last remark β chime in exactly once.
|
| 54 |
events = (
|
| 55 |
_ev("world.observed", "scene-whisperer", text="a"),
|
| 56 |
_ev("agent.spoke", "pocket-actor", text="b"),
|
|
|
|
| 62 |
assert event.kind == "commentary.posted"
|
| 63 |
assert event.payload.get("text")
|
| 64 |
|
| 65 |
+
def test_one_silent_speaker_does_not_wedge_cadence(self, monkeypatch):
|
| 66 |
+
"""A stalled speaker can't block the beat β the old per-speaker quorum bug."""
|
| 67 |
+
monkeypatch.setenv("MAL_COMMENTATOR_EVERY", "4")
|
| 68 |
+
critic = _critic(["scene-whisperer", "pocket-actor", "rafters-critic"])
|
| 69 |
+
# pocket-actor spoke once then went silent (errored out); scene-whisperer carries
|
| 70 |
+
# the show. A count-based cadence still fires; a per-speaker quorum never would.
|
| 71 |
+
events = (
|
| 72 |
+
_ev("agent.spoke", "pocket-actor", text="I want the moon"),
|
| 73 |
+
_ev("world.observed", "scene-whisperer", text="a"),
|
| 74 |
+
_ev("world.observed", "scene-whisperer", text="b"),
|
| 75 |
+
_ev("world.observed", "scene-whisperer", text="c"),
|
| 76 |
+
)
|
| 77 |
+
event = critic.act("r", 5, _projection(), events)
|
| 78 |
+
assert event is not None and event.kind == "commentary.posted"
|
| 79 |
+
|
| 80 |
+
def test_no_speakers_means_silence(self, monkeypatch):
|
| 81 |
monkeypatch.setenv("MAL_COMMENTATOR_EVERY", "1")
|
| 82 |
critic = _critic(["scene-whisperer", "pocket-actor", "rafters-critic"])
|
| 83 |
# Only the critic's own / non-speech events exist β nobody to comment on.
|
|
|
|
| 85 |
assert critic.act("r", 1, _projection(), events) is None
|
| 86 |
|
| 87 |
def test_window_resets_after_a_remark(self, monkeypatch):
|
| 88 |
+
"""The self-trigger guard: a posted beat resets the cadence window."""
|
| 89 |
+
monkeypatch.setenv("MAL_COMMENTATOR_EVERY", "4")
|
| 90 |
critic = _critic(["scene-whisperer", "pocket-actor", "rafters-critic"])
|
| 91 |
events = (
|
| 92 |
_ev("world.observed", "scene-whisperer", text="a"),
|
|
|
|
| 117 |
scenario = reg.build_scenario("thousand-token-wood", tools=default_tool_registry())
|
| 118 |
conductor = Conductor(scenario, governor=reg.governor_for("thousand-token-wood"), ledger=Ledger())
|
| 119 |
conductor.reset("a village of stage props wakes up")
|
| 120 |
+
conductor.step(8) # default cadence (4 beats, ~2 turns) trips early in the run
|
| 121 |
kinds = [e.kind for e in conductor.ledger.events_for_run(conductor.run_id)]
|
| 122 |
assert "commentary.posted" in kinds
|
| 123 |
|