multi-agent-lab / src /agents /handlers.py
agharsallah
feat: Implement well-known typed fields for verdicts in output models
ce159dc
Raw
History Blame Contribute Delete
5.82 kB
"""Custom agent behaviour handlers.
Most agents are pure declarative config (a YAML manifest + the generic
ManifestAgent). An agent only needs a handler here when it does something the
generic turn can't express β€” most often, calling a tool. Handlers register
themselves with :func:`register_handler` and are referenced by ``handler:`` in a
manifest; the manifest still supplies all declarative fields.
"""
from __future__ import annotations
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
@register_handler("spy-host")
class SpyHost(ManifestAgent):
"""The word-pair bluff host: delivers a verdict and unmasks every secret word.
The generic turn produces the verdict *text* (who the host accuses). This handler
then attaches the dramatic ``reveal`` β€” one ``{agent, secret, role}`` row per player β€”
onto the emitted ``judge.verdict`` payload, exactly the shape the Fishbowl verdict
banner renders (``view_model``/``render_verdict``). The reveal is recorded on the real
ledger, so the unmasking is a genuine engine event, not a UI overlay.
The secret-word map below is curated demo content for ``the-steeped`` (the same way the
offline stub carries curated lines) β€” it mirrors the words baked into each player's
persona. Only players actually present on stage are revealed, so editing the cast in
the Lab never produces a phantom row.
"""
# agent name β†’ (secret word, table role) for the shipped "the-steeped" cast.
_REVEAL: dict[str, tuple[str, str]] = {
"spy-cara": ("COFFEE", "HERD"),
"spy-bex": ("COFFEE", "HERD"),
"spy-ovo": ("COFFEE", "HERD"),
"spy-nil": ("TEA", "SPY β€” CAUGHT"),
}
def act(
self,
run_id: str,
turn: int,
projection: StageProjection,
recent_events: tuple[Event, ...],
) -> Event:
event = super().act(run_id, turn, projection, recent_events)
on_stage = {e.actor for e in recent_events}
reveal = [
{"agent": name, "secret": secret, "role": role}
for name, (secret, role) in self._REVEAL.items()
if name in on_stage
]
if reveal:
event.payload["reveal"] = reveal
self._stamp_scoreboard(event)
return event
def _stamp_scoreboard(self, event: Event) -> None:
"""Score the verdict in code β€” the load-bearing split of ADR-0029.
The judge's prose names a suspect (``payload['winner']`` on the live path);
this handler turns that *accusation* into the ground-truth *result* using the
scenario's ``competition.teams``: the herd wins when the named player really is
a spy, the spy wins otherwise. The accusation is preserved as ``accused`` and a
``correct`` flag rides alongside, so the trace stays auditable. Offline (no
``winner`` field) the accusation is recovered from the verdict text, so the
no-API-key demo still ends on a full, deterministic scoreboard. With no spy
team declared, or no recoverable accusation, the round is a ``no_contest``.
"""
comp = self.competition
spies = set((getattr(comp, "teams", None) or {}).get("spy", []))
if getattr(comp, "kind", "none") != "versus" or not spies:
return
accused = event.payload.get("winner") or self._scan_accusation(str(event.payload.get("text", "")))
if not accused:
event.payload.pop("winner", None)
event.payload["no_contest"] = True
return
correct = accused in spies
event.payload["accused"] = accused
event.payload["correct"] = correct
event.payload["winner"] = "herd" if correct else "spy"
def _scan_accusation(self, text: str) -> str | None:
"""Recover the accused player from verdict *text* β€” the first cast name named.
Matches each player by the distinctive tail of its agent name (``spy-cara`` β†’
``cara``), case-insensitively, and returns the one mentioned earliest. The host
itself is excluded so it never accuses the judge."""
low = text.lower()
best: str | None = None
best_at = len(low) + 1
for name in self.cast_names:
if name == self.name:
continue
token = name.split("-")[-1].lower()
at = low.find(token) if token else -1
if at != -1 and at < best_at:
best, best_at = name, at
return best
@register_handler("fortune-teller")
class FortuneTeller(ManifestAgent):
"""Draws an omen from the ``oracle`` tool and weaves it into its prophecy.
Demonstrates the full tool path: capability-checked call β†’ result injected
into the prompt β†’ result also recorded on the emitted event's payload.
"""
def _build_extra_prompt(self, projection: StageProjection, recent_events: tuple[Event, ...]) -> str:
self._last_omen = ""
if self.tools is not None and "oracle" in self.manifest.tools:
self._last_omen = self.call_tool("oracle", seed=projection.current_scene).get("omen", "")
if self._last_omen:
return f"AN OMEN FROM THE ORACLE\n{self._last_omen}\nWeave this omen into your prophecy."
return ""
def act(
self,
run_id: str,
turn: int,
projection: StageProjection,
recent_events: tuple[Event, ...],
) -> Event:
event = super().act(run_id, turn, projection, recent_events)
omen = getattr(self, "_last_omen", "")
if omen:
event.payload["omen"] = omen # tool output is visible on the ledger
return event