@statelyai/agent 2.0.0-alpha.30 → 2.0.0-alpha.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +1743 -0
  2. package/dist/ai-sdk.cjs +8 -8
  3. package/dist/ai-sdk.d.cts +1 -1
  4. package/dist/ai-sdk.d.mts +1 -1
  5. package/dist/ai-sdk.mjs +3 -3
  6. package/dist/{decision-CIb7ddIv.cjs → decision-BPdEaxPf.cjs} +30 -17
  7. package/dist/{decision-BHkvuImD.mjs → decision-CV7WiV5C.mjs} +22 -9
  8. package/dist/{event-log-BZRfBRd7.cjs → event-log-CKWeC7H-.cjs} +125 -27
  9. package/dist/{event-log-DJCMHek_.mjs → event-log-DAqsalT1.mjs} +108 -28
  10. package/dist/{event-log-store-DsP6GCRh.mjs → event-log-store-BGehvVTb.mjs} +2 -2
  11. package/dist/{event-log-store-CIIJohjj.d.mts → event-log-store-C2NjVlWU.d.mts} +13 -5
  12. package/dist/{event-log-store-DZR4IIWe.cjs → event-log-store-DKyE7JKr.cjs} +2 -2
  13. package/dist/{event-log-store-CLAC9q2Y.d.cts → event-log-store-s73r1Hxf.d.cts} +13 -5
  14. package/dist/index.cjs +129 -21
  15. package/dist/index.d.cts +59 -11
  16. package/dist/index.d.mts +59 -11
  17. package/dist/index.mjs +126 -19
  18. package/dist/log.cjs +40 -5
  19. package/dist/log.d.cts +1 -1
  20. package/dist/log.d.mts +1 -1
  21. package/dist/log.mjs +40 -5
  22. package/dist/machines.cjs +1 -1
  23. package/dist/machines.mjs +1 -1
  24. package/dist/{model-source-BCKg4qJ1.cjs → model-source-BQ97_l1-.cjs} +4 -5
  25. package/dist/{model-source-BaHwJ1wK.mjs → model-source-BilDEyqk.mjs} +1 -2
  26. package/dist/openai.cjs +2 -2
  27. package/dist/openai.d.cts +1 -1
  28. package/dist/openai.d.mts +1 -1
  29. package/dist/openai.mjs +2 -2
  30. package/dist/otel.d.cts +1 -1
  31. package/dist/otel.d.mts +1 -1
  32. package/dist/{run-agent-BpHqkaZw.mjs → run-agent-2UnSUhDb.mjs} +93 -37
  33. package/dist/{run-agent-CAVt5R-r.d.mts → run-agent-Atu-wEk7.d.mts} +17 -6
  34. package/dist/{run-agent-CTlMc3Jb.d.cts → run-agent-B5QhabdY.d.cts} +17 -6
  35. package/dist/{run-agent-ctLCvNgL.cjs → run-agent-Cg0xd3K-.cjs} +93 -37
  36. package/dist/{setup-agent-CLPCkXsu.d.cts → setup-agent-BiF1N3td.d.cts} +11 -3
  37. package/dist/{setup-agent-7rV_cujV.mjs → setup-agent-CmhB-U5P.mjs} +38 -3
  38. package/dist/{setup-agent-DoUk7EdC.cjs → setup-agent-CwUJyqKB.cjs} +38 -3
  39. package/dist/{setup-agent-CGELn1qZ.d.mts → setup-agent-J9f_Wu5T.d.mts} +11 -3
  40. package/dist/tanstack-ai.cjs +3 -3
  41. package/dist/tanstack-ai.d.cts +1 -1
  42. package/dist/tanstack-ai.d.mts +1 -1
  43. package/dist/tanstack-ai.mjs +3 -3
  44. package/dist/testing.cjs +73 -29
  45. package/dist/testing.d.cts +47 -10
  46. package/dist/testing.d.mts +47 -10
  47. package/dist/testing.mjs +65 -22
  48. package/dist/{text-logic-DmciuVUQ.d.cts → text-logic-CYP502VT.d.cts} +9 -3
  49. package/dist/{text-logic-BRYOPWvY.d.mts → text-logic-DuK9HIFM.d.mts} +9 -3
  50. package/dist/{usage-BkM3z02Y.d.mts → usage-D7JKAxLc.d.mts} +1 -1
  51. package/dist/{usage-Cm_ovz9V.d.cts → usage-DVw-646x.d.cts} +1 -1
  52. package/dist/validate.cjs +3 -3
  53. package/dist/validate.d.cts +2 -2
  54. package/dist/validate.d.mts +2 -2
  55. package/dist/validate.mjs +3 -3
  56. package/dist/{verify-Cn86rT8l.d.mts → verify-BVz4cURF.d.mts} +14 -6
  57. package/dist/{verify-COFWyprJ.mjs → verify-CoCvR1eM.mjs} +55 -5
  58. package/dist/{verify-CVlsTZ-V.cjs → verify-D_llRCTr.cjs} +57 -7
  59. package/dist/{verify-Dzwd4Mvk.d.cts → verify-Dk3Vwnha.d.cts} +14 -6
  60. package/package.json +34 -4
  61. package/readme.md +70 -40
  62. package/skills/add-evals/SKILL.md +23 -8
  63. package/skills/add-traces/SKILL.md +23 -11
  64. package/skills/generate-machine/SKILL.md +2 -2
  65. package/skills/graph-engineering/SKILL.md +1 -1
  66. package/skills/improve-machine/SKILL.md +7 -6
  67. package/skills/loop-to-machine/SKILL.md +34 -7
  68. package/skills/loop-to-machine/policy.md +14 -18
@@ -30,7 +30,7 @@ console.log(describeMachine(machine)); // goal, states, transitions, events
30
30
 
31
31
  List:
32
32
 
33
- - the decision states: an `agent.decide` invoke, a `decision` tag, or a state a host answers with `decideNext`;
33
+ - the decision states: an `agent.decide` invoke, a `decision` tag, or a state a host answers with `decideNext`. A machine whose model choice is the output of a text request (a tool call it then checks) has no decision states: eval that choice at the **request** level with `runSeam`;
34
34
  - the person's steps: idle states a person answers (`meta.interaction`, the runner's `onIdle`). What the person decides is ground truth, not something to eval; the routing after their answer is a rule;
35
35
  - the guards. `describeMachine` shows where a transition function can go and marks it `(conditional)`, but not the guard's name or logic. Read the source for those;
36
36
  - the sentinel states and the final states.
@@ -57,34 +57,49 @@ If the request names nothing ("write evals"), cover every decision state, every
57
57
 
58
58
  A guard or ordering is always a rule eval. Use a decision eval when the guards leave more than one legal option. Use an outcome eval when a person can label the right end result.
59
59
 
60
+ Draft labels gate nothing, but rule evals need no labels, so they gate CI from day one. The label-based evals start gating as the user confirms labels (`draft: false`). Say so in the hand-back, so nobody reads a green CI as "the outcome evals pass".
61
+
60
62
  ## 4. Write them
61
63
 
62
- Name every eval `<state>: <what it checks>`, so the report reads as a map of the machine. In `test.each` titles, `$name` is a placeholder: write amounts as `100 dollars`, not `$100`.
64
+ Name every eval `<state>: <what it checks>`, so the report reads as a map of the machine. An outcome eval over a whole run takes the final state it expects (`issued: partial-refund refunds 50 dollars`). In `test.each` titles, `$name` is a placeholder: write amounts as `100 dollars`, not `$100`.
63
65
 
64
66
  **Rule evals**, from `@statelyai/agent/testing`:
65
67
 
66
68
  ```ts
67
69
  test("paying: no refund over 100 dollars issues without approval", async () => {
68
- const result = await canReach(machine, "ruleBroken", { input, payloads, outputs, errors });
70
+ const result = await canReach(machine, "<sentinel>", { input, payloads, outputs, errors });
69
71
  expect(result.complete).toBe(true); // reachable: false is a proof only when the search finished
70
72
  expect(result.reachable, JSON.stringify(result.witness)).toBe(false);
71
73
  });
72
74
 
73
- test("deciding: REFUND over the order amount is refused", () => {
74
- expect(refundIsLegal({ ...context, order: { ...order, amount: 40 } }, 41)).toBe(false);
75
+ test("deciding: REFUND over the order amount never reaches paying", async () => {
76
+ const result = await canReach(machine, (s) => s.matches("paying") && s.context.amount > s.context.order.amount, { input, payloads, outputs });
77
+ expect(result.complete).toBe(true);
78
+ expect(result.reachable).toBe(false);
75
79
  });
76
80
  ```
77
81
 
82
+ Check guards through the machine, not by importing the guard and calling it: an imported `refundIsLegal` is always the one in the source, so it passes on a machine built with a loosened guard. A unit test of the guard function is fine as well, never instead.
83
+
78
84
  Use `explorePaths` to assert the set of reachable final states, and `await simulateAgent(...)` for one scripted path. Cover both sides of every threshold. The two treat a closed choice differently:
79
85
 
80
86
  - `explorePaths` and `canReach` use `options.payloads` **instead of** the decision's own offered options, so they explore values the machine would never offer. That is what a boundary proof needs.
81
87
  - `simulateAgent` keeps the offered options: a scripted event the decision did not offer is refused, and the run ends in the decision's `onError`. Script only offered events, or pick an input that offers the value.
82
88
 
83
- **Decision evals.** A row is a decision request and the event a person says is right. Write rows by hand, or take them from recorded traces: the first attempt of each decision is a `request.start` event with `request.kind === "decision"` and `request.attempts.length === 0`, and its `request` carries `name`, `options`, and `metadata`. Cast the serialized `request` to `AgentExecutorDecisionRequest`, and compare events without caring about key order. If the decider reads anything outside the request (the ticket, the tool history), store that in the row too.
89
+ **Decision evals.** A row is a decision request and the event a person says is right. Write rows by hand, or take them from recorded traces with `getDecisionRequests(trace, { name })` from `@statelyai/agent/testing`: each decision's first attempt, with the states it was asked in, its `options`, and its `metadata`. Compare events without caring about key order. If the decider reads anything outside the request (the ticket, the tool history), store that in the row too.
84
90
 
85
91
  ```ts
86
- // Your helper: rows for one decision state, from traces or a fixture file.
87
- const rows: Array<{ id: string; request: AgentExecutorDecisionRequest; expected: ChosenEvent }> = loadDecisionRows("deciding");
92
+ import { getDecisionRequests } from "@statelyai/agent/testing";
93
+
94
+ // One row per recorded decision; `expected` is the label a person confirmed for that decision.
95
+ // A scenario can decide more than once, so label each decision by its position, not the scenario alone.
96
+ const rows = traces.flatMap(({ scenario, trace }) =>
97
+ getDecisionRequests(trace, { name: "chooseResolution" }).map(({ request }, i) => ({
98
+ id: `${scenario.id}#${i}`,
99
+ request,
100
+ expected: labels[`${scenario.id}#${i}`],
101
+ })),
102
+ );
88
103
 
89
104
  test.each(rows)("deciding: $id", async ({ request, expected }) => {
90
105
  const { event } = await decide(request); // the policy decider, or the model executor
@@ -20,7 +20,7 @@ This is step 2 of 4. Before: `loop-to-machine`. Next: `add-evals`, then `improve
20
20
 
21
21
  ## Hard rules
22
22
 
23
- - **Traces never change behavior.** Recording is an observer. No branch reads a trace.
23
+ - **Traces never change behavior.** Recording is an observer. No branch reads a trace. An `onTrace` or `onTransition` handler that throws is reported with `console.error` and skipped. The store is different: it is write-ahead, so a failed write stops the run with `cause: "journal"`. Ask the user whether a run should fail when its log cannot be written (the store) or carry on unrecorded (record from `onEvent` instead).
24
24
  - **A trace must name what produced it.** A run with no machine version or policy version cannot be used to compare versions, so it is useless to the improver.
25
25
  - **Replay is the test.** A log that does not replay to the run's final state with zero model calls is not a trace yet.
26
26
  - **No secrets or personal data in metadata or thread ids.** Use ids, not names or emails. The log itself holds the machine's input and every event verbatim (a ticket's text, a customer's reply), and trace snapshots hold the context: in production, redact or encrypt them per your data rules, and ask the user which applies.
@@ -33,10 +33,11 @@ Declare `version` on every machine whose runs you record: the environment and, i
33
33
  export const deskMachine = deskSetup.createMachine({ id: "refund-desk", version: "1", ... });
34
34
  ```
35
35
 
36
- Also name the **decider version**: what answers the decisions. It is the policy machine's `version` when there is one; otherwise a label for the model and prompt that decide (`gpt-6-luna.p1`), or for a stand-in model in tests (`careful`). Runs by different deciders must never share a version label.
36
+ Also name the **decider version**: whatever produces the model's answers. It is the policy machine's `version` when there is one. Otherwise it is a label for the model, its settings, and its prompts (`gpt-6-luna.xhigh.p<hash of the prompts>`); this holds for a machine with only text requests too. When the prompts are private to another module (a route file), export a prompt version from that module rather than guessing; a release or commit id is the fallback. In tests it is the stand-in model's name (`careful`). Runs by different deciders must never share a label.
37
37
 
38
38
  - Every log entry carries `machineVersion`. Without a declared `version` it falls back to a structural hash that skips functions, and in XState v6 the guards and targets live in transition functions. Changing a guard would not change the hash.
39
39
  - Bump `version` on every behavior change. Adding recording is not one: it only observes. `improve-machine` bumps the policy's on promotion.
40
+ - A test-only variant built from the same factory (a loosened guard) gets its own `version` (`"1-loose"`), so its runs are never mistaken for the real machine's.
40
41
 
41
42
  ## 2. Pick a store
42
43
 
@@ -50,7 +51,9 @@ const result = await runAgent(deskMachine, { input, executors, store, threadId,
50
51
  await result.drain(); // late usage entries, before you read the store or exit
51
52
  ```
52
53
 
53
- **A thread that already has entries is resumed, not overwritten.** Give every new run a new thread id; reuse one only to continue that run.
54
+ **A thread that already has entries is resumed, not overwritten.** Give every new run a new thread id; reuse one only to continue that run. Passing different `input` to a thread that already has entries throws `thread-input-mismatch` instead of handing back another run's result.
55
+
56
+ For a production store, use a table of its own (`threadId`, `index`, the entry as JSON, a unique key on both), not a table that already means something else, such as a usage counter.
54
57
 
55
58
  ## 3. Name threads
56
59
 
@@ -77,7 +80,7 @@ const metadata = {
77
80
  };
78
81
  ```
79
82
 
80
- Read it back with `entries[0].metadata`. A resumed run keeps the stamp of the run that started the log. `executionId` is reserved. The value type is `JsonValue` from `@statelyai/agent/log`.
83
+ Read it back with `entries[0].metadata`. It also holds `executionId` (and `migratedFrom` after a version bridge), which the library adds and reserves, so compare with `toMatchObject`, not `toEqual`. A resumed run keeps the stamp of the run that started the log. Type the stamp as `Record<string, JsonValue>` (`JsonValue` from `@statelyai/agent/log`); an interface without an index signature does not fit.
81
84
 
82
85
  ## 5. Collect trace events
83
86
 
@@ -99,8 +102,8 @@ await writeFile(`runs/${file}.trace.json`, JSON.stringify(trace));
99
102
 
100
103
  Decide with the user whether `runs/` is committed or gitignored.
101
104
 
102
- - In production, send them to OpenTelemetry instead: `createOtelTraceHandler({ tracer })` from `@statelyai/agent/otel` is an `onTrace` handler.
103
- - **A policy run inside a decider** is a separate run. Pass it the same `onTrace` (and its own `store` thread, `<threadId>:policy:<n>`) if you want per-state stats for the policy too.
105
+ - In production, send them to OpenTelemetry: `createOtelTraceHandler({ tracer })` from `@statelyai/agent/otel` is an `onTrace` handler. It needs the optional peer `@opentelemetry/api`; without OpenTelemetry in the app, write the serialized events next to the log instead.
106
+ - **A policy run inside a decider** is a separate run. Pass it the same `onTrace` (and a `store`) if you want per-state stats for the policy too. `createPolicyDecider` gives each decision's policy run its own thread, `policy:<callKey>:<SHA-256 of its input>`; never give it one fixed `threadId`, or the second decision resumes the first.
104
107
  - **A plain XState machine** (built with `createMachine` or `setup`, decided with `decideNext`): drive it with `createAgentRuntime` and `runToQuiescence`, which accept plain machines, and pass each `decideNext` result as `{ event, attempts }` so refused choices reach the log. Push `{ type: "request.start", request: result.request }` onto the trace before sending the event, since a host-driven decision emits no trace itself.
105
108
  - **A plain `createActor` run you cannot change:** attach `recordLog(actor)` from `@statelyai/agent/log` before `actor.start()`, and pass `inspect: traceTransitions((event) => trace.push(serializeTraceEvent(event)))` to `createActor`. `recordLog` cannot record decision attempts.
106
109
 
@@ -116,15 +119,24 @@ test("a recorded run replays to its final state with no model calls", async () =
116
119
  const result = await runAgent(deskMachine, { input, executors, store, threadId: "test:careful:small-clean:r1", metadata });
117
120
  await result.drain();
118
121
  const entries = await store.read("test:careful:small-clean:r1");
119
- const { snapshot } = replay(deskMachine, entries); // a pure fold: nothing runs
120
- expect(snapshot.value).toEqual(result.snapshot.value);
122
+ expect(entries[0]?.event.type).toBe("@agent.init"); // a log was written at all; a run with no outside input has only this entry
123
+ const { snapshot } = replay(deskMachine, entries, { verify: "strict" }); // a pure fold: nothing runs
124
+ // Compare more than the state value: context and output catch a field the log does not keep.
125
+ expect({ status: snapshot.status, value: snapshot.value, context: snapshot.context, output: snapshot.output }).toEqual({
126
+ status: result.snapshot.status,
127
+ value: result.snapshot.value,
128
+ context: result.snapshot.context,
129
+ output: result.snapshot.output,
130
+ });
121
131
  expect(entries[0]!.metadata).toMatchObject({ environmentVersion: "1" });
122
132
  });
123
133
  ```
124
134
 
125
- - `foldTraces(traces, { failed })` over a batch returns a row for every decision state. `failed` judges one whole trace; start with "the last event is not a `run.end` with status `done`, or the run ended in a failure or sentinel state", and ask the user whether a wrong outcome counts too. `withheld` is above 0 only for a closed choice (`payloads`), where the guards refused an option.
126
- - `getDecisionAttempts(entries)` lists the refused choices of a run. A stand-in model that picks from the offered options can never propose an illegal one, so write a raw decide executor for this test that proposes a refused event first: `decide: async (request) => ({ event: request.attempts.length ? request.options![0]!.event : { type: "REFUND", amount: 1000 } })`.
127
- - Running a scenario with and without recording leaves the same world and the same final state.
135
+ `verify: "strict"` checks that every entry carries a hash and that each matches the fold. It does not show the log is complete (a dropped last entry leaves the rest intact), nor that the fold matches the live run, which is why the test compares the snapshots too. A journaled `Error` replays as an `Error` with its `name`, `message`, and a string or number `code`; any other property of the thrown value is not kept. A thrown plain object replays as a plain object.
136
+
137
+ - `foldTraces(traces, { failed })` over a batch returns a row for every state a run rested in. A `choice` state the run passes through in the same step gets no row. `failed` judges one whole trace; start with "the last event is not a `run.end` with status `done`, or the run ended in a failure or sentinel state", and ask the user whether a wrong outcome counts too. A serialized `run.end` is `{ type: "run.end", status, snapshot: { value, context, … } }`: `failed: (t) => { const end = t.at(-1); return end?.type !== "run.end" || end.status !== "done" || ["failed", "invariantBroken"].includes(String(end.snapshot?.value)); }`. `withheld` is above 0 only for a closed choice (`payloads`), where the guards refused an option.
138
+ - `getDecisionAttempts(entries)` lists the refused choices of a run; for a machine with no decisions (`agent.decide` or `decideNext`) it is always empty, so skip this test. A stand-in model that picks from the offered options can never propose an illegal one, so write a raw decide executor for this test that proposes a refused event first: `decide: async (request) => ({ event: request.attempts.length ? request.options![0]!.event : { type: "REFUND", amount: 1000 } })`.
139
+ - Running a scenario with and without recording leaves the same world, the same model calls, and the same final state, and the recorded run's log is not empty.
128
140
 
129
141
  ## 7. Hand back
130
142
 
@@ -31,13 +31,13 @@ import { describeMachine } from "@statelyai/agent";
31
31
  const text = describeMachine(machine); // "Machine <id>", then "Goal: …", states, transitions, events
32
32
  ```
33
33
 
34
- Pass `{ snapshot }` to mark the active states with `*`. Concept page: [Environment and policy](https://github.com/statelyai/agent/blob/main/docs/environment-and-policy.md).
34
+ Pass `{ snapshot }` to mark the active states with `*`. Concept page: [Environment and policy](https://github.com/statelyai/agent/blob/next/docs/environment-and-policy.md).
35
35
 
36
36
  ## 1. Read the schema
37
37
 
38
38
  The config shape is fixed by a shipped JSON Schema. Read it first; it is the contract.
39
39
 
40
- - Consuming the package: `node_modules/@statelyai/agent/agent-workflow.json` (importable as `@statelyai/agent/agent-workflow.json`)
40
+ - Consuming the package: `node_modules/@statelyai/agent/schemas/agent-workflow.json` (importable as `@statelyai/agent/agent-workflow.json`)
41
41
  - In the `statelyai/agent` repo: `schemas/agent-workflow.json`
42
42
 
43
43
  ## 2. Author the config
@@ -18,7 +18,7 @@ Four steps, in order. Each one needs the one before it.
18
18
 
19
19
  Check in this order and start at the first step that is not done:
20
20
 
21
- 1. Is the agent's control flow a machine (`setupAgent`, `createMachine`, an `AgentWorkflowConfig`)? If it is a loop or ad-hoc code, step 1.
21
+ 1. Does the code path production actually runs go through a machine (`setupAgent`, `createMachine`, an `AgentWorkflowConfig`)? Follow the route or entry point to the model call; a machine that nothing calls does not count. If the path is a loop or ad-hoc code, step 1.
22
22
  2. Do runs persist to an event log store with a declared machine `version` and a `metadata` stamp? If not, step 2.
23
23
  3. Is there an objective over ground truth, and evals that name the states they cover? If not, step 3.
24
24
  4. Otherwise, step 4.
@@ -11,7 +11,7 @@ You improve a **policy**: the machine that decides how the agent works toward it
11
11
  locate inputs → read traces → diagnose → propose one diff → gate (≤ 3 repairs) → replay train → replay held-out → recommend or discard → record → pull request
12
12
  ```
13
13
 
14
- This is step 4 of 4, after `loop-to-machine`, `add-traces`, and `add-evals`. To author a JSON policy from scratch, use the `generate-machine` skill. Concept page: [Environment and policy](https://github.com/statelyai/agent/blob/main/docs/environment-and-policy.md).
14
+ This is step 4 of 4, after `loop-to-machine`, `add-traces`, and `add-evals`. To author a JSON policy from scratch, use the `generate-machine` skill. Concept page: [Environment and policy](https://github.com/statelyai/agent/blob/next/docs/environment-and-policy.md).
15
15
 
16
16
  ## Hard rules
17
17
 
@@ -37,7 +37,7 @@ The rounds are the same for both formats. What differs is what the diff may touc
37
37
  **No policy yet.** `loop-to-machine` leaves a single model decision inside the environment when the old code had no policy logic. Then:
38
38
 
39
39
  - Record `v0` in a new `CHANGELOG.md` next to the environment (in a `policy/` directory): "the model decides directly". This entry goes in the working tree.
40
- - The first recommended candidate introduces the policy as `v1`: a policy file that reproduces the current behavior (it passes the environment's prompt and facts through to the model) plus the one rule the diagnosis found, and the routing line in the runner (`withPolicy(modelExecutors)`). The pattern is in `../loop-to-machine/policy.md`.
40
+ - The first recommended candidate introduces the policy as `v1`: a policy file that reproduces the current behavior (it passes the environment's prompt and facts through to the model) plus the one rule the diagnosis found, and the routing line in the runner (`withPolicy(modelExecutors)`). The policy runs as the decider through `createPolicyDecider` from `@statelyai/agent`; the pattern is in `../loop-to-machine/policy.md`.
41
41
  - Its `description` is the environment's goal with measurable success criteria. If the environment's goal has none, draft them from the confirmed objective and list the draft under questions for the user.
42
42
  - If the policy needs a fact the environment does not pass in the decision's `metadata.facts`, that is an environment change request.
43
43
 
@@ -83,8 +83,9 @@ Then find the training threads and compute one outcome row per episode:
83
83
  ```ts
84
84
  import { getDecisionAttempts, getUsageFromEvents, replay } from "@statelyai/agent/log";
85
85
 
86
- // Thread ids name the split and policy version: "train:v<n>:<scenario>:r<k>".
87
- const prefix = `train:v${currentVersion}:`;
86
+ // Thread ids name the split and the decider version, as add-traces set up: "train:<deciderVersion>:<scenario>:r<k>".
87
+ // With a policy, the decider version is the policy's version ("v3"); before one exists, the model's label.
88
+ const prefix = `train:${deciderVersion}:`;
88
89
  const threadIds: string[] = [];
89
90
  let cursor: string | undefined;
90
91
  do {
@@ -151,7 +152,7 @@ Run every check; a failure's output is the repair prompt. Cap repairs at 3.
151
152
  1. **Environment unchanged:** every environment file hashes as recorded. A change is never repaired; discard the candidate.
152
153
  2. **Types:** `tsc --noEmit` passes.
153
154
  3. **Lint:** `lintAgentMachine(policy)` from `@statelyai/agent/testing` reports no errors.
154
- 4. **Paths:** for probe inputs built from recorded decision requests (one per training scenario that reaches the decision: its facts and offered options), `canReach(policy, (snapshot) => snapshot.status === "done" && !isOfferedEvent(snapshot.output), { input })` is `{ reachable: false, complete: true }`: every final output is one of the offered events. Pass `outputs` for any actor the policy invokes, and `errors: { "agent.decide": new Error("down") }` so the fallback path is checked too.
155
+ 4. **Paths:** for probe inputs built from recorded decision requests (`getDecisionRequests(trace, { name })` from `@statelyai/agent/testing`, one per training scenario that reaches the decision: its facts and offered options), `canReach(policy, (snapshot) => snapshot.status === "done" && !isOfferedEvent(snapshot.output), { input })` is `{ reachable: false, complete: true }`: every final output is one of the offered events. Pass `outputs` for any actor the policy invokes, and `errors: { "agent.decide": new Error("down") }` so the fallback path is checked too.
155
156
  5. **Source rules:** the policy file is still one `setupAgent` machine; its imports from the environment are only schemas, types, constants, and exported guard functions; it names no actor the environment owns. These are source checks: read the diff, or grep its imports and actor names.
156
157
  6. **Overfit:** the diff contains no literal from the scenarios (ids, ticket text, customer ids, order ids). A branch that matches one scenario's data is memorization; discard it.
157
158
  7. **Existing evals:** the rule and decision evals from `add-evals` still pass. Tests that pin measured numbers of the current version (a baseline failure list, a call count) are expected to change; update them in the same pull request and list each change in its body.
@@ -231,7 +232,7 @@ Report to the user: rounds run, each candidate with its hypothesis and decision,
231
232
 
232
233
  ## Worked example
233
234
 
234
- [`examples/self-improving-refunds`](https://github.com/statelyai/agent/blob/main/examples/self-improving-refunds) in the `statelyai/agent` repo (not in the installed package) runs this loop end to end for a JSON policy:
235
+ [`examples/self-improving-refunds`](https://github.com/statelyai/agent/blob/next/examples/self-improving-refunds) in the `statelyai/agent` repo (not in the installed package) runs this loop end to end for a JSON policy:
235
236
 
236
237
  - the refund desk is the environment: business rules as guards, and an `illegallyIssued` sentinel proven unreachable,
237
238
  - `playbooks/*.json` are policy versions, with the vocabulary listed in `playbooks/README.md`,
@@ -30,7 +30,11 @@ Read the loop and fill this table before writing anything. Each row maps to a ma
30
30
  | A tool with a side effect someone must gate (refund, send) | An actor invoked by one state, entered only through a guarded transition |
31
31
  | A read-only tool (search, lookup) | An actor the machine invokes itself, or a tool on a text request |
32
32
  | A rule: an `if`, a prompt line, a check after the fact | A guard on the transition it protects |
33
+ | A model call that produces content (a draft, generated code, a summary) | A text request in `setupAgent({ requests })`, invoked by one state |
34
+ | One SDK tool call that both picks the action and carries its content (`edit_graph({ … })`, `send_email({ … })`) | A text request with those tools, then guards on the returned call: exactly one, a tool that was offered, input that passes the tool's schema. Keep it a text request: `agent.decide` would rename the tools to `send_event_<TYPE>` with the description "Choose the '<TYPE>' move.", so the model would no longer see your tool names and descriptions |
35
+ | A tool the browser runs (no `execute`; the client sends results back) | The request ends with the tool calls; an idle state waits for a `TOOL_RESULTS` event the route sends when the client posts results |
33
36
  | A point where the model picks what to do | A decision state: `agent.decide`, usually a closed choice |
37
+ | A check on the model's output, then a retry or a repair call | An actor or guard that checks it, and a `choice` state that routes to the repair request, the next candidate, or a failure final |
34
38
  | A value the model pulled out of the input (an order id, an amount) | Code when the format is fixed (a regex); a text request when it is not |
35
39
  | A step every correct run takes in the same order (verify, then look up) | A state the machine enters itself; the model no longer chooses it |
36
40
  | A person in the loop: `await approve()`, a returned `pending`, or a tool that asks a person (`askCustomer`, `requestApproval`), even a synchronous one | An idle state with `meta.interaction`, answered by `onIdle` in the runner |
@@ -41,14 +45,17 @@ Read the loop and fill this table before writing anything. Each row maps to a ma
41
45
 
42
46
  List the rules as a numbered list in your notes. Every one must end as a guard, a state ordering, or a written reason it was dropped.
43
47
 
48
+ A loop with no decision point (generate, validate, repair) is still worth converting: the machine makes the retry cap, the validation rules, and every failure path explicit and provable. It has no `agent.decide`, so step 4 and the decision parts of step 5 do not apply.
49
+
44
50
  ## 2. Pin the baseline
45
51
 
46
52
  The parity test exists before the machine does.
47
53
 
48
54
  1. **Scenarios.** Reuse the code's tests, fixtures, or benchmark cases. Each has an `id` and, where people know it, the `expected` outcome. If there are none, draft 6–10 that cover every terminal and every rule, and ask the user to confirm them.
49
- 2. **Deterministic stand-in models.** Parity with a live model drifts between runs. Use the code's scripted or mock models if it has them; otherwise write one keyed on the input (see [`examples/mock-model.ts`](https://github.com/statelyai/agent/blob/main/examples/mock-model.ts) in the `statelyai/agent` repo). You want two: one that follows the rules, and one that breaks them. A seeded random model needs its seed derived from the scenario id and a fresh instance per run, or every scenario replays the same draws.
55
+ 2. **Deterministic stand-in models.** Parity with a live model drifts between runs. Use the code's scripted or mock models if it has them; otherwise write one: a function from the request (its prompt, or its `name`) to a fixed answer. ([`examples/mock-model.ts`](https://github.com/statelyai/agent/blob/next/examples/mock-model.ts) in the `statelyai/agent` repo is one, built on the AI SDK's `MockLanguageModelV3`; it is not in the package.) You want two: one that follows the rules, and one that breaks them. A seeded random model needs its seed derived from the scenario id and a fresh instance per run, or every scenario replays the same draws.
50
56
  3. **Ground truth.** Read outcomes and effects from the world, not from the agent's own report. `effects` holds **every write the world records**: the refunds, and also the approvals, the verifications, the questions asked, the escalations. A refund issued without its approval looks correct if `effects` holds only refunds. Text a model wrote (an escalation reason) differs on every run: compare its count or presence, not the text. If the world can check rules after the fact, return those violations. If it cannot, write the check as a plain function over the effects.
51
- 4. **The harness check.** Run the old code on both sides with each stand-in model. Each row's baseline must equal its candidate (the runs are deterministic), and with the rule-following model every row must be `same`. With a rule-breaking model, rows are `regression` by design: the candidate breaks rules.
57
+ 4. **A baseline with no function to call.** When the loop lives inline in a framework handler (a tRPC procedure, a route), call the handler itself in the test, with its model client and storage replaced by the stand-ins (a module mock, or the framework's test caller). Do not extract the loop into a function first: that changes the code you are trying to pin.
58
+ 5. **The harness check.** Run the old code on both sides with each stand-in model. Each row's baseline must equal its candidate (the runs are deterministic), and with the rule-following model every row must be `same`. With a rule-breaking model, rows are `regression` by design: the candidate breaks rules.
52
59
 
53
60
  ```ts
54
61
  import { compareRuns, type ParityResult, type ParityRun } from "@statelyai/agent/testing";
@@ -99,15 +106,16 @@ deciding: {
99
106
  // The facts a decider reads. Arrives as `request.metadata.facts`.
100
107
  metadata: { facts: { order: context.order, requestedAmount: context.requestedAmount } },
101
108
  }),
102
- onError: { target: "escalated" }, // retries exhausted, or no option legal
109
+ onError: { target: "escalating" }, // retries exhausted, or no option legal
103
110
  },
104
111
  on: {
105
112
  REFUND: ({ context, event }) =>
106
113
  refundIsLegal(context, event.amount)
107
114
  ? { target: event.amount > APPROVAL_THRESHOLD ? "awaitingApproval" : "paying", context: { amount: event.amount } }
108
115
  : undefined, // undefined rejects: the decision retries with feedback
109
- ESCALATE: { target: "escalated" },
110
- CLOSE: { target: "closed" },
116
+ // `escalate` and `close` are tools with side effects: states that invoke them, then the final states.
117
+ ESCALATE: { target: "escalating" },
118
+ CLOSE: { target: "closing" },
111
119
  },
112
120
  },
113
121
  ```
@@ -130,9 +138,14 @@ If the code does have that logic, port it as a policy machine that answers `choo
130
138
 
131
139
  Write `runMachine` with the old function's signature and result type, so callers and the parity test change one line.
132
140
 
141
+ - **The existing SDK call becomes the executor.** Write `generateText` (or `streamText`) in `executors` as the old call, with the same model, system prompt, tools, and options, and return `{ result }`. The request still needs `prompt` or `messages` in `setupAgent({ requests })` (`setupAgent` throws `invalid-request` otherwise): pass the conversation as `messages: ({ input }) => input.messages` and read `request.messages` in the executor.
142
+ - **A streaming route keeps its response, not its function signature.** `createAiSdkExecutors().streamText` forwards text chunks only, so a UI stream with tool calls cannot come out of it. Write the `streamText` executor around the old call, pipe its UI stream to the route's response yourself, and return the final messages as `{ result }`.
143
+
133
144
  - `runAgent(machine, { input, executors, actors, onIdle })`.
134
145
  - `onIdle` answers each idle state with the old code's call (`tools.askCustomer(...)`, `tools.requestApproval(...)`). Write the returned events with `as const` on `type`, or the event union does not narrow.
135
146
  - `executors.decide` adapts the code's model to the decision: the closed choice arrives as `request.options` (each `{ event }`), and the facts as `request.metadata?.facts`, typed `unknown`: parse it with the exported facts schema. Every executor slot is optional in the type; check or assert the ones you call.
147
+ - In an `onError` function, the error is `event.error`: `onError: ({ event }) => ({ target: "failed", context: { error: String(event.error) } })`. There is no `error` argument.
148
+ - Make input and events JSON-safe at the boundary: no `Date`, `Map`, class instances, or functions (fields set to `undefined` are dropped). With a store, a value the log cannot hold fails the run; without one, the log stops with a warning.
136
149
  - Rebuild the old result from `result.output` and the world. A field that has no meaning anymore (a turn count with no loop) keeps its type and gets a comment saying what it now holds.
137
150
 
138
151
  ## 6. Proofs that need no model
@@ -141,12 +154,26 @@ From `@statelyai/agent/testing`:
141
154
 
142
155
  - `lintAgentMachine(machine)`: no errors.
143
156
  - `canReach(machine, "<sentinel>", options)` returns `reachable: false` **and** `complete: true`, for each distinct input shape (an input with an order id and one without lead to different paths; loop over them).
144
- - `options.payloads` lists values on both sides of every guard boundary (`0, 40, 41, 100, 101, 240, 241, 1000`). Without it, a closed choice is explored only with its own `payloads`, and `complete: true` then proves nothing about the boundaries.
157
+ - `options.payloads` is a function of the snapshot that returns payload lists per event type. List values on both sides of every guard boundary: `0`, the threshold and one over, and each order amount the outputs below can produce and one over. Without it, a closed choice is explored only with its own `payloads`, and `complete: true` then proves nothing about the boundaries.
158
+ - A free-text event from a person's step (`CUSTOMER_REPLIED`) needs an entry too, one payload per branch it can take, or the search cannot send it.
159
+
160
+ ```ts
161
+ const proofOptions: ExplorePathsOptions = {
162
+ payloads: () => ({
163
+ REFUND: [0, 40, 41, 100, 101, 240, 241, 800, 801].map((amount) => ({ amount })),
164
+ CUSTOMER_REPLIED: [{ reply: "It's ORD-1001" }, { reply: "no idea" }],
165
+ }),
166
+ outputs: { lookupOrder: [ownedOrder, otherCustomersOrder, refundedOrder, { found: false }] },
167
+ errors: { lookupOrder: new Error("down"), "agent.decide": new Error("exhausted") },
168
+ };
169
+ ```
145
170
  - `options.outputs` lists every output each actor can return (found and not found, owned and not, refunded and not); `options.errors` makes each actor fail once.
171
+ - **A machine with a loop** (a repair cycle, a resubmit) needs `options.dedupeBy`, or the search runs to `maxDepth` and reports `complete: false`. Return every context field a guard or choice reads: `dedupeBy: (s) => JSON.stringify([s.value, s.context.attempts, s.context.lastError])`. `outputs` are canned per actor src, not per call: to explore "fails twice, then succeeds", let the guard read a counter from context instead of the output.
172
+ - `explorePaths(...).reachedStates` lists only states a run settles in; a `choice` state it passes through is not listed.
146
173
  - A loosened guard makes `canReach` true. Write this test too, or the proof proves nothing.
147
174
  - Each normal final state is reachable, so the search is not vacuous.
148
175
 
149
- Type the options with `ExplorePathsOptions` from `@statelyai/agent/testing`. `errors` takes one error per actor src, and `"agent.decide"` is a valid key.
176
+ Type the options with `ExplorePathsOptions`, and a `payloads`, `dedupeBy`, or predicate callback's snapshot with `ExploreSnapshot`, both from `@statelyai/agent/testing`. Do not use `AnyMachineSnapshot` from your app's own `xstate`, which may be another version. `errors` takes one error per actor src, and `"agent.decide"` is a valid key.
150
177
 
151
178
  ## 7. Parity
152
179
 
@@ -10,12 +10,15 @@ A policy answers one decision of the environment. It is a `setupAgent` machine,
10
10
 
11
11
  ## What the environment exports
12
12
 
13
- The event schemas and the facts schema, so the policy reuses them instead of redeclaring them. The `agent.decide` input passes the facts as `metadata: { facts }`.
13
+ The event schemas and the facts schema, so the policy reuses them instead of redeclaring them. The `agent.decide` input passes the facts as `metadata: { facts }`. The facts must carry everything any decider reads: the policy's rules, and the adapter that turns a request into a call to your model (the ticket, the customer, the order's owner). A field the adapter needs and the facts lack changes what the model sees, so the policy no longer reproduces the current behavior.
14
14
 
15
15
  ```ts
16
16
  // In the environment file (exported there):
17
+ // Everything a decider reads: the policy's rules, and the model adapter when the policy asks the model.
17
18
  export const factsSchema = z.object({
18
- order: z.object({ orderId: z.string(), amount: z.number(), refunded: z.boolean() }).nullable(),
19
+ ticket: z.string(),
20
+ customerId: z.string(),
21
+ order: z.object({ orderId: z.string(), customerId: z.string(), amount: z.number(), refunded: z.boolean() }).nullable(),
19
22
  requestedAmount: z.number().nullable(),
20
23
  });
21
24
  export const deskEvents = {
@@ -29,7 +32,7 @@ export const deskEvents = {
29
32
 
30
33
  ```ts
31
34
  import { z } from "zod";
32
- import { runAgent, setupAgent, type AgentDecisionExecutor, type AgentRequestExecutors } from "@statelyai/agent";
35
+ import { createPolicyDecider, setupAgent, type AgentDecisionExecutor, type AgentRequestExecutors } from "@statelyai/agent";
33
36
  import { deskEvents, factsSchema } from "./desk.js";
34
37
 
35
38
  /** One proposal: an environment event with its `type`. */
@@ -116,22 +119,13 @@ export const policyMachine = policySetup.createMachine({
116
119
  },
117
120
  });
118
121
 
119
- /** Runs the policy as the environment's decider. */
122
+ /**
123
+ * Runs the policy as the environment's decider. createPolicyDecider passes the request's
124
+ * `metadata.facts`, offered events, `system`, and `prompt` as the policy's input (its input
125
+ * schema validates them), and returns the policy's output event with its token usage.
126
+ */
120
127
  export function policyDecider(executors: AgentRequestExecutors): AgentDecisionExecutor {
121
- return async (request, info) => {
122
- const result = await runAgent(policyMachine, {
123
- input: {
124
- facts: factsSchema.parse(request.metadata?.facts),
125
- options: (request.options ?? []).map((option) => proposalSchema.parse(option.event)),
126
- system: request.system,
127
- prompt: request.prompt,
128
- },
129
- executors,
130
- signal: info?.signal,
131
- });
132
- if (result.status !== "done") throw new Error(`policy settled '${result.status}'`);
133
- return { event: result.output };
134
- };
128
+ return createPolicyDecider(policyMachine, { executors });
135
129
  }
136
130
  ```
137
131
 
@@ -156,3 +150,5 @@ export function withPolicy(modelExecutors: AgentRequestExecutors): AgentRequestE
156
150
  ```
157
151
 
158
152
  Pass `withPolicy(modelExecutors)` as the environment's `executors` in the runner. That one line is the only runner change a policy needs. Decisions with any other name still reach the model.
153
+
154
+ A runner that already takes a decide executor for the decision (`runMachine({ decide })`) needs no routing: pass `policyDecider(modelExecutors)` there. If your model is not an executor (a function that returns an option index, say), wrap it in a decide executor first, the same adapter the runner uses, and keep that adapter out of the environment's imports.