Cheap Talk¶
The BDPD platform exposes a cheap-talk channel through which players
exchange messages, declare intended harvests, and propose or accept
pacts. The channel is arena-scoped and persistent (state lives
in the MessageBus and PactRegistry, both serialised into
turns.jsonl for replay).
Cheap-talk is the v0.9 governance substrate: pacts and graduated
sanctioning are layered on top of it. This page documents the six
canonical player tools as registered in
platform/arena.js (action dispatch) and agents/bdpd_tools.py
(LLM-side tool schemas).
Tool surface¶
Each tool maps 1:1 to an action of a given type returned by an
agent. The arena dispatches the action through the appropriate registry
(MessageBus or PactRegistry); the LLM-side wrappers in
agents/bdpd_tools.py queue the action into the player's outbox.
| Tool | Scope | Registry | Payload |
|---|---|---|---|
broadcast |
all-others | MessageBus |
text: str |
send_private |
one player | MessageBus |
target: str, text: str |
announce_intended_harvest |
all-others | MessageBus |
value: number ≥ 0 |
propose_pact |
named parties | PactRegistry |
parties: str[], terms: { type, … } |
accept_pact |
proposer + parties | PactRegistry |
pactId: str |
pledge |
self-only | PactRegistry |
maxHarvest: number > 0 |
Each tool returns { ok: bool, scope: str, ... } to the caller. The
actual delivery to recipients is mediated by the per-turn cycle
(see Timing below).
Disabling the channel (--no-talk)¶
The cheap-talk surface can be turned off entirely at pilot-script level
with a --no-talk flag (introduced in v1.1 for the BDPD¹ D1 Cell C
control). When the flag is set, the LLM-side tool wrappers in
agents/bdpd_tools.py are not exposed to the model and the
announced field is removed from the response schema; the
MessageBus is still constructed but receives no messages. Agents
can still observe peer harvests through the regular observation
payload — the flag removes signalling, not observation. This
makes a clean architecture-vs-communication factorial decomposition
possible: a --no-talk run with LLM agents (Cell C) isolates the
architectural effect from any communication effect.
Pilot scripts that honour the flag: scripts/pilot_d1_cell_c.mjs
(canonical Cell C reference). Reproducibility-side documentation in
docs/platform/reproducibility.md lists the flag under the BDPD¹
recipes.
Message scopes¶
The MessageBus carries three message scopes (platform/messages.js):
| Scope | Visible to | Payload | Tool that emits it |
|---|---|---|---|
broadcast |
every other player | text |
broadcast |
private |
the named recipient | text |
send_private |
announcement |
every other player | value: number |
announce_intended_harvest |
Announcements carry a numeric value (rounded to 3 decimals), not
text, so the lie_score metric can compute |announced − actual|
without parsing prose (see metrics).
Length cap¶
Longer text is truncated with an ellipsis rather than rejected, so an over-eager agent never stalls a turn.
Timing & prompt-cache discipline¶
Messages emitted at turn t are visible at turn t + 1:
In simultaneous play you cannot react to a message sent in the same tick you are deciding. Cheap-talk therefore carries a natural one-turn lag.
This is not just a fairness choice — it is prompt-cache discipline. The inbox is volatile (it changes every turn). When B1b feeds it to the LLM it is appended after the cache-stable prefix (system + rules + baked history), never spliced into it. Bounding the window to the last turn and capping message length keeps that volatile tail small, protecting the v0.8 prompt-cache hit rate (measured in B1b cache measurement).
Cross-arena delivery (v1.0)¶
When the arena is composed into a World, send_private may target a
player in another arena. The dispatch in arena.js checks whether
the target is local:
case 'send_private': {
const isLocal = this.players.some(p => p.id === a.target);
if (isLocal || !this._world) {
this._messages.sendPrivate(playerId, a.target, a.text, this.turn);
} else {
this._world.routeCrossArenaMessage({ /* ... */ });
}
break;
}
If the target is foreign and the arena is standalone, the message is stored on the local bus for audit but no one reads it (pre-v1.0 quiet-fail behaviour).
broadcast and announce_intended_harvest remain arena-local — there
is no cross-arena broadcast primitive. World-level coordination uses
treaties at the meta-agent layer, not chatter.
Pact tools¶
propose_pact, accept_pact, and pledge write into the
PactRegistry. They map onto the lifecycle documented in
pacts:
| Tool | Effect |
|---|---|
propose_pact |
new pact in proposed state; proposer implicitly accepts |
accept_pact |
records the caller's acceptance; pact becomes active when every party has accepted |
pledge |
shorthand: unilateral harvest_cap pact binding only the caller (auto-active) |
pledge(maxHarvest) is canonical for self-binding declarations because
it skips the multi-party acceptance dance: a 1-party pact needs no
external signature, so the registry activates it on proposal.
A reject_pact primitive exists on the registry (PactRegistry.reject())
but is not exposed as a player tool in v1.1. Rejection happens
implicitly by not accepting; the registry hook is reserved for future
meta-agent use.
Receiving messages (inbox)¶
MessageBus.inboxFor(playerId, { sinceTurn }) returns a
recipient-safe view:
[
{ "id": "...", "turn": 5, "fromId": "alice",
"scope": "broadcast", "text": "let's keep it under 2 this turn" },
{ "id": "...", "turn": 5, "fromId": "bob",
"scope": "announcement", "value": 1.8 },
{ "id": "...", "turn": 5, "fromId": "carol",
"scope": "private", "text": "between us — i'll take 3" }
]
The view excludes the player's own messages (they already know what they sent). Broadcasts and announcements are visible to every other player; private messages only to the named recipient.
Persistence¶
Every message is logged on the bus with {id, turn, fromId, to, scope,
text|value} and serialised into turnRecord.messages (per-turn) plus
bus.toJSON() (cumulative). Replay is bit-identical because all IDs
come from randomUUID() seeded through the arena's deterministic
randomness chain.
Engine-agnostic by construction¶
The cheap-talk surface only ever reads player IDs and a turn number. It
never touches engine internals — neither stock (logistic) nor R/C/P
(Seneca). The same six tools work unchanged across every engine.
Adding a new message scope or tool is a single-file change to
platform/messages.js (scope) or platform/arena.js (dispatch).
Engines, agents, and the metric registry need not be modified.
Downstream consumers¶
| Where | What it reads |
|---|---|
lie_score metric |
announcement value vs actual harvest |
announce_frequency metric |
count of announcement scope messages |
silent_defection metric |
absence of announcement + over-harvest |
PactRegistry.checkViolations |
bound parties from propose_pact / pledge |
sanction perturbation |
the pact violation log (see perturbations) |
| World cross-arena routing | send_private with foreign target |
See also pacts for the agreement lifecycle and metrics for what the channel feeds downstream.