--- title: Whispers emoji: πŸ“‘ colorFrom: blue colorTo: purple sdk: docker app_port: 7860 pinned: false license: apache-2.0 tags: - openenv - multi-agent - reinforcement-learning - theory-of-mind - misinformation - llm --- # Whispers β€” Multi-Agent Information Triage for LLMs > **Theme**: Multi-Agent Interactions (cooperation, competition, negotiation, coalition formation) > **Stack**: OpenEnv `0.2.3` Β· Pydantic Β· FastAPI Β· Unsloth + TRL GRPO Β· HF Spaces (Docker) **Whispers** is a multi-agent text environment in which a small graph of LLM agents must collaboratively reconstruct ground truth from noisy, conflicting, and sometimes adversarial messages β€” the same problem journalists, OSINT analysts, intelligence officers, and peer-reviewers solve every day. Each episode, a hidden ground-truth event is leaked to one or two **witnesses** through a noisy channel; **relays** can only see what their neighbours send them; one or two **adversaries** receive a *false* event and try to inject it; an **editor** must publish a final report whose confidences are scored on a Brier rule. The trained agent plays one of these roles. Success requires four interlocking skills the literature shows current LLMs lack: **source-credibility tracking, calibrated confidence, anti-cascade restraint, and ad-hoc verification-coalition formation**. We frame the task as a *pragmatic-inference game with hidden roles*, building on Bayesian Theory of Mind (Baker et al., 2017), the KAIROS peer-pressure findings (2025), and MARS-style turn-level GRPO (2025). To our knowledge, no prior environment trains an LLM via RL to be a robust node inside such a network using OpenEnv with rubric rewards. --- ## Why Whispers (innovation) | Pillar | Whispers | |---|---| | Cooperation | Honest agents must relay & corroborate facts | | Competition | Adversaries inject false events for hidden payoffs | | Negotiation | Bilateral `request_verify` exchanges trade confidence claims | | Coalition formation | Editors get a bonus for cross-verifying with β‰₯1 honest peer before publishing | | Theory of mind | Each agent must infer *what others have seen* and *whether they are honest* from message content + style | --- ## Environment at a glance ``` β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Witness β”‚ noisy view of truth β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”Œβ”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Relay │◀──▢│ Adversary* β”‚ injects a false event β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”Œβ”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β” β”‚ Editor β”‚ must `publish` a calibrated final report β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` The agent only ever talks to the env via **MCP-style tools** (per OpenEnv RFC 003); the HTTP `reset / step / state` interface is reserved for the trainer. Other seats are filled by scripted heuristics or a fixed baseline LLM so the protagonist gets a stable curriculum. ### Observation Space β€” `WhispersObservation` | Field | Type | Meaning | |---|---|---| | `role` | `Literal[witness, relay, editor, adversary]` | The agent's own role (it knows whether it is good or bad) | | `agent_id` | `int` | Unique node id in the graph | | `inbox` | `list[Message]` | DMs received this turn | | `public_feed` | `list[Message]` | Broadcasts visible to every agent | | `private_facts` | `list[str]` | Witness-only ground-truth fragments (or adversary's planted lies) | | `network_neighbors` | `list[int]` | Ids reachable via `send_message` / `request_verify` | | `fact_check_budget` | `int` | Remaining oracle queries (typically 0–2) | | `step` / `max_steps` | `int` | Turn counter | | `legal_tools` | `list[str]` | Subset of MCP tools allowed *this* turn (e.g. `publish` only for the editor) | ### Action Space β€” `WhispersAction` A discriminated-union: a `tool` field plus optional parameters consumed by the relevant tool. | Tool | Parameters | Effect | |---|---|---| | `send_message` | `target_id`, `content`, `claimed_source`, `confidence` | Direct message to a neighbour | | `broadcast` | `content`, `claimed_source`, `confidence` | Message visible to all next turn | | `fact_check` | `content` | Spend 1 budget; oracle returns `{"verdict": true/false}` | | `request_verify` | `target_id`, `content` | Coalition primitive: ask peer to confirm/deny | | `accuse` | `target_id` | Public accusation, scored by F1 vs ground-truth adversaries | | `publish` | `final_report: dict[field, {value, confidence}]` | Editor only; ends episode | | `wait` | β€” | Pass turn | > Reserved tool names `reset`, `step`, `state`, `close` are explicitly forbidden (per OpenEnv RFC 003). ### Reward Function β€” composable rubric Episode-level normalised reward in `[0, 1]`: ``` value = clamp( 0.40 * truth_alignment # 1 - mean Brier across published fields + 0.20 * calibration # 1 - ECE over confidences + 0.15 * adversary_detection # F1 on accuse() vs ground-truth bad actors + 0.10 * coalition_bonus # +1 if cross-verified with β‰₯1 honest peer pre-publish + 0.10 * efficiency # 1 - steps_used / max_steps - 0.15 * cascade_penalty # forwarded a false claim with conf > 0.5? grows with conf , 0, 1) ``` Per-step intermediate signals (returned in `reward.value` after each `step`, weighted into the running episode total): - `+0.02` β€” useful `fact_check` against a contested claim - `+0.05` β€” `request_verify` answered consistently - `βˆ’0.05` β€” repeated identical broadcast (anti-spam) - `βˆ’0.10` β€” `publish` while the inbox has unresolved contradictions and 0 fact-checks used **Anti-gaming guards.** Trivial "always say I don't know" β†’ `truth_alignment β‰ˆ 0`. Trivial "accuse everyone" β†’ `adversary_detection β‰ˆ 0` (F1, not recall). Trivial "spam fact_check" β†’ budget cap + efficiency burn. > See the worked example in [`whispers/reward.py`](whispers/reward.py). ### Tasks (5 + 1 stretch) | ID | Name | Difficulty | Setup | Headline grader signal | |---|---|---|---|---| | `t1` | Single Relay | easy | 1 witness β†’ 1 relay β†’ 1 editor (you), no adversary | `truth_alignment` | | `t2` | Triangulate | easy | 2 noisy witnesses, you (editor) combine | `truth_alignment + calibration` | | `t3` | Spot the Liar | medium | 2 witnesses; 1 adversarial; no fact-check | `+ adversary_detection` | | `t4` | Cascade Chain | medium | 5-node line; you = middle relay; upstream = adversary | `+ cascade_penalty` | | `t5` | Coalition Under Pressure | hard | 6 nodes, 2 colluding adversaries, 1 honest source, you = editor (budget = 2) | full rubric | | `t6` | Calibrated Brief | hard (stretch) | multi-field event under partial collusion | `calibration` dominates | --- ## Setup ### Run locally (Docker) ```bash git clone https://huggingface.co/spaces/varn03/whispers cd whispers docker build -t whispers . docker run --rm -p 7860:7860 whispers # Health: curl http://localhost:7860/ # Reset: curl -X POST http://localhost:7860/reset -H 'Content-Type: application/json' -d '{"task_id":"t1","seed":0}' ``` ### Run locally (Python) ```bash pip install -e . python -m whispers.server ``` ### Validate spec ```bash pip install openenv-cli # provides the `openenv` CLI openenv validate ``` Expected: `OK openenv.yaml is valid`. The captured run output is reproduced in [`assets/openenv_validate.txt`](assets/openenv_validate.txt) after first run. --- ## Inference (baseline) `inference.py` lives at the repo root and uses the OpenAI client against an HF-hosted endpoint: ```bash export HF_TOKEN=hf_... export API_BASE_URL=https://router.huggingface.co/v1 export MODEL_NAME=Qwen/Qwen2.5-7B-Instruct export WHISPERS_URL=http://localhost:7860 python inference.py # runs all 6 tasks WHISPERS_TASK=t3 python inference.py # run a single task ``` Emits the exact OpenEnv hackathon log format: ``` [START] task=t1 env=whispers model=Qwen/Qwen2.5-7B-Instruct [STEP] step=0 action=send_message reward=0.05 done=false error=null ... [END] success=true steps=6 score=0.812 rewards=0.05,0.10,... ``` ### Baseline scores (real measurements via `scripts/make_plots.py`) The numbers below are **measured**, these are the output of running four deterministic policies through the env across 8 seeds each and averaging the per-task `value`. The full per-task / per-policy table is committed in [`assets/baseline_measurements.json`](assets/baseline_measurements.json). | Task | Difficulty | random | wait | naive_editor | naive_relay | trained (target) | |---|---|---|---|---|---|---| | t1 single_relay | easy | 0.58 | 0.00 | **0.87** | 0.88 | 0.92 | | t2 triangulate | easy | 0.59 | 0.00 | **0.87** | 0.88 | 0.90 | | t3 spot_the_liar | medium | 0.59 | 0.00 | 0.60 | 0.45 | **0.78** | | t4 cascade_chain | medium | 0.55 | 0.55 | 0.55 | **0.08** | **0.72** | | t5 coalition | hard | 0.58 | 0.00 | 0.55 | 0.44 | **0.65** | The interesting baselines are: * **`naive_relay`**: an "always forward inbox at conf=0.85" agent β€” the closest stand-in for an untuned, eager LLM. It scores **0.08 on Cascade Chain** (because it confidently propagates the upstream adversary's lie) and triggers the cascade-resistance plot below. * **`naive_editor`**: publishes the most-confident inbox claim at the last turn β€” a strong baseline on the no-adversary tasks but collapses on t3+ where it can't tell signal from noise. > Run `python scripts/make_plots.py` to reproduce all four baselines + the three plots end-to-end in ~20 s on CPU. To run a real LLM baseline against the live HF Space, use `python inference.py`. --- ## Training (Unsloth + TRL GRPO) Three entry points, pick the one that matches your hardware: | Hardware | Entry point | Model | |---|---|---| | Free Colab T4 | [`notebooks/train_whispers_grpo.ipynb`](notebooks/train_whispers_grpo.ipynb) | Qwen2.5-1.5B-Instruct | | Free Kaggle 1Γ—T4 | [`notebooks/train_whispers_grpo_kaggle_t4.ipynb`](notebooks/train_whispers_grpo_kaggle_t4.ipynb) | Qwen2.5-1.5B-Instruct | | **Workstation RTX A6000 (48 GB)** | [`scripts/train_grpo_a6000.py`](scripts/train_grpo_a6000.py) | **Qwen2.5-3B-Instruct** | All three: 1. Spin up `WhispersEnv` in-process (no HTTP overhead in the hot loop). 2. Load Qwen in 4-bit via `unsloth.FastLanguageModel`, apply LoRA. 3. Drive `trl.GRPOTrainer` with a rollout that calls `env.step()` for up to `max_steps`. 4. Log to WandB; save curves to `assets/`. The **A6000 script** is the production trainer: it uses a **dense multi-component reward** (format + tool-legality + neighbour-validity + per-step shaping + 1.5Γ— terminal score, max β‰ˆ 2.25) and a **three-stage curriculum** (t1 β†’ t1+t2 β†’ full mix). On the T4 notebooks the raw `[0, 1]` terminal-only reward collapses to ~0 for an untrained 1.5B policy and produces zero GRPO advantages β€” that's why the A6000 path moves to a 3B base model and a denser signal. Run it with:: python scripts/train_grpo_a6000.py # or override knobs via env vars: WHISPERS_MODEL=Qwen/Qwen2.5-7B-Instruct GRPO_STEPS=1000 \ python scripts/train_grpo_a6000.py **Phase 2 (stretch)** β€” hybrid self-play with **MARS-style turn-level advantage** + **agent-specific advantage normalisation**, freezing adversaries to scripted lies. ### Headline plots (regenerated by the notebook) ![Learning curves](assets/learning_curve.png) *Mean episode reward vs GRPO step, one line per task; dashed lines are the random and untrained-Qwen baselines.* ![Cascade resistance](assets/cascade_resistance.png) *Fraction of episodes where the agent forwarded a false claim with confidence > 0.5. Lower is better.* ![Rubric breakdown](assets/rubric_breakdown.png) *Where the gains come from: stacked rubric components, baseline vs trained.* > WandB run: see `notebooks/train_whispers_grpo.ipynb` output cell. --- ## Project layout ``` . β”œβ”€β”€ inference.py # mandatory baseline runner (OpenAI client, exact log format) β”œβ”€β”€ openenv.yaml # OpenEnv manifest β”œβ”€β”€ Dockerfile β”œβ”€β”€ requirements.txt β”œβ”€β”€ pyproject.toml β”œβ”€β”€ README.md # this file β”œβ”€β”€ tests/test_smoke.py β”œβ”€β”€ notebooks/train_whispers_grpo.ipynb β”œβ”€β”€ assets/ # plots committed to repo └── whispers/ β”œβ”€β”€ __init__.py β”œβ”€β”€ models.py # Pydantic Observation / Action / Reward / State β”œβ”€β”€ env.py # WhispersEnv core (gym-style API) β”œβ”€β”€ server.py # FastAPI HTTP server (reset / step / state) β”œβ”€β”€ client.py # WhispersClient HTTP wrapper β”œβ”€β”€ tools.py # MCP tool dispatch (send_message, fact_check, ...) β”œβ”€β”€ sim.py # graph, noise model, scripted baseline policies β”œβ”€β”€ reward.py # composable rubric + per-step shaping └── tasks/ # one file per task + grader β”œβ”€β”€ single_relay.py β”œβ”€β”€ triangulate.py β”œβ”€β”€ spot_the_liar.py β”œβ”€β”€ cascade_chain.py β”œβ”€β”€ coalition_under_pressure.py └── calibrated_brief.py ``` ## Additional Links: - [Youtube](https://youtu.be/z7ePTRiB3LE) - [Our Playground](https://whispers-mauve.vercel.app/) - [Our training notebook](https://www.kaggle.com/code/varn03/notebookf1225ae4c4) - [HuggingFace](https://huggingface.co/spaces/varn03/whispers)