@statelyai/agent 2.0.0-alpha.29 → 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.
- package/dist/ai-sdk.cjs +4 -4
- package/dist/ai-sdk.d.cts +5 -5
- package/dist/ai-sdk.d.mts +5 -5
- package/dist/ai-sdk.mjs +4 -4
- package/dist/{decision-Bl_rKQnU.mjs → decision-BHkvuImD.mjs} +2 -2
- package/dist/{decision-oevZnkBe.cjs → decision-CIb7ddIv.cjs} +2 -2
- package/dist/{event-log-Dtb16p1p.cjs → event-log-BZRfBRd7.cjs} +1 -1
- package/dist/{event-log-GPZO9p7_.mjs → event-log-DJCMHek_.mjs} +1 -1
- package/dist/{event-log-store-CN9ygggF.d.cts → event-log-store-CIIJohjj.d.mts} +1 -1
- package/dist/{event-log-store-DS836wch.d.mts → event-log-store-CLAC9q2Y.d.cts} +1 -1
- package/dist/{event-log-store-bCdskPNS.cjs → event-log-store-DZR4IIWe.cjs} +1 -1
- package/dist/{event-log-store-yalq8C77.mjs → event-log-store-DsP6GCRh.mjs} +1 -1
- package/dist/index.cjs +12 -7
- package/dist/index.d.cts +5 -5
- package/dist/index.d.mts +5 -5
- package/dist/index.mjs +12 -7
- package/dist/log.cjs +2 -2
- package/dist/log.d.cts +1 -1
- package/dist/log.d.mts +1 -1
- package/dist/log.mjs +2 -2
- package/dist/machines.cjs +1 -1
- package/dist/machines.mjs +1 -1
- package/dist/{model-source-DjJPjGSP.cjs → model-source-BCKg4qJ1.cjs} +2 -2
- package/dist/{model-source-C7FFP2Sj.mjs → model-source-BaHwJ1wK.mjs} +2 -2
- package/dist/openai.cjs +3 -3
- package/dist/openai.d.cts +2 -2
- package/dist/openai.d.mts +2 -2
- package/dist/openai.mjs +3 -3
- package/dist/otel.d.cts +1 -1
- package/dist/otel.d.mts +1 -1
- package/dist/{run-agent-CuQT1xSS.mjs → run-agent-BpHqkaZw.mjs} +14 -5
- package/dist/{run-agent-CxSyZmIE.d.mts → run-agent-CAVt5R-r.d.mts} +11 -3
- package/dist/{run-agent-C9ubX8Tx.d.cts → run-agent-CTlMc3Jb.d.cts} +11 -3
- package/dist/{run-agent-cToZ3K42.cjs → run-agent-ctLCvNgL.cjs} +14 -5
- package/dist/{setup-agent-DniLvDu7.mjs → setup-agent-7rV_cujV.mjs} +2 -2
- package/dist/{setup-agent-tsc3vm5I.d.mts → setup-agent-CGELn1qZ.d.mts} +2 -2
- package/dist/{setup-agent-DlbgwsIJ.d.cts → setup-agent-CLPCkXsu.d.cts} +2 -2
- package/dist/{setup-agent-DTF3NQg3.cjs → setup-agent-DoUk7EdC.cjs} +2 -2
- package/dist/tanstack-ai.cjs +3 -3
- package/dist/tanstack-ai.d.cts +2 -2
- package/dist/tanstack-ai.d.mts +2 -2
- package/dist/tanstack-ai.mjs +3 -3
- package/dist/testing.cjs +110 -4
- package/dist/testing.d.cts +85 -4
- package/dist/testing.d.mts +85 -4
- package/dist/testing.mjs +110 -5
- package/dist/{text-logic-BDbGjYMh.d.mts → text-logic-BRYOPWvY.d.mts} +2 -2
- package/dist/{text-logic-9fv1i9aK.d.cts → text-logic-DmciuVUQ.d.cts} +2 -2
- package/dist/{usage-D8UphgEA.d.mts → usage-BkM3z02Y.d.mts} +1 -1
- package/dist/{usage-R1NTmLxY.d.cts → usage-Cm_ovz9V.d.cts} +1 -1
- package/dist/validate.cjs +3 -3
- package/dist/validate.d.cts +2 -2
- package/dist/validate.d.mts +2 -2
- package/dist/validate.mjs +3 -3
- package/dist/{verify-B5qGC5WN.mjs → verify-COFWyprJ.mjs} +2 -2
- package/dist/{verify-Cqghu4gz.cjs → verify-CVlsTZ-V.cjs} +2 -2
- package/dist/{verify-CBuYIpsa.d.mts → verify-Cn86rT8l.d.mts} +1 -1
- package/dist/{verify-DYH_2VzU.d.cts → verify-Dzwd4Mvk.d.cts} +1 -1
- package/package.json +1 -1
- package/readme.md +3 -3
- package/skills/add-evals/SKILL.md +129 -0
- package/skills/add-traces/SKILL.md +135 -0
- package/skills/generate-machine/SKILL.md +5 -5
- package/skills/graph-engineering/SKILL.md +33 -0
- package/skills/improve-machine/SKILL.md +70 -19
- package/skills/improve-machine/reference.md +12 -7
- package/skills/loop-to-machine/SKILL.md +183 -0
- package/skills/loop-to-machine/policy.md +158 -0
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: improve-machine
|
|
3
|
-
description: Improve a @statelyai/agent policy machine (a JSON AgentWorkflowConfig) from its run traces toward its goal, inside a fixed environment machine, with gates and a held-out promotion rule. Use when asked to "improve this agent", "self-improve", "optimize the playbook from traces", "tune the policy", "make the agent cheaper or more accurate", or "why is my agent failing" for a machine whose runs are persisted to an event log store.
|
|
3
|
+
description: Improve a @statelyai/agent policy machine (a TypeScript setupAgent machine or a JSON AgentWorkflowConfig) from its run traces toward its goal, inside a fixed environment machine, with gates and a held-out promotion rule, and hand the result to a person as a pull request. Use when asked to "improve this agent", "self-improve", "optimize the playbook from traces", "tune the policy", "make the agent cheaper or more accurate", or "why is my agent failing" for a machine whose runs are persisted to an event log store.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Improve an agent machine
|
|
7
7
|
|
|
8
|
-
You improve a **policy**: the
|
|
8
|
+
You improve a **policy**: the machine that decides how the agent works toward its goal, written in TypeScript or as a JSON `AgentWorkflowConfig`. It runs inside an **environment**: a TypeScript machine whose states and guards encode the rules of the world and the business invariants. One round changes the policy once and measures it:
|
|
9
9
|
|
|
10
10
|
```
|
|
11
|
-
locate inputs → read traces → diagnose → propose one diff →
|
|
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
|
-
To author a policy from scratch, use the `generate-machine` skill. Concept page:
|
|
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).
|
|
15
15
|
|
|
16
16
|
## Hard rules
|
|
17
17
|
|
|
@@ -19,8 +19,32 @@ To author a policy from scratch, use the `generate-machine` skill. Concept page:
|
|
|
19
19
|
- **Never edit a machine while it runs.** A promoted policy applies to new runs only.
|
|
20
20
|
- **The objective comes from ground truth.** Score outcomes from the traces (terminal states, outputs) or from effects recorded in the world. Never ask a model to grade its own runs.
|
|
21
21
|
- **One cause per round, smallest diff.** A diff that fixes two things cannot tell you which one helped.
|
|
22
|
-
- **Diagnose from training scenarios only.** Held-out scenarios are for the promotion decision. Never read their failures to shape a change, and never move a scenario between splits.
|
|
22
|
+
- **Diagnose from training scenarios only.** Held-out scenarios are for the promotion decision. Never read their failures to shape a change, and never move a scenario between splits. Eval files may list baseline failures for every split; read only the training rows.
|
|
23
23
|
- **Stop after 3 rounds without a promotion** (or the limit the user gives).
|
|
24
|
+
- **Propose, never promote.** A round ends in a recommendation. A recommended candidate goes to a person as a pull request with its scorecard; merging it is the promotion. Never merge, deploy, or switch the running policy yourself.
|
|
25
|
+
|
|
26
|
+
## Policy format
|
|
27
|
+
|
|
28
|
+
The rounds are the same for both formats. What differs is what the diff may touch and how it is gated.
|
|
29
|
+
|
|
30
|
+
| | TypeScript policy | JSON policy |
|
|
31
|
+
| --- | --- | --- |
|
|
32
|
+
| The policy | A `setupAgent` machine in its own file(s), with a declared `version` | An `AgentWorkflowConfig` file |
|
|
33
|
+
| May change | The policy file(s) and their `CHANGELOG.md`; once, the line in the runner that routes the decision to the policy; tests that pin measured numbers (see the pull request) | The config's `states` and `context` |
|
|
34
|
+
| Gates | `tsc`, `lintAgentMachine`, `explorePaths` on the policy, the environment check, the overfit check | `verifyAgentConfig` |
|
|
35
|
+
| Vocabulary | What the policy file imports and the events the environment offers | The `allow` lists |
|
|
36
|
+
|
|
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
|
+
|
|
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`.
|
|
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
|
+
- If the policy needs a fact the environment does not pass in the decision's `metadata.facts`, that is an environment change request.
|
|
43
|
+
|
|
44
|
+
For a TypeScript policy, before the first round:
|
|
45
|
+
|
|
46
|
+
- **List the environment files** (the environment machine, its guards, the world and its tools) and record a hash of each. Any change to them fails the round. The runner is not one of them, but its diff may contain only the routing line.
|
|
47
|
+
- **Keep the policy a machine.** A diff may add states, `choice` branches, guards over facts, helpers in the policy file, or change a prompt or a model ref. It may not turn the policy into a free function, call tools the environment owns, or import from the environment anything but its schemas, types, constants, and exported guard functions.
|
|
24
48
|
|
|
25
49
|
## 1. Locate the inputs
|
|
26
50
|
|
|
@@ -30,16 +54,16 @@ Find all six before changing anything. If one is missing, stop and ask.
|
|
|
30
54
|
| -------------- | ------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------- |
|
|
31
55
|
| Goal | Root `description` of the policy config or the environment machine | Draft one with measurable success criteria; ask the user to confirm it |
|
|
32
56
|
| Objective | A scorer over outcomes (an eval file, a `score*` function, a benchmark) | Write one from the goal's success criteria; **ask the user to confirm it** |
|
|
33
|
-
| Traces | The `store` passed to `createAgentRuntime` / `runAgent`; its `listThreads`, or recorded ids | Wire a store first (
|
|
57
|
+
| Traces | The `store` passed to `createAgentRuntime` / `runAgent`; its `listThreads`, or recorded ids | Wire a store first (the `add-traces` skill) and collect runs |
|
|
34
58
|
| Scenarios | A scenario list with expected outcomes, split into train and held-out | Propose a fixed split (about 70/30, every category in both); ask to confirm |
|
|
35
59
|
| Vocabulary | The actors, requests, named guards, actions, decision events, and model refs the host implements | Read them off the host code; never invent names |
|
|
36
60
|
| Policy history | The policy files and a `CHANGELOG.md` next to them | Create the changelog with the current policy as `v0` |
|
|
37
61
|
|
|
38
|
-
The objective defines "better". Do not start a round until the user has confirmed it. A good objective has:
|
|
62
|
+
The objective defines "better". Do not start a round until the user has confirmed it, including how two versions compare when it has more than one field. A good objective has:
|
|
39
63
|
|
|
40
|
-
- a primary metric from the goal (accuracy against expected outcomes, resolution rate),
|
|
64
|
+
- a primary metric from the goal (accuracy against expected outcomes and effects, resolution rate). With several, state the rule: one primary, or none worse and one better,
|
|
41
65
|
- a hard constraint: invariant violations, which must be 0,
|
|
42
|
-
- a cost term (model calls, tokens, guard rejections) used as a tie-breaker or a stated budget.
|
|
66
|
+
- a cost term (model calls, tokens, guard rejections) used as a tie-breaker or a stated budget. Count the calls that reach a model executor, not the environment's decision requests: a policy that answers with a rule makes no model call, and a cost term that counts requests cannot see the saving.
|
|
43
67
|
|
|
44
68
|
## 2. Read
|
|
45
69
|
|
|
@@ -48,6 +72,7 @@ Read both machines as text. `describeMachine` puts the goal first:
|
|
|
48
72
|
```ts
|
|
49
73
|
import { describeMachine, setupAgent } from "@statelyai/agent";
|
|
50
74
|
|
|
75
|
+
// A TypeScript policy: import it. A JSON policy: lower it.
|
|
51
76
|
const { machine: policy } = setupAgent.fromConfig(policyConfig, { compileSchema });
|
|
52
77
|
const environmentText = describeMachine(environment);
|
|
53
78
|
const policyText = describeMachine(policy);
|
|
@@ -100,9 +125,10 @@ Group the failing training episodes by cause. Check these clusters in order:
|
|
|
100
125
|
|
|
101
126
|
1. **Invariant violations.** Any violation is a stop: the environment is wrong, not the policy. Write a change request.
|
|
102
127
|
2. **Wrong terminal state.** The episode ended in `escalated` when the label says `issued`, or the reverse. Look at which decision led there.
|
|
103
|
-
3. **
|
|
104
|
-
4. **
|
|
105
|
-
5. **
|
|
128
|
+
3. **Right terminal state, wrong effect.** The episode ended `issued` with the wrong amount, or reached the right state by a costlier path. Compare the chosen event with the offered options at each decision.
|
|
129
|
+
4. **Guard rejections.** The policy keeps proposing events the environment refuses: `rejected-by-guard` entries in the rows' `failedAttempts`. The policy lacks a check the environment already makes.
|
|
130
|
+
5. **Loops and budget exhaustion.** Repeated state values, or a terminal reached because a turn or retry budget ran out.
|
|
131
|
+
6. **Cost hotspots.** Correct episodes that spend model calls where a `choice` over known facts, or a cheap judgment, would decide.
|
|
106
132
|
|
|
107
133
|
Pick the cluster with the largest effect on the objective. Write the hypothesis down before you edit anything (template in [reference.md](reference.md)): cause, evidence (thread ids), the change, and which metric should move.
|
|
108
134
|
|
|
@@ -111,12 +137,29 @@ Pick the cluster with the largest effect on the objective. Write the hypothesis
|
|
|
111
137
|
Make the smallest policy diff that addresses the one cause.
|
|
112
138
|
|
|
113
139
|
- Typical diffs: add a `choice` branch over facts already in context; replace a model call with a judgment actor from the vocabulary; narrow an `agent.decide` `allowedEvents` list; turn an open decision into a closed choice with `payloads`; tighten a prompt; add an `onError` path; lower a model ref to a cheaper one in the vocabulary.
|
|
114
|
-
-
|
|
115
|
-
-
|
|
140
|
+
- TypeScript policy: compare facts directly in a guard or `choice` instead of adding a named guard; keep helpers in the policy file.
|
|
141
|
+
- JSON policy: use only names in the vocabulary. A named guard the host does not implement fails the `lower` gate.
|
|
142
|
+
- Keep the machine `id`. Bump `version`.
|
|
116
143
|
- Keep the root `description` (the goal) unchanged. Changing the goal is the user's decision.
|
|
117
144
|
|
|
118
145
|
## 5. Gate
|
|
119
146
|
|
|
147
|
+
### TypeScript policy
|
|
148
|
+
|
|
149
|
+
Run every check; a failure's output is the repair prompt. Cap repairs at 3.
|
|
150
|
+
|
|
151
|
+
1. **Environment unchanged:** every environment file hashes as recorded. A change is never repaired; discard the candidate.
|
|
152
|
+
2. **Types:** `tsc --noEmit` passes.
|
|
153
|
+
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
|
+
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
|
+
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
|
+
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.
|
|
158
|
+
|
|
159
|
+
The environment's own `canReach` proofs still cover every policy, because the environment's guards judge every proposed event.
|
|
160
|
+
|
|
161
|
+
### JSON policy
|
|
162
|
+
|
|
120
163
|
Run `verifyAgentConfig` from `@statelyai/agent/validate` with the vocabulary and probe inputs. Probe inputs are one input per training scenario category, chosen to reach every branch the diff touches.
|
|
121
164
|
|
|
122
165
|
```ts
|
|
@@ -150,15 +193,15 @@ const failed = verified.gates.find((gate) => !gate.ok);
|
|
|
150
193
|
|
|
151
194
|
## 6. Replay
|
|
152
195
|
|
|
153
|
-
Run the candidate on the training scenarios, then on the held-out scenarios. Write every replay to the store under new thread ids that name the split and version (`train:v3:<scenario>:r1`, `
|
|
196
|
+
Run the candidate on the training scenarios, then on the held-out scenarios. Write every replay to the store under new thread ids that name the split and version, in the scheme `add-traces` set up (`train:v3:<scenario>:r1`, `held-out:v3:<scenario>:r1`), so the next round can list them by prefix. With no persistent store, write each thread's `result.events` to a file.
|
|
154
197
|
|
|
155
198
|
- Run the current policy and the candidate on the same scenarios, the same world state, and the same model refs.
|
|
156
199
|
- Model calls are nondeterministic. Run each scenario k times (k ≥ 3 when calls are not cached) and compare means.
|
|
157
200
|
- Compute the objective, invariant violations, and cost per split, for both versions.
|
|
158
201
|
|
|
159
|
-
## 7.
|
|
202
|
+
## 7. Recommend or discard
|
|
160
203
|
|
|
161
|
-
State the tolerance before you replay.
|
|
204
|
+
State the tolerance before you replay. Recommend only when every condition holds:
|
|
162
205
|
|
|
163
206
|
- invariant violations are 0 on both splits,
|
|
164
207
|
- the objective on the held-out split is better than the current policy's,
|
|
@@ -167,6 +210,14 @@ State the tolerance before you replay. Promote only when every condition holds:
|
|
|
167
210
|
|
|
168
211
|
Otherwise discard the candidate and keep the current policy. Either way, append an entry to the `CHANGELOG.md` next to the policies: version, hypothesis, diff summary, gate results, before and after per split, and the decision. Record discarded candidates too; they stop the next round from retrying the same idea.
|
|
169
212
|
|
|
213
|
+
### The pull request
|
|
214
|
+
|
|
215
|
+
A recommended candidate becomes one pull request (or, with no remote, one branch and a diff file), opened against the repository's integration branch:
|
|
216
|
+
|
|
217
|
+
- **The diff:** the policy file(s) and the new `CHANGELOG.md` entry; the runner's routing line, when this candidate introduces the policy; and updated pinned measurements, each listed in the body. Nothing else.
|
|
218
|
+
- **The body:** the hypothesis, the scorecard (objective, violations, and cost before and after, train and held-out separately, k runs each), every scenario that changed outcome, the gate results, and the thread ids a reviewer can replay.
|
|
219
|
+
- **One candidate per pull request.** Leave your working tree on the current policy. Later rounds build on the current policy, not on an unmerged candidate.
|
|
220
|
+
|
|
170
221
|
## 8. Stop and report
|
|
171
222
|
|
|
172
223
|
Stop when any of these holds:
|
|
@@ -176,11 +227,11 @@ Stop when any of these holds:
|
|
|
176
227
|
- the remaining failures need an environment change (write a change request),
|
|
177
228
|
- the vocabulary lacks a capability the fix needs (ask for it; name the actor, request, or guard and its input and output).
|
|
178
229
|
|
|
179
|
-
Report to the user: rounds run, each candidate with its hypothesis and decision, the objective before and after on both splits, violations, cost, and any change requests.
|
|
230
|
+
Report to the user: rounds run, each candidate with its hypothesis and decision, the pull request link (or, with no remote, the branch and diff file), the objective before and after on both splits, violations, cost, and any change requests.
|
|
180
231
|
|
|
181
232
|
## Worked example
|
|
182
233
|
|
|
183
|
-
`examples/self-improving-refunds` in the `statelyai/agent` repo runs this loop end to end:
|
|
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:
|
|
184
235
|
|
|
185
236
|
- the refund desk is the environment: business rules as guards, and an `illegallyIssued` sentinel proven unreachable,
|
|
186
237
|
- `playbooks/*.json` are policy versions, with the vocabulary listed in `playbooks/README.md`,
|
|
@@ -12,10 +12,11 @@ Templates for the `improve-machine` skill: a round checklist, the hypothesis, th
|
|
|
12
12
|
[ ] Baseline: objective, violations, cost per split for the current version
|
|
13
13
|
[ ] Failures clustered (train only); one cause picked
|
|
14
14
|
[ ] Hypothesis written
|
|
15
|
-
[ ] Candidate: smallest diff, id kept, version bumped, description unchanged
|
|
16
|
-
[ ]
|
|
15
|
+
[ ] Candidate: smallest diff, id kept, version bumped, description unchanged (or, for a new policy: v0 recorded, v1 reproduces current behavior plus one rule)
|
|
16
|
+
[ ] Gates passed: TS (env unchanged, tsc, lint, paths, source rules, overfit, evals) or JSON (verifyAgentConfig) (repairs used: _/3)
|
|
17
17
|
[ ] Replayed train, then held-out, k runs each, new thread ids
|
|
18
18
|
[ ] Promotion rule applied; CHANGELOG entry written
|
|
19
|
+
[ ] Recommended candidate opened as one pull request with its scorecard; not merged
|
|
19
20
|
[ ] Stop condition checked
|
|
20
21
|
```
|
|
21
22
|
|
|
@@ -39,7 +40,7 @@ You improve a policy machine. You may change only the policy config below.
|
|
|
39
40
|
{describeMachine(environment)}
|
|
40
41
|
|
|
41
42
|
Current policy (version {n}):
|
|
42
|
-
{JSON.stringify(policyConfig, null, 2)}
|
|
43
|
+
{the policy file's source, or JSON.stringify(policyConfig, null, 2)}
|
|
43
44
|
|
|
44
45
|
Vocabulary (use no other names):
|
|
45
46
|
- actors: {actors}
|
|
@@ -53,8 +54,12 @@ Objective on the training scenarios: {objective summary}
|
|
|
53
54
|
Diagnosed cause: {hypothesis.cause}
|
|
54
55
|
Evidence: {failing rows: scenario, expected, reached state, rejections, tokens}
|
|
55
56
|
|
|
56
|
-
Return
|
|
57
|
-
addresses the diagnosed cause. Keep "id" and
|
|
57
|
+
Return the complete policy: the TypeScript file, or one AgentWorkflowConfig as
|
|
58
|
+
JSON. Make the smallest change that addresses the diagnosed cause. Keep "id" and
|
|
59
|
+
"description". Set "version" to {n + 1}. Never match on a scenario's data.
|
|
60
|
+
For a TypeScript policy, keep the result one `setupAgent` machine. Import from
|
|
61
|
+
the environment only schemas, types, constants, and exported guard functions.
|
|
62
|
+
Do not invoke tools the environment owns.
|
|
58
63
|
```
|
|
59
64
|
|
|
60
65
|
On a failed gate, append:
|
|
@@ -69,11 +74,11 @@ Return a corrected config.
|
|
|
69
74
|
## Changelog entry
|
|
70
75
|
|
|
71
76
|
```md
|
|
72
|
-
## v{n} — {
|
|
77
|
+
## v{n} — {recommended | discarded}
|
|
73
78
|
|
|
74
79
|
- Hypothesis: {cause} → {change}
|
|
75
80
|
- Diff: {states or transitions touched}
|
|
76
|
-
- Gates: validate ✓ vocabulary ✓ lower ✓ lint ✓ explore ✓ (repairs: {r})
|
|
81
|
+
- Gates: {TS: environment ✓ tsc ✓ lint ✓ paths ✓ overfit ✓ evals ✓ | JSON: validate ✓ vocabulary ✓ lower ✓ lint ✓ explore ✓} (repairs: {r})
|
|
77
82
|
- Train: objective {a} → {b}, violations {v}, cost {c0} → {c1}
|
|
78
83
|
- Held-out: objective {a} → {b}, violations {v}, cost {c0} → {c1}
|
|
79
84
|
- Regressions: {none | scenario ids}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: loop-to-machine
|
|
3
|
+
description: Convert existing agent code (a while-loop around a model, a tool loop, ad-hoc if/else control flow) into an XState v6 state machine with @statelyai/agent, and prove with a parity report that it behaves like the code it replaces. Use when asked to "turn this agent into a state machine", "port my loop to xstate", "make this a statechart", "refactor this agent into a machine", or "split environment and policy" for code that already runs.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Convert agent code to a machine
|
|
7
|
+
|
|
8
|
+
You replace the control flow of working agent code with a machine, and show where the machine behaves like the old code and where it differs on purpose. The model calls, tools, and retry code move across unchanged.
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
inventory → pin the baseline → environment → (policy) → runner → proofs → parity → hand back
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
This is step 1 of 4. Next: `add-traces`, then `add-evals`, then `improve-machine`. To author a machine with no existing code, use `generate-machine`.
|
|
15
|
+
|
|
16
|
+
## Hard rules
|
|
17
|
+
|
|
18
|
+
- **Pin the baseline before you change anything.** The parity test runs against the old code, which stays in the repo, untouched, until a person deletes it.
|
|
19
|
+
- **Never promise "behaves exactly like".** Promise what the parity report shows: same outcome and effects on these scenarios, with every difference labeled.
|
|
20
|
+
- **Never copy a rule break.** Where the old code broke a rule, the machine blocks it. That difference is `intended`, not a regression.
|
|
21
|
+
- **Rules are code, not prompt text.** A rule that lived only in a system prompt becomes a guard. Keep the prompt line only as context for the model.
|
|
22
|
+
- **Business rules belong to people.** If a rule is ambiguous (which threshold, which order of checks), ask. Never pick one silently.
|
|
23
|
+
|
|
24
|
+
## 1. Inventory the code
|
|
25
|
+
|
|
26
|
+
Read the loop and fill this table before writing anything. Each row maps to a machine element.
|
|
27
|
+
|
|
28
|
+
| In the code | Becomes |
|
|
29
|
+
| ---------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
|
|
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
|
+
| A read-only tool (search, lookup) | An actor the machine invokes itself, or a tool on a text request |
|
|
32
|
+
| A rule: an `if`, a prompt line, a check after the fact | A guard on the transition it protects |
|
|
33
|
+
| A point where the model picks what to do | A decision state: `agent.decide`, usually a closed choice |
|
|
34
|
+
| 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
|
+
| 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
|
+
| 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 |
|
|
37
|
+
| A cap (`turns < 8`, a retry counter) on a loop the machine keeps | A `choice` state against an exported constant |
|
|
38
|
+
| A cap on a loop the machine removed | Nothing. Record that it is gone and why |
|
|
39
|
+
| Each distinct return | A final state |
|
|
40
|
+
| A terminal tool with a side effect (`escalate`, `close`) | A state that invokes it, then the final state; its `onError` goes to a `failed` final |
|
|
41
|
+
|
|
42
|
+
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
|
+
|
|
44
|
+
## 2. Pin the baseline
|
|
45
|
+
|
|
46
|
+
The parity test exists before the machine does.
|
|
47
|
+
|
|
48
|
+
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.
|
|
50
|
+
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.
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
import { compareRuns, type ParityResult, type ParityRun } from "@statelyai/agent/testing";
|
|
55
|
+
|
|
56
|
+
function toParityRun(run: { outcome: string; world: World }): ParityRun {
|
|
57
|
+
return {
|
|
58
|
+
outcome: run.outcome,
|
|
59
|
+
// Every write the world records; model-written text as a count.
|
|
60
|
+
effects: { refunds: run.world.refunds, approvals: run.world.approvals, verified: [...run.world.verified], escalations: run.world.escalations.length },
|
|
61
|
+
violations: run.world.violations, // rules broken, from ground truth
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// A model factory, called once per scenario and per side. Construct a stateful or seeded
|
|
66
|
+
// model inside it (seeded from s.id); returning a shared stateless object is fine.
|
|
67
|
+
const runWith = (runner: typeof runLoop, model: (s: Scenario) => Model) => async (s: Scenario) =>
|
|
68
|
+
toParityRun(await runner({ ...s, model: model(s), world: createWorld(s) }));
|
|
69
|
+
|
|
70
|
+
const MODELS: Array<[string, (s: Scenario) => Model]> = [
|
|
71
|
+
["careful", () => carefulModel],
|
|
72
|
+
["sloppy", () => sloppyModel],
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
test.each(MODELS)("harness: the loop matches itself (%s)", async (_, model) => {
|
|
76
|
+
const report = await compareRuns({ scenarios: SCENARIOS, baseline: runWith(runLoop, model), candidate: runWith(runLoop, model) });
|
|
77
|
+
for (const row of report.scenarios) expect(row.candidate, row.id).toEqual(row.baseline);
|
|
78
|
+
});
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## 3. Author the environment
|
|
82
|
+
|
|
83
|
+
The environment is the machine: states, guards that encode the rules, side-effecting actors, an invariant sentinel. Use `setupAgent` (from `@statelyai/agent`) with zod schemas for `input`, `context`, `output`, and every event. Export the event schemas as one object, so a policy can reuse them later.
|
|
84
|
+
|
|
85
|
+
A gated action:
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
deciding: {
|
|
89
|
+
tags: ["decision"],
|
|
90
|
+
invoke: {
|
|
91
|
+
src: "agent.decide",
|
|
92
|
+
input: ({ context }) => ({
|
|
93
|
+
model: "decider", // a key the host's executors resolve
|
|
94
|
+
name: "chooseResolution", // tests, traces, and a later policy find it by this name
|
|
95
|
+
prompt: `Ticket: ${context.ticket}\nOrder: ${JSON.stringify(context.order)}`,
|
|
96
|
+
allowedEvents: ["REFUND", "ESCALATE", "CLOSE"],
|
|
97
|
+
// A closed choice: the decider picks one of these. The runtime drops the ones the guards refuse.
|
|
98
|
+
payloads: { REFUND: refundAmounts(context).map((amount) => ({ amount })) },
|
|
99
|
+
// The facts a decider reads. Arrives as `request.metadata.facts`.
|
|
100
|
+
metadata: { facts: { order: context.order, requestedAmount: context.requestedAmount } },
|
|
101
|
+
}),
|
|
102
|
+
onError: { target: "escalated" }, // retries exhausted, or no option legal
|
|
103
|
+
},
|
|
104
|
+
on: {
|
|
105
|
+
REFUND: ({ context, event }) =>
|
|
106
|
+
refundIsLegal(context, event.amount)
|
|
107
|
+
? { target: event.amount > APPROVAL_THRESHOLD ? "awaitingApproval" : "paying", context: { amount: event.amount } }
|
|
108
|
+
: undefined, // undefined rejects: the decision retries with feedback
|
|
109
|
+
ESCALATE: { target: "escalated" },
|
|
110
|
+
CLOSE: { target: "closed" },
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
- **Do the deterministic steps yourself.** Identity, lookup, the ownership check: states the machine runs in order. A model only answers what genuinely needs judgment.
|
|
116
|
+
- **Export the guards** as plain functions (`refundIsLegal`) so tests can call them, and build the machine in a factory (`createDeskMachine({ refundIsLegal })`) so a test can pass a loosened guard.
|
|
117
|
+
- **Add an invariant sentinel** for the rule that must never break: a final state with a description starting `SENTINEL:`, entered from an `always` on the state that invokes the side effect (`paying`). An `always` is taken before the state's invoke starts, so the effect never runs. Write the check as its own function, not a call to the guard, so loosening the guard does not loosen the check.
|
|
118
|
+
- **A person's step** is an idle state whose `meta.interaction` tells a host what to render: `{ label: "Approve a refund of ${amount}?", events: { APPROVE: { label: "Approve" }, REJECT: { label: "Reject" } } }`, or `textEvent: { type: "CUSTOMER_REPLIED", field: "reply" }` for free text.
|
|
119
|
+
- **Actors are placeholders.** Declare each tool as `createAsyncLogic<Output, Input>({ run })` from `xstate` in `setupAgent({ actors })` (XState v6 has no `fromPromise`). Give the type parameters, or `onDone`'s `output` is untyped. Pass the real implementations at run time with `runAgent(machine, { actors })`.
|
|
120
|
+
- **Final outputs carry the machine's result** (`{ outcome, orderId, amount }`), JSON only. The runner rebuilds the old return type from it and from the world (step 5).
|
|
121
|
+
- **Declare `version`** on `createMachine` (`version: "1"`). Behavior lives in transition functions, which the structural hash cannot see; traces stamp this string.
|
|
122
|
+
|
|
123
|
+
## 4. A policy, only if the code already has one
|
|
124
|
+
|
|
125
|
+
Keep the decision in the environment as one `agent.decide`, and do not write a policy machine, unless the old code already has decision logic beyond one model call: a rule ladder, heuristics that pick a strategy, a cheap check before the model. A policy that only passes the options through to a model is boilerplate. `improve-machine` introduces a policy when the traces show a rule worth adding.
|
|
126
|
+
|
|
127
|
+
If the code does have that logic, port it as a policy machine that answers `chooseResolution`. The pattern, with code that compiles, is in [policy.md](policy.md).
|
|
128
|
+
|
|
129
|
+
## 5. The runner
|
|
130
|
+
|
|
131
|
+
Write `runMachine` with the old function's signature and result type, so callers and the parity test change one line.
|
|
132
|
+
|
|
133
|
+
- `runAgent(machine, { input, executors, actors, onIdle })`.
|
|
134
|
+
- `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
|
+
- `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.
|
|
136
|
+
- 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
|
+
|
|
138
|
+
## 6. Proofs that need no model
|
|
139
|
+
|
|
140
|
+
From `@statelyai/agent/testing`:
|
|
141
|
+
|
|
142
|
+
- `lintAgentMachine(machine)`: no errors.
|
|
143
|
+
- `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.
|
|
145
|
+
- `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.
|
|
146
|
+
- A loosened guard makes `canReach` true. Write this test too, or the proof proves nothing.
|
|
147
|
+
- Each normal final state is reachable, so the search is not vacuous.
|
|
148
|
+
|
|
149
|
+
Type the options with `ExplorePathsOptions` from `@statelyai/agent/testing`. `errors` takes one error per actor src, and `"agent.decide"` is a valid key.
|
|
150
|
+
|
|
151
|
+
## 7. Parity
|
|
152
|
+
|
|
153
|
+
Switch the parity test's candidate to `runMachine`, and run it with each stand-in model:
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
test.each(MODELS)("parity with the loop: %s", async (_, model) => {
|
|
157
|
+
const report = await compareRuns({ scenarios: SCENARIOS, baseline: runWith(runLoop, model), candidate: runWith(runMachine, model) });
|
|
158
|
+
const unreviewed = report.scenarios.filter((row) => row.label === "regression" && !REVIEWED[row.id]);
|
|
159
|
+
expect(unreviewed).toEqual([]);
|
|
160
|
+
// A reviewed difference still has to be exactly the difference a person accepted.
|
|
161
|
+
for (const row of report.scenarios.filter((row) => REVIEWED[row.id])) REVIEWED[row.id]!(row);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// Effect-only differences a person accepted: who, and the exact difference.
|
|
165
|
+
const REVIEWED: Record<string, (row: ParityResult<Scenario>) => void> = {
|
|
166
|
+
// "no-order-id-no-reply": (row) => { /* accepted by <name>: asks once, not three times */ expect(row.candidate.outcome).toBe(row.baseline.outcome); ... },
|
|
167
|
+
};
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
- Every `regression` row: fix the machine, or, when the old behavior was wrong but broke no rule, ask the user. If the outcomes differ, the answer may become the scenario's `expected`. If only effects differ (the loop asked three times, the machine once), list the row in a reviewed-differences list in the test, with the exact difference asserted and the person who accepted it. Never edit a scenario to make a row pass.
|
|
171
|
+
- Every `intended` row: check that its reason is a real rule. A candidate that fixed a rule break with the same outcome is `intended` too.
|
|
172
|
+
- Run once with a live model if a key is available. Report it as one sample, not a guarantee.
|
|
173
|
+
|
|
174
|
+
## 8. Hand back
|
|
175
|
+
|
|
176
|
+
Report, in this order:
|
|
177
|
+
|
|
178
|
+
1. The files: environment, runner, tests (and a policy, if the code had one). The old code is untouched.
|
|
179
|
+
2. The rule list from step 1, each mapped to its guard, its state, or the reason it was dropped.
|
|
180
|
+
3. The parity table per stand-in model: `same / intended / regression`, and one line per `intended` row with its reason.
|
|
181
|
+
4. The `canReach` results and the loosened-guard test.
|
|
182
|
+
5. Anything you had to decide that the user should confirm.
|
|
183
|
+
6. Next step: `add-traces`.
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# A policy machine
|
|
2
|
+
|
|
3
|
+
A policy answers one decision of the environment. It is a `setupAgent` machine, so `describeMachine`, traces, and evals work on it, and `improve-machine` can change it one diff at a time.
|
|
4
|
+
|
|
5
|
+
- **Input:** the facts it reads, the options the environment offered, and the environment's prompt. **Output:** one event, from a final state.
|
|
6
|
+
- **It proposes; the environment disposes.** The environment's guards still judge whatever the policy returns, so the policy needs no copy of the rules to be safe. It may import the environment's schemas, types, constants, and exported guard functions to decide well, and nothing else from it.
|
|
7
|
+
- **Its first version reproduces the current behavior.** When it still asks the model, it passes the environment's `system`, `prompt`, and `metadata.facts` through unchanged, so the model sees what it saw before.
|
|
8
|
+
- **Declare `version`** and bump it on every change. Its `description` is the environment's goal with measurable success criteria; ask the user to confirm it.
|
|
9
|
+
- **Where it lives:** one file next to the environment (`policy.ts`, or `policy/` with its `CHANGELOG.md`), holding the machine, `policyDecider`, and `withPolicy`.
|
|
10
|
+
|
|
11
|
+
## What the environment exports
|
|
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 }`.
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
// In the environment file (exported there):
|
|
17
|
+
export const factsSchema = z.object({
|
|
18
|
+
order: z.object({ orderId: z.string(), amount: z.number(), refunded: z.boolean() }).nullable(),
|
|
19
|
+
requestedAmount: z.number().nullable(),
|
|
20
|
+
});
|
|
21
|
+
export const deskEvents = {
|
|
22
|
+
REFUND: z.object({ amount: z.number() }),
|
|
23
|
+
ESCALATE: z.object({}),
|
|
24
|
+
CLOSE: z.object({}),
|
|
25
|
+
};
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## The policy
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import { z } from "zod";
|
|
32
|
+
import { runAgent, setupAgent, type AgentDecisionExecutor, type AgentRequestExecutors } from "@statelyai/agent";
|
|
33
|
+
import { deskEvents, factsSchema } from "./desk.js";
|
|
34
|
+
|
|
35
|
+
/** One proposal: an environment event with its `type`. */
|
|
36
|
+
const proposalSchema = z.discriminatedUnion("type", [
|
|
37
|
+
deskEvents.REFUND.extend({ type: z.literal("REFUND") }),
|
|
38
|
+
deskEvents.ESCALATE.extend({ type: z.literal("ESCALATE") }),
|
|
39
|
+
deskEvents.CLOSE.extend({ type: z.literal("CLOSE") }),
|
|
40
|
+
]);
|
|
41
|
+
type Proposal = z.infer<typeof proposalSchema>;
|
|
42
|
+
|
|
43
|
+
const policySetup = setupAgent({
|
|
44
|
+
input: z.object({
|
|
45
|
+
facts: factsSchema,
|
|
46
|
+
options: z.array(proposalSchema),
|
|
47
|
+
// The environment's own prompt, passed through so the model sees what it saw before.
|
|
48
|
+
system: z.string().optional(),
|
|
49
|
+
prompt: z.string().optional(),
|
|
50
|
+
}),
|
|
51
|
+
context: z.object({
|
|
52
|
+
facts: factsSchema,
|
|
53
|
+
options: z.array(proposalSchema),
|
|
54
|
+
system: z.string().optional(),
|
|
55
|
+
prompt: z.string().optional(),
|
|
56
|
+
chosen: proposalSchema.nullable(),
|
|
57
|
+
}),
|
|
58
|
+
output: proposalSchema,
|
|
59
|
+
// Only the events the policy can propose, not every environment event.
|
|
60
|
+
events: { REFUND: deskEvents.REFUND, ESCALATE: deskEvents.ESCALATE, CLOSE: deskEvents.CLOSE },
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/** The offered options of one type, as `payloads` entries (`type` removed). */
|
|
64
|
+
const payloadsOf = (options: readonly Proposal[], type: Proposal["type"]) =>
|
|
65
|
+
options.filter((option) => option.type === type).map(({ type: _type, ...payload }) => payload);
|
|
66
|
+
|
|
67
|
+
const offers = (options: readonly Proposal[], type: Proposal["type"]) =>
|
|
68
|
+
options.some((option) => option.type === type);
|
|
69
|
+
|
|
70
|
+
export const policyMachine = policySetup.createMachine({
|
|
71
|
+
id: "resolution-policy",
|
|
72
|
+
version: "1",
|
|
73
|
+
description: "Pick one offered resolution per ticket. Success: the desk's outcome matches the expected outcome.",
|
|
74
|
+
context: ({ input }) => ({ ...input, chosen: null }),
|
|
75
|
+
initial: "checking",
|
|
76
|
+
states: {
|
|
77
|
+
// A rule ported from the old code: an already-refunded order closes, no model call.
|
|
78
|
+
checking: {
|
|
79
|
+
type: "choice",
|
|
80
|
+
choice: ({ context }) =>
|
|
81
|
+
context.facts.order?.refunded && offers(context.options, "CLOSE")
|
|
82
|
+
? { target: "chosen", context: { chosen: { type: "CLOSE" as const } } }
|
|
83
|
+
: { target: "asking" },
|
|
84
|
+
},
|
|
85
|
+
asking: {
|
|
86
|
+
invoke: {
|
|
87
|
+
src: "agent.decide",
|
|
88
|
+
input: ({ context }) => ({
|
|
89
|
+
model: "decider",
|
|
90
|
+
name: "pickResolution",
|
|
91
|
+
system: context.system,
|
|
92
|
+
prompt: context.prompt ?? `Facts: ${JSON.stringify(context.facts)}`,
|
|
93
|
+
allowedEvents: ["REFUND", "ESCALATE", "CLOSE"],
|
|
94
|
+
// Only what the environment offered; `[]` withholds a type.
|
|
95
|
+
payloads: {
|
|
96
|
+
REFUND: payloadsOf(context.options, "REFUND"),
|
|
97
|
+
ESCALATE: payloadsOf(context.options, "ESCALATE"),
|
|
98
|
+
CLOSE: payloadsOf(context.options, "CLOSE"),
|
|
99
|
+
},
|
|
100
|
+
// Same shape as the environment's request, so the model adapter reads the facts unchanged.
|
|
101
|
+
metadata: { facts: context.facts },
|
|
102
|
+
}),
|
|
103
|
+
onError: { target: "gaveUp" },
|
|
104
|
+
},
|
|
105
|
+
on: {
|
|
106
|
+
REFUND: ({ event }) => ({
|
|
107
|
+
target: "chosen",
|
|
108
|
+
context: { chosen: { type: "REFUND" as const, amount: event.amount } },
|
|
109
|
+
}),
|
|
110
|
+
ESCALATE: { target: "chosen", context: { chosen: { type: "ESCALATE" as const } } },
|
|
111
|
+
CLOSE: { target: "chosen", context: { chosen: { type: "CLOSE" as const } } },
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
chosen: { type: "final", output: ({ context }) => context.chosen! },
|
|
115
|
+
gaveUp: { type: "final", output: { type: "ESCALATE" as const } },
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
/** Runs the policy as the environment's decider. */
|
|
120
|
+
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
|
+
};
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
- Every state that invokes `agent.decide` needs an `onError`, or `lintAgentMachine` warns. A policy that cannot choose falls back to the safest event the environment always accepts (here `ESCALATE`).
|
|
139
|
+
- A rule the policy applies without a model is a `choice` state over the facts, checked against what was offered (`offers`), since the environment may have withheld it.
|
|
140
|
+
|
|
141
|
+
## Running it as the environment's decider
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
/** The model's executors, with the policy answering the environment's `chooseResolution`. */
|
|
145
|
+
export function withPolicy(modelExecutors: AgentRequestExecutors): AgentRequestExecutors {
|
|
146
|
+
const decideWithPolicy = policyDecider(modelExecutors);
|
|
147
|
+
return {
|
|
148
|
+
...modelExecutors,
|
|
149
|
+
decide: (request, info) => {
|
|
150
|
+
if (request.name === "chooseResolution") return decideWithPolicy(request, info);
|
|
151
|
+
if (!modelExecutors.decide) throw new Error("no decide executor");
|
|
152
|
+
return modelExecutors.decide(request, info);
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
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.
|