@archmax-ai/harness 0.1.0 → 0.2.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.
- package/README.md +45 -4
- 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/authoring-skill/archmax-harness/README.md +24 -16
- package/dist/authoring-skill/archmax-harness/SKILL.md +7 -1
- package/dist/authoring-skill/archmax-harness/references/backend-integration.md +58 -2
- package/dist/authoring-skill/archmax-harness/references/hook-and-test-scripts.md +1 -1
- package/dist/authoring-skill/archmax-harness/references/workflow-schema.md +71 -22
- package/dist/authoring-skill/archmax-harness/references/workflow-yaml.md +16 -5
- package/dist/cli/turn.d.ts +9 -4
- package/dist/cli/turn.js +35 -8
- package/dist/cli/turn.js.map +1 -1
- package/dist/cli.js +6 -6
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +12 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -1
- package/dist/kernel/kernel.d.ts +2 -2
- package/dist/kernel/kernel.js +3 -2
- package/dist/kernel/kernel.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 +17 -2
- package/dist/public/spec.js +15 -1
- package/dist/public/spec.js.map +1 -1
- package/dist/sessions/resume.d.ts +4 -0
- package/dist/sessions/resume.js +8 -1
- package/dist/sessions/resume.js.map +1 -1
- 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 +35 -27
- 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/parks.js +8 -6
- package/dist/workflow/parks.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/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 -2
- package/dist/workflow/state.js +2 -2
- 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 +1 -1
- package/dist/workflow/tool-service.js.map +1 -1
- package/dist/workflow/turn-boundary.js +4 -5
- 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 +2 -1
- 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 +1680 -0
- package/skills/archmax-harness/references/workflow-yaml.md +252 -0
|
@@ -0,0 +1,1680 @@
|
|
|
1
|
+
# workflow.yaml — exhaustive schema reference
|
|
2
|
+
|
|
3
|
+
The complete, field-by-field reference for authoring a `workflow.yaml` (and its
|
|
4
|
+
optional `WORKFLOW.md` prose addendum) plus the surrounding workspace. Targets
|
|
5
|
+
**runtime contract version `"2"`**.
|
|
6
|
+
|
|
7
|
+
## Contents
|
|
8
|
+
|
|
9
|
+
- [File shape](#file-shape)
|
|
10
|
+
- [Root fields](#root-fields)
|
|
11
|
+
- [States (`MachineState`)](#states-machinestate)
|
|
12
|
+
- [Transitions](#transitions)
|
|
13
|
+
- [Tool governance](#tool-governance)
|
|
14
|
+
- [Skill governance](#skill-governance)
|
|
15
|
+
- [Mount governance](#mount-governance)
|
|
16
|
+
- [Lifecycle hooks](#lifecycle-hooks)
|
|
17
|
+
— full script contract + worked examples in
|
|
18
|
+
[`hook-and-test-scripts.md`](hook-and-test-scripts.md)
|
|
19
|
+
- [Budgets & error routing](#budgets--error-routing)
|
|
20
|
+
- [Human states](#human-states)
|
|
21
|
+
- [Sub-workflows are tools](#sub-workflows-are-tools)
|
|
22
|
+
- [Removed keys you may meet in an older workflow](#removed-keys-you-may-meet-in-an-older-workflow)
|
|
23
|
+
- [Workspace layout: skills, scripts](#workspace-layout)
|
|
24
|
+
- [Cases](#cases)
|
|
25
|
+
- [Code description docblocks](#code-description-docblocks)
|
|
26
|
+
- [Read-only zones](#read-only-zones)
|
|
27
|
+
- [Annotated example: order-lookup](#annotated-example-order-lookup)
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## File shape
|
|
32
|
+
|
|
33
|
+
**Two-file layout** under `workflows/<name>/`:
|
|
34
|
+
|
|
35
|
+
- **`workflow.yaml`** — the canonical, enforced machine spec: a **pure YAML
|
|
36
|
+
mapping** (no `---` frontmatter fences, no markdown). Parsed with YAML 1.2
|
|
37
|
+
semantics and validated against the schema. Top-level keys: `title`,
|
|
38
|
+
`instructions`, `runtime`, `settings`, `tests`, `tools`, `skills`, `mounts`,
|
|
39
|
+
`extensions`, `editor`, `states`.
|
|
40
|
+
- **`WORKFLOW.md`** (optional, beside it) — a plain-markdown prose addendum with
|
|
41
|
+
**no frontmatter**. A frontmatter block in `WORKFLOW.md` when `workflow.yaml`
|
|
42
|
+
exists is a **validation error**. The runtime renders the system prompt's
|
|
43
|
+
workflow header deterministically from the spec (`title` + `instructions`, and
|
|
44
|
+
NO state of the graph) and appends the `WORKFLOW.md` prose after it — prose is
|
|
45
|
+
never load-bearing and cannot disagree with enforcement. The graph is disclosed
|
|
46
|
+
per model call, for the ACTIVE STATE ONLY.
|
|
47
|
+
There is no "frontmatter wins" rule anymore; `workflow.yaml` is the only
|
|
48
|
+
authority.
|
|
49
|
+
|
|
50
|
+
**Removed**: the single-file `WORKFLOW.md` layout (YAML frontmatter + markdown
|
|
51
|
+
body, without a `workflow.yaml`) no longer loads — it is a load error. Migrate by
|
|
52
|
+
moving the frontmatter into `workflow.yaml` and stripping the fences from the
|
|
53
|
+
prose.
|
|
54
|
+
|
|
55
|
+
## Root fields
|
|
56
|
+
|
|
57
|
+
```yaml
|
|
58
|
+
title?: string # the workflow's HUMAN-READABLE name (metadata only).
|
|
59
|
+
# Identity is the WORKFLOW SLUG — the directory name
|
|
60
|
+
# (`workflows/<slug>/`, also the CLI argument), authored
|
|
61
|
+
# in the SAME kebab-case as state slugs
|
|
62
|
+
# (`order-lookup`); validate ERRORS on a missing or
|
|
63
|
+
# non-kebab workflow slug, exactly as for a state slug.
|
|
64
|
+
# A top-level `name:` is REMOVED — it is
|
|
65
|
+
# a load/validate ERROR; rename it to `title:`.
|
|
66
|
+
instructions?: string # the workflow's STANDING instructions — free-form
|
|
67
|
+
# markdown, direction that holds for the WHOLE run
|
|
68
|
+
# (conventions, house rules, what "done" means).
|
|
69
|
+
# Author it as a LITERAL block (`|-`), not folded
|
|
70
|
+
# (`>-`): it renders as markdown, and folding
|
|
71
|
+
# collapses blank lines into one run-on paragraph.
|
|
72
|
+
# Rendered verbatim as an `## Instructions` block at
|
|
73
|
+
# the top of the workflow prompt section (unlike a
|
|
74
|
+
# state's `instructions`, which are per model call) —
|
|
75
|
+
# so it lands in the static,
|
|
76
|
+
# cacheable prefix. validate WARNS when absent,
|
|
77
|
+
# ERRORS when present but not a non-empty string.
|
|
78
|
+
# A top-level `description:` is REMOVED and now
|
|
79
|
+
# simply ignored; use `instructions:`, rereading the
|
|
80
|
+
# text as direction, not narration.
|
|
81
|
+
# A test case's `description` is UNAFFECTED. A
|
|
82
|
+
# TRANSITION's `description` is now REQUIRED and
|
|
83
|
+
# non-empty (see `transitions` below).
|
|
84
|
+
|
|
85
|
+
disabled?: boolean # `true` = this machine STARTS NO NEW TURNS. Bounds
|
|
86
|
+
# STARTING, not finishing.
|
|
87
|
+
# REFUSES: `archmax run` (one line, non-zero exit, no
|
|
88
|
+
# session minted); a host `invoke`; a firing on a fresh
|
|
89
|
+
# session; a FOLLOW-UP message to a settled session
|
|
90
|
+
# (a new turn is new work) — all rejected AT THE TURN
|
|
91
|
+
# BOUNDARY with `status: rejected` and
|
|
92
|
+
# "Refusing to start: workflow '<slug>' is disabled.",
|
|
93
|
+
# before any state, hook or model call; and a caller's
|
|
94
|
+
# `archmax_workflow_<slug>` call (blocked call, refusal
|
|
95
|
+
# kind `disabled`, before a child is composed — it also
|
|
96
|
+
# beats a declared MOCK, so a case can never assert a
|
|
97
|
+
# delegation production would refuse).
|
|
98
|
+
# STILL WORKS: assembly; `decide` / `reply` / `deliver`
|
|
99
|
+
# (each resumes an unfinished turn, so parked human
|
|
100
|
+
# decisions are never trapped); `archmax validate` (full,
|
|
101
|
+
# plus a warning); and a CALLER's assembly — the tool is
|
|
102
|
+
# still bound, so disabling one leaf never takes its
|
|
103
|
+
# callers offline (validate WARNS on each state that
|
|
104
|
+
# allows a disabled target).
|
|
105
|
+
# `archmax test` SKIPS the suite and exits 0 — not run is
|
|
106
|
+
# not failed. There is NO flag to force cases to run.
|
|
107
|
+
# Read FAIL-CLOSED: anything but an absent key, `null` or
|
|
108
|
+
# boolean `false` disables the workflow, so `disabled:
|
|
109
|
+
# "no"` STOPS runs; validate ERRORS on a non-boolean.
|
|
110
|
+
# NOT a kill switch for work in flight, and NOT a
|
|
111
|
+
# per-state/per-trigger switch (that is a forbid list).
|
|
112
|
+
|
|
113
|
+
runtime?:
|
|
114
|
+
engine?: string # "archmax-harness"
|
|
115
|
+
version?: string | number # target contract — use "2"
|
|
116
|
+
|
|
117
|
+
settings?:
|
|
118
|
+
model?: string # MODEL ID ONLY — the id every state of this workflow runs
|
|
119
|
+
# on, over the endpoint/credentials the assembly already
|
|
120
|
+
# has. Overrides ARCHMAX_MODEL; a state's `model` overrides
|
|
121
|
+
# it. NOT a block: `{ id, temperature }` is a load error —
|
|
122
|
+
# sampling and endpoint are env/host configuration.
|
|
123
|
+
# Opaque: nothing validates the id against a list.
|
|
124
|
+
# Does NOT change the case grader (tests.judge.model).
|
|
125
|
+
timeoutMs?: number # default 15000; per eval/lifecycle script (sandbox budget)
|
|
126
|
+
memoryLimitBytes?: number
|
|
127
|
+
maxPtcCalls?: number | null
|
|
128
|
+
maxResultChars?: number
|
|
129
|
+
prompt_cache?:
|
|
130
|
+
enabled?: boolean # default true (provider prompt caching)
|
|
131
|
+
ttl?: "5m" | "1h" # default "5m"
|
|
132
|
+
|
|
133
|
+
tests?: # cases suite config (declarative data;
|
|
134
|
+
# tests.config.js is REMOVED — see Cases)
|
|
135
|
+
maxConcurrency?: number # only 1 (or absent) accepted — concurrency is
|
|
136
|
+
# not implemented; >1 is a validate error and
|
|
137
|
+
# fails `archmax test` loudly
|
|
138
|
+
caseTimeoutMs?: number # per-case WALL-CLOCK budget, default 120000 —
|
|
139
|
+
# deliberately named differently from
|
|
140
|
+
# settings.timeoutMs (per-script sandbox budget)
|
|
141
|
+
judge?: # {} = enable the grader with the
|
|
142
|
+
model?: string # env-configured default model
|
|
143
|
+
modelOptions?: { temperature?: number, maxTokens?: number }
|
|
144
|
+
# applied over the env model config, via the
|
|
145
|
+
# factory's `judge` role; other keys are errors
|
|
146
|
+
|
|
147
|
+
tools?:
|
|
148
|
+
allow_always?: AllowEntry[] # grants added to every state's allowlist
|
|
149
|
+
forbid_always?: ForbidEntry[]
|
|
150
|
+
# DENIED in EVERY state, and absolute: nothing reaches
|
|
151
|
+
# past it — no state grant, allow_always entry,
|
|
152
|
+
# argument guard or consumer rule. Same entry
|
|
153
|
+
# grammar as allow, plus the tool "*" meaning
|
|
154
|
+
# EVERY tool: { tool: "*", paths: ["logs/**"] }
|
|
155
|
+
# denies that path to everything. "*" in an
|
|
156
|
+
# ALLOW list is a load error.
|
|
157
|
+
# Guards narrow what is denied; globs match
|
|
158
|
+
# dotfiles (secrets/** covers secrets/.env).
|
|
159
|
+
# Inherited by every sub-workflow, naming the
|
|
160
|
+
# workflow that declared it. There is NO
|
|
161
|
+
# `policy:` block — writing one is a load error.
|
|
162
|
+
|
|
163
|
+
skills?:
|
|
164
|
+
allow_always?: string[] # SKILL SLUGS enabled in EVERY state of this workflow —
|
|
165
|
+
# the bundle directory names under skills/, kebab-case.
|
|
166
|
+
# NEVER paths, NEVER globs: the unit you govern is the
|
|
167
|
+
# capability. This is the skills half of
|
|
168
|
+
# tools.allow_always: a GRANT, which a state's own
|
|
169
|
+
# `skills.allow` ADDS to (the enabled set is the UNION),
|
|
170
|
+
# so no state repeats it and no state can subtract from
|
|
171
|
+
# it. ABSENT = none granted workflow-wide, exactly like
|
|
172
|
+
# `allow_always: []` — write the empty list when every
|
|
173
|
+
# capability here is state-specific.
|
|
174
|
+
# validate ERRORS on: a slug no source provides (it
|
|
175
|
+
# lists the known ones), a path/glob/non-string entry,
|
|
176
|
+
# a bundle whose SKILL.md `name:` ≠ its directory; and
|
|
177
|
+
# WARNS on a state entry this list already grants
|
|
178
|
+
# everywhere, and on a state's `allow: []` beside it
|
|
179
|
+
# (an empty state list subtracts nothing).
|
|
180
|
+
forbid_always?: string[] # SKILL SLUGS out of reach of EVERY state, whatever any
|
|
181
|
+
# list grants. Deny beats allow. (The retired root
|
|
182
|
+
# `allow` ceiling is a load error: one skills model.)
|
|
183
|
+
|
|
184
|
+
mounts?:
|
|
185
|
+
allow_always?: MountGrant[] # MOUNT NAMES reachable in EVERY state of this workflow —
|
|
186
|
+
# the keys of the HOST's mount table with their slashes
|
|
187
|
+
# stripped (`reference`, `catalogs/eu`, `AGENTS.md`).
|
|
188
|
+
# NEVER globs: scope paths WITHIN a reachable mount with
|
|
189
|
+
# a tools.allow entry. Only a mount the host declared
|
|
190
|
+
# `governed: true` needs a grant — an UNGOVERNED mount is
|
|
191
|
+
# visible in every state already, and a grant naming one
|
|
192
|
+
# is inert (validate warns). The skills model exactly: a
|
|
193
|
+
# GRANT a state's `mounts.allow` ADDS to (the reachable
|
|
194
|
+
# set is the UNION), never a ceiling. ABSENT = this
|
|
195
|
+
# workflow reaches no governed mount; validate says so
|
|
196
|
+
# ONCE, naming them — write `allow_always: []` to mean it.
|
|
197
|
+
# An entry is a NAME, or `{ mount: <name>, access: read |
|
|
198
|
+
# read_write }` to say what this level gives. The host's
|
|
199
|
+
# readOnly is a CEILING: `read_write` on a read-only mount
|
|
200
|
+
# opens nothing (validate warns); `read` NARROWS a
|
|
201
|
+
# writable one, and no narrower level widens it back.
|
|
202
|
+
# validate ERRORS on a name the table does not mount.
|
|
203
|
+
forbid_always?: string[] # MOUNT NAMES out of reach of EVERY state, governed or
|
|
204
|
+
# not, whatever any list grants. Deny beats allow.
|
|
205
|
+
# Inherited by every sub-workflow, naming the workflow
|
|
206
|
+
# that declared it. A NAME only — a denial is total, so
|
|
207
|
+
# there is no `access` to qualify. (A root `mounts.allow`
|
|
208
|
+
# is a load error: one mounts model.)
|
|
209
|
+
|
|
210
|
+
extensions?:
|
|
211
|
+
hooks?: string[] # custom hook kinds (beyond script/rubric) the
|
|
212
|
+
# runtime registers executors for; undeclared
|
|
213
|
+
# unknown hook kinds are validation errors
|
|
214
|
+
|
|
215
|
+
editor?: Record<string, unknown> # HOST PRESENTATION METADATA — an authoring
|
|
216
|
+
# UI's own state (canvas state positions keyed by
|
|
217
|
+
# slug, edge routing). The one key the runtime
|
|
218
|
+
# never reads: not shape-checked, not prompted,
|
|
219
|
+
# not governed, and excluded from the spec hash.
|
|
220
|
+
# Do NOT author it by hand and do NOT invent a
|
|
221
|
+
# use for it — but PRESERVE it verbatim when you
|
|
222
|
+
# rewrite a spec that already carries one, or the
|
|
223
|
+
# builder that wrote it loses every state position.
|
|
224
|
+
# The root is otherwise STRICT: any other
|
|
225
|
+
# unrecognized key is a load error.
|
|
226
|
+
|
|
227
|
+
states: Record<slug, MachineState> # REQUIRED; ≥1 state, ≥1 with a trigger
|
|
228
|
+
# the KEY is the state's SLUG (see below)
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
## States (`MachineState`)
|
|
232
|
+
|
|
233
|
+
Each key under `states` is the state's **slug**: its identity, and the only thing
|
|
234
|
+
that resolves it — `transitions[].to`, `on_error`, the `to` the agent passes to
|
|
235
|
+
`archmax_advance`, emitted events, session artifacts, and cases
|
|
236
|
+
`reachedState` assertions are all slugs. Author slugs as **hyphen-separated kebab-case** — lowercase alphanumeric
|
|
237
|
+
segments joined by single hyphens (`^[a-z0-9]+(-[a-z0-9]+)*$`, e.g.
|
|
238
|
+
`identify-case`, `report-requested`); no underscores, capitals, doubled hyphens,
|
|
239
|
+
or leading/trailing hyphens —
|
|
240
|
+
`validate` reports a nonconforming slug — or a state declared under an empty key,
|
|
241
|
+
which has no identity at all — as an **error**. A slug is a reference target, so
|
|
242
|
+
rename one only deliberately, and update every reference to it.
|
|
243
|
+
|
|
244
|
+
```yaml
|
|
245
|
+
states:
|
|
246
|
+
<state-slug>:
|
|
247
|
+
requires?: [string] # RUN VARIABLES this state must have set before it can
|
|
248
|
+
# be left: archmax_advance out of it is refused,
|
|
249
|
+
# recoverably, while any is unset. Variables are not
|
|
250
|
+
# declared anywhere, so this list — together with a
|
|
251
|
+
# TRIGGER's `requires` (see below) — is
|
|
252
|
+
# the only spec-visible statement that a variable will
|
|
253
|
+
# exist, which is what makes a ${{name}} guard
|
|
254
|
+
# reference GUARANTEED rather than dependent on agent
|
|
255
|
+
# behavior. Name here anything a downstream guard
|
|
256
|
+
# reads.
|
|
257
|
+
title?: string # HUMAN-READABLE label. Presentational only: carried
|
|
258
|
+
# in a human state's pending-decision record and shown
|
|
259
|
+
# in host surfaces. REACHES NO MODEL CALL. Never a
|
|
260
|
+
# routing target — `to` always takes a slug — so
|
|
261
|
+
# rewording it changes no behavior. Not unique.
|
|
262
|
+
# NOT a substitute for `instructions` (behavior
|
|
263
|
+
# guidance) or a transition `description` (routing).
|
|
264
|
+
|
|
265
|
+
type?: "agent" | "human"
|
|
266
|
+
# default "agent". "human" = a person routes.
|
|
267
|
+
# (`workflow` was removed: a state CALLS another
|
|
268
|
+
# workflow by allowing `archmax_workflow_<slug>`.)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
triggers?: Record<triggerId, TriggerDeclaration | null>
|
|
272
|
+
# marks a start state. The KEY is the trigger id
|
|
273
|
+
# and THIS state is that trigger's entry state —
|
|
274
|
+
# the one place a trigger is declared.
|
|
275
|
+
# manual: = CLI/Deep-Agent entry
|
|
276
|
+
# (prompt = input)
|
|
277
|
+
# <other id>: = tool/event trigger
|
|
278
|
+
# a null/empty value = the id and nothing more
|
|
279
|
+
# SEVERAL keys = the state is the entry for EVERY
|
|
280
|
+
# id, in declaration order (one state, one
|
|
281
|
+
# behavior, several ways in).
|
|
282
|
+
# The value is the declaration (see below):
|
|
283
|
+
# session/message/connection/description/
|
|
284
|
+
# requires/returns
|
|
285
|
+
# plus any key the HOST adds — preserved,
|
|
286
|
+
# lint-warned, never interpreted.
|
|
287
|
+
# NO `entry:` and NO `name:` inside a declaration:
|
|
288
|
+
# the state it sits on IS the entry and the key
|
|
289
|
+
# it sits under IS the id. Either one fails load
|
|
290
|
+
# naming the key. There is also no root
|
|
291
|
+
# `triggers:` block.
|
|
292
|
+
# An id is AUTHORED, so deleting or reordering a
|
|
293
|
+
# sibling never moves it — hosts key deployments
|
|
294
|
+
# on that id.
|
|
295
|
+
# two states declaring the same trigger id is a
|
|
296
|
+
# validation ERROR (start states must be
|
|
297
|
+
# unambiguous). A trigger has ONE entry state; a
|
|
298
|
+
# mapping with several keys is several triggers
|
|
299
|
+
# entering one state. A repeated id on one state
|
|
300
|
+
# is impossible — YAML mappings cannot repeat a key.
|
|
301
|
+
# A shared entry state has ONE behavior: branch on
|
|
302
|
+
# `${{trigger}}` / `args.trigger` where the paths
|
|
303
|
+
# differ, or use separate states.
|
|
304
|
+
|
|
305
|
+
summary?: string # ONE-LINE label for AUTHORS AND HOSTS (an editor's
|
|
306
|
+
# node caption, a generated diagram). RENDERED INTO
|
|
307
|
+
# NO PART OF THE PROMPT — the model never reads it,
|
|
308
|
+
# so it is free, and it can NOT carry routing logic.
|
|
309
|
+
# The routing text the agent reads is the SOURCE
|
|
310
|
+
# state's transition `description`s; nothing about a
|
|
311
|
+
# target state is disclosed at all.
|
|
312
|
+
|
|
313
|
+
instructions?: string # behavior guidance.
|
|
314
|
+
# agent node: surfaced to the LLM while active.
|
|
315
|
+
# Write the state's behavior INLINE here; a
|
|
316
|
+
# skill bundle (skills/<slug>/) is a capability
|
|
317
|
+
# with data or scripts, enabled by slug — not a
|
|
318
|
+
# per-state instruction sheet.
|
|
319
|
+
# human state: describes the decision for the packet
|
|
320
|
+
|
|
321
|
+
before?: HookSpec # gate ENTRY to this state (fail-closed)
|
|
322
|
+
after?: HookSpec # gate the archmax_advance OUT (or run at completion
|
|
323
|
+
# for a terminal state)
|
|
324
|
+
|
|
325
|
+
tools?:
|
|
326
|
+
allow?: AllowEntry[] # grants beyond the always-on tools (plus
|
|
327
|
+
# allow_always grants and the always-allowed
|
|
328
|
+
# archmax_advance); an entry naming
|
|
329
|
+
# an always-on tool NARROWS it.
|
|
330
|
+
forbid?: ForbidEntry[] # what this state DENIES while active — beating the
|
|
331
|
+
# workflow's allow_always, this state's own allow,
|
|
332
|
+
# and the always-on surface alike. Deny beats allow.
|
|
333
|
+
# Not inherited by a sub-workflow; a lifecycle hook
|
|
334
|
+
# is exempt from it (runtime authority).
|
|
335
|
+
|
|
336
|
+
skills?:
|
|
337
|
+
allow?: string[] # which CAPABILITIES this state ADDS, by SLUG — on top
|
|
338
|
+
# of the root's `skills.allow_always`, which is enabled
|
|
339
|
+
# here whatever this says. ABSENT = adds none, the same
|
|
340
|
+
# as `allow: []`. An empty list is NOT a deny (validate
|
|
341
|
+
# warns about one that reads like it) — `forbid` is.
|
|
342
|
+
forbid?: string[] # SLUGS this state does NOT get, subtracting from the
|
|
343
|
+
# workflow's allow_always and from its own allow.
|
|
344
|
+
# Refused as `skill.forbidden` (vs `skill.not-allowed`
|
|
345
|
+
# where nothing granted it).
|
|
346
|
+
# Enabling a slug is a GRANT: skills/<slug>/** becomes
|
|
347
|
+
# readable and its scripts runnable with NO tools.allow
|
|
348
|
+
# path entry. Not enabling one makes the bundle
|
|
349
|
+
# unreachable (read/ls/glob/grep/archmax_run all
|
|
350
|
+
# blocked, rule `skill.not-allowed`, above every
|
|
351
|
+
# declaration) AND undisclosed — its slug and
|
|
352
|
+
# description never enter this state's prompt, and a
|
|
353
|
+
# listing of skills/ does not show it.
|
|
354
|
+
# A tools.allow entry still NARROWS within the enabled
|
|
355
|
+
# set: the two compose as enabled AND allowed.
|
|
356
|
+
# Lifecycle HOOKS are exempt — a `before` hook reading
|
|
357
|
+
# skills/order-data/assets/orders.json still works in a
|
|
358
|
+
# state that enables nothing, so you can gate the
|
|
359
|
+
# agent's use of a capability while keeping the hook
|
|
360
|
+
# that enforces it.
|
|
361
|
+
|
|
362
|
+
mounts?:
|
|
363
|
+
allow?: MountGrant[] # which MOUNTS this state ADDS, by NAME — on top of the
|
|
364
|
+
# root's `mounts.allow_always`, which is reachable here
|
|
365
|
+
# whatever this says. ABSENT = adds none, the same as
|
|
366
|
+
# `allow: []`. An empty list is NOT a deny (validate
|
|
367
|
+
# warns about one that reads like it) — `forbid` is.
|
|
368
|
+
# An entry may name its access:
|
|
369
|
+
# allow: [{ mount: shared, access: read }]
|
|
370
|
+
# reads a writable mount without writing it here. The
|
|
371
|
+
# host's posture is the ceiling — `read_write` cannot
|
|
372
|
+
# open a read-only mount — and a `read` at either level
|
|
373
|
+
# holds, so a state never widens what the workflow
|
|
374
|
+
# narrowed. A write refused by THIS key is
|
|
375
|
+
# `mount.read-only`; one refused by the WIRING is
|
|
376
|
+
# `zone.read-only`.
|
|
377
|
+
forbid?: string[] # MOUNT NAMES this state does NOT get, subtracting from
|
|
378
|
+
# the workflow's allow_always, from its own allow, and
|
|
379
|
+
# from an UNGOVERNED mount's standing visibility alike.
|
|
380
|
+
# Refused as `mount.forbidden` (vs `mount.not-allowed`
|
|
381
|
+
# where nothing enabled it).
|
|
382
|
+
# A governed mount this state does not have is
|
|
383
|
+
# unreachable (read/ls/glob/grep all blocked, above
|
|
384
|
+
# every declaration) AND undisclosed — absent from the
|
|
385
|
+
# volatile prompt block, and filtered out of every
|
|
386
|
+
# ls/glob/grep result, the WORKSPACE ROOT LISTING
|
|
387
|
+
# included. Lifecycle HOOKS are exempt from this key
|
|
388
|
+
# (runtime authority), but not from `forbid_always`.
|
|
389
|
+
|
|
390
|
+
model?: string # MODEL ID ONLY — this state's turns run on it (and the
|
|
391
|
+
# handoff message of a park it holds). Most specific of
|
|
392
|
+
# state.model > settings.model > the assembly's default
|
|
393
|
+
# (createAgent `model`/`modelFactory`, else ARCHMAX_MODEL).
|
|
394
|
+
# Same id as settings.model ⇒ validate WARNS (inert).
|
|
395
|
+
# A rubric keeps its own `model`; a sub-workflow resolves
|
|
396
|
+
# its OWN spec's ids — nothing is inherited across it.
|
|
397
|
+
# A host passing an explicit `model` INSTANCE to
|
|
398
|
+
# createAgent outranks every declared id (one assembly
|
|
399
|
+
# warning names them); `modelFactory` is what honours one.
|
|
400
|
+
|
|
401
|
+
budget?: # per-state execution budget
|
|
402
|
+
maxTurns?: number # bounds this turn's agent loop
|
|
403
|
+
timeoutMs?: number # aborts the turn when exceeded
|
|
404
|
+
on_error?: string # state to route to on a TERMINAL failure
|
|
405
|
+
# (see Budgets & error routing)
|
|
406
|
+
|
|
407
|
+
transitions?: Transition[] # outgoing edges. EMPTY/absent ⇒ terminal state.
|
|
408
|
+
# `parallel:`/`join:` are REMOVED — a run traverses
|
|
409
|
+
# one state at a time; sequence what were branches.
|
|
410
|
+
#
|
|
411
|
+
# Transition = {
|
|
412
|
+
# to: string # a declared state's SLUG
|
|
413
|
+
# description: string # REQUIRED, non-empty
|
|
414
|
+
# type?: "approve" | "reject" | "refine" | "none"
|
|
415
|
+
# }
|
|
416
|
+
#
|
|
417
|
+
# THE AGENT SEES THIS STATE'S EDGES AND NOTHING
|
|
418
|
+
# ELSE OF THE GRAPH — no other state's slug, title,
|
|
419
|
+
# summary, hooks or transitions, and no count of
|
|
420
|
+
# the states. So `description` is the whole of what
|
|
421
|
+
# it knows about where an edge leads, and a missing
|
|
422
|
+
# or blank one is a LOAD ERROR naming
|
|
423
|
+
# states.<slug>.transitions.<i>.description.
|
|
424
|
+
#
|
|
425
|
+
# Write it for a reader standing in THIS state,
|
|
426
|
+
# which knows nothing about the target:
|
|
427
|
+
# GOOD: "The refund is over $50." (when)
|
|
428
|
+
# BAD: "Go to refund-review." (where)
|
|
429
|
+
# A routing condition CANNOT live in the target's
|
|
430
|
+
# summary/instructions — those are never disclosed
|
|
431
|
+
# to the state routing into it.
|
|
432
|
+
#
|
|
433
|
+
# The rendered line marks a target that is a human
|
|
434
|
+
# decision node or terminal (each changes the call
|
|
435
|
+
# or what follows it); nothing else of the target.
|
|
436
|
+
|
|
437
|
+
# human states only (ignored on agent states):
|
|
438
|
+
evidence?: string[] # workspace-relative paths always shown in the packet —
|
|
439
|
+
# a baseline the advancing agent adds to (see Human states)
|
|
440
|
+
approvers?: string[] # informational; not enforced
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
## Parking and sessions
|
|
444
|
+
|
|
445
|
+
A state parks the run **itself**: once it has asked for what it needs, its agent
|
|
446
|
+
calls `archmax_wait({ reason })` and the run suspends right there — no transition,
|
|
447
|
+
no model call spent parking. A delivered event resumes that same state, which
|
|
448
|
+
reads what arrived and then picks its own outgoing transition:
|
|
449
|
+
|
|
450
|
+
```yaml
|
|
451
|
+
clarify:
|
|
452
|
+
title: Ask a clarifying question
|
|
453
|
+
instructions: >-
|
|
454
|
+
Ask exactly one clarifying question, then call archmax_wait with a reason
|
|
455
|
+
naming what you are waiting for. When the reply arrives you continue here:
|
|
456
|
+
read it, and if it identifies the order, advance to answer.
|
|
457
|
+
transitions:
|
|
458
|
+
- { to: answer, description: The reply identifies the order. }
|
|
459
|
+
- { to: closed, description: The conversation closed before a usable reply. }
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
What to know:
|
|
463
|
+
|
|
464
|
+
- `archmax_wait` is available in **every** state, terminal ones included — a run
|
|
465
|
+
that has answered and now awaits a reply parks where it answered instead of
|
|
466
|
+
finishing. `tools: { forbid_always: [archmax_wait] }` removes it workflow-wide.
|
|
467
|
+
- The park is durable: status `awaiting_input` (classified **open**, never
|
|
468
|
+
finished), a checkpointed record of the state and the reason, and the process
|
|
469
|
+
may exit.
|
|
470
|
+
- **Any** delivered trigger id resumes a park — the id is recorded as the run's
|
|
471
|
+
current trigger (`args.trigger`, `${{trigger}}`), never matched against an edge.
|
|
472
|
+
The delivered variables arrive **locked**, and the arrival is written into the
|
|
473
|
+
transcript as an `[event]` **runtime note** — a synthetic `archmax_note` tool
|
|
474
|
+
call and its result, which is how the runtime says something to a run in a
|
|
475
|
+
channel a model cannot read as a person. Nothing may call `archmax_note`, and
|
|
476
|
+
derived views (`calledTool`, `usedNoTools`, the CLI's flow) skip the pair.
|
|
477
|
+
- The removed `type: wait` state kind is now an unknown state kind (`validate`
|
|
478
|
+
reports it as one), and a per-transition `on:` key is ignored. Call
|
|
479
|
+
`archmax_wait` instead.
|
|
480
|
+
- When what the state waits for is a **time** rather than a reply, the call says so:
|
|
481
|
+
`archmax_wait({ reason, until: "1d" })` — a duration (`30m`, `2h`, `1d`) or an
|
|
482
|
+
ISO-8601 instant, normalized to an absolute `resumeAt` on the park record, the
|
|
483
|
+
session summary, and the park event. The runtime **schedules nothing**: `resumeAt`
|
|
484
|
+
tells the host's cron when to `deliver`, and an earlier event resumes the park
|
|
485
|
+
regardless. An unparseable `until` is a tool error and the run does not park.
|
|
486
|
+
- A state that polls ("look for the transaction, else wait a day and retry") bounds
|
|
487
|
+
its own loop with `budget.maxParks` and sends the overrun somewhere with
|
|
488
|
+
`on_error`; the runtime counts parks per state in the checkpoint and refuses the
|
|
489
|
+
park that would exceed the bound as a turn failure:
|
|
490
|
+
|
|
491
|
+
```yaml
|
|
492
|
+
check-bank:
|
|
493
|
+
summary: Look for the transaction; wait a day and retry if it has not posted.
|
|
494
|
+
budget: { maxParks: 5 }
|
|
495
|
+
on_error: escalate
|
|
496
|
+
transitions:
|
|
497
|
+
- { to: reconcile, description: The transaction was found. }
|
|
498
|
+
```
|
|
499
|
+
- A finished session re-invoked is a **new turn** — it continues in the state the
|
|
500
|
+
previous turn ended in, *not* at the trigger's entry state, which applies only to
|
|
501
|
+
a session's first turn. Give a state re-entry transitions if a conversation must
|
|
502
|
+
move on from it; otherwise the agent's way out is `archmax_reset`. To continue
|
|
503
|
+
mid-state instead, park rather than finish.
|
|
504
|
+
|
|
505
|
+
A trigger's **declaration** sits under the `triggers:` of the state it enters,
|
|
506
|
+
keyed by its id. It says which conversation a firing belongs to and what the run
|
|
507
|
+
owes its caller:
|
|
508
|
+
|
|
509
|
+
```yaml
|
|
510
|
+
states:
|
|
511
|
+
intake:
|
|
512
|
+
triggers:
|
|
513
|
+
email_received:
|
|
514
|
+
session: conversation_id
|
|
515
|
+
email_reply:
|
|
516
|
+
session: triggers.-1.conversationId # newest arrival's conversation id
|
|
517
|
+
# unknown/finished conversation → fresh run
|
|
518
|
+
enrich:
|
|
519
|
+
triggers:
|
|
520
|
+
manual: # the one entry: CLI, host firing, or a caller
|
|
521
|
+
description: Enrich one order and say whether it is late. # for callers only
|
|
522
|
+
requires: # a firing must supply these to start
|
|
523
|
+
- order_id # bare name = untyped
|
|
524
|
+
- { name: due, type: date, description: The day the order is due. }
|
|
525
|
+
returns: # the run guarantees these when it completes
|
|
526
|
+
- enrichment_file
|
|
527
|
+
- { name: delayed, type: boolean }
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
- There is no `entry:` key and no root block: the state a declaration sits on is
|
|
531
|
+
the trigger's entry state, written once. `entry:` or `name:` inside a
|
|
532
|
+
declaration fails load naming the key — each would be a second spelling of
|
|
533
|
+
wiring the SDK already reads, and a loose declaration that swallowed one would
|
|
534
|
+
start the run somewhere you did not say.
|
|
535
|
+
- `requires:` / `returns:` are the run's **signature**, and both are enforced. A
|
|
536
|
+
firing that does not supply every `requires` name does not start; a run that
|
|
537
|
+
reaches a terminal state without every `returns` name set is rejected (a run
|
|
538
|
+
that *parks* is not checked — it has not finished). Each entry is a bare
|
|
539
|
+
run-variable name (**untyped**: any value, `null` included) or a strict object
|
|
540
|
+
`{ name, type?, description? }` naming one; the spellings mix in one list and
|
|
541
|
+
names are distinct across both. What the agent must *do* to produce a
|
|
542
|
+
variable still belongs in the `instructions` that set it. The names — with
|
|
543
|
+
type and description when declared, as `name (type) — description` — are
|
|
544
|
+
rendered into the prompt under the trigger's entry state, so the agent is told
|
|
545
|
+
what to produce before it can be failed for omitting it. A trigger's
|
|
546
|
+
`requires:` also **guarantees** the variable for `${{…}}` guards, so `validate`
|
|
547
|
+
stops warning about a guard bound to it. This is what makes delegation
|
|
548
|
+
checkable — see [Sub-workflows are tools](#sub-workflows-are-tools).
|
|
549
|
+
- **`type`** is one of `string`, `integer` (no fractional part), `number`
|
|
550
|
+
(finite), `boolean`, `date` (RFC 3339 `YYYY-MM-DD`, a real calendar day),
|
|
551
|
+
`date-time` (RFC 3339 with a mandatory `Z`/`±hh:mm` offset), `object` (not an
|
|
552
|
+
array) or `array`. Nothing is coerced (`"4"` is not an `integer`) and a typed
|
|
553
|
+
entry never takes `null`. It is held **at the start** (a mistyped seed is
|
|
554
|
+
refused before any model call), **at the write** (`archmax_set_variables` of a
|
|
555
|
+
mistyped typed return of the current trigger is a correctable refusal,
|
|
556
|
+
nothing written) and **at completion** (a mistyped typed return rejects the
|
|
557
|
+
run — in practice a script or hook write). There are no enums, item or
|
|
558
|
+
property schemas, ranges or optional inputs. An unknown `type`, an unknown
|
|
559
|
+
entry key (`default:`) or an empty `description` fails load.
|
|
560
|
+
- **When to type.** Type an entry when something outside the session reads the
|
|
561
|
+
contract: a delegating model that must build the argument, a host publishing an
|
|
562
|
+
MCP tool or a start form. Leave it bare when any value will do. Typing a
|
|
563
|
+
`returns` entry also tells the agent the shape to write.
|
|
564
|
+
- **`description` on the declaration** says, for a **caller**, what calling this
|
|
565
|
+
entry does. It leads the delegation tool's description and a host's MCP tool
|
|
566
|
+
description; it never reaches the model of the session it starts (that
|
|
567
|
+
session's brief is its `instructions`). An entry's own `description` is
|
|
568
|
+
different: it *is* shown to the agent, beside the name.
|
|
569
|
+
- `session:` is a **dotted path over the run's variables** — the same syntax
|
|
570
|
+
`${{…}}` guards use, including array indices and negative indices
|
|
571
|
+
(`-1` = last). No script, no callback: name where the host already puts the
|
|
572
|
+
conversation id. A `${{…}}`-wrapped value is rejected — write the bare path.
|
|
573
|
+
- Related triggers that must land in the same conversation declare the **same
|
|
574
|
+
path**. Each declares it itself: there is no shared lookup to change from
|
|
575
|
+
elsewhere in the document, and the duplication is the deliberate cost of a
|
|
576
|
+
state being legible on its own.
|
|
577
|
+
- A trigger with no `session:` path — or a firing whose path does not resolve —
|
|
578
|
+
gives that run a session of its own (its session id). Every run has exactly one
|
|
579
|
+
session, so there is no absent case.
|
|
580
|
+
- `message:` and `connection:` are **host-resolved** keys: shape-checked,
|
|
581
|
+
preserved and readable back (`messagePathForTrigger`, `connectionForTrigger`),
|
|
582
|
+
but nothing in the runtime acts on them. `message:` takes `session:`'s dotted
|
|
583
|
+
path — where a firing's words are — or the literal `false` for a trigger whose
|
|
584
|
+
firings carry no message; `connection:` is an opaque non-empty slug naming an
|
|
585
|
+
access connection in the host's own configuration. Author them only if the host
|
|
586
|
+
you are targeting reads them.
|
|
587
|
+
- A declaration key the SDK does not define is a **warning**, not a load failure:
|
|
588
|
+
it is ignored, and the workflow still runs. Structural problems — a
|
|
589
|
+
non-mapping `triggers:`, a declaration that is neither a mapping nor empty, one
|
|
590
|
+
id declared by two states, a malformed `session`/`message` path, an empty
|
|
591
|
+
`connection`, an `entry:` or `name:` key — still fail load.
|
|
592
|
+
- A host's own keys ride **inside the declaration**, keyed by the trigger id:
|
|
593
|
+
|
|
594
|
+
```yaml
|
|
595
|
+
states:
|
|
596
|
+
intake:
|
|
597
|
+
triggers: # one state, two event sources
|
|
598
|
+
outlook-mail: { type: ap, piece: microsoft-outlook, event: newEmail }
|
|
599
|
+
slack-message:
|
|
600
|
+
type: ap
|
|
601
|
+
piece: slack
|
|
602
|
+
event: newMessage
|
|
603
|
+
session: triggers.-1.threadId
|
|
604
|
+
connection: acme-slack
|
|
605
|
+
```
|
|
606
|
+
|
|
607
|
+
One state, one behavior, one governance block; `${{trigger}}` and a hook's
|
|
608
|
+
`args.trigger` still read which one actually fired.
|
|
609
|
+
- Every declared trigger **starts** a session. An event that must only ever reach
|
|
610
|
+
a live one — a ticket closing, a payment settling — is declared nowhere: the
|
|
611
|
+
host names the session it delivers into (`archmax deliver <session> --trigger
|
|
612
|
+
<id>`, `agent.workflow.send(sessionId, …)`), or passes a `sessionPath` with the
|
|
613
|
+
firing. A firing naming a trigger no state declares, with no session to reach,
|
|
614
|
+
is refused.
|
|
615
|
+
|
|
616
|
+
Call `archmax_wait` when an **external event** is what lets the work continue (an
|
|
617
|
+
email reply, a webhook, a scheduled reminder the host fires); author a `human`
|
|
618
|
+
state when a **person** picks the edge. Both park the session durably; they differ
|
|
619
|
+
in the resolution channel and in where the run continues — a decision advances
|
|
620
|
+
along the chosen edge, a delivery resumes the parked state.
|
|
621
|
+
|
|
622
|
+
**Runtime control tools** carry the `archmax_` prefix — `archmax_advance`,
|
|
623
|
+
`archmax_reset`, `archmax_wait`, `archmax_run`, `archmax_get_variables`,
|
|
624
|
+
`archmax_set_variables` — and are the names to write in `tools.allow`,
|
|
625
|
+
`tools.allow_always`, and the `forbid`/`forbid_always` lists. These are the only spellings:
|
|
626
|
+
an entry naming an unprefixed variant governs a tool that does not exist,
|
|
627
|
+
silently granting nothing. A host tool may never claim the prefix. Deep Agents' built-ins
|
|
628
|
+
(`read_file`, `task`, …) keep their own names.
|
|
629
|
+
|
|
630
|
+
- A state with **no `tools` block** gets the **always-on tools only**
|
|
631
|
+
(`ls`, `read_file`, `write_file`, `edit_file`, `glob`, `grep`, `write_todos`,
|
|
632
|
+
`archmax_eval`, `archmax_run`, `task`, plus the always-allowed `archmax_advance` and `allow_always`
|
|
633
|
+
grants — minus
|
|
634
|
+
whatever a `forbid` list denies there, and read-only-zone protection).
|
|
635
|
+
Governance is closed by default; there is no way to declare a fully open state.
|
|
636
|
+
- `tools.forbid` on a state **denies** while that state is active, beating every
|
|
637
|
+
grant that would otherwise reach it (see [Tool governance](#tool-governance)).
|
|
638
|
+
- A state with **no transitions** is **terminal**: the run ends when the agent
|
|
639
|
+
finishes its turn there; it cannot call `archmax_advance`.
|
|
640
|
+
|
|
641
|
+
## Transitions
|
|
642
|
+
|
|
643
|
+
```yaml
|
|
644
|
+
transitions:
|
|
645
|
+
- to: string # REQUIRED target state SLUG (must exist); never a title
|
|
646
|
+
description?: string # shown to the agent (or human) to pick the edge
|
|
647
|
+
type?: "approve" | "reject" | "refine" | "none" # default "none"
|
|
648
|
+
```
|
|
649
|
+
|
|
650
|
+
- On **agent** states the agent chooses one edge via
|
|
651
|
+
`archmax_advance({ to, reason })`; `type` is metadata only.
|
|
652
|
+
- On **human** states each transition is a selectable outcome (a UI renders one
|
|
653
|
+
button per edge, labeled by `type`). `refine` conventionally loops back to a
|
|
654
|
+
prior state with the reviewer's comment as correction. Max one transition per
|
|
655
|
+
`type` on a human state.
|
|
656
|
+
|
|
657
|
+
## Tool governance
|
|
658
|
+
|
|
659
|
+
`AllowEntry` forms:
|
|
660
|
+
|
|
661
|
+
```yaml
|
|
662
|
+
# 1. bare tool name (any args)
|
|
663
|
+
- read_file
|
|
664
|
+
|
|
665
|
+
# 2. object with glob arg matchers (matches if the arg satisfies ANY glob)
|
|
666
|
+
- tool: write_file
|
|
667
|
+
args:
|
|
668
|
+
file_path: ["reports/*.json"]
|
|
669
|
+
|
|
670
|
+
# 3. `paths` shorthand for args.file_path (string or array)
|
|
671
|
+
- tool: write_file
|
|
672
|
+
paths: ["reports/*.json"]
|
|
673
|
+
```
|
|
674
|
+
|
|
675
|
+
An object entry may also carry `connection` (the connection slug a
|
|
676
|
+
platform-provided tool resolves through when its collection has several) and
|
|
677
|
+
`source` (any string: a host's own label for where the tool came from) — the
|
|
678
|
+
runtime matches governance by `tool` name alone and never reads `source`.
|
|
679
|
+
|
|
680
|
+
**The model is closed by default, and a denial beats every grant.** Every state always has the
|
|
681
|
+
**always-on tools** (`ls`, `read_file`, `write_file`, `edit_file`, `glob`,
|
|
682
|
+
`grep`, `write_todos`, `archmax_eval`, `archmax_run`). One built-in is **never
|
|
683
|
+
disclosed** and grantable by nothing: `task`, the runtime's own dispatch for
|
|
684
|
+
grading rubrics — a rubric grades the agent rather than serving it, so naming
|
|
685
|
+
`task` in an allow entry is a `validate` error and the kernel refuses the call.
|
|
686
|
+
The planning scratchpad `write_todos` and the sandbox
|
|
687
|
+
`archmax_eval` are standard equipment — always disclosed, always permitted,
|
|
688
|
+
no setting; only a `forbid` list refuses them. The interpreter is safe as a
|
|
689
|
+
default because the `tools.*` calls its code makes are decided against the active
|
|
690
|
+
state by the same kernel: it adds computation over what the state permits, not
|
|
691
|
+
access. `archmax_run` — executing an authored script **file** — is always on on
|
|
692
|
+
the same terms (the script is a file the state could already read); declare it
|
|
693
|
+
per state to **narrow** which files may run there. Any other tool
|
|
694
|
+
must be declared in the state's `tools.allow` or workflow-level
|
|
695
|
+
`tools.allow_always`. A `tools.allow` entry naming an always-on tool **narrows**
|
|
696
|
+
it for that state.
|
|
697
|
+
Denial is declared beside the grant, in the same two positions and with the same
|
|
698
|
+
entries: `tools.forbid_always` at the root denies in every state,
|
|
699
|
+
`tools.forbid` on a state denies while it is active — subtracting from the
|
|
700
|
+
workflow's grant, the state's own `allow` and the always-on surface alike.
|
|
701
|
+
**Deny beats allow, and no narrower level widens a denial.** A forbid entry may
|
|
702
|
+
name the tool `*` (every tool); `*` in an allow list is a load error. A lifecycle
|
|
703
|
+
hook is exempt from the state's `allow` **and** its `forbid` — runtime authority,
|
|
704
|
+
outside that surface both ways — but never from `forbid_always`.
|
|
705
|
+
|
|
706
|
+
Precedence (highest first):
|
|
707
|
+
|
|
708
|
+
0. **Skill and mount governance** — a path inside a skill bundle the active state
|
|
709
|
+
does not enable is refused before anything below is consulted (rule
|
|
710
|
+
`skill.not-allowed`), and so is a path under a mount the state does not have
|
|
711
|
+
(`mount.not-allowed` / `mount.forbidden`, or `mount.read-only` for a write into
|
|
712
|
+
one a grant narrowed to reads). Nothing widens either: not a wildcard
|
|
713
|
+
`tools.allow` entry, not `allow_always`, not a consumer rule.
|
|
714
|
+
1. **Declared denials** — `tools.forbid_always` denies in every state,
|
|
715
|
+
`tools.forbid` denies in the active state, both regardless of any allow list
|
|
716
|
+
(and regardless of always-allowed): deny beats allow, and no narrower level
|
|
717
|
+
widens a denial.
|
|
718
|
+
2. **Always allowed**: `archmax_advance` (baked in, every state).
|
|
719
|
+
3. **Per-state `tools.allow`** — the listed tools/arg-shapes. An entry for an
|
|
720
|
+
always-on tool (or one also in `allow_always`) takes precedence
|
|
721
|
+
(**narrower wins** — the state's arg globs apply, not the broader grant).
|
|
722
|
+
4. **Essential built-ins** — always permitted when no state entry narrows
|
|
723
|
+
them. Listing one in `allow_always` is inert (`archmax validate` warns).
|
|
724
|
+
5. **Workflow `tools.allow_always`** — grants tools that are not always on in every
|
|
725
|
+
state.
|
|
726
|
+
6. **Default**: everything else is blocked.
|
|
727
|
+
|
|
728
|
+
**Progressive disclosure.** The model is only shown the active state's tool
|
|
729
|
+
surface: always-on tools + `allow_always` + the state's entries + `archmax_advance`
|
|
730
|
+
(hidden in terminal states), minus every tool denied there by name. Disclosure is
|
|
731
|
+
name-level (arg-constrained tools stay visible; the kernel enforces args at
|
|
732
|
+
call time) and never replaces enforcement. **Skills are disclosed the same way**:
|
|
733
|
+
each model call names only the skills the active state enables, with their
|
|
734
|
+
descriptions — a disabled bundle's slug never reaches the prompt, and `ls skills/`
|
|
735
|
+
does not list it.
|
|
736
|
+
|
|
737
|
+
## Skill governance
|
|
738
|
+
|
|
739
|
+
Govern a **capability** by its slug, not by path globs. The two levels compose
|
|
740
|
+
exactly as tools do: `skills.allow_always` at the spec root is enabled in **every**
|
|
741
|
+
state, and a state's `skills.allow` **adds** to it. **A skill is enabled only where
|
|
742
|
+
a list names it** — an absent state block adds nothing, exactly as an absent
|
|
743
|
+
`tools.allow` grants no tool — and a capability is declared **once**, at the level
|
|
744
|
+
where the decision was made:
|
|
745
|
+
|
|
746
|
+
```yaml
|
|
747
|
+
skills:
|
|
748
|
+
allow_always: [order-data] # enabled in every state, named once
|
|
749
|
+
|
|
750
|
+
states:
|
|
751
|
+
orders-question: {} # no block, and it still has order-data
|
|
752
|
+
refund-review:
|
|
753
|
+
skills:
|
|
754
|
+
allow: [refund-policy] # this state adds one, and has both
|
|
755
|
+
identify-case:
|
|
756
|
+
skills:
|
|
757
|
+
forbid: [order-data] # routes only: opts out of the grant
|
|
758
|
+
```
|
|
759
|
+
|
|
760
|
+
A state's `allow: []` is not a deny — a state's `allow` only ever adds
|
|
761
|
+
(`validate` warns about an entry that reads like a denial). **`skills.forbid` is
|
|
762
|
+
what subtracts**: it removes a slug from that state's enabled set, the workflow's
|
|
763
|
+
`allow_always` included, so one state opts out of a capability every other state
|
|
764
|
+
keeps; `skills.forbid_always` at the root puts a bundle out of reach everywhere.
|
|
765
|
+
There is no root `skills.allow` ceiling — writing one is a load error.
|
|
766
|
+
|
|
767
|
+
Why by slug, and not with `tools.allow` paths:
|
|
768
|
+
|
|
769
|
+
- **One declaration covers the whole bundle.** Enabling `order-data` makes
|
|
770
|
+
`skills/order-data/**` readable and its scripts runnable. The path-glob
|
|
771
|
+
alternative needs an entry per tool (`read_file`, `ls`, `glob`, `grep`,
|
|
772
|
+
`archmax_run`) and gets subtly wrong the moment a bundle grows a directory.
|
|
773
|
+
- **Path entries do not un-advertise.** A blocked read still leaves the skill's
|
|
774
|
+
name and description in the prompt of every state, so the model keeps reaching
|
|
775
|
+
for a capability it cannot have. A skill that is not enabled is not disclosed.
|
|
776
|
+
- **The two still compose** as *enabled AND allowed*: a `tools.allow` entry
|
|
777
|
+
narrows **within** the enabled set. `{ tool: archmax_run, paths:
|
|
778
|
+
["skills/<slug>/scripts/**"] }` remains worth writing beside the list that
|
|
779
|
+
enables `<slug>` — the skills list puts every other capability out
|
|
780
|
+
of reach, while the entry confines execution to *this* capability's scripts.
|
|
781
|
+
Capability governance and execution governance are different questions.
|
|
782
|
+
- **`archmax_run` can only ever be narrowed.** A non-overridable kernel rule
|
|
783
|
+
(`script.skill-only`) confines it to skill bundles, so a script the agent writes
|
|
784
|
+
into `scratchpad/` is unrunnable, a lifecycle hook script is unrunnable, and
|
|
785
|
+
`{ tool: archmax_run, paths: ["**"] }` grants nothing — `validate` reports such
|
|
786
|
+
an entry as inert.
|
|
787
|
+
|
|
788
|
+
- **No skill enabled means no script at all.** Since `script.skill-only` confines
|
|
789
|
+
`archmax_run` to bundles, a state that enables no skill — nothing on
|
|
790
|
+
`allow_always`, nothing on its own list — can execute nothing: the tool stays on
|
|
791
|
+
the surface and refuses every path.
|
|
792
|
+
|
|
793
|
+
`archmax validate` warns when a state entry names a slug `allow_always` already
|
|
794
|
+
grants everywhere, when a state's `allow: []` beside such a grant subtracts
|
|
795
|
+
nothing, and once when a spec declares no root `skills` block at all in a workspace
|
|
796
|
+
that serves bundles, naming what the workflow cannot reach. Any root block silences
|
|
797
|
+
that last one — write `skills: { allow_always: [] }` when a workflow grants no
|
|
798
|
+
capability workflow-wide.
|
|
799
|
+
|
|
800
|
+
Hooks are **exempt**: a `before`/`after` script reads authored data on runtime
|
|
801
|
+
authority, so a state may enable nothing and still be gated by a hook that reads
|
|
802
|
+
the very bundle the model cannot. That is the shape to reach for when a capability
|
|
803
|
+
should *enforce* something without being *usable*.
|
|
804
|
+
|
|
805
|
+
**`tools.forbid` on a state denies there**, and `tools.forbid_always` at the
|
|
806
|
+
root denies everywhere. Both beat every grant. Migrating a workflow off the
|
|
807
|
+
retired `policy` block:
|
|
808
|
+
|
|
809
|
+
- `policy.forbid_tools: [x]` → `tools.forbid_always: [x]`.
|
|
810
|
+
- `policy.forbid_paths: [g]` → `tools.forbid_always: [{ tool: write_file, paths:
|
|
811
|
+
[g] }, { tool: edit_file, paths: [g] }]` for the identical meaning (it only
|
|
812
|
+
ever blocked those two), or `[{ tool: "*", paths: [g] }]` to deny reads too.
|
|
813
|
+
|
|
814
|
+
## Mount governance
|
|
815
|
+
|
|
816
|
+
A **mount** is a folder (or a single file) the *host* routes into the agent's
|
|
817
|
+
workspace: `skills/`, `AGENTS.md`, and whatever else the wiring composes
|
|
818
|
+
(`reference/`, `catalogs/eu/`). The host owns which backend serves a mount, its
|
|
819
|
+
write posture, and whether the spec governs it; the spec owns which states reach
|
|
820
|
+
it, and what they may do there. **Neither is inferred from the other**, so you
|
|
821
|
+
cannot make a folder appear by naming it — ask the host to mount it.
|
|
822
|
+
|
|
823
|
+
A mount the host declares **governed** (`{ backend, governed: true }` in the
|
|
824
|
+
wiring) is closed by default, exactly like a skill bundle: reachable only from the
|
|
825
|
+
states a `mounts` list enables. An **ungoverned** mount — every mount of a
|
|
826
|
+
zero-config workspace, and every mount of a host that marks nothing — is visible
|
|
827
|
+
in every state, and only a `forbid` takes it away. So a spec with no `mounts`
|
|
828
|
+
block runs unchanged over any table.
|
|
829
|
+
|
|
830
|
+
The two levels compose exactly as `skills` does:
|
|
831
|
+
|
|
832
|
+
```yaml
|
|
833
|
+
mounts:
|
|
834
|
+
allow_always: [reference] # reachable in every state, named once
|
|
835
|
+
forbid_always: [catalogs/uk] # out of reach everywhere, and in every
|
|
836
|
+
# child session this workflow delegates to
|
|
837
|
+
|
|
838
|
+
states:
|
|
839
|
+
intake: {} # no block, and it still has reference/
|
|
840
|
+
triage:
|
|
841
|
+
mounts:
|
|
842
|
+
allow: [catalogs/eu] # this state adds one, and has both
|
|
843
|
+
route:
|
|
844
|
+
mounts:
|
|
845
|
+
forbid: [reference] # opts out of the workflow's grant
|
|
846
|
+
```
|
|
847
|
+
|
|
848
|
+
A state's `allow: []` is not a deny — a state's `allow` only ever adds
|
|
849
|
+
(`validate` warns about an entry that reads like a denial). **`mounts.forbid` is
|
|
850
|
+
what subtracts**, the workflow's `allow_always` and an ungoverned mount's standing
|
|
851
|
+
visibility alike. There is no root `mounts.allow` ceiling — writing one is a load
|
|
852
|
+
error.
|
|
853
|
+
|
|
854
|
+
### Read or read/write
|
|
855
|
+
|
|
856
|
+
A grant entry is either a bare mount name — taking the posture the host wired — or
|
|
857
|
+
a mapping naming the access this level gives:
|
|
858
|
+
|
|
859
|
+
```yaml
|
|
860
|
+
mounts:
|
|
861
|
+
allow_always:
|
|
862
|
+
- reference # the host's posture
|
|
863
|
+
- { mount: shared, access: read_write } # writable where the host allows it
|
|
864
|
+
|
|
865
|
+
states:
|
|
866
|
+
review:
|
|
867
|
+
mounts:
|
|
868
|
+
allow: [{ mount: shared, access: read }] # reads it, cannot write it here
|
|
869
|
+
```
|
|
870
|
+
|
|
871
|
+
The host's `readOnly` is a **ceiling**, not a default to argue with:
|
|
872
|
+
|
|
873
|
+
- `access: read_write` on a mount the workspace serves read-only opens nothing,
|
|
874
|
+
and `validate` warns that the entry is inert. Ask the host to mount it
|
|
875
|
+
`{ readOnly: false }` instead.
|
|
876
|
+
- `access: read` is a restriction, so it behaves like every other restriction
|
|
877
|
+
here: whichever level asks for it gets it, and **no narrower level widens it
|
|
878
|
+
back**. A state cannot re-open what the workflow narrowed.
|
|
879
|
+
- A `forbid` entry is a name only. A denial is total, so there is no access to
|
|
880
|
+
qualify — `{ mount: x, access: read }` in a `forbid` list is a load error.
|
|
881
|
+
|
|
882
|
+
Two refusals, because they are two different edits: a write into a mount the
|
|
883
|
+
**host** serves read-only is `zone.read-only` (fix the wiring), and a write into
|
|
884
|
+
one a **grant** narrowed is `mount.read-only` (fix the spec).
|
|
885
|
+
|
|
886
|
+
### What the agent sees
|
|
887
|
+
|
|
888
|
+
A governed mount the state does not have is not merely refused — it is not shown:
|
|
889
|
+
|
|
890
|
+
- `ls`, `glob` and `grep` results lose every entry under it, the **workspace root
|
|
891
|
+
listing included**, so the agent is never shown a directory it then cannot read.
|
|
892
|
+
- The static "Workspace zones" prompt section names ungoverned mounts only, one
|
|
893
|
+
line each with a `read-only` or `read/write` marker. The
|
|
894
|
+
governed mounts of *this* state are listed in the volatile "Current state"
|
|
895
|
+
block, read-only or read/write per the state's own grant — which is why the
|
|
896
|
+
cacheable prompt prefix stays byte-identical across states.
|
|
897
|
+
- Every call is still decided by the kernel (`mount.not-allowed`,
|
|
898
|
+
`mount.forbidden`, `mount.read-only`), whether or not a listing showed the path.
|
|
899
|
+
Redaction is the courtesy; the kernel is the enforcement.
|
|
900
|
+
|
|
901
|
+
### Nested and file mounts
|
|
902
|
+
|
|
903
|
+
A mount key may be several segments deep (`catalogs/eu`), and a mount name is that
|
|
904
|
+
key with its slashes stripped — that is the token a `mounts` list takes, never a
|
|
905
|
+
glob (`reference/**` is a load error; scope paths *within* an enabled mount with a
|
|
906
|
+
`tools.allow` entry). A path is matched to a mount by **longest prefix**, so
|
|
907
|
+
`catalogs/eu` wins over a `catalogs` mounted beside it, and a sibling
|
|
908
|
+
`catalogs/uk` matches neither. A file mount (`AGENTS.md`) matches its exact path,
|
|
909
|
+
so `AGENTS.md.bak` is not captured.
|
|
910
|
+
|
|
911
|
+
### Diagnostics
|
|
912
|
+
|
|
913
|
+
`archmax validate` reports, given the host's mount table:
|
|
914
|
+
|
|
915
|
+
- **error** — a name the table does not mount at all, naming the mounts it does;
|
|
916
|
+
- a grant naming an **ungoverned** mount (it grants nothing: the mount is visible
|
|
917
|
+
everywhere already — use `forbid` to take one away);
|
|
918
|
+
- an `access: read_write` on a mount the host serves **read-only**;
|
|
919
|
+
- a name in both `allow_always` and a state's `allow` (unless the state entry
|
|
920
|
+
narrows the access, which does real work);
|
|
921
|
+
- a state's `allow: []` beside a non-empty `allow_always`;
|
|
922
|
+
- a `forbid` naming a governed mount nothing would have enabled there;
|
|
923
|
+
- a `forbid_always` name some `allow` list also grants;
|
|
924
|
+
- a `tools.allow` path entry that can only match inside a mount the state does not
|
|
925
|
+
enable (the grant reaches nothing);
|
|
926
|
+
- **once**, when a spec declares no root `mounts` block at all in a workspace that
|
|
927
|
+
governs mounts, naming what the workflow cannot reach. Any root block silences
|
|
928
|
+
it — write `mounts: { allow_always: [] }` when a workflow reaches none.
|
|
929
|
+
|
|
930
|
+
Hooks and rubric graders are bound by `mounts.forbid_always` and **not** by a
|
|
931
|
+
state's `forbid` or its enabled set: they run on runtime authority, exactly as for
|
|
932
|
+
tools, so a `before` hook can read a mount the model in that state cannot.
|
|
933
|
+
|
|
934
|
+
## Session variables in guards
|
|
935
|
+
|
|
936
|
+
A `tools.allow` argument glob may reference a **run variable** — `${{name}}` or
|
|
937
|
+
`${{name.dotted.path}}` — resolved against the run's variables at call time:
|
|
938
|
+
|
|
939
|
+
```yaml
|
|
940
|
+
tools:
|
|
941
|
+
allow:
|
|
942
|
+
- { tool: gmail__send, args: { to: ["${{from_email}}"] } }
|
|
943
|
+
- { tool: crm__lookup, args: { region: ["${{account.region}}"] } }
|
|
944
|
+
- { tool: write_file, paths: ["reports/${{case_id}}/**"] }
|
|
945
|
+
```
|
|
946
|
+
|
|
947
|
+
There is **no `variables:` block** — a variable exists once something sets it:
|
|
948
|
+
the host at assembly (`variables`, every seed locked), the agent mid-run
|
|
949
|
+
(`archmax_set_variables`), or one of the two built-ins.
|
|
950
|
+
|
|
951
|
+
**Two reserved names.** Do not claim either for something else:
|
|
952
|
+
|
|
953
|
+
| | `trigger` | `title` |
|
|
954
|
+
|---|---|---|
|
|
955
|
+
| Set by | the runtime, at every arrival | the **agent**, prompted by the platform prompt |
|
|
956
|
+
| Locked | always | **never**, by any route |
|
|
957
|
+
| Guaranteed set | yes | no — absent until written |
|
|
958
|
+
| In a trigger's `requires:` | accepted | accepted (seeded unlocked) |
|
|
959
|
+
| In a trigger's `returns:` | refused | refused |
|
|
960
|
+
|
|
961
|
+
`trigger` holds the current turn's trigger id. `title` holds a short one-line label
|
|
962
|
+
naming the task the run is doing; the platform system prompt asks the agent to set
|
|
963
|
+
one as its first activity and update it when the task changes significantly, so a
|
|
964
|
+
host can name a run instead of showing a session id. A write must be a non-empty
|
|
965
|
+
single-line string of at most 200 characters (stored trimmed) and must not pass
|
|
966
|
+
`lock: true` — both are refused, atomically, like every other `set_variables`
|
|
967
|
+
rejection. A host may seed a `title`; it lands **unlocked** (the one exception to
|
|
968
|
+
"every seed is locked") as an opening label, and it will not overwrite one an
|
|
969
|
+
earlier turn established.
|
|
970
|
+
|
|
971
|
+
Do not confuse it with the spec-root and per-state `title:` keys, which are static
|
|
972
|
+
human labels for the *workflow* and the *state*. The run variable names the **run**.
|
|
973
|
+
|
|
974
|
+
Because nothing guarantees `title` is set, a guard on `${{title}}` still draws the
|
|
975
|
+
assembly-time warning — correctly.
|
|
976
|
+
|
|
977
|
+
Two declarations *guarantee* one: a state's `requires:` (checked when leaving that
|
|
978
|
+
state) and a **trigger's** `requires:` (checked before the run starts, so a firing
|
|
979
|
+
that omits it never runs). A trigger's `returns:` is the mirror — the variables a
|
|
980
|
+
run guarantees it has set by the time it completes. See a trigger's declaration.
|
|
981
|
+
|
|
982
|
+
A value belongs to the **session**: what one state records is readable by every
|
|
983
|
+
state after it and by every later turn, so a downstream guard can bind to an
|
|
984
|
+
upstream fact. Design for that — `requires` in an early state plus `lock: true` is
|
|
985
|
+
how a later `${{name}}` becomes guaranteed rather than hopeful.
|
|
986
|
+
|
|
987
|
+
**Paths.** Segments descend one level; a numeric segment indexes an array and a
|
|
988
|
+
negative one counts from the end (`${{order.items.-1.sku}}` is the last item).
|
|
989
|
+
Traversal reads own properties only, so `${{tags.length}}` and
|
|
990
|
+
`${{v.constructor}}` do **not** resolve. A reference must resolve to a single
|
|
991
|
+
scalar.
|
|
992
|
+
|
|
993
|
+
**Authoring rules that matter:**
|
|
994
|
+
|
|
995
|
+
- **Lock anything a guard reads.** Host seeds are locked automatically (`title`
|
|
996
|
+
excepted, which is never locked); for an agent-established fact pass `lock: true`. An unlocked variable can be
|
|
997
|
+
overwritten by any later state — that rewrite is permitted, being one writer
|
|
998
|
+
twice, and the later value simply wins — so a guard bound to an unlocked name is
|
|
999
|
+
a weak guard. A lock is the only thing that makes a fact settled.
|
|
1000
|
+
- **Guarantee the reference.** Name it in the `requires` of a state that runs
|
|
1001
|
+
first, or seed it. Otherwise assembly only *warns*, and the run **fails** at
|
|
1002
|
+
the guarded call if the agent never set it — an unresolvable reference is
|
|
1003
|
+
terminal, not a retryable block, because a guard the runtime cannot evaluate
|
|
1004
|
+
is not a guard.
|
|
1005
|
+
- **A positional index makes the guard move.** `${{order.items.-1.sku}}` permits
|
|
1006
|
+
a different value as the array grows. Sometimes exactly right ("the newest
|
|
1007
|
+
attachment"); when you want a *stable* guard, store the value you mean as its
|
|
1008
|
+
own named variable.
|
|
1009
|
+
- **An `archmax_set_variables` typo creates a new variable** rather than erroring — there
|
|
1010
|
+
are no declarations to check against. `requires` is the safety net that turns
|
|
1011
|
+
that into a visible failure.
|
|
1012
|
+
|
|
1013
|
+
Substituted values are matched **literally** (a value holding `*` matches `*`,
|
|
1014
|
+
not everything), so a variable can never widen the guard it lands in.
|
|
1015
|
+
|
|
1016
|
+
### The same references work in the agent's own tool arguments
|
|
1017
|
+
|
|
1018
|
+
`${{…}}` is not only an authoring syntax. Anywhere a tool argument is text, the
|
|
1019
|
+
runtime substitutes references from the run's variables **before** the call is
|
|
1020
|
+
governed or executed — and a reference may sit *inside* a longer string, and
|
|
1021
|
+
repeat:
|
|
1022
|
+
|
|
1023
|
+
```
|
|
1024
|
+
"Thanks — your refund is on its way.
|
|
1025
|
+
|
|
1026
|
+
--- Original message ---
|
|
1027
|
+
|
|
1028
|
+
${{inbound.text}}"
|
|
1029
|
+
```
|
|
1030
|
+
|
|
1031
|
+
So the agent quotes a stored value by naming it rather than retyping it. Three
|
|
1032
|
+
consequences worth authoring around:
|
|
1033
|
+
|
|
1034
|
+
- **A reference is exact.** A copy the model retypes can truncate, paraphrase, or
|
|
1035
|
+
drift; a reference cannot. When a state's job is to emit a large stored value,
|
|
1036
|
+
its `instructions` should **name the variable to interpolate** rather than
|
|
1037
|
+
telling the agent to include the original text.
|
|
1038
|
+
- **The guard and the argument meet on the value.** Substitution runs ahead of the
|
|
1039
|
+
kernel, so `args: { to: ["${{from_email}}"] }` and an argument of
|
|
1040
|
+
`"${{from_email}}"` resolve independently to the same string — they agree by
|
|
1041
|
+
construction, not by the model having retyped an address correctly.
|
|
1042
|
+
- **A bad reference here is recoverable**, unlike a bad guard. The call is refused
|
|
1043
|
+
with the reference named and the agent retries; only an unresolvable *guard* is
|
|
1044
|
+
terminal, because that is a defect the agent cannot fix.
|
|
1045
|
+
|
|
1046
|
+
The transcript keeps the reference — the substituted value goes to the tool and to
|
|
1047
|
+
the `tool-called` event a run trail renders, and no further, so the value's tokens
|
|
1048
|
+
are not re-sent on every later model call. An cases `calledTool` assertion
|
|
1049
|
+
therefore states the **reference** the agent wrote; pin the delivered text through
|
|
1050
|
+
the tool's effect instead (the file it wrote, the variable it set).
|
|
1051
|
+
|
|
1052
|
+
To write the characters `${{…}}` literally — authoring a guard through
|
|
1053
|
+
`write_file`, or writing about the syntax — double the dollar: `$${{name}}`.
|
|
1054
|
+
|
|
1055
|
+
This is **model-facing only**. An `archmax_eval` or hook script already holds the
|
|
1056
|
+
run's variables as `args.variables` and composes strings in JavaScript, so
|
|
1057
|
+
`${{…}}` in a script's `tools.*` call is ordinary text.
|
|
1058
|
+
|
|
1059
|
+
## Lifecycle hooks
|
|
1060
|
+
|
|
1061
|
+
```yaml
|
|
1062
|
+
# single hooks (the yaml wires the hook, nothing more):
|
|
1063
|
+
before:
|
|
1064
|
+
script: hooks/check.js # hand-authored, self-contained JS file;
|
|
1065
|
+
# resolves to workflows/<slug>/hooks/check.js
|
|
1066
|
+
after: { rubric: reply-tone, max_iterations: 2 } # per hook, not per state
|
|
1067
|
+
# a script after hook may carry the same budget:
|
|
1068
|
+
# after: { script: hooks/review.js, max_iterations: 2 }
|
|
1069
|
+
|
|
1070
|
+
# or a list, run in sequence (short-circuits on veto):
|
|
1071
|
+
before:
|
|
1072
|
+
- { script: hooks/pre.js }
|
|
1073
|
+
- rubric: { instructions: judge the tone }
|
|
1074
|
+
```
|
|
1075
|
+
|
|
1076
|
+
- A `HookSpec` is one hook or a list, run in order and short-circuiting on the
|
|
1077
|
+
first non-ok. Each hook is either `{ script: <path> }` (JS in the QuickJS
|
|
1078
|
+
sandbox) or `{ rubric: <declaration> }` (the grader itself, inline).
|
|
1079
|
+
- **A script hook's path is workflow-relative and lives under `hooks/`.**
|
|
1080
|
+
`{ script: hooks/check.js }` resolves to `workflows/<slug>/hooks/check.js` on the
|
|
1081
|
+
authoring plane. An absolute path, a `..` climb, or anything outside `hooks/`
|
|
1082
|
+
(including the former `skills/<name>/scripts/…` spelling) is a load error and a
|
|
1083
|
+
`validate` error. Hook scripts are **not** agent-readable and **not** runnable
|
|
1084
|
+
with `archmax_run`: the runtime runs them, and that separation is what stops an
|
|
1085
|
+
agent probing the guard that gates it.
|
|
1086
|
+
- **Reserved sidecar key** on a script or custom hook: `max_iterations` (see
|
|
1087
|
+
Verdicts below). It is **refused beside a `rubric`**, whose budget is inside its
|
|
1088
|
+
declaration. There is no `instructions`, `model` or `specification` sidecar on
|
|
1089
|
+
any hook.
|
|
1090
|
+
- **Script hooks are hand-authored.** The script is an ordinary,
|
|
1091
|
+
self-contained `.js` file you design and write directly against the sandbox
|
|
1092
|
+
contract; it **must** open with a leading JSDoc title + description (plain
|
|
1093
|
+
prose, no special tags) — see
|
|
1094
|
+
[Code description docblocks](#code-description-docblocks).
|
|
1095
|
+
`archmax validate` errors on a missing script file or a
|
|
1096
|
+
foreign (non-`@archmax-ai/harness/*`) import; it does not check the docblock on a
|
|
1097
|
+
hook script. The runtime never generates: a missing
|
|
1098
|
+
script at run time vetoes (fail-closed).
|
|
1099
|
+
(A rubric's contract is its own `instructions`.)
|
|
1100
|
+
- **Phases**: `before` runs before the agent acts in the state and gates entry;
|
|
1101
|
+
`after` runs after the agent calls `archmax_advance`, before the transition
|
|
1102
|
+
commits (the "veto window"), or at completion for a terminal state.
|
|
1103
|
+
- **Verdicts**: `ok` (proceed), `correct` (agent gets another attempt), `veto`
|
|
1104
|
+
(block). Return the vocabulary or nothing: a bare `false` is **not** a veto, it
|
|
1105
|
+
is no verdict, which fails closed. Both hook kinds
|
|
1106
|
+
can return `correct` from an `after` hook — a script by evaluating to
|
|
1107
|
+
`{ verdict: "correct", reason: "..." }`, a rubric in its response —
|
|
1108
|
+
and both get the same bounded flow.
|
|
1109
|
+
- **`max_iterations`** bounds how many times a `correct` may send the agent back
|
|
1110
|
+
before the hook becomes a hard veto: inside the declaration for a rubric, a
|
|
1111
|
+
sidecar for a script or custom hook, else 0. It is counted **per hook by
|
|
1112
|
+
position**, so two graders on one state never share a budget. An
|
|
1113
|
+
exhausted budget is a *terminal failure* — see
|
|
1114
|
+
[Budgets & error routing](#budgets--error-routing).
|
|
1115
|
+
- **`before` hooks are ok/veto only**: a `correct` verdict from a `before` hook
|
|
1116
|
+
is treated as veto, and `max_iterations` on a `before` hook is a validation
|
|
1117
|
+
error.
|
|
1118
|
+
- **Fail-closed**: a hook that *errors* vetoes the transition. A rubric
|
|
1119
|
+
whose response cannot be parsed into a verdict also fails closed (**veto**),
|
|
1120
|
+
never coerced to ok — as does a `model` id the host cannot serve.
|
|
1121
|
+
|
|
1122
|
+
The hook contract — a default-export function called with one object:
|
|
1123
|
+
|
|
1124
|
+
```js
|
|
1125
|
+
export default async function hook({
|
|
1126
|
+
state, phase /* "before"|"after" */, trigger, variables, messages,
|
|
1127
|
+
from, to, reason, // from/to/reason present on "after"
|
|
1128
|
+
tools, // privileged tool bridge, e.g. tools.readFile
|
|
1129
|
+
}) {
|
|
1130
|
+
return ok(); // or veto(reason) / correct(reason)
|
|
1131
|
+
}
|
|
1132
|
+
```
|
|
1133
|
+
|
|
1134
|
+
Returning nothing is `ok`; a thrown error vetoes (fail-closed); the reason of a
|
|
1135
|
+
`veto` is shown to the agent so it can correct itself. Full contract, field
|
|
1136
|
+
semantics, and complete worked `before`/`after` examples:
|
|
1137
|
+
[`hook-and-test-scripts.md`](hook-and-test-scripts.md).
|
|
1138
|
+
|
|
1139
|
+
## Budgets & error routing
|
|
1140
|
+
|
|
1141
|
+
```yaml
|
|
1142
|
+
states:
|
|
1143
|
+
refund-request:
|
|
1144
|
+
budget:
|
|
1145
|
+
maxTurns: 24 # bounds this turn's agent loop
|
|
1146
|
+
# timeoutMs: 60000 # aborts the turn when exceeded
|
|
1147
|
+
on_error: escalation # terminal-failure target
|
|
1148
|
+
escalation: { instructions: "Explain the failure and hand off." } # terminal
|
|
1149
|
+
```
|
|
1150
|
+
|
|
1151
|
+
Two failure classes, routed differently:
|
|
1152
|
+
|
|
1153
|
+
- **Recoverable rejections keep the agent in place**: an invalid edge in
|
|
1154
|
+
`archmax_advance`, a deliberate hook `veto`, or an in-budget `correct` — the
|
|
1155
|
+
agent sees the reason and retries within the same state. A **tool that
|
|
1156
|
+
throws** is answered the same way: its error message is the call's
|
|
1157
|
+
error-status answer, and it never routes to `on_error`.
|
|
1158
|
+
- **Terminal failures route to `on_error`**: a hook *execution error*, an
|
|
1159
|
+
exhausted iteration budget (`max_iterations` used up), or an exhausted
|
|
1160
|
+
`budget` (`maxTurns`/`timeoutMs`). With `on_error: <state>` declared, the run
|
|
1161
|
+
routes to that state (the failure reason arrives as an `[error]` runtime note)
|
|
1162
|
+
instead of ending; without it, the failure ends the run.
|
|
1163
|
+
|
|
1164
|
+
**Recommendation**: declare `on_error` on states with strict `after` hooks, so
|
|
1165
|
+
an exhausted judge doesn't hard-fail the run. The bundled example demonstrates
|
|
1166
|
+
this: its `refund-request` state pairs `budget.maxTurns: 24` with
|
|
1167
|
+
`on_error: escalation`, a terminal escalation state.
|
|
1168
|
+
|
|
1169
|
+
## Human states
|
|
1170
|
+
|
|
1171
|
+
`type: human` state:
|
|
1172
|
+
- Parks the session with a checkpointed **pending-decision record** built from
|
|
1173
|
+
`instructions`, evidence file paths, and the transition descriptions/`type`s.
|
|
1174
|
+
Nothing is rendered to a file; `archmax run`/`archmax sessions` present that
|
|
1175
|
+
context on demand.
|
|
1176
|
+
- **Evidence is declared plus attached.** The state's `evidence:` is the baseline
|
|
1177
|
+
every reviewer sees; the agent advancing in may attach what this run produced —
|
|
1178
|
+
`archmax_advance({ to: "refund-review", reason: "…", evidence: ["scratchpad/carrier-report.md"] })`
|
|
1179
|
+
— and the record lists declared paths first, attached after, de-duplicated.
|
|
1180
|
+
Attachments are refused (transition untaken, agent may retry) when `to` is not a
|
|
1181
|
+
human state, when a path is not one the agent can read (`scratchpad/…`,
|
|
1182
|
+
`scratchpad/…`, an authored mount), or when more than 20 are attached. Each
|
|
1183
|
+
attachment belongs to the one decision it was made for. Author `evidence:` for
|
|
1184
|
+
what must always be shown; leave run-specific artifacts to the attachment.
|
|
1185
|
+
- A **human** selects the outgoing edge (optionally with a comment). The LLM does
|
|
1186
|
+
not choose. On resume the graph routes deterministically to the chosen target.
|
|
1187
|
+
- The target turn is told what happened by a `[decision]` **runtime note**
|
|
1188
|
+
naming the edge, the comment, and the state the run is now in. It is a note,
|
|
1189
|
+
not a human message: a model reads authorship from a message's channel, and a
|
|
1190
|
+
routing event that looks like a person's request gets treated as one — routed
|
|
1191
|
+
onward, answered in place, or waited on — instead of being the cue to do the
|
|
1192
|
+
target state's work. Do not write instructions that tell a state to "answer
|
|
1193
|
+
the user's latest message" when its only inbound edge is a human decision.
|
|
1194
|
+
- Resume programmatically with `agent.workflow.decide(sessionId, { target, comment })` or via
|
|
1195
|
+
the CLI `archmax decide <sessionId> <target>`.
|
|
1196
|
+
|
|
1197
|
+
## Sub-workflows are tools
|
|
1198
|
+
|
|
1199
|
+
A state that may run another workflow names it in `tools.allow`. That entry is
|
|
1200
|
+
the whole declaration — there is no state kind and no second key.
|
|
1201
|
+
|
|
1202
|
+
```yaml
|
|
1203
|
+
enrich:
|
|
1204
|
+
instructions: >-
|
|
1205
|
+
Enrich the account by calling archmax_workflow_enrich-account with its id,
|
|
1206
|
+
then record what it returned with archmax_set_variables.
|
|
1207
|
+
requires:
|
|
1208
|
+
- enrichment # nothing captures the result — this makes it mandatory
|
|
1209
|
+
tools:
|
|
1210
|
+
allow:
|
|
1211
|
+
- archmax_workflow_enrich-account # the slug, verbatim
|
|
1212
|
+
budget: { timeoutMs: 120000 } # bounds the whole turn, calls included
|
|
1213
|
+
on_error: enrichment-failed
|
|
1214
|
+
transitions:
|
|
1215
|
+
- to: draft-reply
|
|
1216
|
+
description: The account has been enriched.
|
|
1217
|
+
```
|
|
1218
|
+
|
|
1219
|
+
- **The target's signature is the call signature.** Its `manual` trigger's
|
|
1220
|
+
`requires:` are the tool's required parameters (typed in the tool schema
|
|
1221
|
+
where the entries are typed, by the same mapping `signatureJsonSchema`
|
|
1222
|
+
publishes); its `returns:` are what the result carries; its `description`
|
|
1223
|
+
leads the tool description, and its `instructions` never reach the caller:
|
|
1224
|
+
|
|
1225
|
+
```
|
|
1226
|
+
archmax_workflow_enrich-account({ account_id: "acct-42" })
|
|
1227
|
+
→ { message: "Enriched acct-42.",
|
|
1228
|
+
returns: { enrichment_file: "scratchpad/e/acct-42.json", risk_level: "low" } }
|
|
1229
|
+
```
|
|
1230
|
+
|
|
1231
|
+
A target declaring no `returns:` answers with the closing message alone.
|
|
1232
|
+
- **Nothing captures the result.** The agent records what it needs with
|
|
1233
|
+
`archmax_set_variables`, or a script writes it. Declare the state's `requires:`
|
|
1234
|
+
so it cannot advance without doing so — that is what replaces the old automatic
|
|
1235
|
+
capture.
|
|
1236
|
+
- **One call runs one sub-run.** There is no fan-out argument: an agent fans out
|
|
1237
|
+
with parallel tool calls, a script with `Promise.all`. Both are bounded by
|
|
1238
|
+
the dispatcher's `maxConcurrent`, and each call succeeds or fails on its
|
|
1239
|
+
own.
|
|
1240
|
+
- **A script is the deterministic fan-out.** Delegation tools are on the PTC
|
|
1241
|
+
surface, so a script decides how many calls to make:
|
|
1242
|
+
|
|
1243
|
+
```js
|
|
1244
|
+
// the bridge camelCases tool names; a tool result arrives as text
|
|
1245
|
+
const results = await Promise.all(
|
|
1246
|
+
orders.map((o) => tools.archmaxWorkflowEnrichAccount({ account_id: o.id })),
|
|
1247
|
+
);
|
|
1248
|
+
const returns = results.map((r) => JSON.parse(r).returns);
|
|
1249
|
+
```
|
|
1250
|
+
|
|
1251
|
+
A script's delegation **cannot park**: a child that reaches a human state fails
|
|
1252
|
+
that call closed with `parked`.
|
|
1253
|
+
- **Governance is ordinary.** The surface is closed by default, so a state naming
|
|
1254
|
+
no target may call none; `tools.forbid_always` blocks a delegation tool like any
|
|
1255
|
+
other.
|
|
1256
|
+
- **A failure is a tool error** the calling agent can handle; the state's
|
|
1257
|
+
`on_error` catches the turn if it cannot. Depth, cycle, missing-input
|
|
1258
|
+
(`missing-param`), mistyped-input (`invalid-param`), unresolvable-reference
|
|
1259
|
+
(`unresolved-param`) and **disabled-target** refusals are blocked calls —
|
|
1260
|
+
nothing ran, so correct the call and retry. A child whose returns are unset
|
|
1261
|
+
(`missing-return`) or mistyped (`invalid-return`) is a failure: it ran.
|
|
1262
|
+
- **A whole-argument reference keeps its type.** `{ quantity: "${{count}}" }`
|
|
1263
|
+
seeds the child with the number `count` holds, so it satisfies an `integer`
|
|
1264
|
+
parameter; `{ note: "order ${{order_id}}" }` is substituted as text.
|
|
1265
|
+
- **A `disabled: true` target is refused at dispatch, not at assembly.** The tool is
|
|
1266
|
+
still bound and the caller still assembles (disabling one leaf must not take every
|
|
1267
|
+
caller — or its parked sessions — offline); the call is a blocked call of kind
|
|
1268
|
+
`disabled`, and it beats a declared mock. `validate` **warns** on each state that
|
|
1269
|
+
allows a disabled target: the caller's spec is not wrong.
|
|
1270
|
+
- **A caller enters the child's `manual` entry** — the one entry the CLI, a host
|
|
1271
|
+
firing and a call all use. There is no opt-in on the target: whether a
|
|
1272
|
+
delegation may happen is decided by the caller's `tools.allow` entry. A target
|
|
1273
|
+
with no `manual` entry has no state to start in and fails closed. `session:` and
|
|
1274
|
+
`message:` on that trigger are host-facing keys a delegation ignores: a sub-run
|
|
1275
|
+
shares its parent's session, and carries no message. `sub-workflow` is no longer
|
|
1276
|
+
a trigger id — load and `validate` report it with the rename.
|
|
1277
|
+
- **Declare the child's signature** on that trigger — the contract, enforced both
|
|
1278
|
+
ways and at both ingresses:
|
|
1279
|
+
|
|
1280
|
+
```yaml
|
|
1281
|
+
states:
|
|
1282
|
+
enrich:
|
|
1283
|
+
triggers:
|
|
1284
|
+
manual:
|
|
1285
|
+
description: Enrich one order. # leads the caller's tool description
|
|
1286
|
+
requires: # a caller must supply these
|
|
1287
|
+
- { name: order_id, type: string, description: The order to enrich. }
|
|
1288
|
+
returns: # the child guarantees it sets these
|
|
1289
|
+
- enrichment_file
|
|
1290
|
+
- { name: delayed, type: boolean }
|
|
1291
|
+
```
|
|
1292
|
+
|
|
1293
|
+
Develop it standalone on the same contract:
|
|
1294
|
+
`archmax run enrich-order --variables '{"order_id":"ORD-1003"}'`. `${{trigger}}`
|
|
1295
|
+
reads `manual` either way, so the machine cannot branch on having been called.
|
|
1296
|
+
|
|
1297
|
+
A dispatch missing a `requires` name is **refused before the child is
|
|
1298
|
+
composed**; a child that completes without every `returns` name set is
|
|
1299
|
+
**rejected** rather than handing back half a contract (a child that *parks* is
|
|
1300
|
+
not checked — it has not finished). A typed entry is held to its type at both
|
|
1301
|
+
ends: a mistyped argument is refused `invalid-param` before the child runs, and
|
|
1302
|
+
a mistyped return fails the call `invalid-return`. What the child must *do* to
|
|
1303
|
+
produce a variable belongs in the state `instructions` that set it. The names,
|
|
1304
|
+
with type and description where declared, are rendered into the child's prompt
|
|
1305
|
+
under its entry state, so it is told what to produce before it can be failed
|
|
1306
|
+
for omitting it.
|
|
1307
|
+
- **The call answers with the returns, by name.** A signed target answers
|
|
1308
|
+
`{ message, returns: { enrichment_file, delayed } }`; one declaring no
|
|
1309
|
+
`returns:` answers with the closing message alone. The message keeps its own
|
|
1310
|
+
key rather than being folded into the data — prose the model reads, values a
|
|
1311
|
+
guard or a script reads by name. **Nothing captures it into a variable**: the
|
|
1312
|
+
agent records what it needs with `archmax_set_variables`, or a script writes it,
|
|
1313
|
+
and the calling state's `requires:` is what makes that mandatory.
|
|
1314
|
+
- **Mocks are held to the same contract.** An `archmax_workflow_<slug>` mock for a
|
|
1315
|
+
signed target supplies `returns:`, and one that omits a declared name
|
|
1316
|
+
(`missing-return`) or supplies a mistyped typed one (`invalid-return`) fails
|
|
1317
|
+
the mocked dispatch — a mock stands in for the sub-run, never for its agreement.
|
|
1318
|
+
- **No prose crosses the boundary.** A sub-run gets no instruction from its
|
|
1319
|
+
caller — it works from its own state `instructions` plus the arguments it was
|
|
1320
|
+
seeded with as locked variables, both of which reach the model through the
|
|
1321
|
+
system prompt. Nothing to author, nothing to keep in sync.
|
|
1322
|
+
- **Same agent, isolated context, shared session.** Same model, backend, host
|
|
1323
|
+
tools; its **own** grading rubrics, from its own spec; a fresh transcript; the child's *own* per-state
|
|
1324
|
+
`tools.allow`. It shares the run's session, so it writes `scratchpad/…` and
|
|
1325
|
+
`scratchpad/…` exactly as the parent does and the parent reads them afterwards.
|
|
1326
|
+
- **Nothing crosses implicitly.** Down: only the arguments the call passed —
|
|
1327
|
+
the calling run's own variables are not copied. Up: only the result message
|
|
1328
|
+
and the files. Everything the child sets is discarded when it finishes.
|
|
1329
|
+
- **Give each concurrent sub-run its own output path** — they share one run zone.
|
|
1330
|
+
- **Bounds** — the dispatcher's `bounds: { maxDepth: 3, maxConcurrent: 4 }`. No longer a spec key: the strict schema rejects `settings.sub_workflows` by name.
|
|
1331
|
+
Excess concurrency queues; depth and cycles are refused.
|
|
1332
|
+
- **Params are the sub-run's initial variables** — same construction, same name
|
|
1333
|
+
rule, same lock as a host's `variables`. So a delegated machine also runs
|
|
1334
|
+
**standalone** with those inputs supplied directly:
|
|
1335
|
+
`archmax run <child> --variables '{"order_id":"ORD-1003"}'` — no `--trigger`,
|
|
1336
|
+
because a caller and the CLI enter the same `manual` entry. That is how you
|
|
1337
|
+
develop one in isolation, on exactly the contract a caller holds it to.
|
|
1338
|
+
- **A child may stop for a person.** A `type: human` state in a delegated machine
|
|
1339
|
+
suspends the sub-run *and* the delegating run; `archmax decide <sessionId>
|
|
1340
|
+
<target>` resolves the **child's** decision, the sub-run finishes, and the
|
|
1341
|
+
parent carries on with its result. This holds for a **fan-out** too: each
|
|
1342
|
+
dispatch is its own checkpointed task, so resolving one decision re-runs only
|
|
1343
|
+
that sub-run — siblings that finished keep their results.
|
|
1344
|
+
- **Fails closed** — rejection, budget, refusal or an unresolvable param routes
|
|
1345
|
+
through `on_error` (or ends the run rejected); a suspension for a person is not
|
|
1346
|
+
a failure. Ancestor
|
|
1347
|
+
`forbid_always` rules bind inside the child, naming the workflow that
|
|
1348
|
+
declared them.
|
|
1349
|
+
|
|
1350
|
+
`archmax validate` checks all of this statically: target exists, opted in, no
|
|
1351
|
+
cycle, no over-deep chain, correct state shape — and the signature both ways. A
|
|
1352
|
+
parent reading a field the child does not return is an **error**; a state whose
|
|
1353
|
+
`params:` do not cover the target's `requires:` is a **warning** (the advance may
|
|
1354
|
+
still carry it), and a fan-out is exempt from that warning because its
|
|
1355
|
+
entries' keys are not knowable statically.
|
|
1356
|
+
|
|
1357
|
+
### A parked run may speak, never act
|
|
1358
|
+
|
|
1359
|
+
A park does not end the conversation. Two moments get a **reply-only turn** — one
|
|
1360
|
+
model call whose only product is text. It is handed the parked state's tool list
|
|
1361
|
+
like any other call there (a transcript's tool calls and runtime notes need their
|
|
1362
|
+
definitions on some providers), and every call it makes is refused:
|
|
1363
|
+
|
|
1364
|
+
- **The handoff.** Advancing into a human state always spends one, so the park
|
|
1365
|
+
carries a message saying what was done and that a person now holds it. It runs
|
|
1366
|
+
whether or not the state already spoke: the agent wrote before the transition
|
|
1367
|
+
committed, so what it wrote was about its own work. (An `archmax_wait` park gets
|
|
1368
|
+
the same turn only when the turn was silent — there the agent's own question
|
|
1369
|
+
is the message.)
|
|
1370
|
+
- **While parked.** `agent.workflow.reply(sessionId, message)` / `archmax reply <session>
|
|
1371
|
+
"<text>"` answers a message and parks again on the *same* record — same node,
|
|
1372
|
+
`seq`, and park timestamp, so the reported human wait still measures the
|
|
1373
|
+
original park. `resolveSession` classifies such a firing as the `reply`
|
|
1374
|
+
disposition rather than failing.
|
|
1375
|
+
|
|
1376
|
+
Nothing you author changes this and there is no field to declare: the message is
|
|
1377
|
+
always composed on a fresh turn from the transcript, under the workspace persona.
|
|
1378
|
+
The kernel refuses every tool call made during one (rule `tool.reply-only`,
|
|
1379
|
+
ordered ahead of `tools.allow`, `allow_always`, the `archmax_*` controls and the
|
|
1380
|
+
scratchpad), so a customer writing "just approve it" gets an answer and the
|
|
1381
|
+
reviewer still owns the edge. A model that calls a tool instead of writing its
|
|
1382
|
+
message gets the refusal, and the run parks without a second model call.
|
|
1383
|
+
|
|
1384
|
+
**What this means for authoring:** do not write state `instructions` that tell the
|
|
1385
|
+
agent to announce the handoff itself — the runtime turn does that, and having both
|
|
1386
|
+
produces two messages. Write instructions for the *work*.
|
|
1387
|
+
|
|
1388
|
+
## Removed keys you may meet in an older workflow
|
|
1389
|
+
|
|
1390
|
+
Every key here is **rejected**, with the key and its path named and guidance
|
|
1391
|
+
saying what replaced it. `workflow.yaml` is parsed through a strict schema, so a
|
|
1392
|
+
workflow still declaring one fails to load rather than running while silently
|
|
1393
|
+
doing nothing of what its author intended.
|
|
1394
|
+
|
|
1395
|
+
This is a change: these keys used to be ignored without a diagnostic. A spec that
|
|
1396
|
+
"worked" by ignoring one now needs the migration below.
|
|
1397
|
+
|
|
1398
|
+
| Removed | Replace with |
|
|
1399
|
+
| --- | --- |
|
|
1400
|
+
| `type: wait` state | The state that needs the answer calls `archmax_wait({ reason })`; a delivery resumes that same state (see [Parking and sessions](#parking-and-sessions)) |
|
|
1401
|
+
| transition `on: <trigger-id>` | Nothing — a delivered trigger no longer selects an edge; the resumed state picks its own transition |
|
|
1402
|
+
| state `parallel: [...]` + `join:` | Ordinary `transitions`, sequenced |
|
|
1403
|
+
| state `type: trigger` | A `trigger:` field on the state the trigger state routed to |
|
|
1404
|
+
| top-level `name:` | `title:` |
|
|
1405
|
+
| top-level `description:` | `instructions:` |
|
|
1406
|
+
| top-level `policy:` | `tools.forbid_always` (and `skills.forbid_always`) — a load error now |
|
|
1407
|
+
| root `skills.allow:` (the ceiling) | `skills.allow_always: []` plus the state lists — a load error now |
|
|
1408
|
+
| state `type: workflow` | An ordinary agent state allowing `archmax_workflow_<target>` and calling it (see [Sub-workflows are tools](#sub-workflows-are-tools)) |
|
|
1409
|
+
| state `workflow: <slug>` | The `archmax_workflow_<slug>` entry in that state's `tools.allow` — the allow entry *is* the declaration |
|
|
1410
|
+
| state `params:` | The call's arguments |
|
|
1411
|
+
| state `for_each:` | One call per entry — parallel tool calls from the agent, or `Promise.all` in a script |
|
|
1412
|
+
| state `result:` | The tool result; add `requires: [<name>]` to the state so it must record what it needs |
|
|
1413
|
+
| `archmax_advance({ params })` | The call's arguments |
|
|
1414
|
+
|
|
1415
|
+
**No fan-out.** There is no concurrent branching: a run traverses **one state at a
|
|
1416
|
+
time**, moved only by `archmax_advance`, a human decision, or `on_error` routing.
|
|
1417
|
+
Sequence the states that were branches:
|
|
1418
|
+
|
|
1419
|
+
```yaml
|
|
1420
|
+
# REMOVED — do not author this
|
|
1421
|
+
states:
|
|
1422
|
+
gather:
|
|
1423
|
+
parallel: [check-inventory, check-credit]
|
|
1424
|
+
join: summarize
|
|
1425
|
+
|
|
1426
|
+
# author this instead
|
|
1427
|
+
states:
|
|
1428
|
+
check-inventory:
|
|
1429
|
+
transitions: [{ to: check-credit }]
|
|
1430
|
+
check-credit:
|
|
1431
|
+
transitions: [{ to: summarize }]
|
|
1432
|
+
summarize: { instructions: "..." }
|
|
1433
|
+
```
|
|
1434
|
+
|
|
1435
|
+
Concurrent composition **is** sub-workflows: a state calling a delegation tool
|
|
1436
|
+
once per unit of work — the calls run concurrently (see
|
|
1437
|
+
[Sub-workflows are tools](#sub-workflows-are-tools)). Sequence anything that is not that —
|
|
1438
|
+
it costs latency, not correctness.
|
|
1439
|
+
|
|
1440
|
+
## Workspace layout
|
|
1441
|
+
|
|
1442
|
+
```
|
|
1443
|
+
workflows/<name>/workflow.yaml # the machine (canonical spec)
|
|
1444
|
+
workflows/<name>/WORKFLOW.md # OPTIONAL prose addendum (no frontmatter)
|
|
1445
|
+
workflows/<name>/hooks/*.js # lifecycle hook scripts — run BY THE HARNESS.
|
|
1446
|
+
# Authoring plane: the agent cannot read or run
|
|
1447
|
+
# anything under workflows/
|
|
1448
|
+
workflows/<name>/tests/ # cases (*.test.yaml, one case per
|
|
1449
|
+
# file; fixture files referenced with `from:`)
|
|
1450
|
+
skills/<capability>/SKILL.md # a capability, bundled (Agent Skills format).
|
|
1451
|
+
# Shared guidance for content that generalizes
|
|
1452
|
+
# across MULTIPLE states — single-state
|
|
1453
|
+
# behavior goes inline in that state's
|
|
1454
|
+
# `instructions` (never one SKILL.md per state)
|
|
1455
|
+
skills/<capability>/scripts/*.js # its archmax_run sources — run BY THE AGENT
|
|
1456
|
+
# (lifecycle hook scripts are NOT here: they
|
|
1457
|
+
# live in workflows/<name>/hooks/, are run by
|
|
1458
|
+
# the runtime, and the agent cannot read them)
|
|
1459
|
+
skills/<capability>/assets/* # its inputs (read-only)
|
|
1460
|
+
scratchpad/ # THE working area: intermediate files and the
|
|
1461
|
+
# artifacts a state produces. The run IS the
|
|
1462
|
+
# workspace root — resolves to
|
|
1463
|
+
# sessions/<sessionId>/scratchpad/. Always
|
|
1464
|
+
# writable, per-session
|
|
1465
|
+
large_tool_results/ # runtime-offloaded tool results (read-only)
|
|
1466
|
+
conversation_history/ # runtime-offloaded history (read-only)
|
|
1467
|
+
AGENTS.md # workspace persona/system prompt
|
|
1468
|
+
```
|
|
1469
|
+
|
|
1470
|
+
This is the **complete** directory contract — there are no other magic
|
|
1471
|
+
locations, so design the full file set up front instead of exploring.
|
|
1472
|
+
Reference these paths **workspace-relative, without a leading slash**
|
|
1473
|
+
(`skills/order-data/assets/orders.json`, `scratchpad/refund.json`) — in `instructions`, `evidence`,
|
|
1474
|
+
allow-entry globs, and paths referenced inside hook scripts alike.
|
|
1475
|
+
|
|
1476
|
+
The authoring-plane prefix (`workflows/`) is **reserved**: a
|
|
1477
|
+
host cannot mount it (`MountCollisionError` at assembly), an allow entry naming
|
|
1478
|
+
it is a `validate` **error**, and a script that reads it is blocked with a
|
|
1479
|
+
diagnostic naming the prefix. Never write an allow entry, an `evidence` path, or a
|
|
1480
|
+
case `workspace:` key under it.
|
|
1481
|
+
|
|
1482
|
+
### Grading rubrics — inline on the hook
|
|
1483
|
+
|
|
1484
|
+
A grader is the **value** of the hook that applies it, not a file beside the spec
|
|
1485
|
+
and not a named entry a hook points at:
|
|
1486
|
+
|
|
1487
|
+
```yaml
|
|
1488
|
+
states:
|
|
1489
|
+
respond:
|
|
1490
|
+
after:
|
|
1491
|
+
- rubric:
|
|
1492
|
+
instructions: |- # REQUIRED — the criteria and the verdict
|
|
1493
|
+
Judge the tone of the last assistant message. Return `ok`,
|
|
1494
|
+
`correct` or `veto`, always with a concise `reason`.
|
|
1495
|
+
max_iterations?: number # the grade-and-retry budget, counted per hook
|
|
1496
|
+
model?: string # a model id to grade on, resolved through the
|
|
1497
|
+
# assembly's modelFactory (third argument)
|
|
1498
|
+
metadata?: object # host data the runtime never reads
|
|
1499
|
+
```
|
|
1500
|
+
|
|
1501
|
+
A rubric has **no name**, and there is deliberately **no `title`, `description`
|
|
1502
|
+
or `response_format`**: nothing routes to a rubric, nothing labels one in a prompt
|
|
1503
|
+
(a display label goes in `metadata`), and a rubric only ever grades — so every
|
|
1504
|
+
dispatch requests the verdict schema unconditionally.
|
|
1505
|
+
|
|
1506
|
+
Verdict shape: `{ verdict: "ok"|"correct"|"veto", reason: string }`.
|
|
1507
|
+
|
|
1508
|
+
Two states needing the same grader declare it **twice**: a state legible on its
|
|
1509
|
+
own beats a de-duplicated document. Its identity is its position (state, phase,
|
|
1510
|
+
index), which is what the runtime dispatches it under and what its retry budget
|
|
1511
|
+
is keyed against — so two graders on one state never share a budget.
|
|
1512
|
+
|
|
1513
|
+
The agent learns nothing of it: the rendered graph says a phase carries a hook of
|
|
1514
|
+
kind `rubric`, there is no `task` tool to call one with, and no sandbox context
|
|
1515
|
+
has `task()`.
|
|
1516
|
+
|
|
1517
|
+
## Cases
|
|
1518
|
+
|
|
1519
|
+
Declarative YAML case documents under `workflows/<name>/tests/*.test.yaml`
|
|
1520
|
+
(or `.test.yml`) — **one case per file**; the case id is the tests/-relative
|
|
1521
|
+
path minus the extension. `archmax test <workflow>` interprets them
|
|
1522
|
+
**host-side**: no QuickJS sandbox runs a case (the sandbox serves only
|
|
1523
|
+
lifecycle hooks and `archmax_run` inside the driven agent). These are distinct
|
|
1524
|
+
from the runtime's own colocated unit tests. Only `*.test.yaml`/`*.test.yml`
|
|
1525
|
+
files are discovered — a leftover `*.test.js` is ignored without a diagnostic.
|
|
1526
|
+
|
|
1527
|
+
A workflow declaring `disabled: true` runs **no** cases: the suite is reported as
|
|
1528
|
+
**skipped** and the exit code is **0** (not run is not failed, so retiring a
|
|
1529
|
+
workflow never reddens CI). There is no flag to force them; `archmax validate` is
|
|
1530
|
+
the static feedback while a workflow is out of service.
|
|
1531
|
+
|
|
1532
|
+
**Suite config lives in the `tests:` block of `workflow.yaml`**
|
|
1533
|
+
(`maxConcurrency`, `caseTimeoutMs`, `judge` — see [Root fields](#root-fields)).
|
|
1534
|
+
`tests.config.js` is **REMOVED**: its presence is a hard error in
|
|
1535
|
+
`archmax test` and `archmax validate`. There is no `reporters` option.
|
|
1536
|
+
`judge: {}` enables the grader with the env-configured default model.
|
|
1537
|
+
|
|
1538
|
+
**The complete case schema (top-level keys, the flat step-entry list, the
|
|
1539
|
+
assertion grammar, start conditions, mocks, `from:` fixture references) plus
|
|
1540
|
+
worked examples — routing,
|
|
1541
|
+
tool mocking, human-in-the-loop decide flows, triggered starts — and the
|
|
1542
|
+
JS→YAML migration table are in
|
|
1543
|
+
[`hook-and-test-scripts.md`](hook-and-test-scripts.md).**
|
|
1544
|
+
|
|
1545
|
+
Every case carries two mandatory prose fields, both length-capped: a `title`
|
|
1546
|
+
(a short label, ≤ 60 chars, printed beside the verdict) and a `description`
|
|
1547
|
+
(≤ 200 chars — the scenario driven and what is asserted). `archmax validate`
|
|
1548
|
+
**errors** on a missing or over-long field; longer rationale belongs in a YAML
|
|
1549
|
+
comment above the document (see
|
|
1550
|
+
[Code description docblocks](#code-description-docblocks)). A case is one
|
|
1551
|
+
conversation on one session; `steps` is one flat list where actions (`send`,
|
|
1552
|
+
`decide`) and assertions are peers, each assertion evaluating against the
|
|
1553
|
+
nearest action above it. Unknown keys anywhere in a case are validation
|
|
1554
|
+
errors — fail closed.
|
|
1555
|
+
|
|
1556
|
+
```yaml
|
|
1557
|
+
# Nothing is mocked — the case reads the real orders.json fixture, so the
|
|
1558
|
+
# denial is re-derived by the refund policy hook rather than asserted directly.
|
|
1559
|
+
title: Refund denied for a shipped order
|
|
1560
|
+
description: >-
|
|
1561
|
+
A refund for shipped A-1002 parks at refund-review; the reviewer confirms the
|
|
1562
|
+
denial via refund-closed, the run succeeds, and the reply names the order.
|
|
1563
|
+
steps:
|
|
1564
|
+
- send: "Hi, I'm support@acmecorp.com. Please refund order A-1002."
|
|
1565
|
+
- parked: true
|
|
1566
|
+
- reachedState: refund-review
|
|
1567
|
+
- decide:
|
|
1568
|
+
to: refund-closed
|
|
1569
|
+
comment: "The denial matches policy."
|
|
1570
|
+
- succeeded: true
|
|
1571
|
+
- reply:
|
|
1572
|
+
includes: "A-1002"
|
|
1573
|
+
```
|
|
1574
|
+
|
|
1575
|
+
## Code description docblocks
|
|
1576
|
+
|
|
1577
|
+
**Every** code file in the workspace — both kinds: lifecycle hook scripts and
|
|
1578
|
+
`archmax_run` sources — MUST begin with a leading JSDoc block (`/** ... */`): a
|
|
1579
|
+
title line, a blank line, then prose stating what the code does **and how**.
|
|
1580
|
+
Cases are YAML documents, not code — they carry no JSDoc block;
|
|
1581
|
+
their mandatory `title` and `description` fields are short and capped instead,
|
|
1582
|
+
with any longer rationale in a YAML comment above the document. The canonical
|
|
1583
|
+
statement of the
|
|
1584
|
+
rule, with the required depth per file kind, is
|
|
1585
|
+
[SKILL.md → Code files are description-first](../SKILL.md#code-files-are-description-first).
|
|
1586
|
+
|
|
1587
|
+
- The block is the **human-facing contract**: admin UIs display only this
|
|
1588
|
+
description, and the code below it must honor it. Author
|
|
1589
|
+
description-first; when behavior changes, update the description, then edit
|
|
1590
|
+
the code to match — never leave the two contradicting each other.
|
|
1591
|
+
- Write it deep enough to review on its own: inputs read, the sequence of
|
|
1592
|
+
checks or assertions, and each outcome the code can produce. A one-line label
|
|
1593
|
+
is not enough.
|
|
1594
|
+
- It is inert at runtime — the QuickJS sandbox executes the source verbatim
|
|
1595
|
+
(typed `@archmax-ai/harness/*` imports excepted; they are stripped).
|
|
1596
|
+
- The prose ends at the first `@tag` line (tags are allowed but not part of
|
|
1597
|
+
the description), so keep the title and body above any tag. There is no
|
|
1598
|
+
`@specification` tag and no generation marker: the block is a human
|
|
1599
|
+
description, not a machine contract.
|
|
1600
|
+
- `archmax validate` **errors** when an cases is missing its
|
|
1601
|
+
`description` (the case schema requires it). Hook scripts and `archmax_run`
|
|
1602
|
+
sources are **not** checked — there the JSDoc rule is an authoring standard
|
|
1603
|
+
you uphold yourself.
|
|
1604
|
+
- Backends/UIs extract it with the package's exported
|
|
1605
|
+
`parseCodeDescription(source)` helper rather than scraping comments.
|
|
1606
|
+
|
|
1607
|
+
## Read-only zones
|
|
1608
|
+
|
|
1609
|
+
Writes (via `write_file`/`edit_file`) targeting any read-only authored mount —
|
|
1610
|
+
the conventional directories plus root files such as `AGENTS.md`, or whatever the
|
|
1611
|
+
wiring code mounted — are blocked, by the mount itself and by governance
|
|
1612
|
+
(`zone.read-only`). A mount key of several segments (`catalogs/eu`) is matched by
|
|
1613
|
+
**longest prefix**, so a nested mount is a read-only zone exactly as a
|
|
1614
|
+
single-segment one is, and a sibling under the same first segment (`catalogs/uk`)
|
|
1615
|
+
is not.
|
|
1616
|
+
|
|
1617
|
+
A mount the host mounted **writable** is not a read-only zone — its paths are
|
|
1618
|
+
governed like ordinary run paths — but a state may still be given it read-only,
|
|
1619
|
+
with `mounts: { allow: [{ mount: <name>, access: read }] }`. A write refused by
|
|
1620
|
+
that grant is `mount.read-only`; one refused by the wiring is `zone.read-only`.
|
|
1621
|
+
See [Mount governance](#mount-governance).
|
|
1622
|
+
|
|
1623
|
+
The run root is writable, and it has exactly one named working area:
|
|
1624
|
+
`scratchpad/**`, permitted (read and write) in **every** state regardless of
|
|
1625
|
+
`tools.allow`. Agent-visible `scratchpad/…` resolves to
|
|
1626
|
+
`sessions/<sessionId>/scratchpad/…`, so concurrent sessions never collide.
|
|
1627
|
+
|
|
1628
|
+
Because that permission is unconditional, an allow entry naming a path *inside*
|
|
1629
|
+
`scratchpad/` does not constrain where within the area a write lands — `archmax
|
|
1630
|
+
validate` warns when a write entry's paths are all scratchpad paths. To govern
|
|
1631
|
+
*where* a result lands, name an ordinary run path instead (any run-root path
|
|
1632
|
+
outside the reserved areas — `reports/x.json`, or a root-level file); those are
|
|
1633
|
+
matched against the state's `tools.allow` like any other argument. The framework
|
|
1634
|
+
owns no second artifact area.
|
|
1635
|
+
|
|
1636
|
+
## Annotated example: order-lookup
|
|
1637
|
+
|
|
1638
|
+
The bundled reference workspace lives at
|
|
1639
|
+
`examples/customer-support/workflows/order-lookup/` and demonstrates the full
|
|
1640
|
+
v2 layout: `workflow.yaml` (the machine, including the `tests:` block and
|
|
1641
|
+
script/rubric hook wiring) plus a frontmatter-free `WORKFLOW.md` prose addendum
|
|
1642
|
+
(ASCII graph + domain constraints). Read both in full for a real, working
|
|
1643
|
+
example. It declares a top-level `title` and a `title` on every state, with the
|
|
1644
|
+
slugs below as the reference targets, plus a top-level `instructions` block
|
|
1645
|
+
holding the rules that hold across every state — where the data and artifacts
|
|
1646
|
+
live, the tenancy rule, and naming an order's exact status — so no state repeats
|
|
1647
|
+
them. Its shape:
|
|
1648
|
+
|
|
1649
|
+
- `identify-case` (`triggers: { manual: , email_reply: }`, `before: check-requester.js`) routes to one
|
|
1650
|
+
of three cases via transition descriptions.
|
|
1651
|
+
- `orders-question` (terminal) answers from `skills/order-data/assets/orders.json`; an
|
|
1652
|
+
`after: { rubric: order-reply-tone }` grades the reply at completion.
|
|
1653
|
+
- `refund-request` records a decision to `scratchpad/refund.json` (no allow entry
|
|
1654
|
+
narrows it — the working area is open in every state; what holds the agent to a
|
|
1655
|
+
correct file is the hook), runs `after: check-refund.js`, and pairs
|
|
1656
|
+
`budget.maxTurns: 24` with `on_error: escalation` so a terminal failure
|
|
1657
|
+
(hook error, exhausted corrections, exhausted budget) routes to escalation
|
|
1658
|
+
instead of ending the run. On success it advances to `refund-review`.
|
|
1659
|
+
- `refund-review` (`type: human`, `evidence: [scratchpad/refund.json]`) — a person
|
|
1660
|
+
approves (→ terminal `refund-closed`) or refines (→ back to `refund-request`).
|
|
1661
|
+
- `escalation` (terminal) is the error handler entered only via
|
|
1662
|
+
`refund-request`'s `on_error`.
|
|
1663
|
+
- `general-question` (terminal) answers and writes `scratchpad/answer.json`.
|
|
1664
|
+
|
|
1665
|
+
### Source of truth in code
|
|
1666
|
+
|
|
1667
|
+
If you need to confirm a detail, these files define the schema in the runtime:
|
|
1668
|
+
|
|
1669
|
+
| Concern | File |
|
|
1670
|
+
| --- | --- |
|
|
1671
|
+
| Spec types (`MachineState`, `Transition`, `HookSpec`, `AllowEntry`, `WorkflowTestsConfig`) | `src/machine/types.ts` |
|
|
1672
|
+
| Two-file loading (`workflow.yaml` + prose addendum) | `src/machine/load-spec.ts` |
|
|
1673
|
+
| Spec parsing, `ALWAYS_ALLOWED_TOOLS` | `src/machine/machine.ts` |
|
|
1674
|
+
| allow-entry matching, hook normalization | `src/machine/allow.ts` |
|
|
1675
|
+
| Rendered workflow header + per-state graph block | `src/workflow/render-prompt.ts` |
|
|
1676
|
+
| Lifecycle running + verdicts | `src/lifecycle/` |
|
|
1677
|
+
| Governance kernel (precedence) | `src/kernel/kernel.ts` |
|
|
1678
|
+
| `archmax_advance` tool | `src/workflow/advance-state.ts` |
|
|
1679
|
+
| Checkpointed state, `WORKFLOW_STATUSES` | `src/workflow/state.ts` |
|
|
1680
|
+
| Grading rubrics, verdict schema | `src/rubrics/rubrics.ts` |
|