01 Executive summary
What the platform is and how you use it.
The benchmark drops a candidate sales agent into the middle of a real historical conversation and lets it negotiate, turn by turn, against a calibrated customer simulator until the customer converts (won) or walks (lost). Two reference agents — a single-call Baseline and a planner-driven Planner + Gates — give you something to measure against. You plug in your own engine through a one-method contract and run it head-to-head.
It's a pluggable platform: which algorithm runs, what domain frames the conversation, and where the data comes from are each declared once behind an interface (§2). Engines are declared in a registry that the headless runner, the REST API, and the live dual-panel UI all read from — so a new engine appears everywhere with no extra wiring, and the UI discovers the available options dynamically rather than hardcoding them.
02 The four pluggable seams
The platform is organized around four small interfaces. Each has one job, one default implementation, several consumers that read from it, and an extension path that needs no core edits.
Everything that varies between experiments lives behind one of these seams: which algorithm produces the agent's turns (the engine registry), what domain defines the framing and the win/lost criteria (domain packs), where the data comes from (scenario sources), and which goal-oriented task is being tested (benchmark packs). The benchmark runner, the REST API, and the live UI are all consumers — they never hardcode the options, they read them from the seam.
| Seam | Module | Job | Default impl | Read by | Extend without core edits? |
|---|---|---|---|---|---|
| Engine registry | poc/registry.py | One declaration of an engine (id, name, params, factory) is the single source of truth. | baseline, planner, strategist · plugins: llamacpp, llamacpp-strategist, llamacpp-planner (local llama.cpp arms) | Benchmark runner · /api/engines · UI selectors | Yes — strategist.engines entry point |
| Domain pack | poc/domain.py | Tenant framing, opp-type coaching, anchor rendering, won/lost signals. | SalesDomainPack | actor · benchmark outcome check | Yes — subclass + register_domain |
| Scenario source | poc/scenario_source.py | Where scenarios / opp-meta / transcripts / anchors come from. | JsonScenarioSource | load_scenarios() · server data layer | Yes — implement the protocol + set_scenario_source |
| Benchmark pack | poc/benchmark_packs.py | A goal-oriented scenario bundle: manifest (goal, domain) + dataset slice or file. | insurance-renewal · ecommerce-cart | /api/benchmarks · UI Benchmark selector · load_pack_scenarios() | Yes — copy benchmarks/_template/, drop a pack.json |
One more capability rides on the registry: the dual-panel server selects an engine per panel, so any registered engine can face any other (§5). The customer it negotiates against is the same on both sides (§6), keeping every comparison single-variable.
03 End-to-end architecture
One contract — async produce(opp_meta, dialog, business_rules) → (text, meta) — drives both run modes.
Two LLMs under the hood
Customer-facing actor → Gemini 2.5 Pro · Simulator + Planner chain-of-thought → Anthropic Sonnet. The benchmark records each engine's meta verbatim and never interprets it.04 Engines & the connect-your-algorithm API
Declare an engine once; it runs headless, over REST, and in the live UI. This is the path you follow to test a new Strategy/Supervisor algorithm.
The contract — one async method
An engine is any object with a single coroutine. No base class, no inheritance — it's a runtime_checkable Protocol, so structural shape is enough:
async def produce(self, opp_meta: dict, dialog_history: list[dict],
business_rules: str = "") -> tuple[str, dict]:
# returns (customer_facing_text, telemetry_meta)
| Parameter | Shape | Meaning |
|---|---|---|
| opp_meta | dict | Customer profile + scenario context (id, company, 23 profile dims, anchors, voice_profile). Pass through to your stack. |
| dialog_history | list[{role,text}] | role ∈ {"agent","customer"}, chronological. The last customer message is the one to answer. |
| business_rules | str | Tenant compliance text (may be empty). |
| returns | (str, dict) | text = the customer-facing reply; meta = free-form telemetry, recorded verbatim, never interpreted (strategy/tone/confidence/gates…). |
Stateless per call: the dialogue is passed in every turn — keep no per-session state on self. The benchmark and the live replayer both call exactly this method.
Connect it — three steps
1 · Implement
Write a class with produce(). Wrap an HTTP service, a local model, gRPC — whatever your stack is. See examples/example_engine_template.py.
2 · Register
Declare an EngineSpec (id, name, params, factory) — in-tree via register_engine(), or out-of-tree via a strategist.engines entry point.
3 · Run / compare
Headless: Benchmark.run_engine("myid"). Live: pick it in either panel of the UI and watch it negotiate against another engine.
Code — implement, register, run by id
class MyEngine:
def __init__(self, temperature=0.7):
self.temperature = temperature
async def produce(self, opp_meta, dialog_history, business_rules=""):
last = next((m["text"] for m in reversed(dialog_history)
if m["role"] == "customer"), "")
text = await my_model.respond(opp_meta, last, temp=self.temperature)
return text, {"strategy": "reframe", "tone": "warm"}
from poc import register_engine, EngineSpec, ParamSpec, Benchmark, load_scenarios
register_engine(EngineSpec(
id="myengine", name="My Engine",
params=(ParamSpec(name="temperature", label="Temperature", type="string", default="0.7"),),
factory=lambda temperature="0.7": MyEngine(float(temperature)),
))
results = await Benchmark(load_scenarios()).run_engine("myengine", temperature="0.4")
Full walkthrough: docs/PLUGIN_GUIDE.md and docs/API.md.
The registry
- Role
- Single source of truth for which Strategy/Supervisor engines exist and how to build them.
- Type
- In-memory registry of EngineSpec dataclasses, populated at import + lazily from entry points.
- How built
- Built-ins via register(); third-party via the strategist.engines setuptools entry-point group.
- In → Out
- engine id (+ param values) → an object implementing Engine.produce().
- Feeds
- Benchmark.run_engine(), the server's /api/engines, and the browser UI's panel selectors.
What an engine declares
| Field | Meaning | Drives |
|---|---|---|
| id | Stable slug ("planner") | API, results dir, panel value |
| name | Display label | UI dropdown + panel title |
| description | One-liner | UI tooltip |
| runnable | Benchmark-instantiable? | false for strategist (needs prod DB) → sorts last / flagged |
| requires | Capability tags ("mysql","lightrag") | UI badges |
| params | ParamSpec[] (enum/bool/string + default) | Per-engine UI controls; validated server-side |
| live_mode | "produce" | "native" | How the live replayer drives it (generic vs. wired-in) |
| factory | (**params) → Engine | Instantiation for benchmark + generic live path |
Out-of-tree plugins (no core edits)
A third party ships a package exposing a strategist.engines entry point; the registry discovers it the first time it's read. Verified end-to-end: after pip install -e examples/external_engine_plugin, the echo engine appears in all_engine_specs(), /api/engines, and the UI.
Code — declaring & running an engine
# in-tree: declare once
from poc import register_engine, EngineSpec, ParamSpec
register_engine(EngineSpec(
id="planner", name="Planner + Gates",
params=(ParamSpec(name="planner_envelope", label="Econ envelope",
type="enum", default="off", choices=("off","auto","always")),),
factory=lambda planner_envelope="off": PlannerEngine(planner_envelope=planner_envelope),
))
# run any engine by id — params validated against its ParamSpecs
bench = Benchmark(load_scenarios())
results = await bench.run_engine("planner", planner_envelope="auto")
# out-of-tree plugin: pyproject.toml
# [project.entry-points."strategist.engines"]
# echo = "strategist_echo:get_engines"
05 Any-vs-any A/B in the live UI
Both panels are independent, registry-driven selectors — and the default stays byte-identical.
The dual-panel server runs the same scenario down two panels against one shared customer simulator, so the engine is the only variable. Previously the LEFT panel was hardwired to the baseline and only the RIGHT was switchable (between two hardcoded options, via a planner "bridge" module that was never shipped and silently fell back). Now each panel picks any registered engine, with its parameter controls rendered from the spec.
The trick: gate on the engine, not the panel side
The live replayer had ~9 places gated by panel.side == "right" that actually mean "this is the supervised arm". Those were re-keyed to panel_is_supervised = (engine_id == "strategist"). Because the default pairing is LEFT=baseline / RIGHT=strategist, the boolean evaluates exactly as the old side check — so the default comparison is unchanged by construction, while novel pairings (e.g. Planner on the left) become possible.
06 The customer simulator
The agent on the other side of every conversation — the thing that makes the benchmark a closed loop rather than a transcript replay.
- Role
- Plays the customer: reads the dialogue + the engine's latest move and produces the customer's next reply, turn after turn, until the conversation converts or declines.
- Type
- LLM-driven simulator (poc/customer_simulator.py, class CustomerSimulator), v2 reference-aware mode (default on).
- How built
- Anchored to a real historical conversation: it sees the customer's actual reply at the current turn as a posture/tone reference, then writes in this customer's extracted voice.
- In → Out
- reply(dialog_history, live_agent_msg, k) → (customer_text, sim_mode).
- Shared
- One simulator instance faces both A/B panels, so the engine is the only variable across the comparison.
Why a reference-aware simulator
A naive simulator either (a) replays the historical customer — which ignores what the live agent actually said — or (b) free-generates, which drifts off-persona and leaks outcomes (every real-won scenario would tilt "won" regardless of the agent under test). The v2 design threads the needle with two rules:
How calibrated
| Test | Setup | Result |
|---|---|---|
| Realism | held-out continuation, n=23 | 78% outcome agreement; −22 pp pessimism bias (under-states wins) |
| Adaptivity | perturbed agent, n=28, 2 judges | 4.71 / 5 mean; 3.6–7.1% echo rate |
The −22 pp pessimism bias is a known, documented property: the simulator under-states wins, so absolute win-rates read low — but the relative A/B comparison between engines stays informative.
Dispatch & sim_mode
Each reply is tagged with the mode that produced it, recorded per turn in the results:
| sim_mode | When |
|---|---|
| generate_v2_ref | v2 on, a same-turn historical reference exists (the default path) |
| generate_v2_no_ref | v2 on, but the live conversation has run past the transcript — no reference available |
| rephrase | v1 hybrid: live move closely matches history → rephrase the historical reply in-voice |
| generate | v1 hybrid: live move diverges → free-generate on-topic |
07 Domain packs
Sales stays the reference domain — but as a provider, not an inlined assumption.
- Role
- Supplies the domain-specific text + outcome logic the engines and scorer need.
- Type
- DomainPack base class; selected globally via set_active_domain / POC_DOMAIN.
- How built
- SalesDomainPack holds the original Insurance/Ecommerce strings verbatim and is the default.
- In → Out
- tenant / opp-type / anchors / customer text → framing strings & won/lost booleans.
- Feeds
- actor (system prompt) and benchmark (outcome check).
The bundled dataset is, and stays, a sales/insurance corpus — you can't make fixed historical conversations domain-neutral. What was decoupled is the code's coupling: per-tenant business descriptions, opportunity-type coaching, the USD pricing "economic reference frame", and the win/decline detectors all used to be inlined across the core. They now come from the active pack.
| Extracted surface | Was inlined in | Now provided by |
|---|---|---|
| Per-tenant business / conversion description | actor._domain_desc | describe_tenant() |
| Opportunity-type behavioral coaching | actor._opp_type_behavioral_note | opp_type_note() |
| Economic-anchor section (currency, anti-staircase rule) | actor._build_anchor_section | render_anchor_section() |
| Win / decline (won/lost) detection | benchmark._check_customer_outcome | detect_close()/detect_decline() |
Code — a custom domain in ~15 lines
from poc import DomainPack, set_active_domain
class SupportDomain(DomainPack):
name = "support"
def describe_tenant(self, tenant):
return "B2C technical support; resolution = customer confirms the issue is fixed"
def detect_close(self, text):
return "that worked" in text.lower() or "resolved" in text.lower()
def render_anchor_section(self, anchors):
return "" # no pricing in a support domain
set_active_domain(SupportDomain()) # actor + benchmark now read from it
08 Scenario / data sources
Where the data comes from is now an interface, not a hardcoded file path.
- Role
- Read-only access to scenarios, opportunity metadata, transcripts, business rules, anchors.
- Type
- ScenarioSource protocol; active instance swappable via set_scenario_source.
- How built
- JsonScenarioSource over the bundled v1_scenarios.json (default); MySQL via poc/db.py.
- In → Out
- opp_id → opp-meta / messages / anchors; or the full scenario list.
- Feeds
- load_scenarios() and the live server's data layer.
load_scenarios() keeps its legacy path= argument (direct file read) for full backward compatibility, but with no arguments it routes through the active source — so a custom backend (a different DB, an API, another file format) plugs in with one call. The existing function-style server/db.py shim (JSON ↔ MySQL switch on POC_USE_MYSQL) remains the live server's data layer and mirrors the same field shapes.
09 The benchmark dataset
112 scenarios, each anchored in a real historical conversation across two industries.
Two industries are represented: Insurance (auto-insurance renewal — an existing customer at renewal with a prior premium and likely competitor quotes) and Ecommerce (cart abandonment — a buyer who added an item and walked away). Each scenario is a snapshot of one real opportunity: a 23-dimension customer profile, the full historical transcript, and — where available — the economic anchors (last year's price, market average, internal discount ceiling) that ground the negotiation.
How it's built & used
Diversity stratification
15 cells (6 insurance + 9 ecommerce), each sampled by farthest-point sampling on the 23-dimension profile vector — maximizing spread of customer types rather than sampling uniformly, so a small set still exercises a wide range of buyers.
Embedded transcripts & the seed cut
Every scenario carries its full historical transcript. At run time the benchmark seeds the dialogue up to seed_dialog_cut_idx (the point of live takeover), then the engine drives the rest. Embedding the transcript makes the package self-contained — the simulator's same-turn reference needs no DB at run time (§6).
Regression sentinels
About 20% are real-wins: scenarios that genuinely converted historically. They act as a floor — an engine that loses the easy cases is regressing, independent of how it does on the hard ones.
Anchors (when present)
Per-opportunity economic reference frame. The agent must negotiate from these real figures rather than fabricating market claims; anchor_real:false marks cohort-fallback anchors.
The outcome model
A scenario resolves to won or lost. The customer's reply is checked each turn by the active domain pack: a close/payment signal → won; a decline → lost; a deferral ("let me think about it") keeps the conversation going. If no resolution by max_turns, it's recorded lost with end_reason = max_turns_no_close. Results are paired across arms by scenario_id so engines are compared on identical scenarios.
Scenario schema (abridged)
{
"scenario_id": "L_Pr_An_Sk_04", // opaque id (cell-coded)
"opp_id": "00733e8d-…",
"tenant": "Insurance", // or "Ecommerce"
"diversity_bucket": "Price/savings × Analytical × Skeptical",
"real_outcome": "won", // historical ground truth
"is_sentinel": false,
"rng_seed": 17004, // deterministic per-scenario seed
"seed_dialog_cut_idx": 9, // live takeover point
"attributes": { /* 23 dims: primary_motivator, decision_logic, trust_level,
communication_style, objection_pattern, purchase_urgency, … */ },
"anchors": { "last_year_price_usd": 4238, "current_quoted_price_usd": 4581,
"market_avg_for_segment_usd": 4400, "max_discount_pct_internal": 15 },
"anchor_real": true,
"seed_messages": [ /* full historical transcript: direction, text, timestamp, … */ ]
}
Per-scenario result JSON (what a run writes)
{
"arm": "planner", "scenario_id": "L_Pr_An_Sk_04", "tenant": "Insurance",
"real_outcome": "won", "outcome": "won", "end_reason": "customer_won",
"n_live_turns": 3, "elapsed_s": 78.4,
"turns": [ { "live_turn": 0, "agent_text": "…", "customer_text": "…",
"sim_mode": "generate_v2_ref", "agent_meta": { /* your verbatim meta */ } } ]
}
Written to {results_dir}/{arm}/{scenario_id}.json; runs are resumable (existing files are skipped). Aggregate across arms with paired_summary(...).
10 Behavior preservation & cross-cutting concerns
How a large refactor stayed safe without live LLM runs.
Golden safety net
Phase 0 captured the domain-coupled behavior offline — actor-prompt hash, anchor section, opp-type notes, outcome detectors, paired-summary math — before any extraction, so any drift fails loudly.
Default-path identity
Supervised gates re-keyed from panel side to engine id; the default L/R pairing maps 1:1 onto the old branches. New behavior is opt-in.
Graceful degradation
Unknown engine ids fall back to defaults; a failing plugin is logged and skipped without breaking discovery; the JSON data shim runs the server with no prod DB.
11 Testing & verification
32 tests, all green, none requiring an LLM call.
| Suite | Tests | Covers | Status |
|---|---|---|---|
| test_smoke.py | 6 | Imports, bundled-data sanity, protocol check (pre-existing) | pass |
| test_characterization.py | 11 | Golden domain behavior (byte-identical pins) | pass |
| test_registry.py | 8 | Registry, params, create/raise, run_engine delegation | pass |
| test_domain.py | 3 | Domain-pack swap changes actor text + outcomes | pass |
| test_scenario_source.py | 4 | Default + custom sources, legacy path | pass |
12 Roadmap — done vs. pending
All six planned phases landed; a few honest follow-ups remain.
13 Repository map
New files marked; the public library lives in poc/.
├─ __init__.py # public API — registry/domain/source via flat imports
├─ engine.py # Engine protocol + Baseline/Planner/Strategist
├─ registry.py # ★ EngineSpec registry + entry-point discovery
├─ domain.py # ★ DomainPack + SalesDomainPack (default)
├─ scenario_source.py # ★ ScenarioSource + JsonScenarioSource
├─ benchmark.py # runner; run_engine(id) added
├─ customer_simulator.py · actor.py # substrate (actor now reads domain pack)
├─ planner/ # PCA state-graph planner
└─ strategist/ # supervisor chain + gates (source for review)
server/ # FastAPI dual-panel UI
├─ main.py # + GET /api/engines; per-panel engine validation
└─ replayer.py # generic produce() path; supervised gates by engine id
client/ # static UI
├─ index.html · app.js # dynamic L/R selectors from /api/engines
└─ style.css
examples/
├─ external_engine_plugin/ # ★ installable entry-point plugin (echo)
├─ custom_domain_pack.py · run_pluggable_benchmark.py # ★
└─ example_engine_template.py · run_benchmark.py
tests/ # 32 tests, no LLM calls
└─ test_characterization · test_registry · test_domain · test_scenario_source # ★
14 Glossary
| Term | Meaning |
|---|---|
| Engine | An object with async produce(opp_meta, dialog, business_rules) → (text, meta) — produces one agent turn. |
| EngineSpec | The registry record describing an engine (id, name, params, factory, live_mode). |
| live_mode | How the live server drives an engine: "native" (wired-in flow) or "produce" (generic). |
| DomainPack | Supplies domain framing + win/lost signals; SalesDomainPack is the default. |
| ScenarioSource | Pluggable provider of scenarios/opp-meta/transcripts/anchors. |
| opp_meta | Customer profile + scenario context dict passed to every engine turn. |
| anchors | Per-opportunity economic reference frame (prices, discount cap, loyalty). |
| arm | One engine's full run over the scenario set; results compared pairwise. |
| seed cut | The point in the historical transcript where the live agent takes over. |