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 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 each active cast member has spoken
14
- # a few times since its last remark (a per-speaker quorum, tunable via MAL_COMMENTATOR_EVERY),
15
- # then drops one funny, illustrated, spoken-aloud beat. Remove this name from a scenario's
16
- # cast and the engine never knows it existed (ADR-0011).
 
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, skip
174
- # the turn (the conductor records it and moves on) so the conversation advances.
 
 
 
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.repeat_skip", agent=self.manifest.name, text=str(parsed.get("text", ""))[:120])
183
- raise AgentOutputError(f"{self.manifest.name}: repeated a recent line β€” skipped to keep it moving")
 
 
 
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 *each* active cast member has spoken a
7
- few times since its last remark β€” a per-speaker quorum, not a fixed turn count.
8
- It is polled every turn (``schedule.tick_every: 1``) and ABSTAINS (returns
9
- ``None``) until the quorum is met, then delivers exactly one beat.
 
 
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
- _DEFAULT_PER_AGENT = 3
40
 
41
 
42
  @register_handler("commentator")
43
  class Commentator(ManifestAgent):
44
- """Color commentary on a quorum cadence, with an illustrated, spoken beat."""
45
 
46
  # ── cadence ───────────────────────────────────────────────────────────────
47
 
48
- def _per_agent(self) -> int:
49
- """How many times each active speaker must speak before the next remark.
50
 
51
- A handler constant with a ``MAL_COMMENTATOR_EVERY`` env override β€” the manifest
52
- is ``extra='forbid'`` so this cannot be a YAML field yet (a typed CommentaryConfig
53
- sub-schema is the clean follow-up). Floored at 1 so a bad value can't wedge it."""
 
 
 
 
54
  try:
55
- return max(1, int(os.getenv("MAL_COMMENTATOR_EVERY", str(_DEFAULT_PER_AGENT))))
56
  except ValueError:
57
- return _DEFAULT_PER_AGENT
58
 
59
  def _window_since_last(self, events: tuple[Event, ...]) -> tuple[Event, ...]:
60
- """Events after this agent's most recent remark β€” its quorum resets each beat."""
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 _active_speakers(self, events: tuple[Event, ...]) -> set[str]:
68
- """Cast members (never self) who have spoken at least once this run.
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 {e.actor for e in events if e.kind in _SPEECH_KINDS and e.actor in cast and e.actor != self.name}
74
-
75
- def _quorum_met(self, events: tuple[Event, ...]) -> bool:
76
- """True once every active speaker has spoken ``_per_agent`` times since the last beat."""
77
- speakers = self._active_speakers(events)
78
- if not speakers:
79
- return False
80
- need = self._per_agent()
81
- counts = dict.fromkeys(speakers, 0)
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 each active speaker has spoken enough since the last remark.
108
- if not self._quorum_met(recent_events):
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 test_abstains_below_quorum(self, monkeypatch):
41
- monkeypatch.setenv("MAL_COMMENTATOR_EVERY", "2")
42
  critic = _critic(["scene-whisperer", "pocket-actor", "rafters-critic"])
43
- # One message from each speaker β†’ the quorum (2 apiece) 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_quorum(self, monkeypatch):
51
- monkeypatch.setenv("MAL_COMMENTATOR_EVERY", "2")
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 test_no_active_speakers_means_silence(self, monkeypatch):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 quorum window."""
73
- monkeypatch.setenv("MAL_COMMENTATOR_EVERY", "2")
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 quorum (3) trips by ~turn 3
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