Skip to content

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):

MESSAGE_SCOPES = Object.freeze(['broadcast', 'private', 'announcement'])
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

MAX_MESSAGE_LEN = 500   // characters

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:

inbox = bus.inboxFor(playerId, { sinceTurn: this.turn - 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.