@archmax-ai/harness 1.1.0 → 2.0.1

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 (71) hide show
  1. package/README.md +167 -170
  2. package/dist/agent.d.ts +2 -2
  3. package/dist/agent.js +2 -2
  4. package/dist/assembly/compose.js +2 -2
  5. package/dist/assembly/compose.js.map +1 -1
  6. package/dist/authoring-skill/archmax-harness/README.md +13 -15
  7. package/dist/authoring-skill/archmax-harness/SKILL.md +174 -264
  8. package/dist/authoring-skill/archmax-harness/references/backend-integration.md +244 -776
  9. package/dist/authoring-skill/archmax-harness/references/cases.md +178 -53
  10. package/dist/authoring-skill/archmax-harness/references/code-states-and-scripts.md +211 -100
  11. package/dist/authoring-skill/archmax-harness/references/diagnostics.md +57 -78
  12. package/dist/authoring-skill/archmax-harness/references/workflow-schema.md +613 -2006
  13. package/dist/core/deepagents.d.ts +1 -2
  14. package/dist/core/deepagents.js +1 -2
  15. package/dist/core/deepagents.js.map +1 -1
  16. package/dist/kernel/kernel.d.ts +3 -2
  17. package/dist/kernel/kernel.js +8 -4
  18. package/dist/kernel/kernel.js.map +1 -1
  19. package/dist/machine/lint-spec.js +6 -16
  20. package/dist/machine/lint-spec.js.map +1 -1
  21. package/dist/machine/machine.d.ts +8 -10
  22. package/dist/machine/machine.js +19 -13
  23. package/dist/machine/machine.js.map +1 -1
  24. package/dist/machine/spec-schema.d.ts +1 -4
  25. package/dist/machine/spec-schema.js +84 -47
  26. package/dist/machine/spec-schema.js.map +1 -1
  27. package/dist/machine/tool-names.d.ts +24 -34
  28. package/dist/machine/tool-names.js +43 -63
  29. package/dist/machine/tool-names.js.map +1 -1
  30. package/dist/machine/types.d.ts +11 -10
  31. package/dist/machine/types.js.map +1 -1
  32. package/dist/public/spec.d.ts +5 -5
  33. package/dist/public/spec.js +5 -5
  34. package/dist/public/spec.js.map +1 -1
  35. package/dist/validate/validate.js +3 -3
  36. package/dist/validate/validate.js.map +1 -1
  37. package/dist/workflow/automated.js +6 -1
  38. package/dist/workflow/automated.js.map +1 -1
  39. package/dist/workflow/control-tools.js +0 -2
  40. package/dist/workflow/control-tools.js.map +1 -1
  41. package/dist/workflow/grader-state.d.ts +2 -2
  42. package/dist/workflow/grader-state.js +5 -15
  43. package/dist/workflow/grader-state.js.map +1 -1
  44. package/dist/workflow/middleware.js +2 -26
  45. package/dist/workflow/middleware.js.map +1 -1
  46. package/dist/workflow/on-error.js +0 -1
  47. package/dist/workflow/on-error.js.map +1 -1
  48. package/dist/workflow/parks.js +0 -1
  49. package/dist/workflow/parks.js.map +1 -1
  50. package/dist/workflow/render-prompt.d.ts +4 -5
  51. package/dist/workflow/render-prompt.js +24 -27
  52. package/dist/workflow/render-prompt.js.map +1 -1
  53. package/dist/workflow/state.d.ts +0 -9
  54. package/dist/workflow/state.js +0 -3
  55. package/dist/workflow/state.js.map +1 -1
  56. package/dist/workflow/turn-boundary.js +0 -1
  57. package/dist/workflow/turn-boundary.js.map +1 -1
  58. package/package.json +1 -2
  59. package/skills/archmax-harness/README.md +13 -15
  60. package/skills/archmax-harness/SKILL.md +174 -264
  61. package/skills/archmax-harness/references/backend-integration.md +244 -776
  62. package/skills/archmax-harness/references/cases.md +178 -53
  63. package/skills/archmax-harness/references/code-states-and-scripts.md +211 -100
  64. package/skills/archmax-harness/references/diagnostics.md +57 -78
  65. package/skills/archmax-harness/references/workflow-schema.md +613 -2006
  66. package/dist/authoring-skill/archmax-harness/references/code-state-and-test-scripts.md +0 -1424
  67. package/dist/authoring-skill/archmax-harness/references/wiring.md +0 -119
  68. package/dist/authoring-skill/archmax-harness/references/workflow-yaml.md +0 -402
  69. package/skills/archmax-harness/references/code-state-and-test-scripts.md +0 -1424
  70. package/skills/archmax-harness/references/wiring.md +0 -119
  71. package/skills/archmax-harness/references/workflow-yaml.md +0 -402
@@ -1,76 +1,201 @@
1
- # Cases — the `*.test.yaml` grammar
1
+ # Cases
2
2
 
3
- Load this when writing or fixing a case. Worked examples for every shape
4
- (mocks, human decisions, triggered starts, fixtures):
5
- [`code-state-and-test-scripts.md`](code-state-and-test-scripts.md) Part 2.
3
+ Load this when you write or fix a case. A case is one YAML document at
4
+ `workflows/<slug>/tests/<case>.test.yaml`, run by `archmax test`: one conversation on
5
+ one session. The harness reads it on the host, and no sandbox runs it. The case id is the
6
+ path under `tests/` without the extension, and `archmax test <workflow> <filter>` matches
7
+ a substring of it.
6
8
 
7
- ## Keys, actions, assertions
9
+ ## Decide what to mock first
8
10
 
9
- One YAML document per `tests/<case>.test.yaml`; the case id is the
10
- tests-relative path minus the extension (the `test` filter is a substring of
11
- it). Host-interpreted; no sandbox runs a case. **Unknown keys are errors.**
11
+ An unmocked tool call in a case is a real call, on every run of the suite: a real email
12
+ sent, a real card charged. Before you write the file, ask the workflow's owner how much
13
+ to mock, naming the tools the workflow calls:
14
+
15
+ - **Mock** anything that sends, pays, posts, deletes or writes outside the session, and
16
+ anything whose result drifts between runs.
17
+ - **Keep real** reads of fixtures committed to the workspace, the workflow's own scripts
18
+ and code states, and writes under `scratchpad/`.
19
+ - **Ask** about reads of a live system: stable enough to keep, or slow, rate-limited or
20
+ credential-bound enough to mock?
21
+
22
+ Record the answer in a YAML comment above the case.
23
+
24
+ ## Keys
12
25
 
13
26
  | Key | Shape |
14
27
  | --- | --- |
15
- | `title` | required, ≤ 60 chars, one line: the behaviour under test |
16
- | `description` | required, ≤ 200 chars: scenario driven + what is asserted; longer rationale in a YAML comment above |
17
- | `skip` | a reason; reports skipped |
18
- | `trigger` | `{ id }` only — the trigger every turn runs under (default `manual`); unknown id fails the case |
19
- | `variables` | `{ name: value }` seeded and **locked** every turn — the firing's payload; never seed what the agent must establish (assert it with `variables:` instead) |
20
- | `workspace` | `{ <run path>: content }` — strings verbatim, other YAML → JSON, `{ from: <tests-relative path> }` copies a file; only run-zone paths (`trigger.json`, `scratchpad/…`) |
21
- | `mocks` | `[{ tool, whenInput?, result }]` — intercepts the agent's, its scripts', code states' and graders' calls alike (partial-input match); a control tool (`archmax_*`) is never mocked |
22
- | `clock` | ISO 8601 date (`2026-03-20`) or date-time with `Z`/offset — the "now" every model call of the case states; pin it whenever fixtures or prompts carry dates. Prompt only: scripts, the sandbox and recorded timestamps stay real |
23
- | `steps` | one flat list; actions and assertions are peers; an assertion evaluates against the nearest action above it |
24
-
25
- Actions: `send: "<message>"` (a new turn; against a parked run, a reply-only
26
- message that leaves it parked), `decide: { to, comment? }`,
27
- `deliver: { trigger, variables? }`.
28
-
29
- Assertions (**structural** ones halt the case when they miss; later steps are
30
- reported `not-executed`): `succeeded: true`ˢ (the action's outcome is `completed` — not parked, raised, failed or rejected; a failed or blocked tool call the turn recovered from does not fail it, and a miss says why) · `parked: true | decision | input | { channel?, state? }`ˢ ·
31
- `raised: true | <code> | { code?, reason? }`ˢ (the agent ended the turn with `archmax_raise`; code exact, reason exact or `"/regex/flags"`) ·
32
- `reachedState: <slug>`ˢ (a committed non-trigger trail step, an automated state's move included) ·
33
- `trail: { to?, kind?, reason?, count }`ˢ (`kind`: trigger/agent/human/grader/code/sub-workflow/reset/on_error; `count: 0` = never entered) ·
34
- `noTraversal: true`ˢ (nothing moved; a code or grader move is a traversal) · `triggerArrival: <id>`ˢ ·
35
- `reply: { includes?, excludes? }` (token or list; `"/regex/flags"` strings are regexes; matched against every assistant message of the session so far, earlier turns included) ·
36
- `calledTool` / `notCalledTool` / `blockedTool: { name, input? }` (partial input; `blockedTool` = governance refused it — the assertion that tests a guard; the agent's calls only) ·
37
- `ranWorkflow: { workflow, status?, count? }` (whoever dispatched) · `usedNoTools: true` (whole conversation, the agent's calls only) ·
38
- `variables: { expect, path?, locked? }` · `grade: { closedQA, atLeast }` (LLM grades what the action's turn said plus the session's tool record so far; `atLeast` is a hard bar; needs `tests.judge`).
39
-
40
- Put the structural assertion right after the action that should satisfy it,
41
- before content assertions that only make sense in that state:
28
+ | `title` | Required. One line of at most 60 characters naming the behaviour under test, printed beside the verdict. |
29
+ | `description` | Required. At most 200 characters: the scenario and what it asserts. Longer rationale goes in a YAML comment above the document. |
30
+ | `skip` | A reason. The case reports skipped. |
31
+ | `trigger` | `{ id }`. Every turn of the case runs under it (default `manual`), and an unknown id fails the case. A case that exercises a host trigger must declare it. |
32
+ | `variables` | `{ name: value }`, seeded and **locked** on every turn: the trigger's payload. Never seed what the agent must establish; assert it instead. |
33
+ | `workspace` | `{ <path>: content }`, written before the first step. A string is written verbatim, other YAML as JSON, and `{ from: <path under tests/> }` copies a fixture file. Only session paths: a root file such as `trigger.json`, or `scratchpad/…`. |
34
+ | `mocks` | `[{ tool, whenInput?, result }]`. A call whose input matches `whenInput` (partially) returns `result`. It intercepts the agent's calls and its scripts', code states' and graders' calls alike. A control tool is never mocked. |
35
+ | `clock` | An ISO date, or a date-time with an offset: the "now" every model call states. Pin it whenever fixtures or prompts carry dates. Scripts and recorded timestamps keep the real time. |
36
+ | `steps` | One flat list of actions and assertions. |
37
+
38
+ Unknown keys are errors everywhere. `archmax validate` checks every case: the schema,
39
+ `title` and `description`, regexes, trigger ids and `from:` files. Only `*.test.yaml`
40
+ and `*.test.yml` files are cases. `tests.judge` in `workflow.yaml` enables `grade:`.
41
+
42
+ ## Steps
43
+
44
+ Each step is a single-key mapping. An assertion evaluates against the nearest action
45
+ above it, so an assertion before the first action is an error.
46
+
47
+ | Action | Effect |
48
+ | --- | --- |
49
+ | `send: "<message>"` | One turn. Sent to a session parked at a human state, it is a message answered on a reply-only turn, and the session stays parked. |
50
+ | `decide: { to, comment? }` | A person picks the parked human state's transition `to`. |
51
+ | `deliver: { trigger, variables? }` | Delivers a trigger to a session parked with `archmax_wait`. |
52
+
53
+ Structural assertions are marked ˢ.
54
+
55
+ | Assertion | Passes when |
56
+ | --- | --- |
57
+ | `succeeded: true`ˢ | The action's outcome is `completed`: not parked, raised, failed or rejected. A tool error the turn recovered from does not fail it. |
58
+ | `parked: true \| decision \| input \| { channel?, state? }`ˢ | The session is parked. |
59
+ | `raised: true \| <code> \| { code?, reason? }`ˢ | The turn ended with `archmax_raise`. `code` is exact; `reason` is exact or a `"/regex/flags"` string. |
60
+ | `reachedState: <slug>`ˢ | A committed trail step entered the state. The trigger's arrival does not count. |
61
+ | `trail: { to?, kind?, reason?, count }`ˢ | Exactly `count` trail steps match every given field. `count: 0` means the state was never entered. |
62
+ | `noTraversal: true`ˢ | Nothing moved the session: the trail holds only trigger arrivals. |
63
+ | `triggerArrival: <id>`ˢ | The session arrived on that trigger. |
64
+ | `reply: { includes?, excludes? }` | Each token or `"/regex/flags"` string, one or a list, matches (or, for `excludes`, matches none of) every assistant message of the session so far. |
65
+ | `calledTool` / `notCalledTool` / `blockedTool: { name, input? }` | The agent called the tool (`input` matches partially), never called it, or called it and governance refused it. `blockedTool` is how you test a guard. |
66
+ | `ranWorkflow: { workflow, status?, count? }` | A sub-workflow ran, whoever called it, a script included. |
67
+ | `usedNoTools: true` | The agent called no tool in the whole conversation. |
68
+ | `variables: { expect, path?, locked? }` | Each named variable equals its expected value (deep equality). `path` steps into a value; `locked` checks the lock. |
69
+ | `grade: { closedQA, atLeast }` | The judge scores the action's turn against the criterion, and a score below `atLeast` fails the case. Needs `tests.judge`. |
70
+
71
+ - **A missed structural assertion halts the case**, and later steps are reported
72
+ `not-executed`. Put the structural assertion right after the action that should
73
+ satisfy it, before content assertions that only make sense there.
74
+ - `calledTool`, `notCalledTool`, `blockedTool` and `usedNoTools` read the agent's calls
75
+ across the whole conversation, not one turn's. They see the `${{name}}` reference the
76
+ agent wrote, not the value: assert a delivered value through its effect instead.
77
+ - A `/pattern/flags` string is a regex: quote it in YAML. A malformed one is a validate
78
+ error. There is no generic predicate: bespoke logic belongs in a code state.
79
+ - `grade` sees what the action's turn said plus a record of the session's messages and
80
+ tool calls, so it can judge what the agent did as well as what it said. It is the one
81
+ non-deterministic assertion.
82
+
83
+ ## Examples
84
+
85
+ A routed answer, with a grade:
42
86
 
43
87
  ```yaml
88
+ # reachedState pins the route: without it the case passes even when the agent
89
+ # answers from the start state.
90
+ title: Delayed orders answered for a known requester
91
+ description: >-
92
+ A known requester's question routes into orders-question, and the reply names
93
+ the one delayed order, ORD-1003.
44
94
  steps:
45
- - send: "I want a refund for ORD-1001"
46
- - reachedState: refund-review # structural: if this misses, the case stops here
47
- - parked: true
95
+ - send: "Hi, I'm support@acmecorp.com. Which orders are delayed for Acme?"
96
+ - succeeded: true
97
+ - reachedState: orders-question
48
98
  - reply:
49
- includes: "approved"
99
+ includes: ["ORD-1003", "/delayed/i"]
100
+ - grade:
101
+ closedQA: confirms that ORD-1003 is the delayed order for Acme Corp
102
+ atLeast: 0.7
103
+ ```
104
+
105
+ A human state, sent back once and then approved:
106
+
107
+ ```yaml
108
+ title: Refund sent back for changes, then approved
109
+ description: >-
110
+ The reviewer refines once and approves: the session parks at both reviews,
111
+ refund-review is entered twice, and the final reply still names ORD-1001.
112
+ steps:
113
+ - send: "Hi, I'm support@acmecorp.com. I'd like a refund for order ORD-1001."
114
+ - parked: true
115
+ - decide: { to: refund-request, comment: "Restate the refund amount." }
116
+ - parked: true
117
+ - trail: { to: refund-review, count: 2 }
50
118
  - decide: { to: refund-closed }
51
119
  - succeeded: true
120
+ - reply:
121
+ includes: "ORD-1001"
52
122
  ```
53
123
 
54
- **Grader and code states are asserted by what they committed.** Their own tool
55
- calls are not in the transcript (`calledTool`, `usedNoTools` never see them) and a
56
- grader's words are never the `reply`. Pin the move and its reason with `trail`,
57
- a raise with `raised`, a write with `variables`:
124
+ A host trigger, a fixture file and a mock:
58
125
 
59
126
  ```yaml
127
+ # Only the order database read is mocked; the agent's own writes stay real.
128
+ title: Empty order database reports no delayed orders
129
+ description: >-
130
+ With orders.json mocked empty, a requested report still reads the database
131
+ and reports no delayed orders.
132
+ trigger: { id: report_requested }
133
+ variables: { company: "Acme Corp" }
134
+ workspace:
135
+ trigger.json: { from: fixtures/trigger.json }
136
+ mocks:
137
+ - tool: read_file
138
+ whenInput: { file_path: skills/order-data/assets/orders.json }
139
+ result: "[]"
60
140
  steps:
61
- - send: "Hi, I'm support@acmecorp.com. Which orders are delayed?"
62
- - trail: { kind: code, to: identify-case, count: 1 } # the entry screen passed it
141
+ - send: "A delayed-orders report was requested."
142
+ - triggerArrival: report_requested
143
+ - calledTool: { name: read_file, input: { file_path: "/orders\\.json/" } }
144
+ - reply:
145
+ includes: "/no (matching |delayed )?orders/i"
63
146
  ```
64
147
 
148
+ ## Grader and code states in a case
149
+
150
+ An automated state's own calls are not in the transcript, and a grader's words are
151
+ never the reply. Assert what it committed: its move with `trail` (`kind: code` or
152
+ `kind: grader`) or `reachedState`, a raise with `raised`, a write with `variables`, and
153
+ its sub-workflows with `ranWorkflow`.
154
+
65
155
  ```yaml
66
- trigger: { id: enrichment_requested } # the enrichment path
67
- variables: { orders_to_enrich: [{ order_id: "ORD-1002" }, { order_id: "ORD-1003" }] }
68
156
  steps:
69
- - send: "Enrich the requested orders."
70
- - trail: { kind: grader, to: enrich-order, count: 0 } # no send-back
71
- - trail: { kind: grader, to: enrichment-reported, count: 1 } # the grader approved
157
+ - send: "Hi, I'm support@acmecorp.com. Which orders are delayed?"
158
+ - trail: { kind: code, to: identify-case, count: 1 } # the entry screen passed it
159
+ ```
160
+
161
+ A case that declines at entry asserts `reachedState: <declining state>` and
162
+ `trail: { to: <first work state>, count: 0 }`. One whose entry code state raised asserts
163
+ `raised: <code>` and `noTraversal: true`. A mock answers a code state's calls too, so
164
+ mocking a file an entry check reads changes what the check decides.
165
+
166
+ ## Seed from a real session
167
+
168
+ A trigger carries only an id. Its payload arrives as variables, and their names and
169
+ shapes live in the host's call site and in sessions that already ran. Read one instead
170
+ of inventing a payload:
171
+
172
+ ```bash
173
+ archmax sessions # ids, status, state and variable names
174
+ archmax sessions <id> # one session's variables, each with its value and lock
72
175
  ```
73
176
 
74
- A case that declines at entry asserts `reachedState: <declining state>` plus
75
- `trail: { to: <first work state>, count: 0 }`; one whose entry code state raised
76
- asserts `raised: <code>` and `noTraversal: true`.
177
+ A host that writes artifacts keeps the same record at
178
+ `sessions/<id>/artifacts/variables.json`. Take the newest session whose `trigger` matches
179
+ the case's, then map its variables:
180
+
181
+ | Recorded entry | Goes to |
182
+ | --- | --- |
183
+ | `trigger` | the case's `trigger: { id }` |
184
+ | any other locked entry | `variables:`, the trigger's payload |
185
+ | an unlocked entry | nothing: the agent set it, so assert it with `variables: { expect }` |
186
+
187
+ Never copy the whole store. A seed is locked, so seeding a name the agent sets hides a
188
+ missing `archmax_set_variables` call and makes the agent's own write fail.
189
+
190
+ With no session to read, the `${{…}}` references and `requires` lists in
191
+ `workflow.yaml`, and the host's `createAgent({ variables })` and `deliver` calls, name
192
+ what a trigger supplies. To produce a session, run one:
193
+ `archmax run <workflow> "<prompt>" --variables '{"email":"ann@example.com"}'`.
194
+
195
+ ## Running cases
196
+
197
+ `archmax test <workflow>` runs the suite one case at a time; `tests.caseTimeoutMs`
198
+ bounds each. A workflow with `disabled: true` runs no cases: the suite is reported
199
+ skipped and exits 0. To run the same suite against your own agent, use `runTests` on
200
+ `@archmax-ai/harness/testing`
201
+ ([`backend-integration.md`](backend-integration.md#cases-against-your-agent)).
@@ -1,115 +1,226 @@
1
- # Code-state scripts and `archmax_run` scripts — contracts
1
+ # Grader states, code states and scripts
2
2
 
3
- Load this when writing a `workflows/<slug>/scripts/*.js` code-state script or a
4
- `skills/<cap>/scripts/*.js` source. Worked examples, graders and the sandbox details:
5
- [`code-state-and-test-scripts.md`](code-state-and-test-scripts.md) Part 1.
3
+ Load this when you write a code state's script, a grader state, or a skill bundle's
4
+ `archmax_run` script. The keys each state type takes are in
5
+ [`workflow-schema.md`](workflow-schema.md#state-keys).
6
6
 
7
- ## Code-state script contract
7
+ ## Pick the mechanism
8
8
 
9
- A `type: code` state runs its `script: scripts/<file>.js` on entry, with no model
10
- call. The default export is called once with the state's input and **moves the
11
- session itself**; its return value is never read. The typed import is optional
12
- (`import { defineCodeState } from "@archmax-ai/harness/sandbox";`, placed below the
13
- JSDoc; no other import is allowed).
9
+ | The work | Use |
10
+ | --- | --- |
11
+ | A deterministic check the graph runs at a fixed point: a screen, a policy check, recording a result | a code state |
12
+ | A judgment of work already done: tone, completeness, faithfulness | a grader state |
13
+ | Deterministic work the agent decides to run: a calculation, a fan-out | an `archmax_run` script in a skill bundle |
14
+ | Work that is a process of its own, or shared by several workflows | a sub-workflow |
15
+
16
+ The agent cannot call a grader or a code state. It reaches one only by a transition.
17
+
18
+ ## How an automated state runs
19
+
20
+ A grader or code state runs whenever the session reaches it: the agent's advance,
21
+ another automated state's advance, a human decision, an `on_error` route, a turn that
22
+ opens there, a reset. It runs in place of the agent's next model call, so a chain of
23
+ code states makes no model call at all. A chain that never reaches an agent state, a
24
+ human state or a completion ends at the turn's `recursionLimit`, with the session
25
+ `failed` (`recursion-limit`).
26
+
27
+ **What it is offered.** Exactly its own state's tools: its `tools.allow`, the
28
+ workflow-wide grant and the host's `essentialTools`, plus five controls:
29
+ `archmax_advance` (absent in a terminal state), `archmax_raise`,
30
+ `archmax_get_variables`, `archmax_set_variables` and `archmax_get_history`. It is never
31
+ offered `write_todos`, `archmax_eval`, `archmax_run`, `archmax_wait` or `archmax_reset`
32
+ ([Tool governance](workflow-schema.md#tool-governance) has the table), so it cannot park.
33
+ The kernel decides each call against that state. Grant what a check reads on the
34
+ check's own state: a tool, a skill bundle or a mount that another state grants is out
35
+ of reach.
36
+
37
+ **How it decides.** `archmax_advance({ to, reason })` is checked at the call: the
38
+ transition must exist and the state's `requires` must hold. The first accepted advance
39
+ or raise is the decision, and a later one is refused. The decision takes effect when
40
+ the state finishes cleanly.
41
+
42
+ **How it settles.** Exactly one of:
43
+
44
+ | Outcome | When | Effect |
45
+ | --- | --- | --- |
46
+ | advanced | it advanced and finished cleanly | The session moves. The trail records `{ kind: code \| grader, to, reason }`, and the next state reads the reason as a `[code]` or `[grader]` runtime note. A human target parks after its handoff message. |
47
+ | raised | it called `archmax_raise` | The session ends `failed` with that code and reason. |
48
+ | completed | a terminal state finished without either call | The session completes, held to the trigger's `returns`. |
49
+ | failed | it threw, timed out, or (in a non-terminal state) finished without either call | Routed through `on_error`, else the session ends rejected. A raise beats an error; an error discards a recorded advance. |
50
+
51
+ **The history.** Both types read the session with `archmax_get_history`, which answers
52
+ `{ messages, trail }` as JSON: the session as it stood on entry, whole and oldest
53
+ first, across every turn.
54
+
55
+ | Field | Shape |
56
+ | --- | --- |
57
+ | `messages` | `{ role, text, toolCalls?, tool?, id?, note? }`. `role` is `user`, `assistant`, `tool`, `system` or `runtime` (a runtime note, its kind in `note`). An `assistant` entry lists its calls in `toolCalls` as `{ id?, name, args }`; the `tool` entry with the same `id` carries the result in `text`. |
58
+ | `trail` | `{ to, kind, reason?, workflow?, status?, ts }`, where `kind` is `trigger`, `agent`, `human`, `grader`, `code`, `sub-workflow`, `reset` or `on_error`. Count earlier rounds here. |
59
+
60
+ Check the records, not the agent's retelling of them: a tool's result as the history
61
+ carries it, a variable, a file. Never call a tool again just to see its result; for a
62
+ tool that acts on an outside system, that is a second action. The history spans every
63
+ turn, so take the last match where only the latest round counts. An automated state's
64
+ own calls are not in the transcript.
65
+
66
+ ## Code states
67
+
68
+ A code state runs the file its `script` names. Wire it in `workflow.yaml`, here adapted
69
+ from [`examples/refund/`](https://github.com/archmax-ai/harness/tree/main/examples/refund):
70
+
71
+ ```yaml
72
+ check-decision:
73
+ type: code
74
+ script: scripts/check-decision.js # workflows/refund/scripts/check-decision.js
75
+ tools:
76
+ allow: [lookup_customer] # the script calls it, so this state grants it
77
+ on_error: escalate
78
+ transitions:
79
+ - to: review-email
80
+ description: The decision matches the refund policy.
81
+ - to: decide
82
+ description: The decision contradicts the refund policy.
83
+ ```
84
+
85
+ The script's default export is called once. It moves the session itself, and the
86
+ runtime ignores what it returns:
14
87
 
15
88
  ```js
16
89
  /**
17
- * Request screen — the request must name a requester the order records know.
90
+ * Refund decision check — the recorded decision must follow the refund policy.
18
91
  *
19
- * Reads the first user message from the session's history, extracts the first
20
- * email address, reads skills/order-data/assets/orders.json (this state enables
21
- * `order-data`) and matches the address against every record's `email`,
22
- * case-insensitively.
23
- *
24
- * Advances to identify-case when it matches; otherwise to decline, with a reason
25
- * naming what was missing. An unreadable order database throws, so the state
26
- * fails through its on_error.
92
+ * Looks the customer up with lookup_customer by the `email` variable. A refund is
93
+ * due ("approved") only when the latest order is delivered, otherwise "denied".
94
+ * Advances to review-email when `decision` matches; otherwise back to decide,
95
+ * naming the decision the policy requires.
27
96
  */
28
- import { defineCodeState } from "@archmax-ai/harness/sandbox";
29
-
30
- export default defineCodeState(async ({ tools }) => {
31
- const { messages } = JSON.parse(await tools.archmax_get_history());
32
- const orders = JSON.parse(await tools.read_file({ file_path: "skills/order-data/assets/orders.json" }));
33
- const text = messages.find((m) => m.role === "user")?.text ?? "";
34
- const email = text.match(/[\w.+-]+@[\w.-]+\.\w{2,}/)?.[0];
35
- if (!email) return tools.archmax_advance({ to: "decline", reason: "The request names no email address." });
36
- if (!orders.some((o) => o.email?.toLowerCase() === email.toLowerCase())) {
37
- return tools.archmax_advance({ to: "decline", reason: `"${email}" is not in the order database.` });
97
+ export default async function ({ variables, tools }) {
98
+ const customer = JSON.parse(await tools.lookup_customer({ email: variables.email }));
99
+ const order = customer?.order;
100
+ const due = order?.status === "delivered" ? "approved" : "denied";
101
+ if (variables.decision === due) {
102
+ await tools.archmax_advance({ to: "review-email", reason: `The refund is ${due}, as the policy requires.` });
103
+ } else {
104
+ await tools.archmax_advance({
105
+ to: "decide",
106
+ reason: `Order ${order?.id ?? "(none)"} is ${order?.status ?? "unknown"}, so the refund must be ${due}.`,
107
+ });
38
108
  }
39
- await tools.archmax_advance({ to: "identify-case", reason: `${email} is a known requester.` });
40
- });
109
+ }
41
110
  ```
42
111
 
43
112
  | Input | Meaning |
44
113
  | --- | --- |
45
- | `state` | the code state's own slug |
46
- | `trigger?` | the turn's trigger id string (its payload is in `variables`) |
47
- | `variables` | read-only `name → value` map incl. `trigger`; structured values whole; write with `tools.archmax_set_variables` |
48
- | `tools` | **exactly this state's surface**, async, by own name or camelCase (`tools.read_file`, `tools.readFile`): its grants (the workflow-wide grant included, minus `write_todos`, `archmax_eval`, `archmax_run`), the host's essential tools, and the controls `archmax_advance` (absent when terminal), `archmax_raise`, `archmax_get_variables`, `archmax_set_variables`, `archmax_get_history` — the same set a grader gets. Anything else is `undefined`; a call with no argument passes `{}` |
49
-
50
- **The session's history** is read, not passed in:
51
- `const { messages, trail } = JSON.parse(await tools.archmax_get_history())` — the
52
- same `SessionHistory` a grader reads, the session as it stood when the state was
53
- entered, whole and oldest first.
54
-
55
- | Field | Meaning |
56
- | --- | --- |
57
- | `messages` | the **whole** transcript, `{ role, text, toolCalls?, tool?, id?, note? }`; roles `user`/`assistant`/`tool`/`system`/`runtime` (a runtime note, `note` naming its kind; match `role === "runtime"`, never a `[bracket]`). An `assistant` entry lists the calls it made in `toolCalls` (`{ id?, name, args }`); the `tool` entry whose `id` matches carries the tool's name in `tool` and its result in `text` (`JSON.parse` it when the tool returns JSON) |
58
- | `trail` | the whole trail, `{ to, kind, reason?, workflow?, status?, ts }`; `kind` is `trigger`/`agent`/`human`/`grader`/`code`/`sub-workflow`/`reset`/`on_error` — count rounds here |
59
-
60
- | The script… | Effect |
61
- | --- | --- |
62
- | calls `tools.archmax_advance({ to, reason })` and finishes | the session moves; `reason` reaches the next state as a `[code]` runtime note and the trail as a `kind: code` step |
63
- | calls `tools.archmax_raise({ code, reason })` | the session ends `failed` with that code and reason |
64
- | finishes without either, in a terminal state | the session completes, held to the trigger's `returns` |
65
- | finishes without either, in a non-terminal state | **fails**: `code state '<slug>' finished without calling archmax_advance or archmax_raise` → `on_error` |
66
- | throws, times out (`settings.timeoutMs`), imports a foreign module, or its file is missing | fails → `on_error` (else the session ends rejected); a recorded advance is discarded, a raise still stands |
67
-
68
- A refused call **rejects** with governance's reason, and a `try`/`catch` can
69
- handle it: a blocked tool, an `archmax_advance` to an undeclared target, or one
70
- made while the state's `requires` are unmet. The first accepted advance or raise is
71
- the state's decision; a later one is refused. A write with `archmax_set_variables`
72
- is visible to the script's later calls, so it can set what `requires` names and
73
- then advance. Each run starts in a fresh REPL; `console.log` lines become
74
- `code-output` events. A file with no default export runs as a plain script, reading
75
- the same input from the global `args` and calling the global `tools`.
76
-
77
- **Governance is the state's own.** Every `tools.*` call is decided against the code
78
- state, exactly as the agent's calls are: grant what the script reads or calls in
79
- *that* state's `skills.allow` / `mounts.allow` / `tools.allow`; its `forbid`,
80
- `forbid_always` and the safety rules bind it. A code state is never offered
81
- `write_todos`, `archmax_eval`, `archmax_run`, `archmax_wait`, `archmax_reset` or
82
- `task`, even when the workflow's `tools.allow_always` lists one (naming one in its
83
- own `tools.allow` is a `validate` error). Neither script kind can read `workflows/**`.
84
-
85
- ## `archmax_run` script contract
86
-
87
- `skills/<cap>/scripts/<x>.js`: plain script, top-level `await`, `tools.*`,
88
- `console.log`; the **last expression** is the tool result. Input is the global
89
- `args`: the arguments the model passed plus `args.variables` (read-only snapshot).
90
- No control tool and no `task` is on its `tools`. Fan-out shape:
91
-
92
- ```js
93
- /** One sub-run per requested order, all in flight at once; returns [{ order_id, ...returns }]. */
94
- const orders = args.variables?.orders_to_enrich ?? [];
95
- await Promise.all(orders.map(async (o) => {
96
- const answer = await tools.archmaxWorkflowEnrichOrder({ order_id: o.order_id });
97
- return { order_id: o.order_id, ...JSON.parse(answer).returns };
98
- }));
114
+ | `state` | The code state's slug. |
115
+ | `trigger` | The current trigger id. Its payload is in `variables`. |
116
+ | `variables` | The variables as a read-only `name → value` map, structured values whole. Write with `tools.archmax_set_variables`; the script's later calls see the write. |
117
+ | `tools` | The state's tools as async functions, by name (`tools.read_file`, `tools["archmax_workflow_enrich-order"]`) or camelCase (`tools.readFile`). A tool the state does not offer is `undefined`. A call with no argument passes `{}`. A refused call rejects with the reason, which a `try`/`catch` may handle. |
118
+
119
+ - **The return value is not read.** Only `archmax_advance` and `archmax_raise` decide,
120
+ and every path through a non-terminal state's script must call one.
121
+ - **Send work back; throw on infrastructure.** Parse what the agent wrote defensively,
122
+ and send it back with the fix as the `reason`. Let an unreadable database throw, so
123
+ the state fails through `on_error`.
124
+ - **One evaluation in a fresh sandbox.** Each entry runs in a fresh QuickJS REPL, with
125
+ top-level `await` and no Node APIs, bounded as a whole by `settings.timeoutMs`.
126
+ `console.log` lines become `code-output` events, which the CLI prints under the
127
+ state.
128
+ - **Imports** are limited to `@archmax-ai/harness/*` type carriers, which are stripped
129
+ before evaluation. `import { defineCodeState } from "@archmax-ai/harness/sandbox"`
130
+ types the input (`CodeStateInput`, `SessionHistory`). Any other import fails the
131
+ state.
132
+ - **No references.** `${{…}}` in a script's tool call is plain text: compose strings
133
+ from `variables`.
134
+ - **Approve, then end.** A terminal code state completes the session when its script
135
+ returns, with no further model call. Set any declared `returns` with
136
+ `tools.archmax_set_variables` first.
137
+ - **Files.** `tools.read_file` answers a missing or binary file with an `Error: …`
138
+ string instead of throwing, so check before you parse. Use `copy_file`, `move_file`
139
+ and `remove_file` to duplicate, move or delete a file.
140
+
141
+ ## Grader states
142
+
143
+ A grader state runs an isolated subagent. Its system prompt is a compiled-in preamble
144
+ followed by the state's `instructions`, which are its criteria, and its task lists the
145
+ state's transitions. It reads the session only through `archmax_get_history`, and the
146
+ work with its own state's tools. It decides with `archmax_advance`, or ends the session
147
+ with `archmax_raise`.
148
+
149
+ The refund example's grader, with a give-up rule and an `on_error` added:
150
+
151
+ ```yaml
152
+ review-email:
153
+ type: grader
154
+ instructions: |-
155
+ Read scratchpad/email.md and the `decision` variable. Approve the email
156
+ when it is polite, states the decision plainly and names the order.
157
+ Otherwise send it back to `decide`, saying exactly what to change. If the
158
+ trail from archmax_get_history already holds two steps with kind "grader"
159
+ and to "decide", raise `email-rejected` instead.
160
+ on_error: escalate
161
+ transitions:
162
+ - to: send
163
+ description: The email is ready to send.
164
+ - to: decide
165
+ description: The email needs changes.
99
166
  ```
100
167
 
101
- The calling state must allow every tool the script calls (here
102
- `archmax_workflow_enrich-order`). A script's `tools` holds exactly the calling
103
- state's tools, re-scoped at every evaluation (so after an advance the same REPL
104
- holds the next state's); a tool the state does not offer is `undefined`, and a call
105
- the state's guards refuse rejects with governance's reason. The listing is
106
- camelCased, but a tool's own name reaches the same function
107
- (`tools["archmax_workflow_enrich-order"]`, `tools.read_file`).
108
-
109
- **Return into a variable.** A script's result reaches the model as one tool
110
- message and no further — it is not written to the variable store, and a script
111
- cannot write there itself. Return structured data and name the variable in the
112
- calling state's `requires:`; the model then records it with
113
- `archmax_set_variables`, which is what makes it survive the state, guard a
114
- `tools.allow` glob, and be quotable downstream as `${{name}}` instead of retyped.
115
- A result worth computing is worth naming.
168
+ - `instructions` and at least one transition are required; `evidence`, `approvers` and
169
+ `budget.maxParks` are errors.
170
+ - Say in the criteria exactly when to take each transition and when to raise. A grader
171
+ that answers in text without deciding fails through `on_error`.
172
+ - There is no iteration budget. To give up after N rounds, count the earlier send-backs
173
+ on the trail (`{ kind: grader, to: <graded state> }`) and raise.
174
+ - `budget.timeoutMs` bounds its whole run, and the turn's `recursionLimit` bounds its
175
+ loop. Its `model`, else `settings.model`, is resolved under the `grader` model role.
176
+ - Its messages never enter the session's transcript, and its tokens never stream. The
177
+ session sees its decision as a trail step and a runtime note. Its model calls are
178
+ `model-usage` events under its state.
179
+ - The history is not windowed, so name the part that counts: "the last assistant
180
+ message", "the files `enrichments` names".
181
+ - Grade work the agent hands on, not a live answer: everything the agent says in a turn
182
+ is that turn's reply, a draft sent back included.
183
+
184
+ ## `archmax_run` scripts
185
+
186
+ An `archmax_run` script, `skills/<slug>/scripts/<x>.js`, is deterministic work the agent
187
+ decides to run. It is a plain script with top-level `await`.
188
+
189
+ | It gets | |
190
+ | --- | --- |
191
+ | `args` | The arguments the model passed, plus `args.variables`, a read-only snapshot of the variables. |
192
+ | `tools` | The calling state's tools, as for a code state, re-scoped at every evaluation. No control tool and no `task`. |
193
+ | its result | The value of its last expression, returned to the model as the tool result. |
194
+
195
+ Write one in this order:
196
+
197
+ 1. **Get the shapes first**: tool inputs and outputs from the host's describe tools, and
198
+ the variables a session carries from `archmax sessions <id>`. A guessed field name
199
+ costs a whole session.
200
+ 2. **Write it**, JSDoc header first.
201
+ 3. **Land the result in a variable.** The result is one tool message and nothing more,
202
+ and a script cannot set variables. Return structured data and name the variable in
203
+ the calling state's `requires`, so the agent records it with `archmax_set_variables`.
204
+ Later states can then guard on it and quote it as `${{name}}`.
205
+ 4. **Announce it in the bundle's `SKILL.md`.** The `description` says when to run it; the
206
+ body gives its path, its arguments and the variable its result belongs in. A script
207
+ nothing points at is never run.
208
+ 5. **Grant it.** The calling state enables the bundle and allows every tool the script
209
+ calls. Narrow execution with `{ tool: archmax_run, paths: ["skills/<slug>/scripts/**"] }`.
210
+ Then validate.
211
+
212
+ A script may fan out over a delegation tool with `Promise.all`. Each call runs one child
213
+ session and succeeds or fails on its own, so `Promise.allSettled` keeps the successes.
214
+ When the host cancels the turn, a pending call fails and later calls are refused.
215
+
216
+ ## The JSDoc header
217
+
218
+ Both kinds of workspace JavaScript, code-state scripts and `archmax_run` scripts, open
219
+ with a JSDoc block: a title line, a blank line, then what the script reads, what it
220
+ checks or computes, and each outcome. For a code state, that is every `archmax_advance`
221
+ target and why, and every raise. The description ends at the first `@tag`.
222
+
223
+ Admin UIs show this block instead of the code (`parseCodeDescription` extracts it), so
224
+ write it first and keep the code true to it. `archmax validate` does not check it: it is
225
+ an authoring standard. Cases carry the same depth in their `description`, which
226
+ `validate` does check.