@archmax-ai/harness 0.0.0 → 0.2.0
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/README.md +64 -47
- package/dist/agent.d.ts +0 -11
- package/dist/agent.js.map +1 -1
- package/dist/assembly/compose.js +3 -5
- package/dist/assembly/compose.js.map +1 -1
- package/dist/assembly/delegation-registry.js +4 -2
- package/dist/assembly/delegation-registry.js.map +1 -1
- package/dist/assembly/index.d.ts +1 -1
- package/dist/assembly/index.js +0 -1
- package/dist/assembly/index.js.map +1 -1
- package/dist/authoring-skill/archmax-harness/README.md +24 -18
- package/dist/authoring-skill/archmax-harness/SKILL.md +7 -1
- package/dist/authoring-skill/archmax-harness/references/backend-integration.md +66 -11
- package/dist/authoring-skill/archmax-harness/references/cases.md +1 -1
- package/dist/authoring-skill/archmax-harness/references/hook-and-test-scripts.md +2 -2
- package/dist/authoring-skill/archmax-harness/references/wiring.md +1 -1
- package/dist/authoring-skill/archmax-harness/references/workflow-schema.md +66 -20
- package/dist/authoring-skill/archmax-harness/references/workflow-yaml.md +14 -4
- package/dist/cli/bootstrap.d.ts +7 -3
- package/dist/cli/bootstrap.js.map +1 -1
- package/dist/cli/state-flow.js +3 -3
- package/dist/cli/state-flow.js.map +1 -1
- package/dist/cli/turn.d.ts +9 -4
- package/dist/cli/turn.js +36 -9
- package/dist/cli/turn.js.map +1 -1
- package/dist/cli.js +9 -10
- package/dist/cli.js.map +1 -1
- package/dist/core/events.d.ts +4 -9
- package/dist/core/events.js +5 -5
- package/dist/core/events.js.map +1 -1
- package/dist/core/workspace-context.d.ts +0 -2
- package/dist/core/workspace-context.js +0 -2
- package/dist/core/workspace-context.js.map +1 -1
- package/dist/core/zones.d.ts +0 -2
- package/dist/core/zones.js +0 -2
- package/dist/core/zones.js.map +1 -1
- package/dist/env.d.ts +0 -2
- package/dist/env.js +0 -2
- package/dist/env.js.map +1 -1
- package/dist/index.d.ts +15 -13
- package/dist/index.js +18 -7
- package/dist/index.js.map +1 -1
- package/dist/machine/machine.d.ts +9 -0
- package/dist/machine/machine.js +13 -0
- package/dist/machine/machine.js.map +1 -1
- package/dist/machine/signature.d.ts +104 -0
- package/dist/machine/signature.js +210 -0
- package/dist/machine/signature.js.map +1 -0
- package/dist/machine/spec-schema.d.ts +158 -8
- package/dist/machine/spec-schema.js +78 -6
- package/dist/machine/spec-schema.js.map +1 -1
- package/dist/machine/triggers.d.ts +6 -2
- package/dist/machine/triggers.js +7 -2
- package/dist/machine/triggers.js.map +1 -1
- package/dist/machine/types.d.ts +7 -1
- package/dist/machine/types.js.map +1 -1
- package/dist/machine/variables.d.ts +2 -0
- package/dist/machine/variables.js +4 -0
- package/dist/machine/variables.js.map +1 -1
- package/dist/public/spec.d.ts +20 -3
- package/dist/public/spec.js +18 -2
- package/dist/public/spec.js.map +1 -1
- package/dist/sandbox/executor.js.map +1 -1
- package/dist/sessions/resolve.d.ts +0 -2
- package/dist/sessions/resolve.js +1 -4
- package/dist/sessions/resolve.js.map +1 -1
- package/dist/sessions/resume.d.ts +4 -8
- package/dist/sessions/resume.js +10 -5
- package/dist/sessions/resume.js.map +1 -1
- package/dist/sessions/summary.d.ts +0 -2
- package/dist/sessions/summary.js +1 -1
- package/dist/sessions/summary.js.map +1 -1
- package/dist/testing/assertions.js +1 -5
- package/dist/testing/assertions.js.map +1 -1
- package/dist/testing/case-document.d.ts +1 -7
- package/dist/testing/case-document.js.map +1 -1
- package/dist/testing/case-schema.d.ts +2 -2
- package/dist/testing/case-schema.js +8 -21
- package/dist/testing/case-schema.js.map +1 -1
- package/dist/testing/driver.d.ts +4 -2
- package/dist/testing/driver.js +1 -1
- package/dist/testing/driver.js.map +1 -1
- package/dist/testing/grade.d.ts +2 -2
- package/dist/testing/grade.js +1 -1
- package/dist/testing/interpreter.d.ts +4 -2
- package/dist/testing/interpreter.js.map +1 -1
- package/dist/testing/runner.d.ts +13 -9
- package/dist/testing/runner.js +0 -1
- package/dist/testing/runner.js.map +1 -1
- package/dist/testing/target.d.ts +4 -2
- package/dist/workflow/control-tools.d.ts +7 -2
- package/dist/workflow/control-tools.js +27 -2
- package/dist/workflow/control-tools.js.map +1 -1
- package/dist/workflow/governance.js +28 -19
- package/dist/workflow/governance.js.map +1 -1
- package/dist/workflow/middleware.js +5 -10
- package/dist/workflow/middleware.js.map +1 -1
- package/dist/workflow/on-error.js +0 -1
- package/dist/workflow/on-error.js.map +1 -1
- package/dist/workflow/parks.js +9 -13
- package/dist/workflow/parks.js.map +1 -1
- package/dist/workflow/paths.d.ts +0 -1
- package/dist/workflow/paths.js +0 -1
- package/dist/workflow/paths.js.map +1 -1
- package/dist/workflow/prompt-cache.d.ts +0 -2
- package/dist/workflow/prompt-cache.js +0 -7
- package/dist/workflow/prompt-cache.js.map +1 -1
- package/dist/workflow/render-prompt.js +28 -7
- package/dist/workflow/render-prompt.js.map +1 -1
- package/dist/workflow/session-artifacts.d.ts +1 -3
- package/dist/workflow/session-artifacts.js +1 -2
- package/dist/workflow/session-artifacts.js.map +1 -1
- package/dist/workflow/session-view.d.ts +1 -1
- package/dist/workflow/signature-checks.d.ts +22 -0
- package/dist/workflow/signature-checks.js +52 -0
- package/dist/workflow/signature-checks.js.map +1 -0
- package/dist/workflow/state.d.ts +2 -14
- package/dist/workflow/state.js +3 -10
- package/dist/workflow/state.js.map +1 -1
- package/dist/workflow/sub-workflow.d.ts +23 -16
- package/dist/workflow/sub-workflow.js +78 -14
- package/dist/workflow/sub-workflow.js.map +1 -1
- package/dist/workflow/tool-service.js +3 -5
- package/dist/workflow/tool-service.js.map +1 -1
- package/dist/workflow/turn-boundary.js +5 -6
- package/dist/workflow/turn-boundary.js.map +1 -1
- package/dist/workflow/workflow-tools.d.ts +17 -9
- package/dist/workflow/workflow-tools.js +30 -17
- package/dist/workflow/workflow-tools.js.map +1 -1
- package/package.json +5 -4
- package/skills/archmax-harness/README.md +82 -0
- package/skills/archmax-harness/SKILL.md +247 -0
- package/skills/archmax-harness/references/backend-integration.md +681 -0
- package/skills/archmax-harness/references/cases.md +41 -0
- package/skills/archmax-harness/references/diagnostics.md +51 -0
- package/skills/archmax-harness/references/hook-and-test-scripts.md +1070 -0
- package/skills/archmax-harness/references/hooks-and-scripts.md +82 -0
- package/skills/archmax-harness/references/wiring.md +101 -0
- package/skills/archmax-harness/references/workflow-schema.md +1677 -0
- package/skills/archmax-harness/references/workflow-yaml.md +251 -0
- package/dist/behaviour/support.d.ts +0 -232
- package/dist/behaviour/support.js +0 -339
- package/dist/behaviour/support.js.map +0 -1
|
@@ -0,0 +1,1070 @@
|
|
|
1
|
+
# Hook scripts & cases — full reference
|
|
2
|
+
|
|
3
|
+
The complete contract and worked examples for the two authored surfaces this
|
|
4
|
+
reference covers:
|
|
5
|
+
|
|
6
|
+
1. **Lifecycle hook scripts** (`workflows/<slug>/hooks/*.js` — beside the spec
|
|
7
|
+
that wires them, on the **authoring plane**) — deterministic `before`/`after`
|
|
8
|
+
gates, **hand-authored** `.js` files. `workflow.yaml` wires
|
|
9
|
+
`{ script: hooks/<name>.js }`: the path is **workflow-relative** and confined
|
|
10
|
+
to that workflow's `hooks/` directory, so it resolves to
|
|
11
|
+
`workflows/<slug>/hooks/<name>.js`. An absolute path, a `..` climb, or a path
|
|
12
|
+
outside `hooks/` is a load error and a `validate` error. They run in the
|
|
13
|
+
QuickJS sandbox: plain JavaScript with **top-level `await`**, no State APIs, no
|
|
14
|
+
filesystem beyond the `tools.*` bridge.
|
|
15
|
+
|
|
16
|
+
**A hook script is not a runtime script.** The runtime runs a hook; the agent
|
|
17
|
+
runs an `archmax_run` source. The two live in different places on purpose —
|
|
18
|
+
hooks in `workflows/<slug>/hooks/`, runtime scripts in
|
|
19
|
+
`skills/<capability>/scripts/` — and the classes cannot meet: the agent
|
|
20
|
+
cannot read `workflows/**` at all, and `archmax_run` is confined to skill
|
|
21
|
+
bundles by a non-overridable kernel rule (`script.skill-only`). An agent that
|
|
22
|
+
could execute its own guard could probe it for a bypass, which is the whole
|
|
23
|
+
reason for the split. A hook's source also never reaches the model: not in a
|
|
24
|
+
prompt, not in an event, not in a session artifact — a veto reports its
|
|
25
|
+
verdict and reason, never its code.
|
|
26
|
+
2. **Cases** (`workflows/<name>/tests/*.test.yaml`) — declarative
|
|
27
|
+
YAML documents run by `archmax test` and interpreted **host-side**: no
|
|
28
|
+
sandbox executes a case. Suite config is the `tests:` block in
|
|
29
|
+
`workflow.yaml` (there is no `tests.config.js`).
|
|
30
|
+
|
|
31
|
+
Sandbox scripts are self-contained: the only permitted imports are **typed
|
|
32
|
+
`@archmax-ai/harness/*` carriers** (stripped before evaluation — see
|
|
33
|
+
[Typed imports](#typed-imports)); importing from any other specifier is an
|
|
34
|
+
**error**. Each sandbox context gets its own globals — nothing leaks between
|
|
35
|
+
them (`SANDBOX_CONTRACT.context` is `"lifecycle-hook"` for a hook, or `"ptc"`
|
|
36
|
+
for the agent's own `archmax_eval`/`archmax_run` code, which gets **no** hook
|
|
37
|
+
vocabulary at all; `SANDBOX_CONTRACT.version` is `2`).
|
|
38
|
+
|
|
39
|
+
## Contents
|
|
40
|
+
|
|
41
|
+
- [Typed imports](#typed-imports)
|
|
42
|
+
- [Part 1 — Lifecycle hook scripts](#part-1--lifecycle-hook-scripts)
|
|
43
|
+
- [Authoring model: hand-authored](#authoring-model-hand-authored)
|
|
44
|
+
- [The hook contract](#the-hook-contract)
|
|
45
|
+
- [Governance of `tools.*` calls](#governance-of-tools-calls)
|
|
46
|
+
- [`archmax_run` scripts: the `args` contract](#archmax_run-scripts-the-args-contract)
|
|
47
|
+
- [Scripts and sub-workflow fan-out](#scripts-and-sub-workflow-fan-out)
|
|
48
|
+
- [Worked example: a `before` gate](#worked-example-a-before-gate)
|
|
49
|
+
- [Worked example: an `after` policy check](#worked-example-an-after-policy-check)
|
|
50
|
+
- [When to use a grading rubric instead](#when-to-use-a-grading-rubric-instead)
|
|
51
|
+
- [Part 2 — Cases (declarative YAML)](#part-2--cases-cases-declarative-yaml)
|
|
52
|
+
- [Layout and the prose rules](#layout-and-the-prose-rules)
|
|
53
|
+
- [Suite config: the `tests:` block](#suite-config-the-tests-block)
|
|
54
|
+
- [Top-level keys](#top-level-keys)
|
|
55
|
+
- [Step entries: one flat script](#step-entries-one-flat-script)
|
|
56
|
+
- [Assertion grammar](#assertion-grammar)
|
|
57
|
+
- [Structural assertions halt the case](#structural-assertions-halt-the-case)
|
|
58
|
+
- [Start conditions: trigger, variables, and workspace](#start-conditions-trigger-variables-and-workspace)
|
|
59
|
+
- [Recovering a case's inputs from previous runs](#recovering-a-cases-inputs-from-previous-runs)
|
|
60
|
+
- [File fixtures: `from:` references](#file-fixtures-from-references)
|
|
61
|
+
- [Settle the mocking depth before writing a case](#settle-the-mocking-depth-before-writing-a-case)
|
|
62
|
+
- [Worked example: happy path + grade](#worked-example-happy-path--grade)
|
|
63
|
+
- [Worked example: veto, leak check, `noTraversal`](#worked-example-veto-leak-check-notraversal)
|
|
64
|
+
- [Worked example: mocking tool results](#worked-example-mocking-tool-results)
|
|
65
|
+
- [Worked example: human-in-the-loop decisions (refine loop)](#worked-example-human-in-the-loop-decisions-refine-loop)
|
|
66
|
+
- [Worked example: triggered start + fixture file](#worked-example-triggered-start--fixture-file)
|
|
67
|
+
- [Migrating a JS case to YAML](#migrating-a-js-case-to-yaml)
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## Typed imports
|
|
72
|
+
|
|
73
|
+
A hook script may open with a **typed import** — a type carrier that gives IDE
|
|
74
|
+
completion for the hook input and the verdict helpers, and is **stripped before
|
|
75
|
+
QuickJS evaluation** (the runtime implementation is an identity):
|
|
76
|
+
|
|
77
|
+
```js
|
|
78
|
+
import { ok, veto, correct } from "@archmax-ai/harness/sandbox";
|
|
79
|
+
/** … */
|
|
80
|
+
export default async function hook({ state, variables, messages, tools }) {
|
|
81
|
+
/* ... */
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Importing from any **non-`@archmax-ai/harness/*`** specifier is an **error** — sandbox
|
|
86
|
+
scripts are self-contained. **Bare-globals authoring** (no import) is equally
|
|
87
|
+
valid: `ok`, `veto`, `correct` and `defineHook` are prelude globals.
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## Part 1 — Lifecycle hook scripts
|
|
92
|
+
|
|
93
|
+
### Authoring model: hand-authored
|
|
94
|
+
|
|
95
|
+
A script hook references an **ordinary hand-authored** `.js` file. You DESIGN
|
|
96
|
+
AND WRITE the script directly — there is no generation step. `workflow.yaml`
|
|
97
|
+
only wires the hook, one or a list run in order (the first veto stops the list):
|
|
98
|
+
|
|
99
|
+
```yaml
|
|
100
|
+
states:
|
|
101
|
+
identify-case:
|
|
102
|
+
triggers:
|
|
103
|
+
manual:
|
|
104
|
+
before:
|
|
105
|
+
- script: hooks/check-requester.js
|
|
106
|
+
- script: hooks/check-tenancy.js
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
A hook script **MUST** open with a leading JSDoc block — a title line, a blank
|
|
110
|
+
line, and a description of what the check does _and how_: the inputs it reads,
|
|
111
|
+
the sequence of checks, and the condition producing **each** verdict. It is the
|
|
112
|
+
only human-facing contract the file has (admin UIs display it instead of the
|
|
113
|
+
code), and it is what a reviewer refines when the gate is wrong. No
|
|
114
|
+
`@specification` tag, no `// archmax:generated` marker, no generation semantics.
|
|
115
|
+
See [SKILL.md → Code files are description-first](../SKILL.md#code-files-are-description-first).
|
|
116
|
+
|
|
117
|
+
The `specification` sidecar in `workflow.yaml` is **not a recognized key**:
|
|
118
|
+
`{ script: x.js, specification: … }` is a malformed two-key hook and a
|
|
119
|
+
validation **error**.
|
|
120
|
+
|
|
121
|
+
- `archmax validate` reports a hook-script problem only when the script **file
|
|
122
|
+
is missing** or it has a **foreign (non-`@archmax-ai/harness/*`) import**. It does
|
|
123
|
+
**not** check the JSDoc block on any script — the header is an authoring
|
|
124
|
+
standard you uphold yourself. (Cases are YAML; there `archmax validate`
|
|
125
|
+
**errors** on a missing `description` instead.)
|
|
126
|
+
- The runtime never generates scripts: a missing script file at run time
|
|
127
|
+
errors and vetoes (fail-closed).
|
|
128
|
+
|
|
129
|
+
### The hook contract
|
|
130
|
+
|
|
131
|
+
A hook is a **default-export function**. The runtime calls it with **one
|
|
132
|
+
object** and reads its **return value** as the verdict:
|
|
133
|
+
|
|
134
|
+
```js
|
|
135
|
+
/**
|
|
136
|
+
* Requester gate — reject a run whose requester is not a known customer.
|
|
137
|
+
*
|
|
138
|
+
* Reads the first user message from `messages`, extracts the first email
|
|
139
|
+
* address in it, loads skills/order-data/assets/orders.json through the tool
|
|
140
|
+
* bridge, and looks the address up against every order's `email`.
|
|
141
|
+
*
|
|
142
|
+
* Vetoes when the request carries no email address, and when the address
|
|
143
|
+
* matches no order — each with a reason naming what was missing. Otherwise ok.
|
|
144
|
+
*/
|
|
145
|
+
export default async function hook({ state, phase, trigger, variables, messages, tools }) {
|
|
146
|
+
const orders = JSON.parse(await tools.readFile({ file_path: "skills/order-data/assets/orders.json" }));
|
|
147
|
+
const request = messages.find((m) => m.role === "user")?.text ?? "";
|
|
148
|
+
const email = request.match(/[\w.+-]+@[\w.-]+/)?.[0];
|
|
149
|
+
if (!email) return veto("No email address found in your request.");
|
|
150
|
+
if (!orders.some((o) => o.email === email)) return veto(`"${email}" is not in the order database.`);
|
|
151
|
+
console.log(`Allowed: ${email}`);
|
|
152
|
+
return ok();
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
**The input** (`HookInput` in `@archmax-ai/harness/sandbox`; the same fields minus
|
|
157
|
+
`tools` are also the global `args`):
|
|
158
|
+
|
|
159
|
+
| Field | Meaning |
|
|
160
|
+
| --- | --- |
|
|
161
|
+
| `state`, `phase` | The state whose hook is running; `"before"` or `"after"`. |
|
|
162
|
+
| `trigger` | The trigger **id string** the run started from. It carries no payload — the run's input is in `variables`. |
|
|
163
|
+
| `variables` | The run's variables as a plain `name → value` map, including the built-in `trigger`. Structured values are present whole (`variables.order.items[0].sku`). **Read-only**: assigning changes nothing, and the variable tools are absent from `tools.*` — a hook that could write variables would be editing the governance inputs of the state it judges. |
|
|
164
|
+
| `messages` | The recent transcript (last 24 messages), newest last, as `{ role, text, toolCalls? }`. `role` is `user`, `assistant`, `tool` (with `tool: <name>`), `system`, or **`runtime`** — a note the runtime wrote (an arrival, a decision, an error route, a completion check; `note` names the kind). A hook that reacts to narration matches `role === "runtime"`, never a `[bracket]` prefix in a user message. |
|
|
165
|
+
| `from`, `to`, `reason` | Present on `after` hooks: the transition the agent is attempting (`reason` is the agent's `archmax_advance` reason). |
|
|
166
|
+
| `tools` | The privileged tool-call bridge (async), camelCased: `await tools.readFile({ file_path: "skills/order-data/assets/orders.json" })` → file text. See [Governance](#governance-of-tools-calls). |
|
|
167
|
+
|
|
168
|
+
**The verdict** — the three helpers are globals:
|
|
169
|
+
|
|
170
|
+
| Return | Effect |
|
|
171
|
+
| --- | --- |
|
|
172
|
+
| `ok()` (or nothing) | Proceed. |
|
|
173
|
+
| `veto(reason)` | Block. `before`: entry to the state (for a start state, the whole run). `after`: the `archmax_advance` transition, or the completion of a terminal state. The reason is shown to the agent — make it precise and actionable. |
|
|
174
|
+
| `correct(reason)` | **`after` hooks only**: the agent gets another attempt with the reason as correction, bounded by the hook-level `max_iterations` sidecar (`after: { script: hooks/review.js, max_iterations: 2 }`; default 0 — a `correct` with no budget is a hard veto). The budget is counted per hook, so a passing sibling hook does not reset it, and a `correct` refused on a spent budget consumes nothing. A `before` `correct` is treated as a veto and is a validation error. |
|
|
175
|
+
| `false` | A veto (`"precondition not met"`) — the shorthand for `return isAuthorized(x)`. |
|
|
176
|
+
| any other object | A **veto**, with the object's keys named in the reason. A hook's return value *is* its verdict, so a misspelled `verdict` or a shape like `{ ok: false, reason }` is a verdict got wrong; reading it as `ok` would silently permit what the hook meant to block. |
|
|
177
|
+
| `true`, a number, a string | Proceed — a non-object says nothing about the verdict, including a bare body's incidental completion value. |
|
|
178
|
+
|
|
179
|
+
A hook that **throws** — or whose file is missing — **vetoes** with the error
|
|
180
|
+
as the reason (fail-closed). Parse JSON defensively (`try`/`catch`) and turn
|
|
181
|
+
malformed inputs into a `veto` with a good message instead of letting them
|
|
182
|
+
throw; conversely, if a failure should *not* block, catch it and return `ok()`.
|
|
183
|
+
|
|
184
|
+
Other globals: `console.log(...)` (captured into the trail; shown in verbose
|
|
185
|
+
runs), and `defineHook(fn)`, an alias
|
|
186
|
+
for `export default fn`. A file with **no default export** is evaluated as a
|
|
187
|
+
plain script and its **completion value** is the verdict, so a short gate can be
|
|
188
|
+
`orders.length ? ok() : veto("no orders")` on its last line.
|
|
189
|
+
|
|
190
|
+
`${{…}}` does not apply inside a hook: the runtime substitutes references in the
|
|
191
|
+
arguments the *model* writes. A hook already has the values in `variables` and
|
|
192
|
+
composes strings in JavaScript, so `${{…}}` text in a hook's `tools.*` call is
|
|
193
|
+
ordinary data.
|
|
194
|
+
|
|
195
|
+
### Governance of `tools.*` calls
|
|
196
|
+
|
|
197
|
+
Every `tools.*` call is checked by the decision kernel at the moment it runs —
|
|
198
|
+
the same kernel, with the same rules, that governs a tool call the model makes
|
|
199
|
+
directly. What differs is only _whose authority_ the script runs on:
|
|
200
|
+
|
|
201
|
+
| Script kind | The state's own governance (`tools.allow`, `tools.forbid`, `skills.*`) | Safety rules, workflow-wide `forbid_always`, consumer rules |
|
|
202
|
+
| ----------------------------------------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------- |
|
|
203
|
+
| Lifecycle hook (`before:`/`after:`) | **Not applied** — a gate must read evidence the state never grants, and a state's denial does not reach it either | Applied |
|
|
204
|
+
| `archmax_run` script (the model chose to run it) | **Applied** — it runs on the model's authority | Applied |
|
|
205
|
+
|
|
206
|
+
A hook runs on **runtime authority**: outside the active state's tool surface in
|
|
207
|
+
*both* directions. So a hook may
|
|
208
|
+
`await tools.readFile({ file_path: "skills/order-data/assets/orders.json" })`
|
|
209
|
+
even in a state whose `allow` list never mentions `read_file`, one that enables
|
|
210
|
+
no skill at all, and one whose `tools.forbid` names the very tool it calls — while
|
|
211
|
+
the workflow's `tools.forbid_always` and `skills.forbid_always` bind it like
|
|
212
|
+
anything else, and no script of either kind may write into the read-only authored
|
|
213
|
+
zone.
|
|
214
|
+
|
|
215
|
+
Neither kind may **read** the authoring plane at all. A `tools.*` call naming
|
|
216
|
+
`workflows/**` is blocked (`zone.governance-plane`) with a
|
|
217
|
+
diagnostic naming the prefix — a hook cannot read its own spec, a sibling's, or
|
|
218
|
+
the criteria of the rubric declared in it. There is no `task()` in any sandbox
|
|
219
|
+
context: a script that wants a model verdict gets a `rubric` hook declared beside
|
|
220
|
+
it
|
|
221
|
+
instead.
|
|
222
|
+
|
|
223
|
+
A refused call **throws** inside the script, carrying the governance reason.
|
|
224
|
+
The underlying tool never runs whether or not you catch it:
|
|
225
|
+
|
|
226
|
+
```js
|
|
227
|
+
try {
|
|
228
|
+
await tools.writeFile({ file_path: "scratchpad/report.md", content: body });
|
|
229
|
+
} catch (err) {
|
|
230
|
+
console.log(`could not write the report: ${err.message}`);
|
|
231
|
+
}
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
**Authoring rule for `archmax_run` scripts:** if sandboxed code calls `tools.X`,
|
|
235
|
+
the state's `allow` list must cover `tool: X` (with any argument constraints the
|
|
236
|
+
script's call satisfies), or the call is blocked. Grant it per state with
|
|
237
|
+
`tools.allow`, or workflow-wide with `tools.allow_always`. Lifecycle hooks need
|
|
238
|
+
no such grant.
|
|
239
|
+
|
|
240
|
+
### `archmax_run` scripts: the `args` contract
|
|
241
|
+
|
|
242
|
+
An `archmax_run` source (`skills/<capability>/scripts/*.js`) is a plain script:
|
|
243
|
+
top-level `await`, `tools.*`, `console.log`, and the **last expression** is its
|
|
244
|
+
result — returned to the model as the tool result, never into the variable
|
|
245
|
+
store. It reads its input from the global **`args`**:
|
|
246
|
+
|
|
247
|
+
- **the arguments the model passed** to `archmax_run` (`args.<name>`), plus
|
|
248
|
+
- **`args.variables`** — the session's variables as a flat `name → value` map,
|
|
249
|
+
the same map a hook gets. The runtime supplies it on every call, so a script
|
|
250
|
+
reads the run's input (a seeded list, a delivered payload) without the model
|
|
251
|
+
having to retype it.
|
|
252
|
+
|
|
253
|
+
```js
|
|
254
|
+
/** One sub-run per requested order. */
|
|
255
|
+
const orders = args.variables?.orders_to_enrich ?? [];
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
`args.variables` is a read-only snapshot; a script that must persist a result
|
|
259
|
+
returns it and the state's `requires:` makes the model record it with
|
|
260
|
+
`archmax_set_variables`. There is **no `task()`** in an `archmax_run` script or in
|
|
261
|
+
`archmax_eval`. The model has no `task` tool at all: it is disclosed in no state
|
|
262
|
+
and grantable by nothing, because a rubric grades the agent rather than serving it.
|
|
263
|
+
|
|
264
|
+
### Scripts and sub-workflow fan-out
|
|
265
|
+
|
|
266
|
+
A script **is** the deterministic way to drive a fan-out. Delegation tools are on the `tools.*`
|
|
267
|
+
surface — a delegation does work and returns a value, unlike the control tools a script may never
|
|
268
|
+
call — so a script decides how many sub-runs to start, with what inputs, and what to do with each
|
|
269
|
+
answer:
|
|
270
|
+
|
|
271
|
+
```js
|
|
272
|
+
/** One sub-run per delayed order, all in flight at once. */
|
|
273
|
+
const orders = args.variables?.orders_to_enrich ?? [];
|
|
274
|
+
|
|
275
|
+
const enriched = await Promise.all(
|
|
276
|
+
// The bridge camelCases tool names, and a tool result arrives as text.
|
|
277
|
+
orders.map(async (o) => {
|
|
278
|
+
const answer = await tools.archmaxWorkflowEnrichOrder({ order_id: o.order_id });
|
|
279
|
+
return { order_id: o.order_id, ...JSON.parse(answer).returns };
|
|
280
|
+
}),
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
enriched;
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
`Promise.all` genuinely parallelizes: the bridge starts each host call detached and returns its
|
|
287
|
+
promise handle immediately. The runtime still bounds how many sub-runs are in flight
|
|
288
|
+
(the dispatcher's `maxConcurrent`), queueing the rest. The list never enters the model's
|
|
289
|
+
context.
|
|
290
|
+
|
|
291
|
+
Two limits still hold, both inherited rather than special to delegation. A script may only call a
|
|
292
|
+
delegation tool its **calling state** allows — the same kernel verdict an agent's call gets. And a
|
|
293
|
+
script's delegation **cannot park**: a QuickJS frame is not durable, so a child that reaches a human
|
|
294
|
+
state fails that call closed with `parked`.
|
|
295
|
+
|
|
296
|
+
Scripts still may not call `archmax_advance` (moving the state machine is the model's governed
|
|
297
|
+
decision) or `archmax_set_variables`. Concurrent calls succeed or fail **independently**, so a script
|
|
298
|
+
can act on what worked — `Promise.allSettled` is the honest shape when partial success is
|
|
299
|
+
meaningful. Give each sub-run its own output path: they share one run zone.
|
|
300
|
+
|
|
301
|
+
### Worked example: a `before` gate
|
|
302
|
+
|
|
303
|
+
The tenancy half of the bundled example's entry gate
|
|
304
|
+
(`workflows/order-lookup/hooks/check-tenancy.js`), the second hook in
|
|
305
|
+
`identify-case`'s `before` list — so a missing or unknown email has already been
|
|
306
|
+
vetoed by `check-requester.js` before this runs:
|
|
307
|
+
|
|
308
|
+
```js
|
|
309
|
+
/**
|
|
310
|
+
* Tenancy gate — a requester may only reach their own company's orders.
|
|
311
|
+
*
|
|
312
|
+
* Binds the requester's email (the first one in the first user message) to the
|
|
313
|
+
* customers whose orders carry it in skills/order-data/assets/orders.json, then
|
|
314
|
+
* checks the request two ways: it names another customer, or it references an
|
|
315
|
+
* order id (`ORD-…`) owned by another customer. Matching is case-insensitive.
|
|
316
|
+
*
|
|
317
|
+
* Vetoes with the customers or order ids the requester reached for when either
|
|
318
|
+
* check finds one; otherwise ok. If the order database cannot be read, vetoes
|
|
319
|
+
* too (fail-closed).
|
|
320
|
+
*/
|
|
321
|
+
export default async function hook({ messages, tools }) {
|
|
322
|
+
let orders;
|
|
323
|
+
try {
|
|
324
|
+
orders = JSON.parse(await tools.readFile({ file_path: "skills/order-data/assets/orders.json" }));
|
|
325
|
+
if (!Array.isArray(orders)) throw new Error("not an array");
|
|
326
|
+
} catch {
|
|
327
|
+
return veto("Could not load skills/order-data/assets/orders.json - unable to check tenancy.");
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const request = messages.find((m) => m.role === "user")?.text ?? "";
|
|
331
|
+
const email = (request.match(/[\w.+-]+@[\w.-]+\.\w{2,}/)?.[0] ?? "").toLowerCase();
|
|
332
|
+
const own = new Set(
|
|
333
|
+
orders.filter((o) => String(o.email).toLowerCase() === email).map((o) => String(o.customer)),
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
const lower = request.toLowerCase();
|
|
337
|
+
const foreignByName = [...new Set(orders.map((o) => String(o.customer)))].filter(
|
|
338
|
+
(name) => !own.has(name) && lower.includes(name.toLowerCase()),
|
|
339
|
+
);
|
|
340
|
+
const referenced = new Set((request.match(/\bORD-\d+\b/gi) ?? []).map((id) => id.toUpperCase()));
|
|
341
|
+
const foreignById = orders
|
|
342
|
+
.filter((o) => referenced.has(String(o.id).toUpperCase()) && !own.has(String(o.customer)))
|
|
343
|
+
.map((o) => String(o.id));
|
|
344
|
+
|
|
345
|
+
if (foreignByName.length > 0 || foreignById.length > 0) {
|
|
346
|
+
const detail = (foreignByName.length > 0 ? foreignByName : foreignById).join(", ");
|
|
347
|
+
return veto(
|
|
348
|
+
`The email address "${email}" is not authorized to access orders for ${detail}. You may only view orders for your own account.`,
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
return ok();
|
|
352
|
+
}
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
The pattern to internalize: **one `return veto(reason)` per veto condition,
|
|
356
|
+
each with a human-readable reason that names the fix**; `console.log` for
|
|
357
|
+
anything worth seeing in a verbose run; `return ok()` at the end.
|
|
358
|
+
|
|
359
|
+
### Worked example: an `after` policy check
|
|
360
|
+
|
|
361
|
+
An `after` hook re-derives policy deterministically so the agent can never
|
|
362
|
+
record a decision the policy forbids. Wiring (on `refund-request` in the
|
|
363
|
+
bundled example's `workflow.yaml`):
|
|
364
|
+
|
|
365
|
+
```yaml
|
|
366
|
+
after:
|
|
367
|
+
script: hooks/check-refund.js
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
The check (`workflows/order-lookup/hooks/check-refund.js`):
|
|
371
|
+
|
|
372
|
+
```js
|
|
373
|
+
/**
|
|
374
|
+
* Refund policy gate — the agent may not record a refund the policy forbids.
|
|
375
|
+
*
|
|
376
|
+
* Re-derives the refund decision from the order database instead of trusting
|
|
377
|
+
* the one the agent wrote. Reads the recorded ticket from scratchpad/refund.json
|
|
378
|
+
* ({ orderId, decision }), then reads skills/order-data/assets/orders.json and
|
|
379
|
+
* looks the order id up, case-insensitively, against each record's `id`.
|
|
380
|
+
*
|
|
381
|
+
* Policy: a refund is "approved" only when the order exists with status
|
|
382
|
+
* delivered or delayed; an unknown order and any other status must be "denied".
|
|
383
|
+
*
|
|
384
|
+
* Vetoes, each with a reason that names the fix, when: the ticket file is
|
|
385
|
+
* missing or not valid JSON; `decision` is not exactly "approved" or "denied";
|
|
386
|
+
* the order database cannot be read; or the recorded decision contradicts the
|
|
387
|
+
* derived one. Otherwise ok.
|
|
388
|
+
*/
|
|
389
|
+
export default async function hook({ tools }) {
|
|
390
|
+
let refund;
|
|
391
|
+
try {
|
|
392
|
+
refund = JSON.parse(await tools.readFile({ file_path: "scratchpad/refund.json" }));
|
|
393
|
+
} catch (e) {
|
|
394
|
+
return veto(
|
|
395
|
+
`scratchpad/refund.json could not be read or parsed (${e.message}) - write it as a JSON object with "orderId" and "decision" before leaving this state`,
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const decision = refund?.decision;
|
|
400
|
+
if (decision !== "approved" && decision !== "denied") {
|
|
401
|
+
return veto(`scratchpad/refund.json "decision" must be exactly "approved" or "denied"; got ${JSON.stringify(decision)}`);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
let orders;
|
|
405
|
+
try {
|
|
406
|
+
orders = JSON.parse(await tools.readFile({ file_path: "skills/order-data/assets/orders.json" }));
|
|
407
|
+
} catch {
|
|
408
|
+
return veto("skills/order-data/assets/orders.json could not be read - the order database must be accessible to validate the refund decision");
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const wanted = typeof refund.orderId === "string" ? refund.orderId.trim().toLowerCase() : "";
|
|
412
|
+
const order = orders.find((o) => typeof o?.id === "string" && o.id.toLowerCase() === wanted);
|
|
413
|
+
const derived = order && ["delivered", "delayed"].includes(order.status) ? "approved" : "denied";
|
|
414
|
+
console.log(`Refund policy check - derived "${derived}", recorded "${decision}"`);
|
|
415
|
+
|
|
416
|
+
if (decision === derived) return ok();
|
|
417
|
+
const why = !order
|
|
418
|
+
? "it does not exist in the order database"
|
|
419
|
+
: derived === "approved"
|
|
420
|
+
? `its status is "${order.status}"`
|
|
421
|
+
: `its status "${order.status}" is not delivered or delayed`;
|
|
422
|
+
return veto(
|
|
423
|
+
`Policy requires "${derived}" for order ${JSON.stringify(refund.orderId)} because ${why}, but the recorded decision is "${decision}" - update scratchpad/refund.json to decision: "${derived}"`,
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
Note how the veto reasons carry the _fix_: the agent reads the reason,
|
|
429
|
+
rewrites `scratchpad/refund.json`, and retries the transition.
|
|
430
|
+
|
|
431
|
+
### When to use a grading rubric instead
|
|
432
|
+
|
|
433
|
+
Use a `script:` hook when the check is **deterministic** — derivable from
|
|
434
|
+
workspace data with plain code (policy tables, schema checks, cross-file
|
|
435
|
+
consistency). Use a `rubric:` hook when the check requires **judgment**
|
|
436
|
+
(is the reply complete? is the tone right?):
|
|
437
|
+
|
|
438
|
+
```yaml
|
|
439
|
+
states:
|
|
440
|
+
orders-question:
|
|
441
|
+
after:
|
|
442
|
+
- rubric:
|
|
443
|
+
max_iterations: 2 # `correct` verdicts allowed before a hard veto
|
|
444
|
+
instructions: |-
|
|
445
|
+
You review the agent's final reply about the user's orders...
|
|
446
|
+
Return { "verdict": "ok" | "correct" | "veto", "reason": "..." }.
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
A rubric may return `correct` — the agent gets another attempt with the
|
|
450
|
+
grader's reason as correction, budgeted by its own `max_iterations`. Script hooks
|
|
451
|
+
get the exact same flow when they return `correct(reason)` under a
|
|
452
|
+
`max_iterations` sidecar. A rubric whose response cannot be parsed
|
|
453
|
+
into a verdict **fails closed** (veto with a snippet of the raw output). A rubric
|
|
454
|
+
receives a compact JSON payload (`userRequest`, `history`, `state`, `phase`) —
|
|
455
|
+
its `instructions` are written against that, not against a hook's `messages`.
|
|
456
|
+
|
|
457
|
+
---
|
|
458
|
+
|
|
459
|
+
## Part 2 — Cases (declarative YAML)
|
|
460
|
+
|
|
461
|
+
### Layout and the prose rules
|
|
462
|
+
|
|
463
|
+
```
|
|
464
|
+
workflows/<name>/tests/
|
|
465
|
+
<case>.test.yaml # ONE case per file (.test.yml also accepted)
|
|
466
|
+
fixtures/… # fixture files, reachable via `workspace: { <path>: { from: … } }`
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
- **One YAML document per file, one case per document — one conversation on
|
|
470
|
+
one session.** The case id is the tests/-relative path minus the extension
|
|
471
|
+
(`tests/refunds/approved.test.yaml` → case `refunds/approved`); the
|
|
472
|
+
`archmax test` filter argument matches that path as a substring.
|
|
473
|
+
- **Cases are data, not code.** The host parses, validates, and interprets
|
|
474
|
+
them directly — no QuickJS sandbox is created for a case. The sandbox still
|
|
475
|
+
runs lifecycle hooks and `archmax_run` *inside the driven agent*, unchanged.
|
|
476
|
+
- **A case reads like a script.** `steps` is one flat sequential list where
|
|
477
|
+
actions (`send`, `decide`) and assertions are peers; each assertion
|
|
478
|
+
evaluates against the view of the nearest action above it.
|
|
479
|
+
- **Only YAML files are cases.** Discovery matches `*.test.yaml`/`*.test.yml`
|
|
480
|
+
and nothing else: a leftover `*.test.js`/`*.test.ts` or `tests.config.js` is
|
|
481
|
+
ignored **without a diagnostic**, so it neither runs nor warns. Delete such
|
|
482
|
+
files once migrated (see
|
|
483
|
+
[Migrating a JS case to YAML](#migrating-a-js-case-to-yaml)).
|
|
484
|
+
- **Unknown keys are errors.** Unknown top-level keys and step-entry keys
|
|
485
|
+
fail validation — a typo'd `reachedstate:` can never silently pass. So do
|
|
486
|
+
an assertion with no preceding action, a nested `expect:` block (assertions
|
|
487
|
+
are steps themselves), and any multi-session key (`sessions`, `session`,
|
|
488
|
+
`parallel` — "not part of test spec version 1"). `archmax validate`
|
|
489
|
+
statically checks every case: schema and unknown keys, a missing or
|
|
490
|
+
over-long `title`/`description`, malformed regex strings, unknown trigger
|
|
491
|
+
ids, and `from:` fixture existence.
|
|
492
|
+
- **The title and description rules.** Every case declares two mandatory
|
|
493
|
+
prose fields, both **length-capped and enforced** — over budget is an error,
|
|
494
|
+
exactly like a missing field:
|
|
495
|
+
- **`title`** — a short one-line label, **at most 60 characters**. It is
|
|
496
|
+
what `archmax test` prints beside the verdict and what an admin UI lists,
|
|
497
|
+
so it has to fit a line: name the behavior under test
|
|
498
|
+
(`Unknown requester is rejected at entry`), not the mechanics.
|
|
499
|
+
- **`description`** — **at most 200 characters**: one or two sentences
|
|
500
|
+
stating the scenario driven and what is asserted. Not a transcript of the
|
|
501
|
+
steps, which are right below it and read as a script already.
|
|
502
|
+
- Everything longer goes in a **YAML comment above the document** — why the
|
|
503
|
+
case exists, what a particular assertion is really pinning, what was
|
|
504
|
+
deliberately mocked vs. left real, how the fixture is wired. The comment
|
|
505
|
+
has no budget; use it freely.
|
|
506
|
+
- Both budgets are measured on whitespace-collapsed text, so a folded
|
|
507
|
+
(`>-`) and a literal (`|`) block of the same prose score alike.
|
|
508
|
+
- Work title-first: write `title` and `description` before the steps, and
|
|
509
|
+
never leave them contradicting each other. This replaces the JSDoc-header
|
|
510
|
+
rule that applied to JS test files; JSDoc headers now apply to hook
|
|
511
|
+
scripts and `archmax_run` sources only.
|
|
512
|
+
|
|
513
|
+
Run with `archmax test <workflow> [filter]`. A minimal case:
|
|
514
|
+
|
|
515
|
+
```yaml
|
|
516
|
+
# The route assertion is the point: without it the case would still pass if
|
|
517
|
+
# the agent answered the order question straight from the start state.
|
|
518
|
+
title: Order question routed into handle-order
|
|
519
|
+
description: >-
|
|
520
|
+
One turn asking about an order: the run succeeds and the audit trail
|
|
521
|
+
committed the route into handle-order.
|
|
522
|
+
steps:
|
|
523
|
+
- send: "What is the status of order ORD-1001?"
|
|
524
|
+
- succeeded: true
|
|
525
|
+
- reachedState: handle-order
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
### Suite config: the `tests:` block
|
|
529
|
+
|
|
530
|
+
Declarative data in `workflow.yaml` — never executed code:
|
|
531
|
+
|
|
532
|
+
```yaml
|
|
533
|
+
tests:
|
|
534
|
+
maxConcurrency: 1 # cases run at a time (only 1 or absent is accepted)
|
|
535
|
+
caseTimeoutMs:
|
|
536
|
+
120000 # per-case WALL-CLOCK budget (default 120000) —
|
|
537
|
+
# deliberately named differently from settings.timeoutMs,
|
|
538
|
+
# the per-script sandbox budget
|
|
539
|
+
judge: {} # grader settings ({} = env-configured default model;
|
|
540
|
+
# or { model, modelOptions })
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
There is no `reporters` option. A
|
|
544
|
+
`judge:` entry is required for cases that declare `grade:` assertions —
|
|
545
|
+
without it the assertion records an actionable failure instead of silently
|
|
546
|
+
passing.
|
|
547
|
+
|
|
548
|
+
### Top-level keys
|
|
549
|
+
|
|
550
|
+
Unknown keys are validation **errors**. All keys except `title` and
|
|
551
|
+
`description` are optional:
|
|
552
|
+
|
|
553
|
+
| Key | Shape | Meaning |
|
|
554
|
+
| ------------- | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
555
|
+
| `title` | string (**required**, ≤ 60 chars, one line) | Short label naming the behavior under test — what `archmax test` prints beside the verdict. Missing, multi-line, or over budget ⇒ `archmax validate` **error**. |
|
|
556
|
+
| `description` | string (**required**, ≤ 200 chars) | One or two sentences: the scenario driven and what is asserted. Missing or over budget ⇒ `archmax validate` **error**. Longer rationale belongs in a YAML comment above the document. |
|
|
557
|
+
| `skip` | string | A reason; the case reports a skipped verdict and drives nothing. |
|
|
558
|
+
| `variables` | `{ <name>: <value> }` | Session variables seeded for every driven turn, supplied as the runtime's `variables` and therefore **locked**. Values may be structured. This is where a case puts the run's input — a trigger carries none. Recover the real payload from a previous run's `artifacts/variables.json` rather than inventing one: see [Recovering a case's inputs from previous runs](#recovering-a-cases-inputs-from-previous-runs). |
|
|
559
|
+
| `trigger` | `{ id }` | The trigger every driven turn of the case runs under. Carries **only an id**; `args` is rejected with a pointer to `variables:`. Validated against the workflow machine before any step — an unknown id fails the case (even with no steps), listing the workflow's declared triggers. Omitted ⇒ the manual trigger. See [Start conditions](#start-conditions-trigger-variables-and-workspace). |
|
|
560
|
+
| `workspace` | `{ <path>: <content> }` | Files seeded into the case's run workspace **before any step**. Strings are written verbatim; non-string YAML values are JSON-serialized; a single-key `{ from: <tests/-relative path> }` mapping copies a file byte-for-byte. Only agent-visible run-zone paths are seedable (root-level files like `trigger.json`, or `scratchpad/…`); authored (`skills/…`), runtime-internal, and escaping paths are rejected before anything is written. |
|
|
561
|
+
| `mocks` | `[{ tool, whenInput?, result }]` | Declarative tool mocks: when the driven agent calls `tool` with input partially matching `whenInput`, return `result` instead of executing. Intercepts **agent-initiated and PTC** (script `tools.*`) calls alike. See [the mocking section](#settle-the-mocking-depth-before-writing-a-case). |
|
|
562
|
+
| `steps` | flat list of step entries | The case script: actions and assertions as peers (below). |
|
|
563
|
+
|
|
564
|
+
There is **no multi-session surface** in test spec version 1 — no `sessions:`,
|
|
565
|
+
no `session:` on a step, no `parallel:`, and no case-level `expect:` list;
|
|
566
|
+
each is rejected with an actionable error ("not part of test spec version 1").
|
|
567
|
+
A case is one conversation on one session; per-session isolation is an engine
|
|
568
|
+
invariant covered by the SDK's own unit tests (`src/testing/driver.test.ts`),
|
|
569
|
+
not something workflow authors re-test per workflow.
|
|
570
|
+
|
|
571
|
+
### Step entries: one flat script
|
|
572
|
+
|
|
573
|
+
`steps` is a single flat, sequential list — a case reads like a script. Every
|
|
574
|
+
entry is a **single-key mapping**: an **action**, or an **assertion** that
|
|
575
|
+
evaluates against the view of the **nearest action above it** (the completed
|
|
576
|
+
turn that action produced). An assertion before the first action is a schema
|
|
577
|
+
error. The two actions:
|
|
578
|
+
|
|
579
|
+
| Action | Shape | Meaning |
|
|
580
|
+
| -------- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
581
|
+
| `send` | `send: "<message>"` | One user message — a plain string, nothing else (no transport fields; start conditions live at case scope). Runs the agent until it stops (final answer, park, or failure), under the case's declared trigger. Against a run **parked at a human state** it is a message instead: answered on a tool-free turn, leaving the run parked at the same state. |
|
|
582
|
+
| `decide` | `decide: { to, comment? }` | Resume a **parked** run: the simulated human picks the outgoing transition `to` (a state slug), optionally with a review comment (delivered to the agent as correction on `refine` edges). |
|
|
583
|
+
|
|
584
|
+
```yaml
|
|
585
|
+
steps:
|
|
586
|
+
- send: "I'd like a refund for order ORD-1001."
|
|
587
|
+
- parked: true # <- asserts against the send's turn
|
|
588
|
+
- reachedState: refund-review # <- same turn
|
|
589
|
+
- send: "Any news?" # a message to the parked run, not a new turn
|
|
590
|
+
- parked: decision # still parked: nothing it says routes the run
|
|
591
|
+
- decide: { to: refund-closed, comment: "ok" }
|
|
592
|
+
- succeeded: true # <- asserts against the decide's turn
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
`usedNoTools`/`calledTool` read the **whole conversation's** tool calls, not one
|
|
596
|
+
turn's, so do not use them to assert that a reply-only turn called nothing — in a
|
|
597
|
+
multi-step case they still see the earlier turns' calls.
|
|
598
|
+
|
|
599
|
+
There is no nested `expect:` block — writing one is an error with guidance
|
|
600
|
+
that assertions are steps themselves.
|
|
601
|
+
|
|
602
|
+
### Assertion grammar
|
|
603
|
+
|
|
604
|
+
Each assertion is a single-key step entry (repeatable; order-preserving),
|
|
605
|
+
evaluated against the nearest action above it. **Every assertion is hard** —
|
|
606
|
+
`grade` included: the `atLeast` you declare is the bar, and a score under it
|
|
607
|
+
fails the case and the run.
|
|
608
|
+
|
|
609
|
+
Assertions marked **structural** below assert *where the run went*, and a
|
|
610
|
+
failure in one of them **halts the case** — see
|
|
611
|
+
[Structural assertions halt the case](#structural-assertions-halt-the-case).
|
|
612
|
+
|
|
613
|
+
| Assertion | Meaning |
|
|
614
|
+
| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
615
|
+
| `succeeded: true` | **Structural.** The run finished: not failed **and not parked**. |
|
|
616
|
+
| `parked: true` | **Structural.** The run is parked at a human state awaiting a decision. |
|
|
617
|
+
| `variables: { expect, path?, locked? }` | Every named variable is set to the expected value — deep equality, so a structured expectation compares by value. `path` addresses into a value per name (`items.-1.sku` is the last element); `locked` asserts the lock state. |
|
|
618
|
+
| `reachedState: <slug>` | **Structural.** A **committed** (non-trigger) audit-trail step entered the state — agent `archmax_advance`, human decision, `archmax_reset`, or `on_error` route. The trigger-arrival step never counts (it is recorded before the entry state's `before` gate runs, so a vetoed-entry run never "reaches" its start state). |
|
|
619
|
+
| `reply: { includes?, excludes? }` | `includes`: substrings or `/pattern/flags` regex strings that must **all** match; `excludes`: none may match (leak checks — a refusal that quotes the data it refuses still leaks it). Each accepts a **single token or a list** (`includes: "ORD-1003"` works). Matched against the turn's **whole assistant transcript**, not just the final message (a parked run often closes with a short "moving on" message while the substantive answer came earlier). |
|
|
620
|
+
| `calledTool: { name, input? }` | The turn recorded a call to tool `name`; `input` is a **partial** match — mappings match on the declared keys (nested mappings partially), arrays by deep equality, and `/pattern/flags` string values match as **regexes** (recommended for file paths, so the model's exact spelling doesn't matter). |
|
|
621
|
+
| `notCalledTool: { name, input? }` | No matching call occurred. |
|
|
622
|
+
| `blockedTool: { name, input? }` | A matching call was made **and refused by governance** — the assertion for testing a guard. Distinct from `notCalledTool` (nothing was tried, which proves nothing about the guard) and from a tool that ran and errored (only a policy rejection counts). Use it to test a state's `tools.allow` narrowing or its `skills.allow` set. |
|
|
623
|
+
| `usedNoTools: true` | The turn used no tools at all. |
|
|
624
|
+
| `trail: { to?, kind?, reason?, count }` | **Structural.** The number of audit-trail steps matching **all** given fields equals `count` (declare at least one of `to`/`kind`/`reason`). `kind`: `trigger` \| `agent` \| `human` \| `on_error` \| `branch` \| `join`. The trail accumulates across turns — each turn starts with a `trigger` step. |
|
|
625
|
+
| `noTraversal: true` | **Structural.** The trail holds nothing but trigger arrivals — a veto at entry committed no transitions. |
|
|
626
|
+
| `triggerArrival: <trigger-id>` | **Structural.** Some trail step has `kind: trigger` and that trigger id as its reason — the run really started from the declared trigger. |
|
|
627
|
+
| `grade: { closedQA, atLeast }` | LLM-grade the turn's **run evidence** against the `closedQA` criterion — its final reply *plus* a bounded chronological record of the assistant messages and tool calls behind it (each call with input, output, and status; mocked calls appear like real ones). The two are weighed differently: a criterion about what the user was **told** is met only by the reply, one about what the agent **did** may be met by the record — so `closedQA: looked the order up before answering` is a legitimate criterion, not only phrasing checks. **`atLeast` decides the outcome** — score ≥ `atLeast` passes, below it **fails the case**; the judge model's own opinion never overrides your declared bar, so the record can't disagree with the verdict. The judge is instructed to keep its reason to 1–3 sentences, so it fits on the verdict line. Requires `tests.judge` in `workflow.yaml`. |
|
|
628
|
+
|
|
629
|
+
**Regex form**: a string shaped `/pattern/flags` is compiled as a regular
|
|
630
|
+
expression; anything else matches as a plain substring (in `reply` tokens) or
|
|
631
|
+
by equality (in tool-input matchers). Malformed regex strings are **static
|
|
632
|
+
validation errors**, never silently matched as literals. Quote them in YAML —
|
|
633
|
+
`"/approv/i"`, `"/output\\/report\\.json/"`.
|
|
634
|
+
|
|
635
|
+
**There is no generic predicate.** No `satisfies`, no inline JS — that is the
|
|
636
|
+
point of the format. Bespoke logic belongs in a vitest unit test against the
|
|
637
|
+
engine or in a lifecycle hook.
|
|
638
|
+
|
|
639
|
+
### Structural assertions halt the case
|
|
640
|
+
|
|
641
|
+
A failed **structural** assertion (`succeeded`, `parked`, `reachedState`,
|
|
642
|
+
`trail`, `noTraversal`, `triggerArrival`) stops the case where it stands: no
|
|
643
|
+
further `send`/`decide` is driven and no further assertion is evaluated. Once
|
|
644
|
+
the run took a path the case did not describe, every later step would drive or
|
|
645
|
+
grade the wrong run — a `decide` after a failed `parked` cannot succeed, and a
|
|
646
|
+
`reply` check after a failed `reachedState` grades a state the run never
|
|
647
|
+
entered.
|
|
648
|
+
|
|
649
|
+
Content assertions (`reply`, `calledTool`, `notCalledTool`, `blockedTool`, `usedNoTools`,
|
|
650
|
+
`grade`) do **not** halt: a wrong reply is a defect in the run, not evidence the
|
|
651
|
+
run stopped being the one under test.
|
|
652
|
+
|
|
653
|
+
**What this means for how you author a case.** Put the structural assertion you
|
|
654
|
+
depend on immediately after the action that should have satisfied it, before the
|
|
655
|
+
content assertions that only make sense in that state:
|
|
656
|
+
|
|
657
|
+
```yaml
|
|
658
|
+
steps:
|
|
659
|
+
- send: "I want a refund for ORD-1001"
|
|
660
|
+
- reachedState: refund-review # ← structural: if this misses, stop here
|
|
661
|
+
- parked: true
|
|
662
|
+
- reply: # ← only meaningful in refund-review
|
|
663
|
+
includes: "approved"
|
|
664
|
+
- decide: { to: refund-closed }
|
|
665
|
+
- succeeded: true
|
|
666
|
+
```
|
|
667
|
+
|
|
668
|
+
A halt is reported as an ordinary failed verdict naming the assertion that
|
|
669
|
+
missed — **no error is set**, because a halt is a verdict the case reached, not
|
|
670
|
+
a failure of the machinery. Every assertion step that never ran is reported with
|
|
671
|
+
`status: "not-executed"` (one record per step, carrying its `steps` index), so a
|
|
672
|
+
stopped case still accounts for everything it declared: `archmax test` prints an
|
|
673
|
+
`N assertions not executed` line, and a host reading records can grey them out
|
|
674
|
+
instead of showing nothing and implying they passed.
|
|
675
|
+
|
|
676
|
+
Each record's `status` — `passed` \| `failed` \| `not-executed` — is the
|
|
677
|
+
authoritative outcome; the boolean `pass` is the alias `status === "passed"`.
|
|
678
|
+
|
|
679
|
+
### Start conditions: trigger, variables, and workspace
|
|
680
|
+
|
|
681
|
+
A case declares its **start conditions** — which trigger its driven runs start
|
|
682
|
+
from, what input those runs carry, and which files exist in the run workspace
|
|
683
|
+
before anything happens — at **case scope, never per step**:
|
|
684
|
+
|
|
685
|
+
```yaml
|
|
686
|
+
title: Delayed-orders report compiled from a trigger
|
|
687
|
+
description: >-
|
|
688
|
+
The report_requested trigger starts every driven turn, carrying the company
|
|
689
|
+
the firing named as a locked run variable.
|
|
690
|
+
trigger:
|
|
691
|
+
id: report_requested
|
|
692
|
+
variables:
|
|
693
|
+
company: "Acme Corp" # locked, exactly as a host's variables would be
|
|
694
|
+
workspace:
|
|
695
|
+
trigger.json: { company: "Acme Corp" } # non-string ⇒ JSON-serialized
|
|
696
|
+
steps:
|
|
697
|
+
- send: "A delayed-orders report was requested."
|
|
698
|
+
- succeeded: true
|
|
699
|
+
- triggerArrival: report_requested
|
|
700
|
+
```
|
|
701
|
+
|
|
702
|
+
- **`trigger`** — every driven turn of the case runs under this trigger
|
|
703
|
+
(each turn restarts the run at that trigger's start state; the trail
|
|
704
|
+
records `{ kind: trigger, reason: <id> }` per turn). Omit it for the
|
|
705
|
+
`manual` trigger. It is validated **before any step executes**: an unknown
|
|
706
|
+
id fails the case with the workflow's declared triggers, even when the case
|
|
707
|
+
has no steps — and `archmax validate` reports the same statically. There is
|
|
708
|
+
deliberately no per-step trigger: a follow-up `send` cannot run under a
|
|
709
|
+
different trigger than the case declares. **When the workflow declares
|
|
710
|
+
non-manual triggers, every case exercising one MUST declare it.**
|
|
711
|
+
- **`variables`** — the run's input, seeded on every driven turn as the
|
|
712
|
+
runtime's `variables` and therefore **locked**, values structured or
|
|
713
|
+
scalar. A seed here is indistinguishable from a production host seed to
|
|
714
|
+
everything downstream: a `${{…}}` reference in a `tools.allow` glob resolves
|
|
715
|
+
from it, a state's `requires:` gate is satisfied by it, a lifecycle hook reads
|
|
716
|
+
it in `variables`, and `archmax_set_variables` refuses to rewrite it.
|
|
717
|
+
**Recover the real payload rather than inventing one** — see
|
|
718
|
+
[Recovering a case's inputs from previous runs](#recovering-a-cases-inputs-from-previous-runs).
|
|
719
|
+
- **`workspace`** — input files seeded before any step. Keys are run-zone
|
|
720
|
+
paths (root-level files like `trigger.json`, or `scratchpad/…`); authored
|
|
721
|
+
paths (`skills/…`) and runtime areas are rejected before anything is
|
|
722
|
+
written. This mirrors the host convention of materializing a trigger's
|
|
723
|
+
payload as a workspace file — declare the payload exactly as production
|
|
724
|
+
would write it.
|
|
725
|
+
|
|
726
|
+
Two operational notes:
|
|
727
|
+
|
|
728
|
+
- Seeding writes the file; **reading it is still governed** — the triggered
|
|
729
|
+
start state must allow the read tool the agent needs (`read_file` is an
|
|
730
|
+
always-on tools unless a state narrows it).
|
|
731
|
+
- Hosts that pin case sessions via `sessionIdForCase` reuse sessions across test
|
|
732
|
+
runs: declared files are overwritten each run, but a file seeded by an
|
|
733
|
+
_earlier_ run whose declaration was since removed can linger. The default
|
|
734
|
+
(per-run unique sessions) starts clean.
|
|
735
|
+
|
|
736
|
+
### Recovering a case's inputs from previous runs
|
|
737
|
+
|
|
738
|
+
A trigger carries only an id, so everything a firing passes arrives as
|
|
739
|
+
variables — and those names, shapes, and values live in the host's call site and
|
|
740
|
+
in sessions that already ran, not in the workflow. Read a real run rather than
|
|
741
|
+
guessing:
|
|
742
|
+
|
|
743
|
+
```bash
|
|
744
|
+
archmax sessions # ids, status, state, parked state, `vars=` names
|
|
745
|
+
archmax sessions <sessionId> # that session's variables: value + locked state
|
|
746
|
+
```
|
|
747
|
+
|
|
748
|
+
That reads the session's **checkpoint**, so it works for any session that has run,
|
|
749
|
+
including one driven by `archmax run` (the CLI emits no run artifacts). A host that
|
|
750
|
+
calls `emitRunArtifacts` leaves the same record on disk as
|
|
751
|
+
`sessions/<sessionId>/artifacts/variables.json`
|
|
752
|
+
(`{ sessionId, workflow, variables: { name: { value, locked } } }`).
|
|
753
|
+
|
|
754
|
+
Pick the newest session whose recorded `trigger` matches the trigger the case
|
|
755
|
+
drives, then map its store onto the case:
|
|
756
|
+
|
|
757
|
+
| Recorded entry | Where it belongs |
|
|
758
|
+
| --- | --- |
|
|
759
|
+
| `trigger` (always locked) | the case's `trigger: { id: … }`, never `variables:` |
|
|
760
|
+
| any other `locked: true` | the case's `variables:` block — the firing's payload |
|
|
761
|
+
| `locked: false` | nothing: the agent established it, so **assert** it with `variables: { expect: … }` |
|
|
762
|
+
|
|
763
|
+
Never copy the whole store into `variables:`. Case seeds are locked, so seeding a
|
|
764
|
+
name the agent should establish hides a missing `archmax_set_variables` call *and*
|
|
765
|
+
makes the agent's own write fail against the lock — the case then exercises a run
|
|
766
|
+
production never performs.
|
|
767
|
+
|
|
768
|
+
`locked` is the whole attribution, and it is sufficient: a locked entry came from
|
|
769
|
+
the host (an `variables` seed, a delivery's seeds, or the built-in
|
|
770
|
+
`trigger`), an unlocked one from the agent's own `set_variables`. What you read is
|
|
771
|
+
the store as the session's **latest** turn left it, so locked entries are also the
|
|
772
|
+
turn-invariant part.
|
|
773
|
+
|
|
774
|
+
To produce a payload to read back, drive one yourself:
|
|
775
|
+
`archmax run <workflow> "<prompt>" --variables '{"from_email":"a@b.c"}'` then
|
|
776
|
+
`archmax sessions <id>`.
|
|
777
|
+
|
|
778
|
+
With no previous run to read, derive the same facts statically: the `${{…}}`
|
|
779
|
+
references in `tools.allow`/`allow_always` and the names in every state's
|
|
780
|
+
`requires:` are the variables the workflow depends on (`archmax validate` warns
|
|
781
|
+
about references nothing guarantees), and the host's
|
|
782
|
+
`createAgent({ variables })` / `deliver(sessionId, { trigger,
|
|
783
|
+
variables })` call sites name what production supplies.
|
|
784
|
+
|
|
785
|
+
### File fixtures: `from:` references
|
|
786
|
+
|
|
787
|
+
Bulk fixtures (a captured `trigger.json`, a large orders dump) live as real
|
|
788
|
+
files under `tests/` rather than inline YAML, and a case names each one it
|
|
789
|
+
wants: any `workspace` entry may declare `{ from: <path> }` instead of inline
|
|
790
|
+
content. The path resolves relative to the workflow's `tests/` directory, so
|
|
791
|
+
`from: fixtures/trigger.json` and `from: shared/orders-dump.json` (a fixture
|
|
792
|
+
shared across cases) both work. There is **no auto-seeded sibling data
|
|
793
|
+
directory**: a file is seeded only when a case's `workspace:` says so.
|
|
794
|
+
|
|
795
|
+
Fail-closed rules — reported by `archmax validate` and enforced again at run
|
|
796
|
+
bootstrap: a `from:` path that escapes `tests/` or names a missing file is an
|
|
797
|
+
error before any step runs, and a `*.test.yaml` file anywhere under `tests/`
|
|
798
|
+
is a case.
|
|
799
|
+
|
|
800
|
+
### Settle the mocking depth _before_ writing a case
|
|
801
|
+
|
|
802
|
+
An unmocked tool call in a test is a **real** tool call, on every run of the
|
|
803
|
+
suite: a real email sent, a real card charged, a real row written to a live
|
|
804
|
+
system. That is the one decision about a test its author cannot make alone, so
|
|
805
|
+
**ask the owner how much of the workflow's tool surface to mock before you
|
|
806
|
+
write the file**.
|
|
807
|
+
|
|
808
|
+
Ask concretely rather than in the abstract — name the tools this workflow
|
|
809
|
+
actually calls and propose a split:
|
|
810
|
+
|
|
811
|
+
- **Mock by default** anything that sends, pays, posts, deletes, or writes
|
|
812
|
+
outside the session zone, and anything whose result drifts between runs (live
|
|
813
|
+
inventory, today's date-dependent data). A test that is not safe to re-run is
|
|
814
|
+
not a test.
|
|
815
|
+
- **Leave real by default** reads against fixtures committed to the workspace,
|
|
816
|
+
the workflow's own scripts and hooks, and run-zone file writes — mocking those
|
|
817
|
+
would test the mock instead of the workflow.
|
|
818
|
+
- **Ask** about reads against a live system: cheap and stable enough to keep
|
|
819
|
+
real, or slow, rate-limited, or credential-bound enough to mock?
|
|
820
|
+
|
|
821
|
+
Record the answer in the **YAML comment above the case document** — the
|
|
822
|
+
`description` is capped at a sentence or two and belongs to what is asserted,
|
|
823
|
+
while a reader checking the case against the workflow needs to know what was
|
|
824
|
+
deliberately left real, which is exactly the kind of rationale the comment
|
|
825
|
+
exists for.
|
|
826
|
+
|
|
827
|
+
Mocks are the top-level `mocks:` list; they intercept **agent-initiated and
|
|
828
|
+
PTC** (script `tools.*`) calls with the same partial-input matching as
|
|
829
|
+
`calledTool`. Interception is guaranteed under `archmax test`. A host running
|
|
830
|
+
the same cases through `runTests` with its own `createTarget` must wire
|
|
831
|
+
`createToolMockMiddleware()` into that agent — a case that declares `mocks:`
|
|
832
|
+
against a target that cannot intercept them is refused with an error **before
|
|
833
|
+
the agent runs**, never run half-mocked. Mockless cases run against any
|
|
834
|
+
workflow-governed target. See
|
|
835
|
+
[backend-integration.md](backend-integration.md#cases-against-your-agent).
|
|
836
|
+
|
|
837
|
+
### Worked example: happy path + grade
|
|
838
|
+
|
|
839
|
+
```yaml
|
|
840
|
+
# `reachedState` is what pins the routing: without it the case would still
|
|
841
|
+
# pass if the agent answered straight from the start state. The judge
|
|
842
|
+
# (threshold 0.7) checks the order is attributed to the requester's company.
|
|
843
|
+
title: Delayed orders answered for a known requester
|
|
844
|
+
description: >-
|
|
845
|
+
One turn from support@acmecorp.com (present in skills/order-data/assets/orders.json): the run
|
|
846
|
+
succeeds, the trail committed the route into orders-question, and the reply
|
|
847
|
+
names the one delayed order, ORD-1003.
|
|
848
|
+
steps:
|
|
849
|
+
- send: "Hi, I'm support@acmecorp.com. Which orders are delayed for Acme?"
|
|
850
|
+
- succeeded: true
|
|
851
|
+
- reachedState: orders-question
|
|
852
|
+
- reply:
|
|
853
|
+
includes: ["ORD-1003", "/delayed/i"]
|
|
854
|
+
- grade:
|
|
855
|
+
closedQA: >-
|
|
856
|
+
confirms that order ORD-1003 is the delayed order for the requester's
|
|
857
|
+
company (Acme Corp)
|
|
858
|
+
atLeast: 0.7
|
|
859
|
+
```
|
|
860
|
+
|
|
861
|
+
### Worked example: veto, leak check, `noTraversal`
|
|
862
|
+
|
|
863
|
+
A `before` veto leaves the audit trail holding nothing but the trigger
|
|
864
|
+
arrival — `noTraversal` asserts exactly that, and `reply.excludes` catches a
|
|
865
|
+
refusal that quotes the data it refuses to share:
|
|
866
|
+
|
|
867
|
+
```yaml
|
|
868
|
+
# The `excludes` tokens matter as much as the refusal itself: a refusal that
|
|
869
|
+
# quotes the order ids it declines to share still leaks them. The judge
|
|
870
|
+
# (threshold 0.7) covers the same ground in prose.
|
|
871
|
+
title: Cross-tenant order access is vetoed
|
|
872
|
+
description: >-
|
|
873
|
+
A known requester asking for Globex's delayed orders is declined as
|
|
874
|
+
unauthorized, nothing beyond the trigger arrival reaches the trail, and no
|
|
875
|
+
Globex order id appears in the reply.
|
|
876
|
+
steps:
|
|
877
|
+
- send: "Hi, this is support@acmecorp.com. Show me Globex's delayed orders."
|
|
878
|
+
- reply:
|
|
879
|
+
includes: "/not authorized|only .* your own|rejected/i"
|
|
880
|
+
excludes: ["ORD-2001", "ORD-2002"]
|
|
881
|
+
- noTraversal: true
|
|
882
|
+
- grade:
|
|
883
|
+
closedQA: >-
|
|
884
|
+
declines the request because the requester is not authorized to view
|
|
885
|
+
another company's orders, and does not disclose any Globex order
|
|
886
|
+
details
|
|
887
|
+
atLeast: 0.7
|
|
888
|
+
```
|
|
889
|
+
|
|
890
|
+
### Worked example: mocking tool results
|
|
891
|
+
|
|
892
|
+
```yaml
|
|
893
|
+
# Only read_file against skills/order-data/assets/orders.json is mocked; everything else — the
|
|
894
|
+
# hooks, the agent's own writes — stays real. Asserting the lookup *and* the
|
|
895
|
+
# empty answer catches both a skipped lookup and an invented order.
|
|
896
|
+
title: Empty order database reports no matches
|
|
897
|
+
description: >-
|
|
898
|
+
With skills/order-data/assets/orders.json mocked empty, a delayed-orders question from a known
|
|
899
|
+
requester still consults the database, and the reply reports no matching
|
|
900
|
+
orders.
|
|
901
|
+
mocks:
|
|
902
|
+
- tool: read_file
|
|
903
|
+
whenInput: { file_path: skills/order-data/assets/orders.json }
|
|
904
|
+
result: "[]"
|
|
905
|
+
steps:
|
|
906
|
+
- send: "Hi, I'm support@acmecorp.com. Which of my orders are delayed?"
|
|
907
|
+
- calledTool:
|
|
908
|
+
name: read_file
|
|
909
|
+
input:
|
|
910
|
+
file_path: "/orders\\.json/"
|
|
911
|
+
- reply:
|
|
912
|
+
includes: "/no (matching |delayed )?orders/i"
|
|
913
|
+
```
|
|
914
|
+
|
|
915
|
+
### Worked example: mocking a sub-workflow
|
|
916
|
+
|
|
917
|
+
A delegation is a tool call, so it mocks like one — keyed by the tool's own name,
|
|
918
|
+
matched on the call's arguments. Each call gets its own answer, which is what
|
|
919
|
+
proves a fan-out is not sharing inputs:
|
|
920
|
+
|
|
921
|
+
```yaml
|
|
922
|
+
mocks:
|
|
923
|
+
- tool: archmax_workflow_enrich-order
|
|
924
|
+
whenInput: { order_id: "ORD-1002" }
|
|
925
|
+
result:
|
|
926
|
+
message: "ORD-1002 is shipped."
|
|
927
|
+
# The target's `manual` trigger declares these, so a mock standing in
|
|
928
|
+
# for that sub-run has to supply them: a mock replaces the run, never its
|
|
929
|
+
# contract, and one omitting a declared name fails the dispatch.
|
|
930
|
+
returns:
|
|
931
|
+
enrichment_file: "scratchpad/enrichment/ORD-1002.json"
|
|
932
|
+
delayed: false
|
|
933
|
+
steps:
|
|
934
|
+
- send: "Enrich the requested orders."
|
|
935
|
+
- ranWorkflow: { workflow: enrich-order, count: 2 }
|
|
936
|
+
- variables:
|
|
937
|
+
expect:
|
|
938
|
+
enrichments:
|
|
939
|
+
- order_id: "ORD-1002"
|
|
940
|
+
delayed: false
|
|
941
|
+
```
|
|
942
|
+
|
|
943
|
+
Assert with **`ranWorkflow`**, not `calledTool`, whenever a script drives the
|
|
944
|
+
fan-out: `calledTool` reads the *model's* calls, and the point of a script-driven
|
|
945
|
+
fan-out is that the model makes none of them. `ranWorkflow` reads the trail, which
|
|
946
|
+
records a step per sub-run whoever made the call — including a mocked one.
|
|
947
|
+
|
|
948
|
+
### Worked example: human-in-the-loop decisions (refine loop)
|
|
949
|
+
|
|
950
|
+
Drive a run into a human state, assert it parked, then decide as the reviewer —
|
|
951
|
+
including the send-back-for-changes (`refine`) loop, whose second parking is
|
|
952
|
+
pinned with a `trail` count:
|
|
953
|
+
|
|
954
|
+
```yaml
|
|
955
|
+
# The `trail` count is the point: refund-review is entered exactly twice —
|
|
956
|
+
# initial submit plus one resubmit — so a third entry would mean the refine
|
|
957
|
+
# edge looped. The final `reply` check pins that the correction did not lose
|
|
958
|
+
# its subject.
|
|
959
|
+
title: Refund sent back for changes, then approved
|
|
960
|
+
description: >-
|
|
961
|
+
The reviewer refines once and then approves: the run parks at both review
|
|
962
|
+
points, refund-review is entered exactly twice, the run succeeds, and the
|
|
963
|
+
final reply still names ORD-1001.
|
|
964
|
+
steps:
|
|
965
|
+
- send: "Hi, I'm support@acmecorp.com. I'd like a refund for order ORD-1001."
|
|
966
|
+
- parked: true
|
|
967
|
+
- decide:
|
|
968
|
+
to: refund-request
|
|
969
|
+
comment: >-
|
|
970
|
+
Please restate the refund amount and confirm the order was delivered
|
|
971
|
+
before it can be approved.
|
|
972
|
+
- parked: true
|
|
973
|
+
- trail:
|
|
974
|
+
to: refund-review
|
|
975
|
+
count: 2
|
|
976
|
+
- decide:
|
|
977
|
+
to: refund-closed
|
|
978
|
+
comment: "The explanation is clear now."
|
|
979
|
+
- succeeded: true
|
|
980
|
+
- reply:
|
|
981
|
+
includes: "ORD-1001"
|
|
982
|
+
```
|
|
983
|
+
|
|
984
|
+
### Worked example: triggered start + fixture file
|
|
985
|
+
|
|
986
|
+
The case declares its trigger on the document, its input as a locked variable,
|
|
987
|
+
and a payload fixture the host would materialize as a real file under `tests/`,
|
|
988
|
+
seeded byte-for-byte before any step:
|
|
989
|
+
|
|
990
|
+
```yaml
|
|
991
|
+
# tests/triggered-report.test.yaml — tests/fixtures/trigger.json holds the
|
|
992
|
+
# payload fixture. `triggerArrival` is what pins the start: it names the
|
|
993
|
+
# trigger the run really arrived on, not merely the state it ended up in.
|
|
994
|
+
title: Delayed-orders report compiled from a trigger
|
|
995
|
+
description: >-
|
|
996
|
+
The report_requested trigger starts the run: the agent reads the seeded
|
|
997
|
+
trigger.json payload, writes scratchpad/report.json, and the reply names Acme's
|
|
998
|
+
one delayed order, ORD-1003.
|
|
999
|
+
trigger:
|
|
1000
|
+
id: report_requested
|
|
1001
|
+
variables:
|
|
1002
|
+
company: "Acme Corp"
|
|
1003
|
+
workspace:
|
|
1004
|
+
trigger.json: { from: fixtures/trigger.json }
|
|
1005
|
+
steps:
|
|
1006
|
+
- send: "A delayed-orders report was requested."
|
|
1007
|
+
- succeeded: true
|
|
1008
|
+
- triggerArrival: report_requested
|
|
1009
|
+
# `/pattern/` input values match as regexes, so the assertion holds
|
|
1010
|
+
# whether the model spells the path with or without a leading slash.
|
|
1011
|
+
- calledTool:
|
|
1012
|
+
name: read_file
|
|
1013
|
+
input:
|
|
1014
|
+
file_path: "/trigger\\.json/"
|
|
1015
|
+
- calledTool:
|
|
1016
|
+
name: write_file
|
|
1017
|
+
input:
|
|
1018
|
+
file_path: "/output\\/report\\.json/"
|
|
1019
|
+
- reply:
|
|
1020
|
+
includes: ["ORD-1003", "Acme"]
|
|
1021
|
+
```
|
|
1022
|
+
|
|
1023
|
+
### Migrating a JS case to YAML
|
|
1024
|
+
|
|
1025
|
+
JS test cases (`defineEval`, the sandbox `t` driver, `@archmax-ai/harness/testing`) are
|
|
1026
|
+
**removed**; a `*.test.js`/`*.test.ts` file is no longer recognized and is
|
|
1027
|
+
skipped silently. The mapping:
|
|
1028
|
+
|
|
1029
|
+
| JS surface (removed) | YAML equivalent |
|
|
1030
|
+
| ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
|
|
1031
|
+
| File JSDoc header + `defineEval({ description })` | The mandatory `title` (≤ 60 chars) + `description` (≤ 200 chars); rationale that fits neither moves to a YAML comment above the document |
|
|
1032
|
+
| `defineEval({ trigger })` / `({ workspace })` | Top-level `trigger:` / `workspace:` (inline content or `{ from: … }`) |
|
|
1033
|
+
| `await t.send("msg")` | `- send: "msg"` step entry |
|
|
1034
|
+
| `await t.decide(to, comment)` | `- decide: { to, comment }` step entry |
|
|
1035
|
+
| `t.newSession(...)` / `Promise.all` over sessions | **Not part of test spec version 1** — a case is one conversation on one session; per-session isolation is an engine invariant covered by the SDK's own unit tests |
|
|
1036
|
+
| `t.mockTool(name, { whenInput, result })` | `mocks: [{ tool, whenInput, result }]` |
|
|
1037
|
+
| `t.succeeded()` / `t.parked()` | `- succeeded: true` / `- parked: true` step entries after the action |
|
|
1038
|
+
| `t.messageIncludes("x")` / `t.messageIncludes(/x/i)` | `- reply: { includes: ["x", "/x/i"] }` step entry (single token or list) |
|
|
1039
|
+
| Leak checks via `satisfies(...)` on the reply | `- reply: { excludes: [...] }` step entry |
|
|
1040
|
+
| `t.calledTool(name, { input })` / `t.notCalledTool` / `t.usedNoTools()` | `- calledTool: { name, input? }` / `- notCalledTool:` / `- usedNoTools: true` step entries (RegExp input values become `"/pattern/flags"` strings) |
|
|
1041
|
+
| `t.reachedState(name)` | `- reachedState: <name>` step entry |
|
|
1042
|
+
| `t.auditTrail()` + `filter(...).length === n` | `- trail: { to?, kind?, reason?, count }`; "nothing but the trigger arrival" ⇒ `- noTraversal: true`; trigger-arrival reason ⇒ `- triggerArrival: <id>` |
|
|
1043
|
+
| `t.judge.autoevals.closedQA(c).atLeast(n)` | `- grade: { closedQA: c, atLeast: n }` step entry |
|
|
1044
|
+
| `t.skip(reason)` | `skip: <reason>` |
|
|
1045
|
+
| bespoke predicates | **No YAML equivalent — deliberate.** Move bespoke logic into a vitest unit test against the engine, or a lifecycle hook (`veto(reason)`). |
|
|
1046
|
+
| `import { defineEval } from "@archmax-ai/harness/testing"` | Nothing — the subpath is removed; YAML imports nothing |
|
|
1047
|
+
|
|
1048
|
+
Removed package exports: `createDriveTool`, `DriveRequest`, `DriveSession`,
|
|
1049
|
+
and the `@archmax-ai/harness/testing` subpath. New root exports for hosts that parse
|
|
1050
|
+
cases themselves: `parseCaseDocument`, `CaseSchemaError`, the case types
|
|
1051
|
+
(`CaseDocument`, `CaseStep`, `CaseExpectation`, …), and the test-spec
|
|
1052
|
+
versioning surface (`DEFAULT_TEST_SPEC_VERSION`, `SUPPORTED_TEST_SPEC_VERSIONS`,
|
|
1053
|
+
`resolveTestSpecVersion`, `isTestSpecVersionSupported`,
|
|
1054
|
+
`UnsupportedTestSpecVersionError`). `@archmax-ai/harness/sandbox` carries the hook
|
|
1055
|
+
contract (`ok`/`veto`/`correct`, `defineHook`, `HookInput`).
|
|
1056
|
+
|
|
1057
|
+
---
|
|
1058
|
+
|
|
1059
|
+
### Source of truth in code
|
|
1060
|
+
|
|
1061
|
+
| Concern | File in the SDK |
|
|
1062
|
+
| ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
|
|
1063
|
+
| Sandbox prelude (verdict helpers, `defineHook`) | `src/sandbox/assets/parts/core.js`, `lifecycle-hook.js`; `src/sandbox/prelude.ts` |
|
|
1064
|
+
| Hook source preparation (`@archmax-ai/harness/*` import stripping, `export default`, foreign-import errors) | `src/sandbox/imports.ts` |
|
|
1065
|
+
| Executor, sandbox tools, PTC gateway, rubric-dispatch bridge | `src/sandbox/executor.ts`, `tools.ts`, `ptc-gateway.ts`, `bridge.ts` |
|
|
1066
|
+
| Typed entry point (`ok`/`veto`/`correct`, `defineHook`, `HookInput`) | `src/public/sandbox.ts` |
|
|
1067
|
+
| Lifecycle verdict parsing & fail-closed rules | `src/lifecycle/runner.ts` |
|
|
1068
|
+
| Hook input assembly (`messages`) | `src/lifecycle/runner.ts`, `src/lifecycle/transcript.ts` |
|
|
1069
|
+
| Case engine (host-side) | `src/testing/` — case schema `case-schema.ts`, discovery `discovery.ts`, assertion evaluation `assertions.ts`, grader `grade.ts`, runner `runner.ts` |
|
|
1070
|
+
| Validation diagnostics (missing script file, foreign import, YAML case checks) | `src/validate/validate.ts` |
|