Agent SDK (bdpd_sdk)¶
agents/bdpd_sdk/ is the Python SDK that powers BDPD's LLM agents. It
provides a thin, provider-agnostic wrapper around an OpenAI-compatible
chat client (currently DeepSeek), plus the structured memory, tool-use
loop, and tracing primitives that the middle-man (bdpd_agent.py)
delegates to on every turn.
This page documents the public surface and the layered
relationship with agents/bdpd_tools.py. For prompt-shape and
caching discipline see prompt design; for the
HTTP/Flask interface to the platform see LLM index.
Public surface¶
from agents.bdpd_sdk import (
BDPDAgent, # chat-with-memory primitive
PlayerMemory, # typed cross-turn scratchpad
Tool, tool, # @tool decorator + Tool dataclass
ToolCallEvent, # one round-trip in the tool loop
TraceWriter, # JSONL trace of every LLM call
make_client, # OpenAI-compatible client factory
DEFAULT_BASE_URL, # DeepSeek public endpoint
DEFAULT_MODEL, # "deepseek-v4-flash"
)
Everything you need for a typical agent fits in those nine symbols.
BDPDAgent — the chat primitive¶
agent = BDPDAgent(
system_prompt, # str
*,
model = "deepseek-v4-flash",
client = None, # defaults to make_client()
max_history = 32, # turns of rolling chat history (also `--max-history` CLI flag)
temperature = 0.4,
max_tokens = 256,
tools = None, # list of @tool functions or Tool instances
max_tool_iterations = 4, # tool-loop budget per chat() call
on_tool_call = None, # (name, args, result) -> None
)
reply: str = agent.chat(
user_message,
*,
memory = None, # optional PlayerMemory
response_format = None, # optional OpenAI JSON-mode spec
)
After every call, two diagnostic fields are populated:
agent.last_usage—{prompt_tokens, completion_tokens, prompt_cache_hit_tokens, prompt_cache_miss_tokens, ...}from the DeepSeekusageblock. Used by the cache-prefix smoke tests.agent.last_reasoning_content— the model's reasoning trace when thinking is enabled. v0.8 keeps thinking disabled (see DeepSeek v4 notes); this staysNonein normal operation.
Prompt-cache discipline¶
chat() bakes the memory snap into the user message, not into a
separate system message. The reason is DeepSeek's prompt cache: it keys
on the longest stable prefix, and the snap mutates every turn (e.g.
observedAggressorIntensity is refreshed via EMA on every
observation). Injecting the snap before history would invalidate the
cache for everything after it. With the snap baked into the (immutable
once stored) user message, the prefix [system, user₁+snap₁,
assistant₁, …, userₙ₋₁+snapₙ₋₁, assistantₙ₋₁] stays stable and only
the newest user content is uncached.
Empirical hit-rate growth in BDPD pilots: ~50% at turn 1 → ~85% by turn 30 (see B1b cache measurement).
max_history and the rolling window¶
max_history (constructor argument, also exposed as the --max-history
CLI flag on agents/bdpd_agent.py and on agents/cards_ai_play.py via
its sibling --history-window) bounds the chat history kept in the
agent's deque(maxlen=max_history*2) (×2 because every user turn pairs
with one assistant turn). The default jumped from 6/8 to 32 in v0.9
once the cache-prefix discipline was verified: at 32, the rolling window
covers the entire 30-turn arena without the runtime ever evicting a
turn, which keeps the prefix monotone and the cache hit-rate high.
Lower it only when running with constrained context budgets or when
deliberately probing memory effects in an experiment.
PlayerMemory — typed scratchpad¶
A structured, turn-spanning belief store. The middle-man owns the
instance and passes it into BDPDAgent.chat(..., memory=...); if
memory=None, BDPDAgent behaves as if the class did not exist (zero
cost).
Fixed field set (§4 of BDPD_ultimate_edition.md):
| Field | Type | Meaning |
|---|---|---|
beliefsAboutOthers |
dict[str, str] |
opponent ID → free-text belief |
plannedDefectionTurn |
int \| None |
turn the agent intends to defect |
observedAggressorIntensity |
float in [0, 1+] |
EMA of others' harvest / own capacity |
notes |
list[str] |
free-form scratchpad escape hatch |
The intended workflow:
- The middle-man updates structured fields after each observation
(e.g. EMA refresh on
observedAggressorIntensity). BDPDAgent.chat()callsmemory.to_prompt()to render a compact snap that gets baked into the next user message.- The model may write back through tool calls (e.g.
note(text)), though most pilots leave write-back disabled.
Distinct from code-agent
memory. The JS platform's code-agent contractdecide(obs, memory)(see agents) is a separate, JS-only scratchpad belonging to the in-VM agent closure.PlayerMemorybelongs to the Python LLM agent and crosses middle-man boundaries.
@tool decorator and Tool dataclass¶
from agents.bdpd_sdk import tool
@tool
def query_history(turn_start: int, turn_end: int) -> list[dict]:
"""Return the player's own log entries in [turn_start, turn_end]."""
...
@tool(
name = "trend", # rename the LLM-visible tool
parameter_descriptions = {"window": "How many recent turns"},
)
def compute_trend(window: int) -> dict:
"""Slope of recent harvests / regen ratio."""
...
Auto-schema rules (from _json_type_for):
- Primitives:
int → integer,float → number,str → string,bool → boolean. list[primitive]→ JSON array with typed items.dict→ free-form JSON object (no inner schema).*args/**kwargs→ rejected (raises on import).- Untyped parameters → rejected (raises on import).
- No Pydantic, no dataclass params, no streaming, no async,
no
parallel_tool_calls.
The decorator returns a Tool instance carrying the function, the
JSON schema, and the OpenAI-format spec. Pass a list of decorated
functions to BDPDAgent(..., tools=[...]); the agent's internal
run_tool_loop handles the ping-pong.
Tool implementations live elsewhere¶
bdpd_sdk/tools.py contains only the decorator, the dataclass,
and the loop machinery. The actual player-bound tool implementations
(broadcast, propose_pact, query_history closed over the
middle-man's TURN_LOG, …) live in agents/bdpd_tools.py. This is a
deliberate two-layer split:
| Layer | What it is | Stability |
|---|---|---|
bdpd_sdk/tools.py |
engine-agnostic SDK machinery | stable, semver-tracked |
agents/bdpd_tools.py |
BDPD-specific tool closures + outbox | follows arena changes |
Read agents/bdpd_tools.py:make_player_tools(...) and
make_action_tools(...) to see the canonical wiring — they are the
upstream reference every player-facing example follows.
Tool loop¶
When tools is non-empty, chat() runs an internal loop bounded by
max_tool_iterations (default 4). Each iteration:
- POST messages to the LLM.
- If the response is a final text reply → return it.
- If the response is a
tool_callschunk → run each tool, append thetoolrole result, fire the optionalon_tool_call(name, args, result)hook (oneToolCallEventper round-trip). - Loop back to step 1.
On budget exhaustion, the loop forces a final-text response by sending
the original request with tool_choice="none". response_format (if
given) is applied only on this fallback step — DeepSeek rejects
JSON mode together with tools, so the loop steers JSON shape via the
system prompt alone in the middle of the dance.
The on_tool_call hook is the canonical extension point: TraceWriter
uses it to log each round-trip without driving the loop itself.
TraceWriter — JSONL trace¶
from agents.bdpd_sdk import TraceWriter
trace = TraceWriter("traces/run42.jsonl")
agent = BDPDAgent(
system_prompt,
tools = [broadcast, propose_pact, query_history],
on_tool_call = trace.on_tool_call,
)
# ... run pilot ...
trace.close()
One line per LLM call (system prompt hash, user message, tool calls,
final reply, usage). The pilot scripts (scripts/pilot_*.mjs,
smoke_b1*) all consume this format; downstream notebooks read
turns.jsonl instead.
make_client — OpenAI-compatible client factory¶
from agents.bdpd_sdk import make_client
client = make_client(
base_url = "https://api.deepseek.com/v1", # default
api_key = "...", # falls back to env vars
)
Environment variable precedence (highest first):
| Variable | Used for |
|---|---|
DEEPSEEK_API_KEY |
preferred |
BDPD_API_KEY |
first fallback |
OPENAI_API_KEY |
second fallback |
DEEPSEEK_BASE_URL |
preferred base URL |
API_BASE_URL |
base URL fallback |
The default model is deepseek-v4-flash. Note the v4 model lineup
deprecation timeline for deepseek-chat / deepseek-reasoner —
see cloud APIs.
Engine-agnostic invariant¶
The SDK does not import any engine code. Tool implementations in
agents/bdpd_tools.py read only the observation envelope and player
log entries — never stock, R, C, P, or any engine internal.
Adding a new engine (engine/<kind>.js) does not require any
change to this SDK or to the prompt shape.
This is the same invariant documented for the JS platform in architecture and for the cheap-talk surface in cheap-talk.
Minimal example¶
from agents.bdpd_sdk import BDPDAgent, PlayerMemory, tool, TraceWriter
@tool
def own_recent_harvest(window: int) -> list[float]:
"""Last `window` actual harvests, oldest first."""
return [e["actual"] for e in TURN_LOG[-window:]]
trace = TraceWriter("traces/demo.jsonl")
memory = PlayerMemory()
agent = BDPDAgent(
system_prompt = "You are alice, a conservative player. ...",
tools = [own_recent_harvest],
on_tool_call = trace.on_tool_call,
)
reply = agent.chat(
user_message = "It is turn 5. Choose a harvest.",
memory = memory,
response_format = {"type": "json_object"},
)
print(reply) # {"harvest": 1.5, "actions": [...]}
trace.close()
The middle-man at agents/bdpd_agent.py does roughly this on every
/decide call, wired into the arena's action protocol.
Versioning¶
| Symbol | Stability since |
|---|---|
BDPDAgent |
v0.8 |
PlayerMemory |
v0.8 |
@tool / Tool |
v0.8 |
ToolCallEvent |
v0.8 |
TraceWriter |
v0.8 |
make_client |
v0.8 |
Breaking changes will be announced in the changelog.
v0.8.1 / v0.9 added no breaking changes to the symbols above; new
functionality (e.g. tool-loop on chat()) is opt-in.