simulate.py, is the one file between the runner and your agent. The scan generates it for you;
this page is the contract for when you write one by hand or edit the generated one.
How the runner uses it
simulate.py is not a script you launch. The runner starts it in a child process (python -m plurai.child simulate.py), imports it, and calls its hooks over a private channel on the child’s standard input and output.
So:
- Do not print to stdout from the harness or anything it spawns. stdout is the runner’s channel. Send logs to stderr or to a file you control.
- No
if __name__ == "__main__":block is needed. - Never call
asyncio.run()inside a hook. The runner already drives an event loop. Any hook may beasync def; the runner awaits it. - Load your agent’s
.envyourself. The child imports onlysimulate.py, so settings that live only in.env(model, provider keys) are invisible unless the harness loads them, for example withdotenv.load_dotenv().
Structure
A harness does its work in this order, at import time:1
Stub production dependencies
Replace modules that connect to production on import with
MagicMock, before anything imports them:2
Define the mocks
Each mocked tool is a small A handler reads the world three ways:
@plurai.mock handler that reads the scenario’s world. Keep handlers simple; the
rich description of the tool (what it reads and writes, its business rules) lives in its descriptor.ctx.world_state.get_entity(id), ctx.world_state.query(type, **filters), or plurai.world_state_snapshot() for the whole map. It writes with
ctx.world_state.apply({id: {...fields}}).@plurai.observe("rag_search") registers an observer instead: the real tool runs, and the observer records
its result into the world.3
Route your agent's tools to the mocks
Patch tool functions before your agent is imported (
plurai.apply_mocks(module, {"name": "mock", ...})), or
swap a LangGraph ToolNode for plurai.MockToolNode. See
Python agent frameworks.4
Import your agent and export the hooks
Import your agent unchanged, then define
invoke_agent and any optional hooks below.Hooks
The runner discovers these functions by name on the module.invoke_agent
Payload
payload["messages"] holds only the new user message, never the conversation so far.
It is empty on the opening call when the agent speaks first (agent.io: {may_open: true}), so never index
[-1] blindly. config["configurable"]["thread_id"] is stable for the whole scenario. The scenario’s agent
context is spread into the payload on turn 1 only.
Other payload keys:
A document the user hands over arrives in the new message’s content as a
file part with a data: URI. It is
never an agent-context key or a seed_state value. Declare it with agent.io: {input: [text, file]} in
plurai.config.yaml.
Return value
Return your agent’s own output, unaltered:- the agent state dict with the message list under
messages(LangGraph’s result), or - a plain string, or
- a message list under
messagesin your framework’s own shape: LangChain objects, OpenAI role dicts or Anthropic content blocks. The SDK converts them; do not normalize.
content: "" with
its tool calls. Never paint actions into the reply text, and never invent a sentence for a silent turn.
Every key of a returned dict other than messages is saved per turn in transcript.json as agent_state. These
keys have a defined meaning:
For trace platforms to show each model call, return the turn’s whole chain: each assistant message with its tool
calls and the model and token usage your framework reports. A LangGraph or LangChain result already carries them.
Sync or async: write
def invoke_agent for graph.invoke, async def invoke_agent for graph.ainvoke. Pass
your production entry’s run settings, step cap included; never raise a cap such as LangGraph’s
recursion_limit for the simulation.
invoke_pipeline
invoke_pipeline(inputs) -> dict, sync or async, for a one-shot agent with no conversation: the runner calls it
once with the scenario’s inputs and scores the (inputs, outputs) pair. Declare shape: pipeline in
plurai.config.yaml. A bench that exports invoke_pipeline and no invoke_agent without declaring a shape is
refused at Run start.
seed_state
- Three positional parameters, always. The third is passed on every scenario,
Nonefor a fresh one. A two-parameter hook fails every Run during setup, and the SDK names the required signature. initial_agent_contextholds the scenario’s context values, including those declaredinject: request.initial_world_state["entities"]is the scenario’s seeded world. Apply it withplurai.world_state.apply(...), the same surface your mocks read. The world is per scenario, so there is nothing to clear first.resume_seedis the conversation a resumed scenario starts from (a scenario built from a production trace). Apply it to your agent’s history; what “apply” means is your harness’s decision. ReturnNonewhen it was restored in full, or a dict with string keys saying what was left out. Raiseplurai.HarnessFaultwhen fewer messages are restored than seeded.- A harness with no
seed_stateruns fresh scenarios and is refused on a resumed one.
plurai.store_record(row), which drops the
routing keys (_entity_type and the backend’s id copy). Keep the original row for routing and for
world_snapshot().
reset
reset() takes no arguments and returns nothing. It clears what your harness owns before each scenario: a file,
a database table, a checkpointer’s database, a store on a real service. Never clear plurai.world_state here; the
backend re-seeds it per scenario. A config that declares world_state.owner: agent or external with no
reset() is refused at Run start.
Up to concurrency × trials copies of your harness run at once. Anything the harness creates (a port, a working
folder, a lock file, a record) must be per child: bind port 0, use tempfile.mkdtemp(), and tag records with
plurai.active_session_id().
world_snapshot
world_snapshot() takes no arguments and returns the world your harness owns as {entity_id: {...fields}}, each
value carrying _entity_type. The runner calls it once at the end and reports it as the scenario’s final world,
which world-reading evals are scored against. It must be JSON-serializable.
Without it, on a bench that declares its own world, world-reading evals resolve to not-measured instead of scoring
an empty world.
For a file your agent wrote, add one row with plurai.file_row(path, entity_type="output_file"). For a file
handed to your agent, pass preview=False. Declare the row’s fields in world_state.entity_types.
world_state.owned_types and
return only your own rows; the Run combines them with the mock’s rows of every other type.
Faults: whose failure is it
The Run separates failures that belong to your agent from failures that belong to the harness.
A mock that refuses a world write is recorded the same way as a
HarnessFault.
Mock results and errors
plurai.mock_result(response) turns a mock response into what your agent sees: the tool’s value, or a raised
plurai.MockToolError for a failed call. How a failure reaches your agent follows the tool’s declared
error_channel in plurai.config.yaml: raises raises MockToolError as the real tool would; returns hands
back the failure value. Catch MockToolError in the harness and give your agent its own failure, never a value
that reads as success.
Check your harness
The plugin’s scan runs two verifiers and a smoke run over a generated or edited harness. To run them after a manual edit, ask in chat: “Verify my simulate.py.” Then run one scenario:Next steps
API reference
Every supported name in the
plurai package.Configuration
plurai.config.yaml, including world_state and agent.io.