Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions docs/agent-fighter.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,39 @@ default it fails if either player produces zero controller decisions; use
`--min-decisions`, `--min-decisions-per-player`, `--min-total-damage`, and
`--min-rounds` for stricter local or CI checks.

## Seeded local-policy decisions

Pass `--seed <uint32>` to seed the local policy when the headless runner starts
the server:

```bash
bun --cwd examples/agent-fighter headless --seed 42 --matches 3 --duration-ms 10000
```

The JSON summary's `localPolicy` block records the seed and RNG version when
the runner controls the spawned server. The seed changes only the local
policy's randomized close-range and approach choices. It does not seed the
arena, browser, timers, or OpenAI Responses API. If an OpenAI decision is used,
its result is outside this seeded path.

For the same agent, the same seed and RNG/policy version produce the same local
decision sequence when given the same observation sequence. This does not
promise a repeatable whole match: browser timing, simulation observations,
runtime behavior, and software changes can alter that sequence. Recheck results
when the policy, RNG version, Bun/runtime, or relevant configuration changes.

`--seed` is rejected together with `--url`, because the runner cannot control
an already-running server. To seed an external server, set the variable before
starting it, then omit `--seed` from the runner command:

```bash
OPENCONTROLLER_AGENT_FIGHTER_SEED=42 bun --cwd examples/agent-fighter start
bun --cwd examples/agent-fighter headless --url http://127.0.0.1:5173
```

When targeting an external server, the runner summary marks the local policy as
external with an unknown random source and omits a seed it did not set.

Agents start stopped by default. Start, stop, and reset controls live on the
controller telemetry page. The OpenAI decision loop is also guarded by
`OPENCONTROLLER_OPENAI_ACTIONS_PER_MINUTE` to prevent runaway action volume.
Expand Down
19 changes: 19 additions & 0 deletions examples/agent-fighter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,25 @@ catches broken controller connections without depending on combat outcomes. Use
`--min-decisions`, `--min-decisions-per-player`, `--min-total-damage`, and
`--min-rounds` for stronger local or CI regression checks.

To seed local-policy randomness on a server started by the runner:

```bash
bun --cwd examples/agent-fighter headless --seed 42 --matches 3 --duration-ms 10000
```

The summary records the seed and RNG version under `localPolicy`. The seed
affects only randomized local-policy decisions, not arena/browser randomness,
timers, or OpenAI Responses API outcomes. For the same agent, seed, RNG/policy
version, and observation sequence, the local decision sequence repeats. This
does not make full matches repeatable because observations and timing can
change. Recheck results after policy, RNG, runtime, or relevant configuration
changes.

`--seed` requires a runner-spawned server and is rejected with `--url`. For an
external server, set `OPENCONTROLLER_AGENT_FIGHTER_SEED=42` before startup; the
runner will mark that server's RNG as unknown and will not report a seed it did
not control.

Agents start stopped by default. Use the controller telemetry page to start,
stop, or reset the duel. The server also enforces
`OPENCONTROLLER_OPENAI_ACTIONS_PER_MINUTE` so an active browser cannot accidentally
Expand Down
114 changes: 114 additions & 0 deletions examples/agent-fighter/__tests__/seeded-policy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { describe, expect, test } from "bun:test";
import {
localPolicyRunMetadata,
managementForRunnerSummary,
parseHeadlessArgs,
} from "../headless-options";
import {
createLocalPolicyRandom,
decideLocally,
localPolicySeedLimit,
parseLocalPolicySeed,
} from "../local-policy";

const closeRangeObservation = {
mode: "playing",
me: { hp: 100, x: 200 },
enemy: { x: 250, attacking: false },
};

describe("seeded Agent Fighter local policy", () => {
test("repeats decisions for a seed and the same observation sequence", () => {
const firstRandom = createLocalPolicyRandom(1234, "player-1");
const secondRandom = createLocalPolicyRandom(1234, "player-1");

const first = Array.from({ length: 20 }, () =>
decideLocally("player-1", closeRangeObservation, firstRandom),
);
const second = Array.from({ length: 20 }, () =>
decideLocally("player-1", closeRangeObservation, secondRandom),
);

expect(first).toEqual(second);
});

test("different seeds can change a randomized close-range choice", () => {
const first = decideLocally(
"player-1",
closeRangeObservation,
createLocalPolicyRandom(0, "player-1"),
);
const second = decideLocally(
"player-1",
closeRangeObservation,
createLocalPolicyRandom(2, "player-1"),
);

expect(first).toBe("heavy");
expect(second).toBe("light");
});

test("seeded choices do not use or replace global Math.random", () => {
const originalRandom = Math.random;
Math.random = () => {
throw new Error("seeded policy unexpectedly used global Math.random");
};
try {
expect(
decideLocally(
"player-1",
closeRangeObservation,
createLocalPolicyRandom(0, "player-1"),
),
).toBe("heavy");
} finally {
Math.random = originalRandom;
}
});

test("validates local policy seeds and the runner's control boundary", () => {
expect(parseLocalPolicySeed(undefined)).toBeUndefined();
expect(parseLocalPolicySeed("0")).toBe(0);
expect(parseLocalPolicySeed(String(localPolicySeedLimit))).toBe(
localPolicySeedLimit,
);
expect(() => parseLocalPolicySeed("-1")).toThrow("integer between 0");
expect(() => parseLocalPolicySeed("1.5")).toThrow("integer between 0");
expect(() => parseLocalPolicySeed("4294967296")).toThrow(
"integer between 0",
);

const seededOptions = parseHeadlessArgs(["--seed", "42"]);
expect(seededOptions.seed).toBe(42);
expect(localPolicyRunMetadata(seededOptions)).toMatchObject({
server: "runner-spawned",
randomSource: "mulberry32-v1",
seed: 42,
});
const externalOptions = parseHeadlessArgs([
"--url",
"http://localhost:5173",
]);
expect(localPolicyRunMetadata(externalOptions)).toMatchObject({
server: "external",
randomSource: "unknown-external-server",
});
expect(localPolicyRunMetadata(externalOptions)).not.toHaveProperty("seed");
expect(
managementForRunnerSummary(
{
localPolicy: { seed: 99, randomSource: "mulberry32-v1" },
},
externalOptions,
)?.localPolicy,
).toEqual({
server: "external",
randomSource: "unknown-external-server",
seedScope:
"The headless runner did not configure this external server's seed.",
});
expect(() =>
parseHeadlessArgs(["--url", "http://localhost:5173", "--seed", "42"]),
).toThrow("--seed requires a runner-spawned server");
});
});
Loading
Loading