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

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 (80) hide show
  1. package/dist/ai-sdk.cjs +150 -27
  2. package/dist/ai-sdk.d.cts +59 -36
  3. package/dist/ai-sdk.d.mts +59 -36
  4. package/dist/ai-sdk.mjs +148 -24
  5. package/dist/{decision-RQ0iv0oZ.mjs → decision-BHkvuImD.mjs} +74 -8
  6. package/dist/{decision-CmM8-HHz.cjs → decision-CIb7ddIv.cjs} +113 -30
  7. package/dist/{text-logic-f5F5sCdc.cjs → event-log-BZRfBRd7.cjs} +814 -7
  8. package/dist/{text-logic-CIqHAmBi.mjs → event-log-DJCMHek_.mjs} +680 -4
  9. package/dist/{event-log-store-BIPOz-di.d.mts → event-log-store-CIIJohjj.d.mts} +90 -5
  10. package/dist/{event-log-store-2xzFmgr2.d.cts → event-log-store-CLAC9q2Y.d.cts} +90 -5
  11. package/dist/event-log-store-DZR4IIWe.cjs +95 -0
  12. package/dist/event-log-store-DsP6GCRh.mjs +84 -0
  13. package/dist/index.cjs +576 -58
  14. package/dist/index.d.cts +104 -510
  15. package/dist/index.d.mts +104 -510
  16. package/dist/index.mjs +547 -32
  17. package/dist/log.cjs +77 -20
  18. package/dist/log.d.cts +5 -5
  19. package/dist/log.d.mts +5 -5
  20. package/dist/log.mjs +58 -3
  21. package/dist/machines.cjs +1 -1
  22. package/dist/machines.d.cts +15 -15
  23. package/dist/machines.d.mts +15 -15
  24. package/dist/machines.mjs +1 -1
  25. package/dist/model-source-BCKg4qJ1.cjs +44 -0
  26. package/dist/model-source-BaHwJ1wK.mjs +39 -0
  27. package/dist/openai.cjs +11 -11
  28. package/dist/openai.d.cts +28 -26
  29. package/dist/openai.d.mts +28 -26
  30. package/dist/openai.mjs +3 -3
  31. package/dist/otel.cjs +5 -4
  32. package/dist/otel.d.cts +5 -7
  33. package/dist/otel.d.mts +5 -7
  34. package/dist/otel.mjs +5 -3
  35. package/dist/{run-agent-xNTxpt82.mjs → run-agent-BpHqkaZw.mjs} +181 -44
  36. package/dist/{run-agent-COXXc0RW.d.mts → run-agent-CAVt5R-r.d.mts} +95 -30
  37. package/dist/{run-agent-ByO-Bxyd.d.cts → run-agent-CTlMc3Jb.d.cts} +95 -30
  38. package/dist/{run-agent-Dmf8GV2d.cjs → run-agent-ctLCvNgL.cjs} +230 -88
  39. package/dist/{setup-agent-BuanDXeh.mjs → setup-agent-7rV_cujV.mjs} +15 -9
  40. package/dist/setup-agent-CGELn1qZ.d.mts +577 -0
  41. package/dist/setup-agent-CLPCkXsu.d.cts +577 -0
  42. package/dist/{setup-agent-DV4cTou-.cjs → setup-agent-DoUk7EdC.cjs} +31 -20
  43. package/dist/tanstack-ai.cjs +627 -0
  44. package/dist/tanstack-ai.d.cts +204 -0
  45. package/dist/tanstack-ai.d.mts +204 -0
  46. package/dist/tanstack-ai.mjs +618 -0
  47. package/dist/testing.cjs +204 -985
  48. package/dist/testing.d.cts +132 -347
  49. package/dist/testing.d.mts +132 -347
  50. package/dist/testing.mjs +191 -973
  51. package/dist/{text-logic-IjfYy9DP.d.cts → text-logic-BRYOPWvY.d.mts} +52 -12
  52. package/dist/{text-logic-D-czVPYp.d.mts → text-logic-DmciuVUQ.d.cts} +52 -12
  53. package/dist/{types-BgNbNKnJ.d.mts → types-Dp5_Ru4R.d.cts} +2 -2
  54. package/dist/{types-hR5f5YXa.d.cts → types-Dp5_Ru4R.d.mts} +2 -2
  55. package/dist/usage-BkM3z02Y.d.mts +17 -0
  56. package/dist/usage-Cm_ovz9V.d.cts +17 -0
  57. package/dist/{utils-_q7F-AZu.d.mts → utils-BnXdWih3.d.mts} +1 -2
  58. package/dist/{utils-o4YRdHJX.d.cts → utils-ebVpCYoF.d.cts} +1 -2
  59. package/dist/validate.cjs +239 -8
  60. package/dist/validate.d.cts +110 -3
  61. package/dist/validate.d.mts +110 -3
  62. package/dist/validate.mjs +238 -7
  63. package/dist/verify-COFWyprJ.mjs +1181 -0
  64. package/dist/verify-CVlsTZ-V.cjs +1222 -0
  65. package/dist/verify-Cn86rT8l.d.mts +414 -0
  66. package/dist/verify-Dzwd4Mvk.d.cts +414 -0
  67. package/package.json +44 -31
  68. package/readme.md +40 -28
  69. package/skills/add-evals/SKILL.md +129 -0
  70. package/skills/add-traces/SKILL.md +135 -0
  71. package/skills/generate-machine/SKILL.md +143 -69
  72. package/skills/graph-engineering/SKILL.md +33 -0
  73. package/skills/improve-machine/SKILL.md +241 -0
  74. package/skills/improve-machine/reference.md +101 -0
  75. package/skills/loop-to-machine/SKILL.md +183 -0
  76. package/skills/loop-to-machine/policy.md +158 -0
  77. package/dist/event-log-store-2pAFiWuc.mjs +0 -576
  78. package/dist/event-log-store-C-COhIXP.cjs +0 -702
  79. package/dist/usage-BdEjFuIi.mjs +0 -5
  80. package/dist/usage-efOQPdf_.cjs +0 -10
@@ -0,0 +1,129 @@
1
+ ---
2
+ name: add-evals
3
+ description: Write evals for a @statelyai/agent or XState agent machine at the right level — rule checks with no model, decision evals at one decision state, outcome evals over whole runs, and per-state stats from traces — each tied to the states it covers. Use when asked to "write evals", "add evals for the approval logic", "test the agent", "measure the agent", "build an eval suite", or "what should I eval" for an agent built as a state machine.
4
+ ---
5
+
6
+ # Add evals to an agent machine
7
+
8
+ The machine tells you what to eval and at which level. Every eval names the states it covers, so a failing eval points at a part of the machine.
9
+
10
+ ```
11
+ read the machine → map the request to states → pick the level per state → write the evals → baseline → hand back
12
+ ```
13
+
14
+ This is step 3 of 4. Before: `loop-to-machine`, `add-traces`. Next: `improve-machine`.
15
+
16
+ ## Hard rules
17
+
18
+ - **The cheapest level that can catch the failure.** A rule is checked without a model. Only judgment needs a model in the eval.
19
+ - **Ground truth, never a model's opinion of itself.** Outcome evals read terminal states and effects in the world. A model grades text only where no ground truth exists, and that eval says so in its name.
20
+ - **Labels belong to people.** Draft expected outcomes and expected events; ask the user to confirm them before they gate anything. Keep drafts in their own list, marked unconfirmed, and out of every gate.
21
+ - **Never edit the machine to make an eval pass.** A failing eval is a finding. Report it.
22
+ - **Held-out scenarios stay held out.** Evals may run on them; nobody tunes against them.
23
+
24
+ ## 1. Read the machine
25
+
26
+ ```ts
27
+ import { describeMachine } from "@statelyai/agent";
28
+ console.log(describeMachine(machine)); // goal, states, transitions, events
29
+ ```
30
+
31
+ List:
32
+
33
+ - the decision states: an `agent.decide` invoke, a `decision` tag, or a state a host answers with `decideNext`;
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
+ - 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
+ - the sentinel states and the final states.
37
+
38
+ ## 2. Map the request to states
39
+
40
+ Turn the user's words into states. "Evals for the approval logic" means every state and guard that touches approval: the guard that routes a refund over the threshold, the approval step and each answer to it, the sentinel check before the payment, and the final states they lead to. Write the mapping down:
41
+
42
+ | Asked about | States and guards |
43
+ | ----------- | -------------------------------------------------------------------------------------------------- |
44
+ | approval | `deciding` (REFUND: over threshold → `awaitingApproval`), `awaitingApproval` (APPROVE, REJECT), `paying` (sentinel check), `issued`, `closed` |
45
+
46
+ If the request names nothing ("write evals"), cover every decision state, every sentinel, and the run as a whole.
47
+
48
+ ## 3. Pick the level
49
+
50
+ | Level | Catches | Runs a model | Tools |
51
+ | ------------ | ---------------------------------------------------- | --------------------- | ------------------------------------------------------------------ |
52
+ | **Rule** | A guard, an ordering, an invariant that must hold | No | Exported guard functions, `canReach`, `explorePaths`, `simulateAgent` |
53
+ | **Decision** | A wrong choice at one decision state | The decider under test | The decider called on recorded or written decision requests |
54
+ | **Request** | A bad text output from one request | That request | `runSeam`, scored on `seamOutput` and the path after it. Not applicable when the machine has no text requests; say so |
55
+ | **Outcome** | A wrong end result for a whole scenario | The whole agent | Scenarios, the runner, an objective over terminal states and effects |
56
+ | **Stats** | Which state the failures cluster in | No (reads traces) | `foldTraces` |
57
+
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
+
60
+ ## 4. Write them
61
+
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`.
63
+
64
+ **Rule evals**, from `@statelyai/agent/testing`:
65
+
66
+ ```ts
67
+ test("paying: no refund over 100 dollars issues without approval", async () => {
68
+ const result = await canReach(machine, "ruleBroken", { input, payloads, outputs, errors });
69
+ expect(result.complete).toBe(true); // reachable: false is a proof only when the search finished
70
+ expect(result.reachable, JSON.stringify(result.witness)).toBe(false);
71
+ });
72
+
73
+ test("deciding: REFUND over the order amount is refused", () => {
74
+ expect(refundIsLegal({ ...context, order: { ...order, amount: 40 } }, 41)).toBe(false);
75
+ });
76
+ ```
77
+
78
+ 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
+
80
+ - `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
+ - `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
+
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.
84
+
85
+ ```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");
88
+
89
+ test.each(rows)("deciding: $id", async ({ request, expected }) => {
90
+ const { event } = await decide(request); // the policy decider, or the model executor
91
+ expect(event).toEqual(expected);
92
+ });
93
+ ```
94
+
95
+ Which deciders gate CI: a deterministic reference decider (a scripted or rules-based one) gates. A live model is a measurement, not a unit test: run it outside CI, several times, and report the rate. A seeded random decider is measured over several seeds, never gated.
96
+
97
+ **Request evals.** For a text request (a drafted question, a summary), `runSeam(machine, { input, scripts, seam: { request: "draftQuestion" }, candidate })` runs the whole machine with only that request live. Score `seamOutput`, and the states the run reached after it.
98
+
99
+ **Outcome evals.** One function over a batch of scenario runs, reading ground truth. Score the effects as well as the outcome: a run that ends `issued` with the wrong amount is wrong.
100
+
101
+ ```ts
102
+ export function objective(runs: Array<{ scenario: Scenario; outcome: string; refund?: Refund; violations: number; modelCalls: number }>) {
103
+ return {
104
+ accuracy: runs.filter((r) => r.outcome === r.scenario.expected && sameRefund(r.refund, r.scenario.expectedRefund)).length / runs.length,
105
+ violations: runs.reduce((n, r) => n + r.violations, 0), // must be 0
106
+ modelCalls: runs.reduce((n, r) => n + r.modelCalls, 0), // cost
107
+ };
108
+ }
109
+ ```
110
+
111
+ Read the terminal state from the event logs `add-traces` recorded (`replay(machine, entries).snapshot`, `replay` from `@statelyai/agent/log`). Effects and violations live in the world, not the log: save them next to the thread id when the run ends, so the objective can be recomputed without running anything. Use `matchesTrajectory` only for an ordering a person requires (approval before payment), never to pin the whole path; build its input from the `machine.transition` trace events' `snapshot.value`.
112
+
113
+ **Stats.** `foldTraces(traces, { failed })` (from `@statelyai/agent/testing`) returns `{ state, visits, failures, withheld }` per state. A failed run counts against **every** state it visited, so states after a bad decision rank as high as the decision itself. Use the table to see which states failing runs pass through, then look at the decision states among them, their `withheld` counts, and their decision attempts. `failed` sees only the trace: to judge a run against its label, keep a map from trace to scenario.
114
+
115
+ ## 5. Coverage
116
+
117
+ If every decider scores the same on the confirmed scenarios, the scenarios cannot tell a better decider from a worse one. Draft scenarios on the boundaries the evals found (an amount just under and over a threshold, a partial request), label them as drafts, and ask the user to confirm them and add them to the scenario set. They become part of the train or held-out split only once confirmed.
118
+
119
+ ## 6. Baseline
120
+
121
+ Run every eval once on the current machine and record the numbers: which pass, the outcome scores per split, the stats table. Failing evals are findings, not bugs in the eval, unless the label was wrong; then ask.
122
+
123
+ ## 7. Hand back
124
+
125
+ 1. The state mapping from step 2.
126
+ 2. The evals by level, each named by state.
127
+ 3. The baseline numbers, train and held-out separately.
128
+ 4. Every failing eval and the state it points at.
129
+ 5. Next step: `improve-machine`, with the objective from step 4 as its scorer.
@@ -0,0 +1,135 @@
1
+ ---
2
+ name: add-traces
3
+ description: Make every run of a @statelyai/agent or XState machine leave a replayable, stamped trace — an event log thread plus trace events — so evals and self-improvement can read what happened and what produced it. Use when asked to "add tracing", "add logging", "persist runs", "stamp traces", "record what the agent did", or "set up observability" for an agent machine, or before writing evals or improving a policy from traces.
4
+ ---
5
+
6
+ # Add traces to an agent machine
7
+
8
+ Every run should leave two records, both stamped with what produced it:
9
+
10
+ | Record | What it is | Read by |
11
+ | ---------------- | ------------------------------------------------------------------------------ | ---------------------------------------------------- |
12
+ | Event log thread | The external inputs the machine consumed, in order. The source of truth | `replay`, `getDecisionAttempts`, `getUsageFromEvents` |
13
+ | Trace events | Observations: transitions with `from`, decision requests with offered options | `foldTraces`, OpenTelemetry, debugging |
14
+
15
+ ```
16
+ version the machines → pick a store → name threads → stamp → collect trace events → prove replay → hand back
17
+ ```
18
+
19
+ This is step 2 of 4. Before: `loop-to-machine`. Next: `add-evals`, then `improve-machine`.
20
+
21
+ ## Hard rules
22
+
23
+ - **Traces never change behavior.** Recording is an observer. No branch reads a trace.
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
+ - **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
+ - **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.
27
+
28
+ ## 1. Version the machines
29
+
30
+ Declare `version` on every machine whose runs you record: the environment and, if there is one, the policy machine.
31
+
32
+ ```ts
33
+ export const deskMachine = deskSetup.createMachine({ id: "refund-desk", version: "1", ... });
34
+ ```
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.
37
+
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
+ - Bump `version` on every behavior change. Adding recording is not one: it only observes. `improve-machine` bumps the policy's on promotion.
40
+
41
+ ## 2. Pick a store
42
+
43
+ - **Tests and local runs:** `createInMemoryEventLogStore()` from `@statelyai/agent/log`, or write each thread's `result.events` to a JSON file after the run.
44
+ - **Production:** implement `AgentEventLogStore` against your database and check it with `assertEventLogStoreConformance`. Implement the optional `listThreads` so a split can be paged by prefix.
45
+
46
+ Pass it with a `threadId`. The store is write-ahead: no model call starts until the entries before it are durable.
47
+
48
+ ```ts
49
+ const result = await runAgent(deskMachine, { input, executors, store, threadId, metadata });
50
+ await result.drain(); // late usage entries, before you read the store or exit
51
+ ```
52
+
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
+
55
+ ## 3. Name threads
56
+
57
+ A thread id is how runs are found later. Use a prefix scheme and keep it in one function:
58
+
59
+ | Runs | Thread id |
60
+ | ------------------------ | -------------------------------------------- |
61
+ | Scenario runs for evals | `<split>:<deciderVersion>:<scenarioId>:r<k>`, `k` = the repeat index |
62
+ | Production | `prod:<domain id>` (ticket id, session id) |
63
+
64
+ `store.listThreads?.({ prefix: "train:careful:" })` then pages through one split of one decider. `listThreads` is optional on a custom store.
65
+
66
+ ## 4. Stamp each run
67
+
68
+ `metadata` lands on the log's first (`@agent.init`) entry. Put there everything needed to tell two runs' producers apart:
69
+
70
+ ```ts
71
+ const metadata = {
72
+ environmentVersion: deskMachine.version ?? "unversioned",
73
+ deciderVersion, // see step 1
74
+ models: { decider: "openai/gpt-6-luna" }, // the machine's model refs → what the host resolved them to
75
+ scenario: scenario.id, // eval runs only
76
+ split: scenario.split, // eval runs only
77
+ };
78
+ ```
79
+
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`.
81
+
82
+ ## 5. Collect trace events
83
+
84
+ `onTrace` sees every trace event. Serialize and keep them per thread:
85
+
86
+ ```ts
87
+ import { serializeTraceEvent, type JsonSerializableTraceEvent } from "@statelyai/agent";
88
+
89
+ const trace: JsonSerializableTraceEvent[] = [];
90
+ const result = await runAgent(deskMachine, { ..., onTrace: (event) => trace.push(serializeTraceEvent(event)) });
91
+ await result.drain();
92
+
93
+ // For local batches: one file per thread. Encoding keeps colons and path separators out of the name.
94
+ const file = encodeURIComponent(threadId);
95
+ await mkdir("runs", { recursive: true });
96
+ await writeFile(`runs/${file}.events.json`, JSON.stringify(result.events));
97
+ await writeFile(`runs/${file}.trace.json`, JSON.stringify(trace));
98
+ ```
99
+
100
+ Decide with the user whether `runs/` is committed or gitignored.
101
+
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.
104
+ - **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
+ - **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
+
107
+ ## 6. Prove it
108
+
109
+ Write these tests, with the stand-in model from `loop-to-machine`:
110
+
111
+ ```ts
112
+ import { getDecisionAttempts, replay } from "@statelyai/agent/log";
113
+ import { foldTraces } from "@statelyai/agent/testing";
114
+
115
+ test("a recorded run replays to its final state with no model calls", async () => {
116
+ const result = await runAgent(deskMachine, { input, executors, store, threadId: "test:careful:small-clean:r1", metadata });
117
+ await result.drain();
118
+ 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);
121
+ expect(entries[0]!.metadata).toMatchObject({ environmentVersion: "1" });
122
+ });
123
+ ```
124
+
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.
128
+
129
+ ## 7. Hand back
130
+
131
+ 1. Where runs are stored and how thread ids are built.
132
+ 2. The stamp fields.
133
+ 3. Where trace events go.
134
+ 4. The replay test passing.
135
+ 5. Next step: `add-evals`.
@@ -1,16 +1,38 @@
1
1
  ---
2
2
  name: generate-machine
3
- description: Author a @statelyai/agent workflow as JSON and check it before it runs. Use when asked to generate or author an agent machine, create a workflow config, write an AgentWorkflowConfig, or produce an LLM-authored state machine for an agent — and when a generated config fails Ajv validation or agent lint and needs repair.
3
+ description: Author a @statelyai/agent workflow as JSON and check it before it runs. Use when asked to generate or author an agent machine, create a workflow config, write an AgentWorkflowConfig, or produce an LLM-authored state machine for an agent — and when a generated config fails verifyAgentConfig, Ajv validation, or agent lint and needs repair.
4
4
  ---
5
5
 
6
6
  # Generate an agent machine
7
7
 
8
- An agent machine is data. You author a JSON `AgentWorkflowConfig`, then run it through gates that all work with no API key: Ajv → `fromConfig` → lint → simulate. Do not hand back a config that has not passed all four.
8
+ An agent machine is data. You author a JSON `AgentWorkflowConfig`, then run it through `verifyAgentConfig`, whose gates all work with no API key: validate → vocabulary → lower → lint → explore. Do not hand back a config that has not passed every gate.
9
9
 
10
10
  ```
11
- author → validate (validateAgentConfig) → lower (fromConfig) → lint (lintAgentMachine) → simulate → hand back
11
+ split environment/policy → author (description = goal) → verifyAgentConfig → repair (≤ 3) → persist traces → hand back
12
12
  ```
13
13
 
14
+ To improve an existing policy from its traces, use the `improve-machine` skill instead.
15
+
16
+ ## 0. Split environment and policy
17
+
18
+ Decide what is fixed before you write any JSON.
19
+
20
+ - **Environment** (TypeScript, fixed): the rules of the world and the business invariants, as states and guards. People author it and verify it with `canReach` / `explorePaths` from `@statelyai/agent/testing`. You never generate it, and no generated config may change it.
21
+ - **Policy** (JSON, generated): how the agent works toward the goal: which step next, which tool, when to ask a person, cheap judgment vs model call. This is the `AgentWorkflowConfig` you author.
22
+ - The policy plugs into the environment at its decision points and outputs one event; the environment's guards decide whether to accept it.
23
+ - No split needed for a single judgment inside a fixed workflow, or for a workflow where every step is dictated by rules. Author one machine.
24
+
25
+ If the request asks you to generate a business rule (an approval threshold, a payout limit), stop: that belongs in the environment, in TypeScript, reviewed by a person. Say so.
26
+
27
+ Before editing an existing machine, read it as text:
28
+
29
+ ```ts
30
+ import { describeMachine } from "@statelyai/agent";
31
+ const text = describeMachine(machine); // "Machine <id>", then "Goal: …", states, transitions, events
32
+ ```
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).
35
+
14
36
  ## 1. Read the schema
15
37
 
16
38
  The config shape is fixed by a shipped JSON Schema. Read it first; it is the contract.
@@ -20,23 +42,32 @@ The config shape is fixed by a shipped JSON Schema. Read it first; it is the con
20
42
 
21
43
  ## 2. Author the config
22
44
 
45
+ Start with the root `description`: it is the machine's **goal**, the overall objective plus measurable success criteria. `describeMachine` renders it first as `Goal: …`, and an improver later climbs toward it. Write criteria a scorer can compute from outcomes (terminal states, effects in the world, cost), not "be helpful":
46
+
47
+ ```json
48
+ "description": "Resolve each support ticket in one session. Success: escalations only for tickets that need a human; every reply approved before sending; at most 2 model calls per ticket."
49
+ ```
50
+
23
51
  Rules the runtime enforces. Each one maps to a build error or a lint diagnostic:
24
52
 
25
53
  - Every value is a JSON literal or a whole-string `"{{ }}"` expression reading a dot path on `input`, `context`, or `event`. No JavaScript.
26
54
  - Guard expressions are truthy-only: no comparisons, operators, or negation.
27
- - A model call is either a named `requests` entry invoked with `src: "<requestName>"`, or an inline invoke with `src: "agent.decide"` plus an `allowedEvents` list.
55
+ - A model call is either a named `requests` entry invoked with `src: "<requestName>"`, or an inline invoke with `src: "agent.decide"` plus an `allowedEvents` list. A `requests` entry's templates read only its own `input` (`"{{ input.ticket }}"`); the invoke's `input` maps context into it.
28
56
  - A state invoking `agent.decide` MUST handle every allowed event in its `on`.
29
57
  - An `agent.decide` invoke has no `onDone` (a decision produces no output). Use `onError` for the retries-exhausted path.
30
58
  - A request invoke resolves with `{ result, messages }`, so `onDone.assign` reads its result from `"{{ event.output.result.<field> }}"` (`"{{ event.output.result }}"` for a plain-text request). A non-request `actors` invoke reads `"{{ event.output.<field> }}"` directly.
31
59
  - Every path must reach a `"type": "final"` state, and each final state needs an `output` when `schemas.output` is declared.
32
60
  - Do not invent guard or action names. Only `"{{ }}"` guards, `assign`, and `emit` exist unless the host tells you which named guards/actions it implements.
33
- - Model refs are strings (`"openai/gpt-5.4-mini"`); the host resolves them.
61
+ - Model refs are strings (`"openai/gpt-6-luna"`); the host resolves them.
62
+ - To make a decision a closed choice, add `payloads` to the `agent.decide` input: one payload list per event type, without `type` (`"payloads": { "REFUND": [{ "amount": 20 }, { "amount": 50 }], "ESCALATE": [] }`). The model then picks one listed event instead of writing a payload. A type with no key is offered as `{ type }`; `[]` withholds it.
63
+ - An idle human step can carry `meta.interaction` for the host to render with `getInteraction`: a `label`, `events` (one button per event type), `choices` (one button per full event, `{ "label": "Refund $20", "event": { "type": "REFUND", "amount": 20 } }`, or a context path string holding such a list), and `textEvent` for free text. A bare `"textEvent": "REJECT"` puts the text in a `text` field; `{ "type": "REJECT", "field": "reason" }` names the field. `meta` is not template-evaluated.
34
64
 
35
65
  Reference config — decision, text request, idle human step, one final state:
36
66
 
37
67
  ```json
38
68
  {
39
69
  "id": "support-ticket",
70
+ "description": "Resolve each support ticket in one session. Success: escalations only for tickets that need a human; every reply approved before sending; at most 2 model calls per ticket.",
40
71
  "schemas": {
41
72
  "input": {
42
73
  "type": "object",
@@ -71,9 +102,9 @@ Reference config — decision, text request, idle human step, one final state:
71
102
  "context": { "ticket": "{{ input.ticket }}" },
72
103
  "requests": {
73
104
  "draftReply": {
74
- "model": "openai/gpt-5.4-mini",
105
+ "model": "openai/gpt-6-luna",
75
106
  "system": "Draft a short, courteous support reply.",
76
- "prompt": "{{ context.ticket }}",
107
+ "prompt": "{{ input.ticket }}",
77
108
  "input": {
78
109
  "type": "object",
79
110
  "properties": { "ticket": { "type": "string" } },
@@ -93,7 +124,7 @@ Reference config — decision, text request, idle human step, one final state:
93
124
  "id": "triageDecision",
94
125
  "src": "agent.decide",
95
126
  "input": {
96
- "model": "openai/gpt-5.4-mini",
127
+ "model": "openai/gpt-6-luna",
97
128
  "system": "Decide whether this ticket needs escalation or a drafted reply.",
98
129
  "prompt": "{{ context.ticket }}",
99
130
  "allowedEvents": ["ESCALATE", "REPLY"],
@@ -132,36 +163,27 @@ Reference config — decision, text request, idle human step, one final state:
132
163
  }
133
164
  ```
134
165
 
135
- ## 3. Validate with `validateAgentConfig`
136
-
137
- `validateAgentConfig` from `@statelyai/agent/validate` checks a candidate against the shipped workflow schema with Ajv 2020 built in. It needs `ajv` installed (an optional peer of the package). Run it before anything else touches the config.
138
-
139
- ```ts
140
- import { validateAgentConfig } from "@statelyai/agent/validate";
141
- import type { AgentWorkflowConfig } from "@statelyai/agent";
142
-
143
- function validateGeneratedConfig(candidate: unknown): AgentWorkflowConfig {
144
- const result = validateAgentConfig(candidate);
145
- if (result.valid) return candidate as AgentWorkflowConfig;
146
- throw new Error(
147
- result.errors.map((error) => `${error.path || "(root)"} ${error.message}`).join("\n"),
148
- );
149
- }
150
- ```
166
+ ## 3. Verify with `verifyAgentConfig`
151
167
 
152
- Each error carries a JSON Pointer `path`, so a repair prompt can name the exact bad field.
168
+ `verifyAgentConfig(config, options)` from `@statelyai/agent/validate` runs every pre-run gate and stops at the first failure. It never throws on a bad config; each gate is a record `{ name, ok, detail? }`. It needs `ajv` installed (an optional peer of the package).
153
169
 
154
- ## 4. Lower with `fromConfig`
170
+ | Gate | Checks with | Fails on |
171
+ | ------------ | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
172
+ | `validate` | `validateAgentConfig` (shipped schema) | malformed config shape |
173
+ | `vocabulary` | `allow` lists | an invoke src, named guard, named action, `agent.decide` event, or model ref not listed |
174
+ | `lower` | `setupAgent.fromConfig` | unresolved named guard/action, `onDone` on an `agent.decide` invoke |
175
+ | `lint` | `lintAgentMachine` | error findings (warnings too with `lint: { warnings: true }`) |
176
+ | `explore` | `explorePaths`, once per input | a path ending outside `explore.terminals` (default `['done']`), a loop past `maxDepth`, a `maxPaths`/`maxNodes` cap, untried payloads (`unexpandedEvents`) |
155
177
 
156
- `fromConfig` needs a `compileSchema` that turns the JSON Schemas _inside_ the config into Standard Schema validators. Two engines, two jobs: the 2020 build above checks the config document; this one compiles the per-field schemas. The library bundles no JSON Schema engine, by design.
178
+ `compileSchema` turns the JSON Schemas _inside_ the config into Standard Schema validators for `fromConfig`. The library bundles no JSON Schema engine:
157
179
 
158
180
  ```ts
159
181
  import Ajv from "ajv";
160
- import { setupAgent, type SchemaCompiler, type StandardSchemaV1 } from "@statelyai/agent";
182
+ import type { SchemaCompiler, StandardSchemaV1 } from "@statelyai/agent";
161
183
 
162
184
  const ajv = new Ajv({ strict: false });
163
185
 
164
- export const ajvCompileSchema: SchemaCompiler = (jsonSchema, name): StandardSchemaV1 => {
186
+ export const compileSchema: SchemaCompiler = (jsonSchema, name): StandardSchemaV1 => {
165
187
  const validate = ajv.compile(jsonSchema);
166
188
  return {
167
189
  "~standard": {
@@ -175,87 +197,139 @@ export const ajvCompileSchema: SchemaCompiler = (jsonSchema, name): StandardSche
175
197
  message: `${name}${e.instancePath} ${e.message}`,
176
198
  })),
177
199
  },
178
- // Expose the source JSON Schema so lint's serializability checks can read the shape.
200
+ // Expose the source JSON Schema so lint and describeMachine can read the shape.
179
201
  jsonSchema: { input: () => jsonSchema },
180
202
  },
181
203
  };
182
204
  };
205
+ ```
183
206
 
184
- const { machine, schemas } = setupAgent.fromConfig(config, { compileSchema: ajvCompileSchema });
207
+ Run the gates. List the host's real names in `allow`; an omitted key is unrestricted:
208
+
209
+ ```ts
210
+ import { verifyAgentConfig } from "@statelyai/agent/validate";
211
+
212
+ const result = await verifyAgentConfig(candidate, {
213
+ compileSchema,
214
+ allow: {
215
+ actors: [],
216
+ requests: ["draftReply"],
217
+ guards: [],
218
+ actions: [],
219
+ events: ["ESCALATE", "REPLY", "APPROVE", "REJECT"],
220
+ models: ["openai/gpt-6-luna"],
221
+ },
222
+ explore: {
223
+ inputs: [{ ticket: "Export downloads a 0-byte CSV on Safari." }],
224
+ outputs: { draftReply: [{ reply: "" }] },
225
+ // ESCALATE's `reason` is a free-form string: give exploration one to try.
226
+ payloads: () => ({ ESCALATE: [{ reason: "needs a human" }] }),
227
+ },
228
+ });
229
+ // result.ok, result.gates, result.config (after validate), result.machine (after lower)
185
230
  ```
186
231
 
187
- Lowering is itself a gate: it throws on an unresolved named guard/action and on an `onDone` attached to an `agent.decide` invoke.
232
+ - Exploration forks past idle waits on every event the waiting state accepts, so a human step still ends `done`. Add `terminals: ["done", "idle"]` only when a path may legitimately stop at a wait with nothing to accept.
233
+ - `guards` / `actions`: named implementations, passed to `fromConfig`. `actors`: bound with `machine.provide({ actors })` after lowering.
234
+ - `explore` keys other than `inputs` and `terminals` pass through to `explorePaths`: `outputs` (output lists per request or actor src: one element is a canned output, two or more fork one branch each), `errors` (fork a failing branch per src), `payloads` (fork one branch per payload at decisions and idle waits), `dedupeBy` (explore each snapshot key once; the key must cover every field a guard reads), `maxDepth` (default 8), `maxPaths` (default 200), `maxNodes` (default 100,000).
235
+ - Stub each request's output from its declared `output` schema (`""` for string, `0` for number, `[]` for array, recurse on `properties`). Add an `errors` entry for every src whose `onError` path matters.
236
+ - Pick `explore.inputs` that reach every branch the goal cares about. Exploration covers only the inputs you give it.
237
+ - A passing `lint` gate carries its warnings in `detail`. Report them.
188
238
 
189
- ## 5. Lint
239
+ ## 4. Repair loop
240
+
241
+ A failed gate's `detail` names the offending path, state, or name, one problem per line. Feed it back verbatim with the rejected config, and run the gates again:
190
242
 
191
243
  ```ts
192
- import { lintAgentMachine } from "@statelyai/agent/testing";
193
- const diagnostics = lintAgentMachine(machine);
194
- lintAgentMachine(machine, { throw: true }); // throws AgentLintError on error-severity findings
244
+ let prompt = originalTask;
245
+ for (let attempt = 0; attempt < 3; attempt++) {
246
+ const candidate = await author({ system, prompt });
247
+ const result = await verifyAgentConfig(candidate, verifyOptions);
248
+ if (result.ok) return result;
249
+ const failed = result.gates.find((gate) => !gate.ok)!;
250
+ prompt = `${originalTask}\n\nYour previous config:\n${JSON.stringify(candidate)}\n\nGate '${failed.name}' failed:\n${failed.detail}\nReturn a corrected config.`;
251
+ }
195
252
  ```
196
253
 
197
- Every check applies to config-built machines, reachability included — the lowering keeps the config's transition targets, so `unreachable-state` and `missing-final` read the real graph. Do not disable checks.
254
+ Keep the cap at 3. A config that fails three repairs is usually asking for something the data form cannot express: author it in TypeScript with `setupAgent` instead, or report that the vocabulary is missing something.
198
255
 
199
- The one that bites most often is `decide-without-events`: an `allowedEvents` list with no matching `on` transitions produces a decision the machine can never deliver.
256
+ ## 5. Manual fallback
200
257
 
201
- ## 6. Simulate a dry run
202
-
203
- Lint is structural. A dry run proves a path actually settles. No API key needed.
258
+ Without `verifyAgentConfig` (older package, or a gate you need to run alone), run the same gates by hand:
204
259
 
205
260
  ```ts
206
- import { simulateAgent } from "@statelyai/agent/testing";
261
+ import { setupAgent, type AgentWorkflowConfig } from "@statelyai/agent";
262
+ import { validateAgentConfig } from "@statelyai/agent/validate";
263
+ import { explorePaths, lintAgentMachine, simulateAgent } from "@statelyai/agent/testing";
264
+
265
+ const validation = validateAgentConfig(candidate); // { valid, errors: [{ path, message }] }, path is a JSON Pointer
266
+ if (!validation.valid) {
267
+ // Stop here: feed the errors back as the repair prompt. Never lower an invalid config.
268
+ throw new Error(validation.errors.map((e) => `${e.path} ${e.message}`).join("\n"));
269
+ }
270
+ const { machine } = setupAgent.fromConfig(candidate as AgentWorkflowConfig, { compileSchema }); // throws on unresolved names
271
+ lintAgentMachine(machine, { throw: true }); // throws AgentLintError on error findings
207
272
  const dryRun = await simulateAgent(machine, {
208
273
  input: { ticket: "" },
209
274
  script: {
210
275
  decisions: { "agent.decide": [{ type: "REPLY" }] },
211
276
  text: { draftReply: [{ reply: "" }] },
212
277
  },
213
- });
214
- // dryRun.status: 'done' | 'idle' | 'exhausted'
278
+ }); // dryRun.status: 'done' | 'idle' | 'exhausted' ('exhausted' = loops, reject)
279
+ const report = await explorePaths(machine, {
280
+ input: { ticket: "" },
281
+ outputs: { draftReply: [{ reply: "" }] },
282
+ payloads: () => ({ ESCALATE: [{ reason: "needs a human" }] }),
283
+ }); // report.unexpandedEvents lists types that still need a payloads entry
215
284
  ```
216
285
 
217
- Derive the script from the config rather than guessing: take the first entry of each decision's `allowedEvents`, and stub each request's output from its declared `output` schema (`""` for string, `0` for number, `[]` for array, recurse on `properties`).
218
-
219
- - `'exhausted'` → the machine loops. Reject it.
220
- - `'idle'` → it stopped at a human step. Expected when the config has one.
221
- - To cover every branch instead of one path, use `explorePaths` / `canReach` (its result is `{ reachable, witness }`).
286
+ The lint check that bites most often is `decide-without-events`: an `allowedEvents` list with no matching `on` transitions. Do not disable checks.
222
287
 
223
- ## 7. Repair loop
288
+ ## 6. Persist traces from day one
224
289
 
225
- Every gate throws with a message naming the offending field, state, or path. Feed that message back verbatim and regenerate:
290
+ An improver needs data. Wire an event log store and a thread id per run before the first real run, so every run is a trace:
226
291
 
227
292
  ```ts
228
- for (let attempt = 0; attempt < 3; attempt++) {
229
- try {
230
- return buildAndVet(await author({ system, prompt }));
231
- } catch (error) {
232
- prompt = `${originalTask}\n\nYour previous config was rejected:\n${(error as Error).message}\nReturn a corrected config.`;
233
- }
234
- }
293
+ import { runAgent } from "@statelyai/agent";
294
+ import { createInMemoryEventLogStore } from "@statelyai/agent/log";
295
+
296
+ const store = createInMemoryEventLogStore(); // tests only; implement AgentEventLogStore on your database
297
+ const result = await runAgent(machine, {
298
+ input,
299
+ executors,
300
+ store,
301
+ threadId: `tickets:${ticketId}`,
302
+ });
235
303
  ```
236
304
 
237
- Keep the cap at ~3. A config that fails three schema-shaped repairs is usually asking for something the data form cannot express — author it in TypeScript with `setupAgent` instead.
305
+ - `threadId` is required with `store`. One thread per run.
306
+ - Give thread ids a prefix an improver can list by, such as `tickets:<ticketId>`. `store.listThreads({ prefix, limit, cursor })` pages through them. It is optional on a custom `AgentEventLogStore`, so with a store that lacks it, record each thread id with its scenario or ticket.
307
+ - Failed decision attempts are journaled on the entry they explain; `getDecisionAttempts(entries)` from `@statelyai/agent/log` reads them back.
308
+ - `replay(machine, await store.read(threadId))` from `@statelyai/agent/log` rebuilds the final state with no model calls. Pass the machine that recorded the thread: with an environment and a policy, the environment; `getUsageFromEvents(entries)` sums token usage.
238
309
 
239
- ## 8. Hand back
310
+ ## 7. Hand back
240
311
 
241
312
  Deliver the config JSON plus how to run it:
242
313
 
243
314
  ```ts
244
- import { runToQuiescence } from "@statelyai/agent";
315
+ import { runAgent } from "@statelyai/agent";
245
316
  import { createAiSdkExecutors } from "@statelyai/agent/ai-sdk";
246
317
 
247
- const result = await runToQuiescence(
248
- createAgentRuntime(machine, { executors: createAiSdkExecutors({ resolveModel }) }),
249
- { input: { ticket: "Export downloads a 0-byte CSV on Safari." } },
250
- );
318
+ const run = await runAgent(result.machine!, {
319
+ input: { ticket: "Export downloads a 0-byte CSV on Safari." },
320
+ executors: createAiSdkExecutors({ resolveModel }),
321
+ store,
322
+ threadId,
323
+ });
251
324
  ```
252
325
 
253
- Say which gates passed, list any warning-severity lint diagnostics, and report the dry-run status. Model refs are strings, so the host supplies `resolveModel`.
326
+ Say which gates passed (list `result.gates`), list any lint warnings, state the goal and its success criteria, and say where traces are written. Model refs are strings, so the host supplies `resolveModel`.
254
327
 
255
328
  ## Known limits
256
329
 
257
330
  - **Named guards and actions are host-resolved.** A config carries no functions. `guard: "isReady"` only works if the host passes an `isReady` implementation to `fromConfig`; an unresolved name is a build-time throw. Either list the host's names in the prompt or forbid named guards/actions entirely.
258
331
  - **Expressions are dot-path templates only.** `"{{ context.a.b }}"` — no comparisons, no arithmetic, no method calls, no partial interpolation inside a larger string.
259
- - **Ajv validity is not semantic validity.** A config can validate and still invoke a request that does not exist or route to a state that solves nothing. That is what lint and simulate are for.
260
- - **A dry run covers one path.** Use `explorePaths` when the branch structure matters.
261
- - **Model refs and tool names are unchecked strings.** They resolve at run time, in the host.
332
+ - **Ajv validity is not semantic validity.** A config can validate and still invoke a request that does not exist or route to a state that solves nothing. That is what the `vocabulary`, `lint`, and `explore` gates are for.
333
+ - **Exploration covers the inputs you give it.** A branch no `explore.inputs` entry reaches is unverified.
334
+ - **Model refs are checked only with `allow.models`.** Tool names stay unchecked strings that resolve at run time, in the host.
335
+ - **The gates check structure, not quality.** Whether a config is better than another is measured by replaying scenarios against an objective. See the `improve-machine` skill.