Skip to content

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 DeepSeek usage block. 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 stays None in 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:

  1. The middle-man updates structured fields after each observation (e.g. EMA refresh on observedAggressorIntensity).
  2. BDPDAgent.chat() calls memory.to_prompt() to render a compact snap that gets baked into the next user message.
  3. 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 contract decide(obs, memory) (see agents) is a separate, JS-only scratchpad belonging to the in-VM agent closure. PlayerMemory belongs 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:

  1. POST messages to the LLM.
  2. If the response is a final text reply → return it.
  3. If the response is a tool_calls chunk → run each tool, append the tool role result, fire the optional on_tool_call(name, args, result) hook (one ToolCallEvent per round-trip).
  4. 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.