@archmax-ai/harness 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +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/workflow-schema.md +66 -20
- package/dist/authoring-skill/archmax-harness/references/workflow-yaml.md +14 -4
- 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 +4 -5
- 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/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 +28 -19
- package/dist/workflow/governance.js.map +1 -1
- package/dist/workflow/middleware.js +5 -10
- package/dist/workflow/middleware.js.map +1 -1
- package/dist/workflow/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/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 +1677 -0
- package/skills/archmax-harness/references/workflow-yaml.md +251 -0
|
@@ -0,0 +1,681 @@
|
|
|
1
|
+
# Wiring @archmax-ai/harness into a backend — integration reference
|
|
2
|
+
|
|
3
|
+
How to embed the `@archmax-ai/harness` package in a service so a worker can
|
|
4
|
+
**execute** workflows and an API/frontend can **visualize** run state. All names
|
|
5
|
+
below are the real public API (`src/index.ts`); confirm against the installed
|
|
6
|
+
package's `dist/index.d.ts`.
|
|
7
|
+
|
|
8
|
+
## Contents
|
|
9
|
+
|
|
10
|
+
- [Install (registry & local tarball)](#install)
|
|
11
|
+
- [Public exports](#public-exports)
|
|
12
|
+
- [createAgent options](#createworkflowagent-options)
|
|
13
|
+
- [Agent](#workflowruntime)
|
|
14
|
+
- [Running a workflow](#running-a-workflow)
|
|
15
|
+
- [Human-in-the-loop](#human-in-the-loop)
|
|
16
|
+
- [Exposing a workflow to outside callers (MCP, forms, APIs)](#exposing-a-workflow-to-outside-callers)
|
|
17
|
+
- [Lifecycle events (live visualization)](#lifecycle-events)
|
|
18
|
+
- [Session artifacts (historical visualization)](#run-artifacts)
|
|
19
|
+
- [Model configuration](#model-configuration)
|
|
20
|
+
- [Custom backends](#custom-backends)
|
|
21
|
+
- [Cases against your agent](#cases-against-your-agent)
|
|
22
|
+
- [Suggested worker / API / frontend split](#suggested-split)
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
**Registry** (once published): `npm i @archmax-ai/harness`.
|
|
29
|
+
|
|
30
|
+
**Local / pre-publish** — install exactly what the registry would ship (a real copy
|
|
31
|
+
in `node_modules`, not a symlink), so behavior matches production:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
# in the harness repo
|
|
35
|
+
npm run build
|
|
36
|
+
npm pack --pack-destination /tmp # → /tmp/archmax-ai-harness-<version>.tgz
|
|
37
|
+
|
|
38
|
+
# in the consuming service
|
|
39
|
+
npm add /tmp/archmax-ai-harness-<version>.tgz
|
|
40
|
+
# or pnpm --filter <pkg> add /tmp/archmax-ai-harness-<version>.tgz
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The published tarball contains only `dist/` + `README.md` (the package `files`
|
|
44
|
+
allowlist), so `dist/index.d.ts` types travel with it — a shared types package can
|
|
45
|
+
re-export the run-artifact/event shapes for an API/frontend without importing the
|
|
46
|
+
runtime. Because the version rarely changes during development, `remove` then `add`
|
|
47
|
+
(or prune the store) so a repack of the same version actually reinstalls.
|
|
48
|
+
|
|
49
|
+
**This skill ships inside the package** (staged into `dist/authoring-skill/archmax-harness/`):
|
|
50
|
+
resolve it from the install via the `BUNDLED_AUTHORING_SKILL_DIR` export — the
|
|
51
|
+
`dist/authoring-skill` directory holding `archmax-harness/`, so this file is
|
|
52
|
+
`<BUNDLED_AUTHORING_SKILL_DIR>/archmax-harness/SKILL.md` — e.g.
|
|
53
|
+
`npx skills add ./node_modules/@archmax-ai/harness/dist/authoring-skill/archmax-harness`.
|
|
54
|
+
Prefer this over
|
|
55
|
+
syncing a copy from a source checkout, so the skill can never drift from the
|
|
56
|
+
installed SDK's validator. Tooling that needs the install root without importing
|
|
57
|
+
the runtime can use `require.resolve("@archmax-ai/harness/package.json")` (exposed in the
|
|
58
|
+
`exports` map).
|
|
59
|
+
|
|
60
|
+
## Public exports
|
|
61
|
+
|
|
62
|
+
From `@archmax-ai/harness` (`src/index.ts`):
|
|
63
|
+
|
|
64
|
+
| Export | Kind | Purpose |
|
|
65
|
+
| --- | --- | --- |
|
|
66
|
+
| `createAgent` | fn | Assemble a runnable workflow-governed agent |
|
|
67
|
+
| `Agent` | type | Returned runtime (`graph`, `decide`, `deliver`, `resolveSession`, `runs`, `emitRunArtifacts`, `dispose`, `resolveTrigger`) |
|
|
68
|
+
| `CreateAgentParams` | type | Assembly options |
|
|
69
|
+
| `DecideOutcome` | type | Return of `decide()` |
|
|
70
|
+
| `DeliverOutcome`, `TriggerDelivery` | type | Argument / return of `deliver()` |
|
|
71
|
+
| `WorkflowLoadError` | class | Thrown when the workflow spec (`workflow.yaml`) is missing/invalid (fails closed) |
|
|
72
|
+
| `DEFAULT_WORKFLOW` | const | `"order-lookup"` |
|
|
73
|
+
| `BUNDLED_AUTHORING_SKILL_DIR` | const | The packaged `dist/authoring-skill` directory holding `archmax-harness/` (`<dir>/archmax-harness/SKILL.md`). The example workspace is not shipped in `dist/`; clone the repo to use it. |
|
|
74
|
+
| `WORKFLOW_STATUSES` | const | `running` \| `completed` \| `rejected` \| `awaiting_decision` (`awaitingDecision`) \| `awaiting_input` (`awaitingInput`) |
|
|
75
|
+
| `isFinished`, `classifyStatus` | fn | The open/finished partition — a parked session is **open** |
|
|
76
|
+
| `WorkflowLifecycleEvent`, `WorkflowEventHandler` | type | Event stream types |
|
|
77
|
+
| `createWorkflowEventEmitter`, `renderEventLine` | fn | Event emitter + console formatter |
|
|
78
|
+
| `createWorkspaceContext` | fn | Compose the workspace backends (session zone + mounts + authoring backend) |
|
|
79
|
+
| `createFilesystemSessionStore`, `createBackendSessionStore`, `createMemorySessionStore` | fn | Run-store factories (physical storage for the session zone) |
|
|
80
|
+
| `SessionStoreRequiredError`, `SessionStoreCapabilityError` | class | Assembly / capability errors for run storage |
|
|
81
|
+
| `workflowStateSchema`, `workflowPaths`, `sessionPaths`, `runArtifactPaths` | schema/fn | State schema + path helpers |
|
|
82
|
+
| `runTests`, `RunTestsOptions`, `CaseResult` | fn/type | Run a workflow's cases — [against your own agent](#cases-against-your-agent) |
|
|
83
|
+
| `SIGNATURE_TYPES`, `normalizeSignature`, `signatureForTrigger`, `signatureJsonSchema`, `signatureValueIssues` | const/fn | A trigger's typed signature as JSON Schema and its conformance rule — [exposing a workflow](#exposing-a-workflow-to-outside-callers); also on `@archmax-ai/harness/spec` |
|
|
84
|
+
|
|
85
|
+
## createAgent options
|
|
86
|
+
|
|
87
|
+
All optional (`CreateAgentParams`, `src/assembly/index.ts`):
|
|
88
|
+
|
|
89
|
+
| Option | Type | Notes |
|
|
90
|
+
| --- | --- | --- |
|
|
91
|
+
| `rootDir` | `string` | Workspace root for the default filesystem backend. Defaults to cwd. |
|
|
92
|
+
| `workflow` | `string` | Named workflow under `workflows/<name>/`. |
|
|
93
|
+
| `trigger` | `TriggerInput` | `{ id, args? }`; defaults to `{ id: "manual" }`. Selects the start state. |
|
|
94
|
+
| `model` | `BaseChatModel` | Defaults to the env-configured OpenAI-compatible model. Inject your own to bypass env. |
|
|
95
|
+
| `backend` | `BackendProtocolV2` | Serves the authored workspace. Defaults to a `FilesystemBackend` over `rootDir`. |
|
|
96
|
+
| `authoring` | `BackendProtocolV2` | Serves the **authoring plane** — `workflows/**` (machine specs including the rubrics their states declare, `WORKFLOW.md`, `hooks/`, `tests/`) — to the runtime only. Never a route in the agent's workspace, so no tool call or PTC call can reach it. Defaults to `backend` when you supply one, else a `FilesystemBackend` over the resolved root. Handing it to a **writable** mount throws `AuthoringBackendExposedError`; a read-only mount may share it. |
|
|
97
|
+
| `mounts` | `Record<string, MountSpec>` | Authored mounts as a `CompositeBackend` route table: key → backend, or `{ backend, readOnly, governed, searchable }`. Trailing slash = directory mount; no slash = exact-path file mount (`"/AGENTS.md"`). Read-only unless declared otherwise. `governed: true` hands the spec's `mounts` block the decision of **which states** may reach it (closed by default, like a skill bundle) and whether each may write there; a mount not so declared is visible in every state exactly as today, so a table that marks nothing behaves unchanged. `readOnly` is a **ceiling** the spec cannot lift: a grant may narrow a writable mount to reads in a state, never open a read-only one. A key may be several segments deep (`"/catalogs/eu/"`); its **first** segment is what is reserved, so `/workflows/x/` fails as `/workflows/` does. Omitted on the default backend: `defaultMounts(rootDir)` (the conventional table — `/skills/`, `/.platform/`, `/AGENTS.md`, none governed — extend with `{ ...defaultMounts(root), "/templates/": … }`). Omitted with a custom `backend`: nothing authored is served; expose subtrees with `mountSubtree(backend, "skills")`. A key colliding with a run area — or naming the authoring-plane prefix (`/workflows/`) — throws `MountCollisionError`. `searchable: false` declares a mount the runtime never searches on its own initiative: a root-wide `grep`/`glob` fans out over the other mounts only, while a search the agent addresses at the mount (or a path inside it) still reaches its backend and returns the backend's own answer — matches or `{ error }` — verbatim. Use it for a backend that must refuse searches (a remote folder served live). Listing and reading are unchanged, the prompt marks it "browse only", names arrive as `MountPrefixes.unsearchable`, and it is ignored on a file mount. |
|
|
98
|
+
| `sessionStore` | `SessionStore` | Physical storage for run state (checkpoints, artifacts, the per-session `scratchpad/`). Zero-config default: `createFilesystemSessionStore({ dir: "<rootDir>/sessions" })`. Required with a custom `backend` (else `SessionStoreRequiredError`). Build with `createBackendSessionStore({ backend, prefix? })` for S3-style storage or `createMemorySessionStore()` for ephemeral runs. |
|
|
99
|
+
| `systemPrompt` | `string` | Appended after workspace `AGENTS.md`. |
|
|
100
|
+
| `checkpointer` | `BaseCheckpointSaver` | Custom-adapter escape hatch; takes precedence over the session store for checkpoint persistence. Defaults to a durable `BackendCheckpointSaver` writing through the session store. Use `MemorySaver` for ephemeral runs. |
|
|
101
|
+
| `middleware` | `AgentMiddleware[]` | Extra middleware appended after workflow instrumentation. |
|
|
102
|
+
| `onEvent` | `WorkflowEventHandler` | Subscribe to all lifecycle diagnostics; suppresses console output when set. |
|
|
103
|
+
| `promptCache` | `PromptCacheOptions` | `{ enabled?, ttl?: "5m" \| "1h" }`. Provider prompt caching for the stable prefix; on by default. Falls back to `settings.prompt_cache`, then `ARCHMAX_PROMPT_CACHE`/`_TTL`. |
|
|
104
|
+
| `pricing` | `PricingTable` | USD per 1M tokens, keyed by model id (`default` matches any): `{ input?, output?, cacheRead?, cacheWrite? }`. Looked up by the id the response reported, else the id the runtime was configured to run — so a keyed table prices a workload behind a proxy that echoes no model back. Input rate applies to the input tokens the cache did not serve. Makes `costUsd` appear on usage events, run metadata, and the CLI footer. Falls back to `ARCHMAX_PRICE_*`. Unpriced → tokens only. |
|
|
105
|
+
|
|
106
|
+
Fails closed: throws `WorkflowLoadError` if the named workflow's `workflow.yaml`
|
|
107
|
+
is missing or has no valid machine spec (never silently runs ungoverned; a
|
|
108
|
+
`WORKFLOW.md` carrying frontmatter with no `workflow.yaml` — the removed
|
|
109
|
+
single-file layout — is a load error with migration guidance).
|
|
110
|
+
|
|
111
|
+
## Agent
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
interface Agent {
|
|
115
|
+
graph: /* LangGraph runnable */;
|
|
116
|
+
workflow: string;
|
|
117
|
+
runtimeContract: ResolvedRuntimeContract;
|
|
118
|
+
resolveTrigger(trigger?: TriggerInput): ResolvedTrigger;
|
|
119
|
+
emitRunArtifacts(sessionId: string, trajectory: Trajectory): Promise<string | null>;
|
|
120
|
+
runs: { // run handles over the configured session store
|
|
121
|
+
list(): Promise<SessionSummary[]>; // [] when the store/checkpointer can't list
|
|
122
|
+
get(sessionId: string): Promise<SessionSummary | null>;
|
|
123
|
+
delete(sessionId: string): Promise<boolean>; // throws SessionStoreCapabilityError
|
|
124
|
+
};
|
|
125
|
+
decide(sessionId: string, resolution: DecisionResolution): Promise<DecideOutcome>;
|
|
126
|
+
dispose(sessionId: string): void;
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Running a workflow
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
import { createAgent, WORKFLOW_STATUSES } from "@archmax-ai/harness";
|
|
134
|
+
|
|
135
|
+
const agent = await createAgent({ workflow, onEvent, workspace: { rootDir } });
|
|
136
|
+
const sessionId = `run-${runId}`;
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
const result = await agent.invoke(
|
|
140
|
+
{ messages: [{ role: "user", content: prompt }] },
|
|
141
|
+
{ configurable: { thread_id: sessionId }, recursionLimit: 50 },
|
|
142
|
+
);
|
|
143
|
+
// result: { messages, status?, workflowState?, pendingDecision? }
|
|
144
|
+
if (result.status === WORKFLOW_STATUSES.awaitingDecision) {
|
|
145
|
+
// parked — see Human-in-the-loop
|
|
146
|
+
} else {
|
|
147
|
+
return lastAssistantText(result.messages);
|
|
148
|
+
}
|
|
149
|
+
} finally {
|
|
150
|
+
agent.dispose(sessionId); // cleans up sandbox/PTC state for the session
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Use `agent.stream(...)` instead of `invoke` when you want to record a
|
|
155
|
+
trajectory or push incremental updates.
|
|
156
|
+
|
|
157
|
+
`lastAssistantText` walks `result.messages` from the end and returns the last
|
|
158
|
+
assistant/AI message's text.
|
|
159
|
+
|
|
160
|
+
## Multi-turn conversations (chat history)
|
|
161
|
+
|
|
162
|
+
`agent.invoke` accepts an **array of role-tagged messages**, not just one user
|
|
163
|
+
string — the `messages` channel is a LangChain `add_messages` channel, so
|
|
164
|
+
whatever you pass is appended to the session's transcript. There is no need to
|
|
165
|
+
concatenate a prior chat into one giant user prompt; give the model real turns.
|
|
166
|
+
|
|
167
|
+
**Preferred — session persistence.** Sessions are durable by default (the
|
|
168
|
+
`BackendCheckpointSaver` writes each turn to the session store). Drive a live
|
|
169
|
+
conversation by re-invoking the **same `thread_id`** once per user message,
|
|
170
|
+
sending only the new message each time — prior turns are already in the
|
|
171
|
+
checkpoint:
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
// turn 1
|
|
175
|
+
await agent.invoke(
|
|
176
|
+
{ messages: [{ role: "user", content: "Look up order 1234" }] },
|
|
177
|
+
{ configurable: { thread_id: "chat-42" }, recursionLimit: 50 },
|
|
178
|
+
);
|
|
179
|
+
// turn 2 — SAME thread_id; only the new message
|
|
180
|
+
await agent.invoke(
|
|
181
|
+
{ messages: [{ role: "user", content: "And when did it ship?" }] },
|
|
182
|
+
{ configurable: { thread_id: "chat-42" }, recursionLimit: 50 },
|
|
183
|
+
);
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
The `init` state re-runs on each turn, resetting `workflowState` to the trigger's
|
|
187
|
+
start state while **preserving the accumulated messages** — exactly right for
|
|
188
|
+
chat: each user message re-enters the workflow with full history retained.
|
|
189
|
+
|
|
190
|
+
**Alternative — seed prior history on the first invoke.** When the transcript
|
|
191
|
+
lives in your own store (not a harness session), pass it as the initial `messages`
|
|
192
|
+
array with the newest user turn last:
|
|
193
|
+
|
|
194
|
+
```ts
|
|
195
|
+
await agent.invoke(
|
|
196
|
+
{
|
|
197
|
+
messages: [
|
|
198
|
+
{ role: "user", content: "Test" },
|
|
199
|
+
{ role: "assistant", content: "Test complete…" },
|
|
200
|
+
{ role: "user", content: "who are you?" },
|
|
201
|
+
{ role: "assistant", content: "I'm an agent that…" },
|
|
202
|
+
{ role: "user", content: "what was my first message?" }, // the new turn
|
|
203
|
+
],
|
|
204
|
+
},
|
|
205
|
+
{ configurable: { thread_id: "chat-42" }, recursionLimit: 50 },
|
|
206
|
+
);
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Gotchas:
|
|
210
|
+
|
|
211
|
+
- **A hook reads the transcript from `messages`, newest last.** A hook that keys
|
|
212
|
+
off "the current request" should take the *last* `user` entry, not the first —
|
|
213
|
+
the first is the message that opened the session. (A grading rubric still
|
|
214
|
+
receives the compact `userRequest`/`history` payload its rubric is written
|
|
215
|
+
against; there `userRequest` is the *first* user message.)
|
|
216
|
+
- **A definition edit is adopted, not refused.** A session takes its next turn
|
|
217
|
+
under the `workflow.yaml` in force now; the recorded `specHash` says which
|
|
218
|
+
definition each turn ran under but gates nothing. A turn positioned in a state
|
|
219
|
+
the edit removed reopens at its trigger's entry state rather than failing.
|
|
220
|
+
- **Seed assistant turns as text only.** Don't fabricate dangling `tool_calls`
|
|
221
|
+
in seeded history, or the provider may reject the turn.
|
|
222
|
+
- **Not every message in a session's transcript is a person's.** The runtime
|
|
223
|
+
writes **runtime notes** of its own — `[decision]`, `[event]`, `[error]`,
|
|
224
|
+
`[after]`, `[sub-workflow: …]` — carried as a synthetic `archmax_note` tool call
|
|
225
|
+
and its result, so a model cannot read them as a person speaking. Ask
|
|
226
|
+
`isRuntimeNote(message)` / `runtimeNoteKind(message)` rather than inferring
|
|
227
|
+
authorship from role or a `[bracket]` prefix: a turn's lower bound is a human
|
|
228
|
+
message **or** a note, and a transcript renderer should draw a note as a
|
|
229
|
+
runtime line, not as the user's own bubble. The one message a person really
|
|
230
|
+
sends into a running session — a reply to a run parked at a human state — is an
|
|
231
|
+
ordinary human message with no marker.
|
|
232
|
+
|
|
233
|
+
```ts
|
|
234
|
+
import { isRuntimeNote, runtimeNoteKind } from "@archmax-ai/harness";
|
|
235
|
+
|
|
236
|
+
const opensTurn = (m: unknown) => isRuntimeNote(m) || m.role === "user";
|
|
237
|
+
const label = (m: unknown) => runtimeNoteKind(m); // "decision" | "event" | … | null
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
## Human-in-the-loop
|
|
241
|
+
|
|
242
|
+
```ts
|
|
243
|
+
if (result.status === WORKFLOW_STATUSES.awaitingDecision && result.pendingDecision) {
|
|
244
|
+
const { state, transitions, evidence } = result.pendingDecision;
|
|
245
|
+
// Present `transitions` (each { to, description?, type? }) + evidence to a human.
|
|
246
|
+
// `evidence` is the state's declared paths followed by whatever the advancing
|
|
247
|
+
// agent attached via `archmax_advance({ evidence })` — one merged list, no
|
|
248
|
+
// host-side assembly, and no need to re-read the spec.
|
|
249
|
+
// When they pick:
|
|
250
|
+
const outcome = await agent.workflow.decide(sessionId, { target: chosenTo, comment });
|
|
251
|
+
// DecideOutcome: { status?, workflowState?, reparked, state?, reply, rejected?, messages }
|
|
252
|
+
// `rejected` says why when status is "rejected"; Outcome from send() carries it too.
|
|
253
|
+
if (outcome.reparked) { /* parked again at another human state */ }
|
|
254
|
+
}
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
`decide` throws `SessionNotParkedError` if the session isn't awaiting a decision, and
|
|
258
|
+
`InvalidDecisionTargetError` if `target` isn't a declared transition of the parked
|
|
259
|
+
state. `sessions.list()` enumerates durable sessions (empty with a `MemorySaver` or a non-listable session store).
|
|
260
|
+
|
|
261
|
+
## Event-in-the-loop: a run that parked itself
|
|
262
|
+
|
|
263
|
+
The agent can park the run with `archmax_wait({ reason })` — in any state, terminal
|
|
264
|
+
ones included — when the work cannot continue without something from outside. The
|
|
265
|
+
session's status becomes `awaiting_input` (classified **open**, never finished) and
|
|
266
|
+
`result.pendingInput` carries `{ state, title?, reason, parkedAt }`: the state it
|
|
267
|
+
stopped in and, in its own words, what it is waiting for.
|
|
268
|
+
|
|
269
|
+
```ts
|
|
270
|
+
if (result.status === WORKFLOW_STATUSES.awaitingInput && result.pendingInput) {
|
|
271
|
+
const { state, reason } = result.pendingInput; // show "waiting in <state>: <reason>"
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// When the event arrives, deliver it. The run continues in the state it parked in;
|
|
275
|
+
// the trigger id becomes its current trigger and the variables arrive locked.
|
|
276
|
+
const outcome = await agent.workflow.deliver(sessionId, {
|
|
277
|
+
trigger: { id: "email_reply" },
|
|
278
|
+
variables: { reply_body: "It's ORD-1001." },
|
|
279
|
+
});
|
|
280
|
+
// DeliverOutcome: DecideOutcome + { variables, parkedChannel? }
|
|
281
|
+
if (outcome.reparked) { /* parked again — outcome.parkedChannel says which channel */ }
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
### Waking a scheduled park
|
|
285
|
+
|
|
286
|
+
A park may declare when it wants to be resumed (`archmax_wait({ reason, until: "1d" })`),
|
|
287
|
+
which lands on the record, the summary, and the park event as an absolute
|
|
288
|
+
`resumeAt`. **The SDK schedules nothing** — no timer, no cron — so this is your
|
|
289
|
+
job, and it is a query:
|
|
290
|
+
|
|
291
|
+
```ts
|
|
292
|
+
const now = new Date().toISOString();
|
|
293
|
+
for (const t of await agent.sessions.list()) {
|
|
294
|
+
if (t.status === WORKFLOW_STATUSES.awaitingInput && t.resumeAt && t.resumeAt <= now) {
|
|
295
|
+
await agent.workflow.deliver(t.sessionId, { trigger: { id: "timer" }, variables: { now } });
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
A duplicate firing is safe: `deliver` refuses a session that is no longer parked. A
|
|
301
|
+
`resumeAt` gates nothing — an event arriving earlier resumes the park normally — and
|
|
302
|
+
a state that polls can cap its own retry loop with `budget.maxParks`, whose overrun
|
|
303
|
+
routes through the state's `on_error`.
|
|
304
|
+
|
|
305
|
+
`deliver` throws `SessionNotAwaitingInputError` if the session isn't parked awaiting
|
|
306
|
+
an event and `MissingDeliveryTriggerError` if no id is supplied. The id is
|
|
307
|
+
**recorded, never matched**: any trigger resumes a park, so there is no awaited-id
|
|
308
|
+
list to check against — an id no state declares is deliverable exactly like a
|
|
309
|
+
declared one. `resolveSession({ trigger, variables })` tells you which of
|
|
310
|
+
the two a firing is — a `turn` to invoke, or a `resume` to deliver into — always on
|
|
311
|
+
the conversation's own id.
|
|
312
|
+
|
|
313
|
+
An event that must **never start** a run is therefore declared nowhere in the spec:
|
|
314
|
+
every trigger a state declares starts a session at that state. Name the session
|
|
315
|
+
yourself — `send(sessionId, { delivery })` / `deliver(sessionId, …)` — or pass
|
|
316
|
+
`sessionPath` with the firing (`resolveSession({ trigger, variables, sessionPath })`)
|
|
317
|
+
and let the runtime read the id out of the firing's own variables. A firing that
|
|
318
|
+
names neither, on a trigger no state declares, throws
|
|
319
|
+
`UnknownSessionTriggerError`: there is nowhere for it to go.
|
|
320
|
+
|
|
321
|
+
One turn per invoke, on **one** session. A finished session re-invoked with a new
|
|
322
|
+
message takes its next turn on the same id, continuing in the state the previous
|
|
323
|
+
turn ended in — the position, variables, transcript and files are already there, so
|
|
324
|
+
**never seed a previous transcript into the `messages` channel**. Pass only the new
|
|
325
|
+
message.
|
|
326
|
+
|
|
327
|
+
The runtime does not reposition a later turn, so nothing is injected into the
|
|
328
|
+
transcript to explain a jump. When a turn needs a path the current state cannot
|
|
329
|
+
reach, the agent calls `archmax_reset` to return to the state the conversation began
|
|
330
|
+
in (available in every state). To continue *mid-state* instead — inside the state
|
|
331
|
+
that stopped, with the arrival in its transcript — the turn must park rather than
|
|
332
|
+
finish.
|
|
333
|
+
|
|
334
|
+
## Exposing a workflow to outside callers
|
|
335
|
+
|
|
336
|
+
A workflow's trigger signature (`requires`/`returns`, optionally typed) is the
|
|
337
|
+
contract the runtime already enforces at the session boundary. A host that
|
|
338
|
+
exposes a workflow as an MCP tool, an OpenAPI operation or a typed start form
|
|
339
|
+
builds its schema **from that signature**, with the helpers on the browser-safe
|
|
340
|
+
`@archmax-ai/harness/spec` subpath (also on the root, as the same bindings), so
|
|
341
|
+
the published contract and the enforced one cannot drift:
|
|
342
|
+
|
|
343
|
+
```ts
|
|
344
|
+
import {
|
|
345
|
+
parseMachineSpec,
|
|
346
|
+
signatureForTrigger,
|
|
347
|
+
signatureJsonSchema,
|
|
348
|
+
signatureValueIssues,
|
|
349
|
+
} from "@archmax-ai/harness/spec";
|
|
350
|
+
|
|
351
|
+
const parsed = parseMachineSpec(yaml.parse(workflowYaml));
|
|
352
|
+
if (!parsed.ok) throw new Error(parsed.issues.map((i) => `${i.path}: ${i.message}`).join("\n"));
|
|
353
|
+
const signature = signatureForTrigger(parsed.spec, "manual"); // { description?, requires, returns }
|
|
354
|
+
|
|
355
|
+
// MCP tool (or an OpenAPI request/response body): the same mapping the delegation tool uses.
|
|
356
|
+
const mcpTool = {
|
|
357
|
+
name: "refund-order",
|
|
358
|
+
description: signature?.description, // caller-facing; never shown to the session's agent
|
|
359
|
+
inputSchema: signatureJsonSchema(signature?.requires ?? []),
|
|
360
|
+
outputSchema: signatureJsonSchema(signature?.returns ?? []),
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
// On a request, before any session opens: the rule the turn boundary applies.
|
|
364
|
+
const issues = signatureValueIssues(signature?.requires ?? [], body);
|
|
365
|
+
if (issues.length) return reply(400, issues.map((i) => i.message)); // { kind, name, type?, found?, message }
|
|
366
|
+
|
|
367
|
+
// A turn needs a message; an MCP call has none of its own, so say what was asked.
|
|
368
|
+
const outcome = await agent.workflow!.send(sessionId, { message: "Refund the order.", variables: body });
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
- `signatureJsonSchema(entries)` → `{ type: "object", properties, required }`:
|
|
372
|
+
one property per entry in declaration order, every name required,
|
|
373
|
+
`date`/`date-time` as `{ type: "string", format }`, an untyped entry as `{}`,
|
|
374
|
+
an entry's `description` carried over. It adds no `additionalProperties`; set
|
|
375
|
+
it for your own ingress.
|
|
376
|
+
- `signatureValueIssues(entries, values)` → one issue per missing name and per
|
|
377
|
+
non-conforming value; `[]` means the turn boundary will not refuse the map for
|
|
378
|
+
its signature. It **never coerces**: a form that collects text converts
|
|
379
|
+
`"4"` to `4` before it validates or seeds.
|
|
380
|
+
- A trigger's `description` is written for the caller. Use it as the MCP tool
|
|
381
|
+
or operation description. The session's own model never sees it.
|
|
382
|
+
- A host that reads specs itself must read `requires`/`returns` through
|
|
383
|
+
`normalizeSignature` (both spellings → `{ name, type?, description? }[]`),
|
|
384
|
+
never as `string[]`: an entry may be an object. Hosts must be on the release
|
|
385
|
+
that introduced typed entries before any workspace they read uses them.
|
|
386
|
+
|
|
387
|
+
## Lifecycle events
|
|
388
|
+
|
|
389
|
+
Pass `onEvent`; every `WorkflowLifecycleEvent` carries a `level`
|
|
390
|
+
(`info`/`warn`/`error`) plus a typed payload. Map these to a live run UI:
|
|
391
|
+
|
|
392
|
+
| Event `type` | Meaning |
|
|
393
|
+
| --- | --- |
|
|
394
|
+
| `state-enter` / `state-leave` | state entered / left (`{ state, next? }`) |
|
|
395
|
+
| `advance` | transition committed (`{ from, to, reason?, callId? }` — `callId` names the `archmax_advance` call that drove it; absent on the turn's opening arrival) |
|
|
396
|
+
| `hook-start` / `hook-output` / `hook-passed` / `hook-verdict` / `hook-rejected` | lifecycle hook activity + verdict (`ok`/`correct`/`veto`) |
|
|
397
|
+
| `parked` / `decided` | human state parked / resolved (`{ state, sessionId, awaiting, to? }` — a human park carries no `callId`, since no call asked for it) |
|
|
398
|
+
| `parked` (`awaiting: "input"`) / `delivered` | the agent parked the run (`{ state, sessionId, reason, resumeAt?, callId }` — the `archmax_wait` call) / an event resumed it (`{ state, trigger, to }`, where `to` is the same state) |
|
|
399
|
+
| `agent-text` | complete assistant message in a state (`{ state, text, messageId }` — always the id the message is stored under; absent only on a `partial: true` event from a failed turn) |
|
|
400
|
+
| `agent-text-delta` | streaming text chunk (`{ state, text, messageId? }`) — flows for every run, even when the graph is driven with `invoke` |
|
|
401
|
+
| `tool-called` / `tool-result` / `tool-blocked` | tool passed governance (`callId`, `args`) / settled (`callId`, `status`, `durationMs`, `output` preview) / denied. **Every** governed call, `archmax_advance` included — so a UI drawing a row per call draws one per transition; filter it if you already draw transitions from `advance`/`state-leave`. A tool that **throws** settles `status: "error"` with the error's message as `output` while the turn **continues**: the model reads the same message as the call's answer, and nothing is thrown out of `send` — read tool failures here |
|
|
402
|
+
| `rubric-start` / `rubric-result` | grading-rubric dispatch bracketed by `dispatchId`, with the rubric's `name` |
|
|
403
|
+
| `model-usage` | per model call provider token usage: `inputTokens` (the total, of which the cache counts are a breakdown), `outputTokens`, `cacheReadTokens`, `cacheCreationTokens`, `model?` (the id the call was priced against), `costUsd?` |
|
|
404
|
+
| `prompt-shaping` | assembly-time payload summary: resolved `profile`, prompt-cache mechanism, `withheld` built-ins |
|
|
405
|
+
| `skills-loaded`, `hooks-summary`, `graph-topology`, `workflow-reset`, `warning` | assembly-time diagnostics |
|
|
406
|
+
|
|
407
|
+
For a browser client, forward these over SSE/WebSocket keyed by `sessionId`. To
|
|
408
|
+
render a live chat transcript: accumulate `agent-text-delta` into a bubble
|
|
409
|
+
keyed by `messageId`, replace it with the final `agent-text` bearing the same
|
|
410
|
+
`messageId`, and interleave tool cards from `tool-called`/`tool-result` paired
|
|
411
|
+
by `callId` (order everything by `seq`).
|
|
412
|
+
|
|
413
|
+
Building a **durable record** rather than a live view? The ids join the stream to
|
|
414
|
+
the stored messages exactly: `callId` equals the issuing AI message's
|
|
415
|
+
`tool_calls[].id` and the settling `ToolMessage.tool_call_id`, and
|
|
416
|
+
`agent-text.messageId` equals the assistant message's `id`. Every event a call
|
|
417
|
+
*causes* carries that call's id too (`advance`, `parked`, `variables-set`,
|
|
418
|
+
`title-set`, and the sub-workflow pair's `toolCallId`), so the events one call
|
|
419
|
+
produced group from ids alone — no scanning back to a message boundary. An event
|
|
420
|
+
of those types with no `callId` is one the runtime caused, not a call: a turn
|
|
421
|
+
opening, a human park, run-start seeding.
|
|
422
|
+
|
|
423
|
+
`title-set` is the one event carrying a variable's **value** — the reserved
|
|
424
|
+
`title`, whenever it changes. `variables-set` still carries names only; the value
|
|
425
|
+
rides its own event so that rule needs no exception, and it is safe there because
|
|
426
|
+
a title is bounded to one short line by its write check. Indexing a run's title on
|
|
427
|
+
your own record is a handler for this event and nothing else — no checkpoint read,
|
|
428
|
+
which matters when you install your own checkpointer.
|
|
429
|
+
|
|
430
|
+
### Reading tokens and cost
|
|
431
|
+
|
|
432
|
+
Usage figures are the **provider's own** (LangChain `usage_metadata`), not
|
|
433
|
+
estimates — including how many input tokens were served from the prompt cache.
|
|
434
|
+
Use the exported tracker instead of summing events yourself:
|
|
435
|
+
|
|
436
|
+
```ts
|
|
437
|
+
import { createAgent, createUsageTracker } from "@archmax-ai/harness";
|
|
438
|
+
|
|
439
|
+
// Wrap your own subscriber, or omit `onEvent` if you have none.
|
|
440
|
+
const usage = createUsageTracker({ onEvent: forwardToClient });
|
|
441
|
+
const agent = await createAgent({ onEvent: usage.handler, /* … */ });
|
|
442
|
+
|
|
443
|
+
await agent.invoke(input, { configurable: { thread_id: sessionId } });
|
|
444
|
+
|
|
445
|
+
const totals = usage.totals(sessionId);
|
|
446
|
+
// { inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens, costUsd? }
|
|
447
|
+
usage.bySession(); // Map<sessionId, UsageSummary> for concurrent runs
|
|
448
|
+
usage.reset(sessionId); // drop a finished run's totals
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
Rising `cacheReadTokens` is how you confirm prompt caching is actually working;
|
|
452
|
+
`costUsd` is present only when pricing is configured — report tokens rather than a
|
|
453
|
+
guessed cost when it is absent. `formatUsage`, `addSummaries`, `emptyUsage` and
|
|
454
|
+
`hasUsage` are exported for rendering and combining summaries; the extraction and
|
|
455
|
+
pricing internals are not part of the public surface.
|
|
456
|
+
|
|
457
|
+
### Reading a run's cost
|
|
458
|
+
|
|
459
|
+
A run's usage is **checkpointed**, so a session handle answers it for a run that
|
|
460
|
+
parked and resumed — including in another process:
|
|
461
|
+
|
|
462
|
+
```ts
|
|
463
|
+
const summary = await agent.sessions.get(sessionId);
|
|
464
|
+
summary?.usage;
|
|
465
|
+
// { inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens, costUsd? }
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
`costUsd` appears only when pricing is configured; without it, tokens are
|
|
469
|
+
reported and cost is omitted, never guessed. A parked run reports what it has
|
|
470
|
+
spent so far and keeps accumulating on resume. The same totals land in
|
|
471
|
+
`sessions/<sessionId>/artifacts/metadata.json` as `usage`, and `createUsageTracker()`
|
|
472
|
+
gives the same figure live in process.
|
|
473
|
+
|
|
474
|
+
### Measuring anything else — fold the stream
|
|
475
|
+
|
|
476
|
+
The SDK keeps no metrics ledger. Tool calls, governance refusals, the path a run
|
|
477
|
+
took, corrections and vetoes, rubric dispatches, and how long a session
|
|
478
|
+
sat waiting for a person are all already on the typed event stream, and a backend
|
|
479
|
+
counts what it needs:
|
|
480
|
+
|
|
481
|
+
```ts
|
|
482
|
+
const tally = { toolCalls: 0, toolBlocks: 0, path: [] as string[] };
|
|
483
|
+
const agent = await createAgent({
|
|
484
|
+
onEvent: (e) => {
|
|
485
|
+
forwardToClient(e);
|
|
486
|
+
if (e.type === "tool-called") tally.toolCalls++;
|
|
487
|
+
if (e.type === "tool-blocked") tally.toolBlocks++;
|
|
488
|
+
if (e.type === "state-enter") tally.path.push(e.state);
|
|
489
|
+
},
|
|
490
|
+
/* … */
|
|
491
|
+
});
|
|
492
|
+
```
|
|
493
|
+
|
|
494
|
+
Two properties make this the right home for it: the events are the ones the
|
|
495
|
+
runtime acts on, so a fold cannot drift from behavior the way a parallel ledger
|
|
496
|
+
can; and a backend counts what it cares about — a per-tenant tool budget, a
|
|
497
|
+
per-state SLA — rather than what the SDK guessed.
|
|
498
|
+
|
|
499
|
+
A fold is a **live view**: it sees only events this process observed. Cost is the
|
|
500
|
+
deliberate exception, because it is checkpointed.
|
|
501
|
+
|
|
502
|
+
## Session artifacts
|
|
503
|
+
|
|
504
|
+
Each run persists under its own folder in the session store — which is also the
|
|
505
|
+
agent's workspace root for that run (`sessions/<sessionId>/` on the filesystem default):
|
|
506
|
+
|
|
507
|
+
```
|
|
508
|
+
sessions/<sessionId>/
|
|
509
|
+
checkpoints/ # LangGraph checkpoint records (durable resume; also
|
|
510
|
+
# the sole record of a parked human decision)
|
|
511
|
+
scratchpad/ # the run's working area — intermediate files AND the
|
|
512
|
+
# artifacts a state produces (agent-visible as
|
|
513
|
+
# `scratchpad/…`; the only such area)
|
|
514
|
+
large_tool_results/ # runtime-offloaded oversized tool results (agent reads only)
|
|
515
|
+
conversation_history/ # runtime-offloaded history (agent reads only)
|
|
516
|
+
artifacts/
|
|
517
|
+
graph.json # SerializedGraph (topology; includes mermaid)
|
|
518
|
+
graph.mmd # Mermaid diagram
|
|
519
|
+
trajectory.json # Trajectory (per-state messages + tool calls)
|
|
520
|
+
trail.json # Audit trail (committed transitions + rationale)
|
|
521
|
+
variables.json # The run's variables: name -> { value, locked }.
|
|
522
|
+
# `locked` is the attribution: locked = host-established
|
|
523
|
+
# (variables, a delivery's seeds, the built-in
|
|
524
|
+
# `trigger`), unlocked = set by the agent. Written for a
|
|
525
|
+
# governed run; an empty store still writes `{}`.
|
|
526
|
+
metadata.json # RunMetadata (runtime contract, package version,
|
|
527
|
+
# specHash, and the run's `usage` summary — tokens
|
|
528
|
+
# by kind plus costUsd when priced)
|
|
529
|
+
```
|
|
530
|
+
|
|
531
|
+
Schemas:
|
|
532
|
+
|
|
533
|
+
```ts
|
|
534
|
+
interface SerializedGraph {
|
|
535
|
+
nodes: string[];
|
|
536
|
+
edges: { source: string; target: string; conditional: boolean; label?: string }[];
|
|
537
|
+
entry?: string; finals: string[]; mermaid: string;
|
|
538
|
+
}
|
|
539
|
+
interface Trajectory {
|
|
540
|
+
sessionId?: string; workflow: string; prompt?: string; finalAnswer: string;
|
|
541
|
+
segments: { state: string; enteredState?: string; exitedState?: string;
|
|
542
|
+
items: (MessageItem | ToolCallItem)[] }[];
|
|
543
|
+
}
|
|
544
|
+
// MessageItem: { kind:"message", role, content }
|
|
545
|
+
// ToolCallItem: { kind:"tool_call", name, args, result: string|null }
|
|
546
|
+
interface RunMetadata { sessionId: string; workflow: string;
|
|
547
|
+
runtimeContract: ResolvedRuntimeContract; packageVersion: string; }
|
|
548
|
+
// trail.json: { sessionId, workflow, steps: TrailStep[] } — the audit trail,
|
|
549
|
+
// re-derived from checkpointed state on every artifact write.
|
|
550
|
+
// TrailStep: { to, kind: "trigger"|"agent"|"human"|"reset"|"on_error",
|
|
551
|
+
// reason?, ts }
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
An **API** reads these files (through the same session store, or via `runArtifactPaths(sessionId)`
|
|
555
|
+
path helpers) to render historical runs: `graph.json`/`graph.mmd` for the diagram,
|
|
556
|
+
`trajectory.json` for the step-by-step timeline, `trail.json` for the decision
|
|
557
|
+
path (which edges committed, by whom, and why), `variables.json` for the run's
|
|
558
|
+
inputs and established facts (also what a test author reads to reproduce a real
|
|
559
|
+
firing's payload — see
|
|
560
|
+
[hook-and-test-scripts.md](hook-and-test-scripts.md#recovering-a-cases-inputs-from-previous-runs)).
|
|
561
|
+
Note it holds whatever the run's variables held, verbatim: a host that must not
|
|
562
|
+
persist an input should not seed it. To persist artifacts yourself,
|
|
563
|
+
stream the run and call `agent.emitRunArtifacts(sessionId, trajectory)`.
|
|
564
|
+
|
|
565
|
+
## Model configuration
|
|
566
|
+
|
|
567
|
+
Env (loaded from the workspace root `.env`, real process env wins):
|
|
568
|
+
`ARCHMAX_API_BASE_URL`, `ARCHMAX_API_KEY`, `ARCHMAX_MODEL`, optional
|
|
569
|
+
`ARCHMAX_TEMPERATURE`, `ARCHMAX_MAX_TOKENS` — any OpenAI-compatible endpoint.
|
|
570
|
+
Or inject a model directly:
|
|
571
|
+
|
|
572
|
+
```ts
|
|
573
|
+
import { ChatOpenAI } from "@langchain/openai";
|
|
574
|
+
const model = new ChatOpenAI({
|
|
575
|
+
model: "claude-sonnet-5",
|
|
576
|
+
apiKey: process.env.MY_KEY,
|
|
577
|
+
configuration: { baseURL: "https://api.example.com/v1" },
|
|
578
|
+
});
|
|
579
|
+
const agent = await createAgent({ model });
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
## Custom backends
|
|
583
|
+
|
|
584
|
+
`BackendProtocolV2` (from `deepagents`) decouples storage — implement
|
|
585
|
+
`readRaw`/`write`/`edit`/`ls` over filesystem, a store, a sandbox, S3, or a remote.
|
|
586
|
+
Pass it as `backend` (authored workspace); run state goes through a `sessionStore`
|
|
587
|
+
(e.g. `createBackendSessionStore({ backend: s3Backend, prefix: "tenants/a/runs" })`).
|
|
588
|
+
A custom `backend` **requires** an explicit `sessionStore` — assembly throws
|
|
589
|
+
`SessionStoreRequiredError` instead of inferring storage. The SDK owns the logical
|
|
590
|
+
`sessions/<sessionId>/…` namespace; the store owns where it physically lives.
|
|
591
|
+
Persisting the session zone off-box (an S3-backed session store) is how you share run
|
|
592
|
+
state across a worker fleet and an API.
|
|
593
|
+
|
|
594
|
+
## Cases against your agent
|
|
595
|
+
|
|
596
|
+
`runTests` runs a workflow's cases — declarative YAML documents
|
|
597
|
+
under `workflows/<name>/tests/*.test.yaml`, interpreted host-side — with the
|
|
598
|
+
same discovery, case interpretation, judge, and verdicts `archmax test` uses.
|
|
599
|
+
By default it builds its own agent, which is *not* your agent: no connection
|
|
600
|
+
tools, no mounts, no session store, no checkpointer — and it reads the suite off
|
|
601
|
+
the local filesystem under `rootDir`, which is *not* your authored tree if that
|
|
602
|
+
tree lives in a store. Supply both:
|
|
603
|
+
|
|
604
|
+
```ts
|
|
605
|
+
import { runTests, createAgent, createToolMockMiddleware } from "@archmax-ai/harness";
|
|
606
|
+
|
|
607
|
+
const { results, exitCode } = await runTests({
|
|
608
|
+
workflow,
|
|
609
|
+
rootDir,
|
|
610
|
+
// Where the spec, the case documents and their `from:` fixtures are read
|
|
611
|
+
// from. Omit it only when they really are files under `rootDir`.
|
|
612
|
+
authoring,
|
|
613
|
+
createTarget: async ({ workflow, rootDir, onEvent }) =>
|
|
614
|
+
createAgent({
|
|
615
|
+
workflow,
|
|
616
|
+
onEvent,
|
|
617
|
+
backend,
|
|
618
|
+
tools: await resolveConnectionTools(tenant),
|
|
619
|
+
checkpointer,
|
|
620
|
+
middleware: [createToolMockMiddleware()],
|
|
621
|
+
// REQUIRED — see below,
|
|
622
|
+
workspace: { rootDir, sessionStore },
|
|
623
|
+
}),
|
|
624
|
+
sessionIdForCase: (file) => `${hostRunId}/${file}`,
|
|
625
|
+
});
|
|
626
|
+
```
|
|
627
|
+
|
|
628
|
+
| Option | Why |
|
|
629
|
+
| --- | --- |
|
|
630
|
+
| `authoring` | The backend the **suite itself** is read from: the machine spec, `workflows/<name>/tests/*.test.yaml`, and the fixtures their `from:` entries name. The same option `createAgent` takes, and normally the same backend — the agent under test runs the authored tree the cases were read from. Defaults to a filesystem backend over the resolved root, so a host whose authored tree lives in a store and omits it discovers **no cases at all** and gets an empty run rather than a failure. |
|
|
631
|
+
| `createTarget` | The agent under test — an already-built runtime, or a factory that builds one. Must be **workflow-governed** — a plain agent is refused before any case runs. In the factory form, `rootDir` in the context is the resolved workspace root. |
|
|
632
|
+
| `sessionIdForCase` | Names the *agent* session the case runs on — a case is one conversation on one session (default `test-default-<runId>-<file>`), invoked once per case file — so its checkpoints, `scratchpad/…` files, and token usage join your own run record. |
|
|
633
|
+
|
|
634
|
+
**Your target owns its mocks — and the runtime checks.** A case's `mocks:`
|
|
635
|
+
list intercepts agent-initiated tool calls through
|
|
636
|
+
`createToolMockMiddleware()`, which the default target wires for you.
|
|
637
|
+
`createAgent` stamps `capabilities.toolMocks` from the middleware
|
|
638
|
+
actually wired; a case that declares mocks against a target without the
|
|
639
|
+
capability **fails loudly before the agent runs**, naming the fix — never run
|
|
640
|
+
with partial interception (scripts' PTC calls mocked, the agent's own calls
|
|
641
|
+
real). A bespoke target that intercepts by other means sets
|
|
642
|
+
`capabilities.toolMocks` itself. Mockless cases run against any
|
|
643
|
+
workflow-governed target.
|
|
644
|
+
|
|
645
|
+
**Sub-workflows inherit the assembly.** When a workflow delegates (by advancing
|
|
646
|
+
an `archmax_workflow_<slug>` tool), the child runtime is
|
|
647
|
+
composed from the *same* resolved inputs as the parent — model and
|
|
648
|
+
`modelFactory`, `backend` and `mounts`, `sessionStore` and `checkpointer`, host
|
|
649
|
+
`tools` and `essentialTools`, `sandboxRuntime`, `hookExecutors`,
|
|
650
|
+
`policyRules`, `promptCache` and `pricing` — differing only in
|
|
651
|
+
the machine that governs it and the system prompt that machine renders. So a
|
|
652
|
+
custom backend serves the child's spec too, and a `modelFactory` supplies its
|
|
653
|
+
model. Composition is **lazy and memoized per workflow slug**: a workflow that is
|
|
654
|
+
never delegated to is never loaded, and one delegated from many places is
|
|
655
|
+
composed once. A child whose spec is broken surfaces as a *dispatch* failure, not
|
|
656
|
+
an assembly failure — a parent still assembles.
|
|
657
|
+
|
|
658
|
+
The child runs on the parent's **session id** (which is what gives it the same
|
|
659
|
+
run zone) under a nested checkpoint namespace, so `sessions.list()` reports one
|
|
660
|
+
session and the parent session's metrics include the child's tokens.
|
|
661
|
+
`capabilities.subWorkflows` is stamped `true` for a governed assembly and `false`
|
|
662
|
+
for the plain one.
|
|
663
|
+
|
|
664
|
+
Omit both options and behavior is identical to `archmax test`. Cases run
|
|
665
|
+
sequentially (`tests.maxConcurrency` above 1 is rejected, not silently
|
|
666
|
+
serialized) and there is no cancellation signal: call `runTests` once per file
|
|
667
|
+
(`filter`) if you need to interrupt between cases; a single case is bounded by
|
|
668
|
+
`tests.caseTimeoutMs`.
|
|
669
|
+
|
|
670
|
+
## Suggested split
|
|
671
|
+
|
|
672
|
+
- **Worker**: `createAgent` + `agent.invoke`/`stream`; `agent.workflow.decide` for
|
|
673
|
+
approvals. Owns execution and the durable session store/checkpointer.
|
|
674
|
+
- **API**: reads `sessions/<sessionId>/artifacts/*` (shared session store) and/or relays
|
|
675
|
+
the `onEvent` stream; exposes `sessions.list()`, run detail, and a decide endpoint.
|
|
676
|
+
- **Frontend**: renders `graph.json`/`graph.mmd`, the `trajectory.json` timeline,
|
|
677
|
+
the `variables.json` inputs, and the live event stream; posts human decisions
|
|
678
|
+
back to the API.
|
|
679
|
+
|
|
680
|
+
Keep the heavy runtime import (LangChain/LangGraph) in the worker; the API/frontend
|
|
681
|
+
only need the artifact/event *types*, which ship in `dist/index.d.ts`.
|