Skip to content

Plugins ​

Everything above src/core is a plugin registered by name. A new action, module, provider, activation policy, metric or adapter is one file that exports register(registry); the kernel never changes. The interfaces on this page are declared in src/core/protocols.ts; the public API re-exports them from @misakaikato/simulacra.

Loading a plugin ​

A scenario lists its plugins, relative to the scenario file:

yaml
plugins: ["./rules.ts"]

The CLI accepts --plugin <path> on run, audit and resume, repeatable; a path listed by both the scenario and the flag registers once. Each module must export register(registry), which may return nothing or a Result. A returned error (typically a duplicate kind) or a throw during import is reported with the plugin path, and the run fails before any tick.

From code, build the registry yourself and pass it in:

ts
import { createDefaultRegistry, runScenario } from "@misakaikato/simulacra";

const registry = createDefaultRegistry();
register(registry);
await runScenario(scenario, "runs/demo", { registry });

createDefaultRegistry() holds every built-in kind; registering the same kind twice returns an error instead of replacing the built-in.

The registry ​

slotelement typefactory signature
actionsActionDefregister(def); actions are declared, not constructed
executorsExecutorregister(kind, factory)
transitionsTransitionregister(kind, factory)
modulesModuleregister(kind, factory)
providersDecisionProviderregister(kind, factory)
policiesActivationPolicyregister(kind, factory)
metricsMetricregister(kind, factory)
instrumentsQuestionnaireregister(kind, factory)
adaptersAdapterregister(kind, factory)

A factory is (spec, ctx) => Result<T, PluginError>. spec is the plugin spec from the scenario (kind, name, options); ctx carries the scenario, the registry, a logger, the run's single LLM gateway (plugins must not build their own) and provider(name), which resolves another provider declared in the scenario so composite providers can reach their downstream. parseOptions(slot, spec, schema) validates spec.options against a zod schema and turns a failure into a PluginError with paths.

Actions ​

defineAction takes one declaration and yields the tool schema shown to the model, argument validation and the resolver:

fieldmeaning
nameThe action name a decision refers to.
descriptionShown to the model in the tool schema.
paramsA zod object schema; arguments are validated before resolve runs.
requiresModulesModule names that must be present for the action to be available.
fallbackExactly one action per registry is the fallback the kernel substitutes when a decision cannot be honoured.
resolve(call, ctx) => Promise<Effect[]>; effects go through the resolver, never straight into the world.

An effect is one of set, inc, append (one column of one row), create, delete (a row), envSet (an environment key) or setColumn (one column for many rows at once, the vectorised path cohorts use). Every effect names its cause, the event id it descends from, which is what inspect follows. This is the README's example:

ts
import { defineAction, toEntityId, type Registry } from "@misakaikato/simulacra";
import { z } from "zod";

export const register = (registry: Registry) =>
	registry.actions.register(
		defineAction({
			name: "donate",
			description: "Give part of your balance to another agent.",
			params: z.object({ target: z.string(), amount: z.number().positive() }),
			requiresModules: ["ledger"],
			fallback: false,
			resolve: async (call) => [
				{
					op: "inc",
					entity: "agent",
					id: toEntityId(call.args.target),
					column: "ledger.balance",
					value: call.args.amount,
					cause: call.cause,
				},
				{
					op: "inc",
					entity: "agent",
					id: call.agentId,
					column: "ledger.balance",
					value: -call.args.amount,
					cause: call.cause,
				},
			],
		}),
	);

Declare the plugin in a scenario with plugins: ["./ledger.ts"], or pass --plugin on the command line. Modules declare the columns they own, components declare what they read and write, and the kernel validates both at assembly time. examples/prisoners_dilemma/rules.ts is a complete example of a custom module, two actions, and a rule provider.

Modules ​

A module owns columns and steps the world once per tick, after every executor has acted.

membermeaning
nameInstance name; also the owner of the columns it declares.
concurrencySafeModules that are safe to step under Promise.all say so; the others step in order.
declare(world)Declares columns (entity, name, dtype, default, owner, merge rule last, sum, max or append).
actions()The actions the module contributes.
observe(view, ids, t)What the module shows each agent; merged into the agent's observation.
step(view, t, rng)Effects to apply at the end of the tick.
initialize?Runs once before tick 0; its effects belong to the initialised world the first checkpoint captures.
graph?()A GraphView (neighbors, degree, edgeCount) for routers and transitions.
getState, setStateModule state for checkpoints; return null when the world holds everything.

Modules read through WorldView (ids, count, column, row, env, columns, hash) and never write directly; the resolver applies effects with the declared merge rules and rejects conflicts, which are counted as droppedEffects.

Providers ​

A decision provider answers a batch of requests:

membermeaning
decide(requests, ctx)One Result<Decision, ProviderFailure> per request, in request order; a length or agent mismatch fails the whole batch.
reset(seedPath)Re-seeds per replication so composite providers stay deterministic.
fit?(trace)Optional: learn from (request, decision) pairs, as the surrogate does.
audit?(ctx)Optional: the provider's own numbers, which land in the metrics.
getState, setStateProvider state for checkpoints.

A Decision carries agentId, action, args, provenance (llm, surrogate, prototype, cache, rule or interview), cost and parseOk; parseOk: false means the provider fell back to a default action. The request gives the agent's state, observation, actionSpace, optional features and, for LLM executors, the rendered prompt.

Most plugins do not implement the interface by hand. createRuleProvider({ name, seed, rule }) wraps a (request, rng) => Decision function with a per-agent rng derived from the scenario seed, the round's seed path and the agent id, so an agent's stream does not depend on batch order; a throwing rule is a per-agent failure, not a crash. examples/prisoners_dilemma/rules.ts registers four opponent strategies this way under one kind, pdRule, and picks one from options.strategy.

Activation policies ​

A policy selects who acts each tick from the world and the tick's rng alone, so activation is reproducible:

ts
interface ActivationPolicy {
	readonly name: string;
	select(world: WorldView, t: LogicalTime, rng: Rng): Activation;
}

Activation.agents maps agent ids to a mode (llm, rule, manual or interview); manualCalls may supply ready-made action calls for agents activated in manual mode, which the kernel dispatches without observe or decide.

Metrics ​

ts
interface Metric {
	readonly name: string;
	compute(view: WorldView, log: EventLog, runId: RunId): number | readonly number[];
}

Register a metric under a kind in registry.metrics; a scenario then lists it under instruments with an every interval, and each computed value becomes a measurement event and a key in result.json. A metric may query the event log with the typed query filter or with free SQL over the events table.

Adapters ​

An adapter brings an external simulation under the same audit harness:

ts
interface Adapter {
	readonly name: string;
	toScenario(external: JsonValue): Result<Scenario, string>;
	run: RunFn; // (scenario, seed, outDir) => Promise<RunResult>
}

The built-in script adapter runs a subprocess that takes --config, --seed and --out and writes result.json; the oasis adapter imports an OASIS SQLite database as a run directory (also available as simulacra import-oasis). Both are in src/adapters/.

A complete plugin in code ​

examples/programmatic/02-custom-plugin.ts defines a module, two actions and a rule provider, registers them and runs a scenario built from a YAML string, without any file on disk. The test suite executes it.

ts
// Define a world module, two actions and a rule provider in code, register them
// on a registry and run a scenario object without any YAML on disk.
// 在代码里定义一个世界模块、两个动作与一个规则 provider,注册到注册表上,
// 不落任何 YAML 文件即可运行一个场景对象。
//
//   bun examples/programmatic/02-custom-plugin.ts

import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { z } from "zod";
import {
	createDefaultRegistry,
	createRuleProvider,
	defineAction,
	ok,
	parseScenarioYaml,
	runScenario,
	toEntityId,
	type Decision,
	type DecisionRequest,
	type Effect,
	type Module,
	type Registry,
	type Rng,
} from "../../src/index";

const ENTITY = "agent";
const BALANCE = "ledger.balance";

// Actions declare zod params and resolve to effects; the kernel validates the args and applies
// the effects through its single write path.
// 动作声明 zod 参数并解析成效果;内核校验参数,并经唯一写入口应用效果。
const donate = defineAction({
	name: "donate",
	description: "Give one unit of balance to another agent.",
	params: z.object({ target: z.string() }),
	requiresModules: ["ledger"],
	fallback: false,
	resolve: async (call) => [
		{
			op: "inc",
			entity: ENTITY,
			id: toEntityId(call.args.target),
			column: BALANCE,
			value: 1,
			cause: call.cause,
		},
		{
			op: "inc",
			entity: ENTITY,
			id: call.agentId,
			column: BALANCE,
			value: -1,
			cause: call.cause,
		},
	],
});

const hold = defineAction({
	name: "hold",
	description: "Keep the balance this tick.",
	params: z.object({}),
	requiresModules: ["ledger"],
	fallback: true,
	resolve: async () => [],
});

// A module owns its columns (declare), contributes actions, feeds observations and may step the
// world each tick; this one only keeps a balance.
// 模块拥有自己的列(declare)、贡献动作、提供观察,并可以在每个 tick 推进世界;这个只维护余额。
const ledger: Module = {
	name: "ledger",
	concurrencySafe: true,
	declare: (world) =>
		world.declare({
			entity: ENTITY,
			name: "balance",
			dtype: "f64",
			default: 10,
			owner: "ledger",
			merge: "sum",
		}),
	actions: () => [donate, hold],
	observe: (view, ids) =>
		Object.fromEntries(
			ids.map((id) => [id, { balance: view.column<number>(ENTITY, BALANCE).get(id) ?? 0 }]),
		),
	step: async () => [] as readonly Effect[],
	getState: () => null,
	setState: () => undefined,
};

// Agents with an above-average balance donate to a random neighbor in the population.
// 余额高于平均的 agent 向人口里随机一个邻居捐赠。
const generous = (req: DecisionRequest, rng: Rng): Decision => {
	const balance = Number(req.observation["balance"] ?? 0);
	const others = (req.state["others"] as readonly string[] | undefined) ?? [];
	const target = others.length > 0 ? rng.pick(others) : undefined;
	const action = balance > 10 && target !== undefined ? "donate" : "hold";
	return {
		agentId: req.agentId,
		action,
		args: action === "donate" && target !== undefined ? { target } : {},
		provenance: "rule",
		cost: { llmCalls: 0, promptTokens: 0, completionTokens: 0, cachedTokens: 0, wallMs: 0 },
		parseOk: true,
	};
};

// Registering on the default registry keeps the built-in plugins; the scenario refers to the
// new kinds by name.
// 注册到默认注册表上保留了内置插件;场景按名字引用新的 kind。
const register = (registry: Registry) => {
	const m = registry.modules.register("ledger", () => ok(ledger));
	if (!m.ok) throw new Error(JSON.stringify(m.error));
	const p = registry.providers.register("generous", (_spec, ctx) =>
		ok(createRuleProvider({ name: "generous", seed: ctx.scenario.seed, rule: generous })),
	);
	if (!p.ok) throw new Error(JSON.stringify(p.error));
};

// The scenario names the module and provider kinds registered above; llm and prompt are empty
// because a rule provider never calls a model.
// 场景引用上面注册的模块与 provider kind;llm 与 prompt 为空,因为规则 provider 从不调用模型。
const yaml = `
scenarioId: ledger-demo
seed: 3
population:
  n: 12
  provenance: synthetic
  source: { kind: synthetic }
  fields:
    - { name: name, dtype: str, sampling: { kind: choice, choices: [Ada, Ben, Cy, Dee] } }
modules:
  - { kind: ledger }
executors:
  - kind: focal
    name: people
    options:
      provider: rule
      actions: [donate, hold]
      components:
        - { kind: persona }
        - { kind: instructions, options: { text: "Decide whether to donate one unit." } }
providers:
  rule: { kind: generous }
policy: { kind: allAgents }
instruments: []
steps:
  - { kind: run, ticks: 4 }
llm: {}
prompt: {}
`;

const scenario = parseScenarioYaml(yaml);
if (!scenario.ok) throw new Error(JSON.stringify(scenario.error));

const registry = createDefaultRegistry();
register(registry);

const outDir = mkdtempSync(join(tmpdir(), "simulacra-example-"));
const result = await runScenario(scenario.value, outDir, { registry, logLevel: "error" });
if (!result.ok) throw new Error(result.error.message);
console.log(
	`status ${result.value.status}, activated ${result.value.integrity.activated}, rejected ${result.value.integrity.rejectedActions}`,
);
console.log(result.value.integrity.complete ? "complete" : "incomplete");
rmSync(outDir, { recursive: true, force: true });

For a plugin loaded from a scenario file, read examples/prisoners_dilemma/rules.ts: a module that pairs agents and settles payoffs in step, cooperate and defect actions with defect as the fallback, and a rule provider with four strategies.