@jr2/orchestrator 0.1.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/LICENSE +21 -0
- package/README.md +23 -0
- package/bin/server.ts +23 -0
- package/console/canvas.ts +843 -0
- package/console/components/app.ts +79 -0
- package/console/components/drawer.ts +131 -0
- package/console/components/fleet.ts +117 -0
- package/console/components/machine-pane.ts +85 -0
- package/console/components/nav.ts +81 -0
- package/console/components/schema-form.ts +137 -0
- package/console/main.ts +383 -0
- package/console/page.html +28 -0
- package/console/store.ts +336 -0
- package/console/style.css +700 -0
- package/console/tsconfig.json +18 -0
- package/package.json +61 -0
- package/src/actor.ts +562 -0
- package/src/agent.ts +124 -0
- package/src/ambient.ts +50 -0
- package/src/config.ts +297 -0
- package/src/customize.ts +348 -0
- package/src/durability.ts +135 -0
- package/src/fingerprint.ts +92 -0
- package/src/gate.ts +76 -0
- package/src/harness-client.ts +503 -0
- package/src/http.ts +753 -0
- package/src/images.ts +303 -0
- package/src/index.ts +40 -0
- package/src/instance.ts +294 -0
- package/src/machine-doc.ts +334 -0
- package/src/names.ts +78 -0
- package/src/open.ts +17 -0
- package/src/parts.ts +500 -0
- package/src/pool.ts +284 -0
- package/src/registration.ts +340 -0
- package/src/repo-fetch.ts +259 -0
- package/src/repo-identity.ts +145 -0
- package/src/repos.ts +330 -0
- package/src/run-host.ts +1095 -0
- package/src/sandbox-kubectl.ts +1136 -0
- package/src/server.ts +220 -0
- package/src/setup.ts +360 -0
- package/src/snapshot-store.ts +150 -0
- package/src/stub-harness.ts +217 -0
- package/src/tokens.ts +126 -0
- package/src/vocabulary.ts +99 -0
- package/src/wire.ts +103 -0
- package/src/workspace.ts +874 -0
- package/tsconfig.instance.json +26 -0
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jr2/orchestrator",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The jr2 Orchestrator: xstate runtime, HTTP API and Console, and the kit pieces a Workflow imports.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"private": false,
|
|
7
|
+
"publishConfig": {
|
|
8
|
+
"access": "public"
|
|
9
|
+
},
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/snapwich/jr2.git",
|
|
13
|
+
"directory": "packages/orchestrator"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://github.com/snapwich/jr2#readme",
|
|
16
|
+
"bugs": {
|
|
17
|
+
"url": "https://github.com/snapwich/jr2/issues"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"jr2",
|
|
21
|
+
"agents",
|
|
22
|
+
"agentic",
|
|
23
|
+
"xstate",
|
|
24
|
+
"kubernetes",
|
|
25
|
+
"workflow"
|
|
26
|
+
],
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=24"
|
|
29
|
+
},
|
|
30
|
+
"type": "module",
|
|
31
|
+
"exports": {
|
|
32
|
+
".": "./src/index.ts",
|
|
33
|
+
"./tsconfig.instance.json": "./tsconfig.instance.json"
|
|
34
|
+
},
|
|
35
|
+
"types": "./src/index.ts",
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@hono/node-server": "^2.0.6",
|
|
38
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
39
|
+
"elkjs": "^0.11.1",
|
|
40
|
+
"hono": "^4.12.27",
|
|
41
|
+
"preact": "^10.29.8",
|
|
42
|
+
"ts-blank-space": "^0.9.0",
|
|
43
|
+
"xstate": "^5.18.0",
|
|
44
|
+
"zod": "^4.4.3",
|
|
45
|
+
"@jr2/agent-protocol": "0.1.0"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@types/node": "^26.0.1",
|
|
49
|
+
"@jr2/harness": "0.1.0"
|
|
50
|
+
},
|
|
51
|
+
"files": [
|
|
52
|
+
"bin",
|
|
53
|
+
"console",
|
|
54
|
+
"src",
|
|
55
|
+
"tsconfig.instance.json"
|
|
56
|
+
],
|
|
57
|
+
"scripts": {
|
|
58
|
+
"typecheck": "tsc --noEmit && tsc -p console --noEmit",
|
|
59
|
+
"test": "node --test"
|
|
60
|
+
}
|
|
61
|
+
}
|
package/src/actor.ts
ADDED
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
// The run-lifecycle Actor (ADR-0002/0011/0016): an xstate `fromCallback` actor that drives one
|
|
2
|
+
// Agent run. It does two things on start, and undoes both on stop:
|
|
3
|
+
//
|
|
4
|
+
// 1. REGISTERS the invocation's event surface: `tools` names are resolved against the
|
|
5
|
+
// INVOKING MACHINE's vocabulary (per-Machine scoping — an unlisted name fails at invoke time)
|
|
6
|
+
// and registered in the host's table under the instance's agent address, with a deliver
|
|
7
|
+
// closure over THIS invocation's `sendBack`. The Agent's domain tool calls arrive from its
|
|
8
|
+
// Adapter (`/agents/<iid>/events` — ADR-0013), are validated by the table, and land on the
|
|
9
|
+
// state that invoked the agent — at any nesting depth, no routing, no `instanceId` on
|
|
10
|
+
// domain events (the closure IS the provenance). `/agents/<iid>/surface` serves exactly
|
|
11
|
+
// this registration, so menus are state-scoped by lifecycle (ADR-0006's dynamic
|
|
12
|
+
// advertisement, for free — and, per ADR-0013, with no `list_changed` needed: the
|
|
13
|
+
// Harness re-lists per Submission).
|
|
14
|
+
//
|
|
15
|
+
// 2. ADMITS the run over the Harness at `input.endpoint` — the port is constructed
|
|
16
|
+
// per-invocation from serializable input (ADR-0007/0011 doctrine), and a dev stub is just
|
|
17
|
+
// a different URL, never a different code path. The Admission the Harness answers with
|
|
18
|
+
// (`{ streamUrl, offset, submissionId }`) IS the durable re-attach handle (ADR-0016): the
|
|
19
|
+
// actor reports it through the run binding into the HOST LEDGER (persisted beside the
|
|
20
|
+
// snapshot, in the same save), and on restore the host rewrites the child's persisted
|
|
21
|
+
// input (drop `prompt`, set `attach`) so the actor re-follows the admitted submission
|
|
22
|
+
// instead of re-POSTing the prompt. The Harness stream carries lifecycle only
|
|
23
|
+
// (`agent.fault` when the Submission settles failed); domain events never ride it.
|
|
24
|
+
//
|
|
25
|
+
// The port factory — `(endpoint) => AgentRunPort`, not a wire client — is the dependency, so the
|
|
26
|
+
// actor is unit-testable without a live Harness: `agentActorWith(() => mock, def)` is the seam,
|
|
27
|
+
// and the canonical `agent(def)` (bound to the real wire client) lives in harness-client.ts so
|
|
28
|
+
// this module never pulls the wire client onto the test load path.
|
|
29
|
+
//
|
|
30
|
+
// The other closure is the DEFINITION (ADR-0049): one logic object per Agent slot, carrying the
|
|
31
|
+
// definition it runs. Placement (ADR-0031) reads `workspace` off it — never off a roster, which
|
|
32
|
+
// could not tell two Machines' `coder`s apart — and every admission carries it to the Harness,
|
|
33
|
+
// which holds no roster either and runs what this Turn handed it.
|
|
34
|
+
//
|
|
35
|
+
// Stopping the actor ENDS THE TURN (ADR-0024). It abandons the run locally (admission/settlement
|
|
36
|
+
// consumption) AND aborts the submission remotely, because an Agent slot is an invoke: leaving the
|
|
37
|
+
// state means "I am no longer interested in this answer", and an Agent whose turn has ended but
|
|
38
|
+
// whose submission has not is an unaccounted-for writer in the Workspace.
|
|
39
|
+
//
|
|
40
|
+
// The ONE exception is the host ending the run for its own reasons — `RunHost.stop()`, whose runs
|
|
41
|
+
// must stay alive server-side for ADR-0007's restore to re-attach. That is a FLAG the host sets on
|
|
42
|
+
// the run binding (`hostStopping`), never a fact inferred here: process shutdown stops no actors
|
|
43
|
+
// at all, and restore is a fresh process, so there is nothing to infer it from.
|
|
44
|
+
|
|
45
|
+
import { fromCallback, type CallbackActorLogic } from "xstate";
|
|
46
|
+
import { requireBoundAgent, type AgentDeclaration, type AgentDefinition, type ThinkingLevel } from "./agent.ts";
|
|
47
|
+
import { ambientHandlesFor } from "./ambient.ts";
|
|
48
|
+
import { INSTANCE_HARNESS_SERVICE } from "./names.ts";
|
|
49
|
+
import { agentAddress, resolveAccepts, runBindingOf } from "./registration.ts";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* One admitted Submission — the durable re-attach handle (ADR-0016). The wire fields are
|
|
53
|
+
* server-provided opaque strings (the wire's `AdmissionResponse` — ADR-0027) plus the
|
|
54
|
+
* actor-stamped `instanceId`, so the whole record is serializable: it lives in the host ledger
|
|
55
|
+
* and rides the rewritten child input on restore.
|
|
56
|
+
*/
|
|
57
|
+
export type AgentAdmission = {
|
|
58
|
+
/** Fully resolved stream URL for observing the conversation's durable stream. */
|
|
59
|
+
streamUrl: string;
|
|
60
|
+
/** Opaque stream offset captured at admission — replaying from here yields exactly this
|
|
61
|
+
* submission's events (compared and stored verbatim, never arithmetic'd). */
|
|
62
|
+
offset: string;
|
|
63
|
+
/** Correlates the admitted prompt with its settlement. */
|
|
64
|
+
submissionId: string;
|
|
65
|
+
/**
|
|
66
|
+
* The conversation this admission was admitted under — stamped by the ACTOR at ledger time
|
|
67
|
+
* (the wire response carries no iid). Equal to the invocation's iid until a runaway reroll
|
|
68
|
+
* (ADR-0035) advances it; a restore reads it back so a rerolled run re-registers, nudges and
|
|
69
|
+
* aborts the LIVE conversation, never the dead original the ledger is keyed by.
|
|
70
|
+
*/
|
|
71
|
+
instanceId?: string;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* What a WORKFLOW writes on an Agent slot's invoke (ADR-0015/0016/0049): this turn's prompt —
|
|
76
|
+
* everything else is derived. The AGENT is not written here at all: the slot key is its name
|
|
77
|
+
* (`actors: { coder: agent(def) }`, `src: "coder"`), so a name the Machine does not carry is a
|
|
78
|
+
* compile error on `src` instead of a runtime miss. `jr2Setup.createMachine` wraps the invoke
|
|
79
|
+
* input to finalize it into {@link AgentRunInput}: the slot key lands as `agentName`, the tool
|
|
80
|
+
* menu derives from the invoking state's transitions, the instance id is minted (fresh session by
|
|
81
|
+
* default; `session: "continue"` or a `conversation` pin derives a deterministic id so
|
|
82
|
+
* re-invocations continue one conversation), and endpoint/sandbox resolve ambiently from the
|
|
83
|
+
* enclosing `workspace()`.
|
|
84
|
+
*/
|
|
85
|
+
export type AgentTurnInput = {
|
|
86
|
+
/** This turn's task framing — lands as the conversation's next user message. */
|
|
87
|
+
prompt: string;
|
|
88
|
+
/**
|
|
89
|
+
* This turn's DIALS (ADR-0018) — how hard to run, layered over the definition's own
|
|
90
|
+
* values. One definition value may be carried by several Machines, so the same persona
|
|
91
|
+
* legitimately runs at different settings in different workflows: a reviewer on a one-line diff
|
|
92
|
+
* and the same reviewer on an architecture change want identical instructions and different
|
|
93
|
+
* effort.
|
|
94
|
+
*
|
|
95
|
+
* IDENTITY is deliberately absent — no `instructions`, `workspace` or `cwd` here. A call site
|
|
96
|
+
* that rewrote those would make the Agent's name a lie, and `workspace` in particular carries
|
|
97
|
+
* ADR-0028's containment claim, which per-invocation escalation would void.
|
|
98
|
+
*/
|
|
99
|
+
model?: string;
|
|
100
|
+
thinkingLevel?: ThinkingLevel;
|
|
101
|
+
/**
|
|
102
|
+
* Session continuity (ADR-0016). Absent = FRESH: every invocation is a new conversation
|
|
103
|
+
* (jr's lossy handoff — revision agents read notes + code, never the prior conversation).
|
|
104
|
+
* `"continue"` = the same `(state path, agent, scope)` re-invocation continues ONE
|
|
105
|
+
* conversation; the prompt lands as its next user turn.
|
|
106
|
+
*/
|
|
107
|
+
session?: "continue";
|
|
108
|
+
/** Distinguishes conversations that would otherwise share a `continue` identity (e.g. a
|
|
109
|
+
* reviewer fresh per task: `scope: task.id`). */
|
|
110
|
+
scope?: string;
|
|
111
|
+
/**
|
|
112
|
+
* Pin the conversation to a workflow-chosen name — the CROSS-MACHINE continue (ADR-0016's
|
|
113
|
+
* opt-in continuation, where `session: "continue"` cannot reach: its derived id carries the
|
|
114
|
+
* invoking actor's path, so it only spans states of one machine). Invocations naming the same
|
|
115
|
+
* `conversation` derive ONE deterministic, run-scoped instance id (`<runId>/<name>/<agent>`)
|
|
116
|
+
* wherever in the actor tree they sit — a triage state before the `workspace()` and an assess
|
|
117
|
+
* state inside its body continue one conversation, the prompt landing as its next user turn.
|
|
118
|
+
* Only sound where every invocation lands on the same Harness, because a conversation is an
|
|
119
|
+
* Instance ID on ONE server: a `workspace: "none"` Agent (always the Instance Harness —
|
|
120
|
+
* definition-wins, ADR-0031) or a fixed explicit `endpoint`.
|
|
121
|
+
*/
|
|
122
|
+
conversation?: string;
|
|
123
|
+
/** Workspace-less runs only (stub Harness): explicit endpoint, no ambient resolution. */
|
|
124
|
+
endpoint?: string;
|
|
125
|
+
/** Escape hatch: override the derived menu. */
|
|
126
|
+
tools?: readonly string[];
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/** What the actor is invoked with AFTER jr2Setup finalization: the durable handle, this turn's
|
|
130
|
+
* surface, and — only outside a workspace — an explicit Harness. */
|
|
131
|
+
export type AgentRunInput = {
|
|
132
|
+
/** The Agent's name — its SLOT KEY, injected by the menu walk (ADR-0049), never authored. It
|
|
133
|
+
* is what the Harness route, the minted iid, the markers and the telemetry all name. */
|
|
134
|
+
agentName: string;
|
|
135
|
+
instanceId: string;
|
|
136
|
+
/**
|
|
137
|
+
* The Harness base URL, EXPLICIT (ADR-0016): only for a workspace-less run (the mechanics
|
|
138
|
+
* tier's stub Harness, a dev endpoint — just a URL, ADR-0011). Inside a `workspace()` leave it
|
|
139
|
+
* unset: the actor resolves endpoint AND sandbox ambiently from the enclosing wrapper via the
|
|
140
|
+
* actor parent chain, and the registration records that wrapper's Sandbox — the ADR-0013 token
|
|
141
|
+
* scope — with no way for the workflow to forget it. Explicit `endpoint` wins when both exist.
|
|
142
|
+
* A `workspace: "none"` definition needs neither: its Turn resolves to the Instance Harness,
|
|
143
|
+
* whatever encloses the invocation (definition-wins — ADR-0031).
|
|
144
|
+
*/
|
|
145
|
+
endpoint?: string;
|
|
146
|
+
/**
|
|
147
|
+
* The Sandbox to scope delivery to (ADR-0013), EXPLICIT — normally ambient (above). An
|
|
148
|
+
* explicit workspace-less run has none: no Sandbox token can claim its surface.
|
|
149
|
+
*/
|
|
150
|
+
sandbox?: string;
|
|
151
|
+
prompt?: string;
|
|
152
|
+
/** This turn's dials, passed through from {@link AgentTurnInput}. Plain strings, so they ride
|
|
153
|
+
* the persisted child input; on restore the Submission already exists server-side with its
|
|
154
|
+
* model fixed, so a re-attach never re-resolves them. */
|
|
155
|
+
model?: string;
|
|
156
|
+
thinkingLevel?: ThinkingLevel;
|
|
157
|
+
/**
|
|
158
|
+
* Re-attach to this already-admitted submission instead of admitting a fresh prompt. Set by
|
|
159
|
+
* the host on restore (which also drops `prompt`) from the run's admission ledger.
|
|
160
|
+
*/
|
|
161
|
+
attach?: AgentAdmission;
|
|
162
|
+
/**
|
|
163
|
+
* This invocation is closed to the ADR-0035 reroll — set by the input mapper for
|
|
164
|
+
* `session: "continue"` and a `conversation` pin (both name an EXISTING conversation, and
|
|
165
|
+
* the runaway's one recovery is a fresh one — exactly what they opted out of), and for a
|
|
166
|
+
* caller-passed `instanceId` (fresh on its first invocation, but jr2 did not mint the id and
|
|
167
|
+
* must not derive reroll identity from one it does not own — ADR-0016's minting doctrine).
|
|
168
|
+
* A gated runaway goes straight to the terminal fault.
|
|
169
|
+
*
|
|
170
|
+
* A continuation carries NO check that it continues the same persona (ADR-0049, closing
|
|
171
|
+
* consequence, open): two Machines that each carry a `coder` slot and pin the same
|
|
172
|
+
* `conversation` continue one conversation under two definitions, and the second Turn gets its
|
|
173
|
+
* own instructions and Working-tool filter over the first's context. The ledger holds no
|
|
174
|
+
* definition to compare against, and adding a digest would refuse an edited-`instructions`
|
|
175
|
+
* redeploy too, which ADR-0030 lets continue — so the refusal waits on a persona identity that
|
|
176
|
+
* survives a retune.
|
|
177
|
+
*/
|
|
178
|
+
continuation?: boolean;
|
|
179
|
+
/** Event names (from the invoking Machine's vocabulary) this invocation accepts over MCP. */
|
|
180
|
+
tools: readonly string[];
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
/** Telemetry sent up when the run is out of options: the Submission settled failed/aborted
|
|
184
|
+
* (infra fault after the turn's own provider retries — ADR-0027), the no-signal nudge budget ran
|
|
185
|
+
* dry, or the runaway reroll budget did (ADR-0035). The ONE terminal event (ADR-0016) — where it
|
|
186
|
+
* routes is workflow policy. */
|
|
187
|
+
export type FaultTelemetry = {
|
|
188
|
+
type: "agent.fault";
|
|
189
|
+
instanceId: string;
|
|
190
|
+
reason: string;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
/** What the actor itself originates upward — telemetry. Domain events also flow through its
|
|
194
|
+
* `sendBack`, but they are the registration table's deliveries, typed by the workflow's defs. */
|
|
195
|
+
export type AgentRunUpEvent = FaultTelemetry;
|
|
196
|
+
|
|
197
|
+
/** The only event the parent sends down: an interrupt that abandons the run. */
|
|
198
|
+
export type AgentRunReceiveEvent = { type: "CANCEL" };
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The port the Actor drives. Narrow by design — admit a prompt (returning the durable
|
|
202
|
+
* admission), follow an admission to settlement, and end one — so a test can supply a synthetic
|
|
203
|
+
* client and settle, fault or abort it by hand. The real, wire-backed implementation lives
|
|
204
|
+
* in harness-client.ts. `admit`/`settle` honor `signal`: aborting abandons the LOCAL consumption only
|
|
205
|
+
* (see module header), and the rejection it causes is swallowed by the stopped actor.
|
|
206
|
+
*/
|
|
207
|
+
export interface AgentRunPort {
|
|
208
|
+
/** Admit one prompt; resolves with the admission the moment the Harness accepts it. The
|
|
209
|
+
* DEFINITION rides the admission (ADR-0049) — it is the Agent slot's, read off the logic this
|
|
210
|
+
* invocation named, never off the persisted input: the Harness holds no roster, and a restore
|
|
211
|
+
* must run the definition the Machine carries NOW, not a copy a snapshot froze. */
|
|
212
|
+
admit(input: AgentRunInput, opts: AgentAdmitOptions): Promise<AgentAdmission>;
|
|
213
|
+
/**
|
|
214
|
+
* Follow an admitted submission until it settles. Resolving means the submission completed;
|
|
215
|
+
* rejecting means it settled failed/aborted (or the conversation is gone).
|
|
216
|
+
*/
|
|
217
|
+
settle(admission: AgentAdmission, opts?: { signal?: AbortSignal }): Promise<void>;
|
|
218
|
+
/**
|
|
219
|
+
* End the instance's in-flight (and queued) work — the turn is over (ADR-0024). Resolving means
|
|
220
|
+
* the intent is RECORDED, not that the submission has settled; jr2 never observes that outcome,
|
|
221
|
+
* because the actor is already stopped by the time this is called. The actor passes no `signal`
|
|
222
|
+
* for exactly that reason — its own controller is already aborted — but the option is here for
|
|
223
|
+
* parity with the other two.
|
|
224
|
+
*/
|
|
225
|
+
abort(agentName: string, instanceId: string, opts?: { signal?: AbortSignal }): Promise<void>;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** What every admission carries beside the input: the Agent definition this Turn runs (ADR-0049),
|
|
229
|
+
* plus the local abandon signal. Not part of {@link AgentRunInput} on purpose — that shape is
|
|
230
|
+
* PERSISTED as the child's input, and a definition frozen into a snapshot would outlive the
|
|
231
|
+
* Machine edit that changed it. */
|
|
232
|
+
export type AgentAdmitOptions = { definition: AgentDefinition; signal?: AbortSignal };
|
|
233
|
+
|
|
234
|
+
/** Build a port for one invocation from its serializable input (ADR-0011 static-import doctrine). */
|
|
235
|
+
export type AgentRunPortFactory = (endpoint: string) => AgentRunPort;
|
|
236
|
+
|
|
237
|
+
/** Absorbed-turn-mechanics knobs (ADR-0016): defaulted, never Machine context. */
|
|
238
|
+
export type AgentRunOptions = {
|
|
239
|
+
/**
|
|
240
|
+
* How many times a turn that settles COMPLETED without having called any menu tool is
|
|
241
|
+
* re-prompted ("you must call one of: …") before the terminal `agent.fault`. jr's dominant
|
|
242
|
+
* failure mode: the Harness settles a silent turn `completed` like any other (ADR-0006), so
|
|
243
|
+
* this loop is jr2-owned. Default 2.
|
|
244
|
+
*/
|
|
245
|
+
nudgeBudget?: number;
|
|
246
|
+
/**
|
|
247
|
+
* How many times a RUNAWAY — a turn the Harness itself ended because it would not conclude,
|
|
248
|
+
* settled failed with the typed `"runaway"` error (ADR-0035) — is rerolled: a FRESH
|
|
249
|
+
* conversation under an iid derived from the original, admitting the IDENTICAL prompt. The
|
|
250
|
+
* degenerate context is poisoned, so the recovery is a new roll of the dice, never a nudge into
|
|
251
|
+
* the same conversation — and a continuation gets none at all (it opted out of fresh
|
|
252
|
+
* conversations). Default 1: two independent runaways are evidence the task itself is
|
|
253
|
+
* pathological, which belongs with the workflow's fault routing.
|
|
254
|
+
*/
|
|
255
|
+
runawayBudget?: number;
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The runaway settlement class (ADR-0035), read STRUCTURALLY off a settle rejection: the wire
|
|
260
|
+
* client's `SettlementFault` carries the Settlement, but this module is wire-free (see header),
|
|
261
|
+
* so the literal is restated (`SUBMISSION_RUNAWAY` in `./wire.ts`) and the shape
|
|
262
|
+
* duck-typed. A lost conversation (404) carries no settlement, so it stays terminal like every
|
|
263
|
+
* other class.
|
|
264
|
+
*/
|
|
265
|
+
function runawayReason(err: unknown): string | undefined {
|
|
266
|
+
if (typeof err !== "object" || err === null) return undefined;
|
|
267
|
+
const error = (err as { settlement?: { error?: { type?: string; message?: string } } }).settlement?.error;
|
|
268
|
+
return error?.type === "runaway" ? (error.message ?? "runaway") : undefined;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** The forced-final-pick re-prompt (ADR-0006, absorbed here by ADR-0016). */
|
|
272
|
+
function nudgePrompt(tools: readonly string[]): string {
|
|
273
|
+
return (
|
|
274
|
+
`Your previous turn ended without calling one of the required workflow tools. ` +
|
|
275
|
+
`You MUST end your turn by calling exactly one of: ${tools.join(", ")}. ` +
|
|
276
|
+
`Pick the one that matches the true state of your work and call it now.`
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** One Agent slot's logic: the run-lifecycle actor closed over ONE definition and BRANDED with it
|
|
281
|
+
* (ADR-0049). The brand is the whole resolution mechanism — the actor reads its definition off its
|
|
282
|
+
* own closure, `jr2Setup` recognizes the slot by `isAgent`, and a `.provide()` that swaps the slot
|
|
283
|
+
* swaps the definition with it, because the two are one object.
|
|
284
|
+
*
|
|
285
|
+
* The brand is a RUNTIME property, read back through `isAgent`, and deliberately NOT part of this
|
|
286
|
+
* type: the unit-test seam is `provide({ actors: { coder: fake } })` (ADR-0049), and a required
|
|
287
|
+
* `definition` here would make every fake carry a definition it never uses. */
|
|
288
|
+
export type AgentLogic = CallbackActorLogic<AgentRunReceiveEvent, AgentTurnInput | AgentRunInput>;
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Build one Agent slot's actor logic over an injected port factory — the seam `agent()` (bound to
|
|
292
|
+
* the real wire client, harness-client.ts) and every unit test share.
|
|
293
|
+
*
|
|
294
|
+
* On start it registers the invocation's event surface, then either admits `prompt` (recording
|
|
295
|
+
* the admission in the host ledger — the durable handle) or, when the host set `attach` on
|
|
296
|
+
* restore, re-follows the persisted admission. A `CANCEL` from the parent (or the actor being
|
|
297
|
+
* stopped) abandons local consumption and destroys the registration; a failed settlement
|
|
298
|
+
* surfaces as `agent.fault` so the Machine can react rather than hang on a dead run.
|
|
299
|
+
*
|
|
300
|
+
* The `declaration` is a CLOSURE, not a lookup: the Turn's placement (ADR-0031) reads it off the
|
|
301
|
+
* logic the invoke actually named, so two Machines carrying different `coder`s each resolve their
|
|
302
|
+
* own, and no roster is consulted anywhere (ADR-0049). It is the AUTHOR's declaration, model
|
|
303
|
+
* possibly Open — narrowed to the wire's definition on start (ADR-0054), before anything is
|
|
304
|
+
* admitted and before placement is resolved, so that everything below this line reads a model.
|
|
305
|
+
*/
|
|
306
|
+
export function agentActorWith(
|
|
307
|
+
portFactory: AgentRunPortFactory,
|
|
308
|
+
declaration: AgentDeclaration,
|
|
309
|
+
options: AgentRunOptions = {},
|
|
310
|
+
): AgentLogic {
|
|
311
|
+
const nudgeBudget = options.nudgeBudget ?? 2;
|
|
312
|
+
const runawayBudget = options.runawayBudget ?? 1;
|
|
313
|
+
// Typed as the union so BOTH shapes typecheck on an invoke: jr2Setup machines write
|
|
314
|
+
// AgentTurnInput (and the config wrapper finalizes it before the actor ever runs); plain
|
|
315
|
+
// setup() machines must pass the finalized shape themselves — checked loudly below.
|
|
316
|
+
const logic = fromCallback<AgentRunReceiveEvent, AgentTurnInput | AgentRunInput>((args) => {
|
|
317
|
+
const { system, self, sendBack, receive } = args;
|
|
318
|
+
const input = args.input as AgentRunInput;
|
|
319
|
+
const { instanceId } = input;
|
|
320
|
+
if (!instanceId || !input.agentName) {
|
|
321
|
+
throw new Error(
|
|
322
|
+
`an Agent slot was invoked with unfinalized input — declare it on a jr2Setup(...) machine ` +
|
|
323
|
+
`(\`actors: { <name>: agent(def) }\`, which names the Agent, mints the instance id and ` +
|
|
324
|
+
`derives the menu), or pass \`agentName\`/\`instanceId\`/\`tools\` explicitly`,
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// The second fence (ADR-0054): a Turn is never admitted under an Open model. `jr2 up`'s walk is
|
|
329
|
+
// the first and catches every registered Machine; this one catches what it never walked, and
|
|
330
|
+
// refuses HERE rather than sending a Symbol the wire would drop silently.
|
|
331
|
+
const definition = requireBoundAgent(input.agentName, declaration);
|
|
332
|
+
|
|
333
|
+
// Resolve the Harness coordinates (ADR-0016/0031). Explicit input wins (the workspace-less
|
|
334
|
+
// stub path); then the DEFINITION decides: `workspace: "none"` pins the Turn to the Instance
|
|
335
|
+
// Harness always — even inside an enclosing workspace(), because a conversation is an
|
|
336
|
+
// Instance ID on ONE Harness and a continued advisor must land on the server that holds it
|
|
337
|
+
// (definition-wins, ADR-0031); everyone else resolves the nearest enclosing workspace()'s
|
|
338
|
+
// handles — walked structurally via the actor parent chain, so a sibling workspace's handles
|
|
339
|
+
// are unreachable (ADR-0013).
|
|
340
|
+
const binding = runBindingOf(system);
|
|
341
|
+
const workspace = definition.workspace ?? "write";
|
|
342
|
+
let endpoint: string;
|
|
343
|
+
let sandbox: string | undefined;
|
|
344
|
+
if (input.endpoint) {
|
|
345
|
+
endpoint = input.endpoint;
|
|
346
|
+
sandbox = input.sandbox;
|
|
347
|
+
} else if (workspace === "none") {
|
|
348
|
+
if (!binding.instanceHarness) {
|
|
349
|
+
throw new Error(
|
|
350
|
+
`turn ${instanceId}: agent "${input.agentName}" has workspace: "none" — its Turn runs on ` +
|
|
351
|
+
`the Instance Harness (ADR-0031), and this host knows no Instance Harness address (deployed ` +
|
|
352
|
+
`instances derive it from their namespace; tests pass an explicit \`endpoint\`)`,
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
endpoint = binding.instanceHarness;
|
|
356
|
+
// The registration records the PLACEMENT's name as its delivery scope — ADR-0013's
|
|
357
|
+
// doctrine, extended to the second placement: the Instance Harness Adapter bears a token
|
|
358
|
+
// signed for this name (deploy.ts), so it may speak for the Turns hosted there and for no
|
|
359
|
+
// Workspace's. The Instance token still may (it is the operator, tokens.ts).
|
|
360
|
+
sandbox = INSTANCE_HARNESS_SERVICE;
|
|
361
|
+
} else {
|
|
362
|
+
const ambient = ambientHandlesFor(self);
|
|
363
|
+
if (!ambient?.endpoint) {
|
|
364
|
+
throw new Error(
|
|
365
|
+
`turn ${instanceId}: agent "${input.agentName}" has workspace: "${workspace}" — ` +
|
|
366
|
+
`invoke it inside a workspace() (ambient resolution), or pass an explicit \`endpoint\` ` +
|
|
367
|
+
`(workspace-less stub path)`,
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
endpoint = ambient.endpoint;
|
|
371
|
+
sandbox = input.sandbox ?? ambient.sandbox;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Register this invocation's event surface (throws on a name outside the INVOKING MACHINE's
|
|
375
|
+
// vocabulary — ADR-0011's invoke-time check, scoped to `self._parent.logic` because names are
|
|
376
|
+
// per-Machine — which errors the run loudly at the invoking state).
|
|
377
|
+
// `signaled` is the no-signal detector: a delivered menu event means the Agent ended its
|
|
378
|
+
// turn the intended way, so a completed settlement needs no nudge. ONE invocation can hold
|
|
379
|
+
// more than one surface: a runaway reroll (ADR-0035) is a fresh conversation whose menu
|
|
380
|
+
// dials `/mcp/<derived iid>`, so its address must be live too — same defs, same deliver,
|
|
381
|
+
// because whichever conversation answers, it answers THIS invocation.
|
|
382
|
+
let signaled = false;
|
|
383
|
+
// The conversation this turn currently rides — advanced by a runaway reroll (ADR-0035) and,
|
|
384
|
+
// on restore, read back off the ledgered admission's stamp: a reroll is ledgered under the
|
|
385
|
+
// ORIGINAL iid (the persisted input's key) but stamped with its OWN, so a restored run
|
|
386
|
+
// registers, nudges and aborts the LIVE conversation, never the dead original the Harness
|
|
387
|
+
// already ended.
|
|
388
|
+
let currentIid = input.attach?.instanceId ?? instanceId;
|
|
389
|
+
const defs = resolveAccepts(self, input.tools);
|
|
390
|
+
const disposers: Array<() => void> = [];
|
|
391
|
+
const registerSurface = (iid: string) =>
|
|
392
|
+
disposers.push(
|
|
393
|
+
binding.table.register({
|
|
394
|
+
address: agentAddress(iid),
|
|
395
|
+
runId: binding.runId,
|
|
396
|
+
kind: "agent",
|
|
397
|
+
id: iid,
|
|
398
|
+
defs,
|
|
399
|
+
sandbox,
|
|
400
|
+
deliver: (event) => {
|
|
401
|
+
signaled = true;
|
|
402
|
+
// The settlement-pick marker (ADR-0023), BEFORE the delivery moves the Machine: the pick
|
|
403
|
+
// must land on the feed ahead of the status delta it causes, or the narrative reads
|
|
404
|
+
// effect-then-cause.
|
|
405
|
+
const { type, ...payload } = event;
|
|
406
|
+
binding.marker?.({
|
|
407
|
+
kind: "pick",
|
|
408
|
+
agent: input.agentName,
|
|
409
|
+
endpoint,
|
|
410
|
+
event: type,
|
|
411
|
+
...(Object.keys(payload).length ? { payload } : {}),
|
|
412
|
+
});
|
|
413
|
+
sendBack(event);
|
|
414
|
+
},
|
|
415
|
+
// The state that invoked us — the machine the menu derived from, so the only one whose
|
|
416
|
+
// guards can say whether a pick would move anything (ADR-0029). Same `_parent` the ambient
|
|
417
|
+
// walk above uses; structural, so a sibling's snapshot is unreachable.
|
|
418
|
+
invoker: self._parent,
|
|
419
|
+
}),
|
|
420
|
+
);
|
|
421
|
+
registerSurface(currentIid);
|
|
422
|
+
|
|
423
|
+
const client = portFactory(endpoint);
|
|
424
|
+
const controller = new AbortController();
|
|
425
|
+
// Shared per run, created on demand so the ordering guarantee holds for any binding.
|
|
426
|
+
const pendingAborts = (binding.pendingAborts ??= new Map<string, Promise<void>>());
|
|
427
|
+
let stopped = false;
|
|
428
|
+
// Ledgered under the ORIGINAL iid — the persisted input's key, like a nudge's — with the
|
|
429
|
+
// LIVE conversation stamped on the record, so a restore settle-follows the live submission
|
|
430
|
+
// AND re-addresses it (see `currentIid` above).
|
|
431
|
+
const ledger = (admission: AgentAdmission) =>
|
|
432
|
+
binding.recordAdmission?.(instanceId, { ...admission, instanceId: currentIid });
|
|
433
|
+
|
|
434
|
+
const abandon = () => {
|
|
435
|
+
if (stopped) return;
|
|
436
|
+
stopped = true;
|
|
437
|
+
for (const dispose of disposers.splice(0)) dispose();
|
|
438
|
+
// Stop consuming the stream. The remote end of the turn is the next paragraph.
|
|
439
|
+
controller.abort();
|
|
440
|
+
// A turn ends with the state that asked for it (ADR-0024) — whatever ended the invocation:
|
|
441
|
+
// the Agent's own pick settling the state, an `after:` timeout, an ancestor transition, a
|
|
442
|
+
// Pool cancelling a child. The one exception is the host stopping the run for its own
|
|
443
|
+
// reasons, which ADR-0007's restore re-attaches to.
|
|
444
|
+
if (binding.hostStopping) return;
|
|
445
|
+
// Fire-and-forget, and unreportable BY CONSTRUCTION: this actor is stopped, so there is no
|
|
446
|
+
// `agent.fault` left to raise. An orphan that survives a failed abort 404s on every tool
|
|
447
|
+
// call and settles on its own.
|
|
448
|
+
const iid = currentIid;
|
|
449
|
+
const aborting = client.abort(input.agentName, iid).catch(() => {});
|
|
450
|
+
pendingAborts.set(iid, aborting);
|
|
451
|
+
void aborting.then(() => {
|
|
452
|
+
if (pendingAborts.get(iid) === aborting) pendingAborts.delete(iid);
|
|
453
|
+
});
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
// Down-channel: interrupts only (ADR-0002 split-channel model).
|
|
457
|
+
receive((event) => {
|
|
458
|
+
if (event.type === "CANCEL") abandon();
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
// Admit (or re-attach) kicked off async; the synchronous callback returns the cleanup fn
|
|
462
|
+
// immediately. The admission is recorded in the host ledger BEFORE settlement is awaited,
|
|
463
|
+
// so a crash right after admission still restores into re-attach, never a re-prompt.
|
|
464
|
+
//
|
|
465
|
+
// Three absorbed fault classes (ADR-0016), deliberately distinct:
|
|
466
|
+
// - INFRA faults: provider retries run inside the turn, Harness-side, and the wire
|
|
467
|
+
// client reconnects transparently — so a `settle` rejection means the Submission settled
|
|
468
|
+
// failed/aborted, or the conversation is lost (ADR-0027). Terminal, no jr2 re-run.
|
|
469
|
+
// - NO-SIGNAL: the Submission settles COMPLETED but no menu tool was called. The Harness
|
|
470
|
+
// calls that a normal turn, so jr2 owns a budgeted re-prompt on the SAME iid (the conversation
|
|
471
|
+
// continues; each nudge is a fresh admission, ledgered like any other).
|
|
472
|
+
// - RUNAWAY: the Harness ended a turn that would not conclude and settled it failed with
|
|
473
|
+
// the typed "runaway" error (ADR-0035). The degenerate context is poisoned, so the
|
|
474
|
+
// budgeted reroll is the OPPOSITE of a nudge: a fresh conversation under a derived iid,
|
|
475
|
+
// admitting the identical prompt — closed to continuations, which opted out of exactly that.
|
|
476
|
+
// Any budget exhausting emits the ONE terminal `agent.fault { reason }`.
|
|
477
|
+
void (async () => {
|
|
478
|
+
const fault = (reason: string) => {
|
|
479
|
+
if (!stopped) sendBack({ type: "agent.fault", instanceId, reason } satisfies FaultTelemetry);
|
|
480
|
+
};
|
|
481
|
+
try {
|
|
482
|
+
// Queue behind any abort still in flight for this iid (ADR-0024). Non-trivial only under
|
|
483
|
+
// `session: "continue"`, which is the only way two invocations share an instance id — and
|
|
484
|
+
// there it is mandatory: the Harness queues per conversation and an abort settles what
|
|
485
|
+
// is queued behind it, so losing this race would kill the new turn before it ran, silently.
|
|
486
|
+
await pendingAborts.get(instanceId);
|
|
487
|
+
let admission = input.attach;
|
|
488
|
+
if (!admission) {
|
|
489
|
+
admission = await client.admit(input, { definition, signal: controller.signal });
|
|
490
|
+
ledger(admission);
|
|
491
|
+
// The admission marker (ADR-0023): the Turn and its framing, once — a re-attach
|
|
492
|
+
// continues a Turn already announced, and a nudge (below) is mechanism, not narrative
|
|
493
|
+
// (its telemetry already rides the feed).
|
|
494
|
+
binding.marker?.({ kind: "admission", agent: input.agentName, endpoint, prompt: input.prompt ?? "" });
|
|
495
|
+
}
|
|
496
|
+
let nudges = 0;
|
|
497
|
+
let rerolls = 0;
|
|
498
|
+
for (;;) {
|
|
499
|
+
try {
|
|
500
|
+
await client.settle(admission, { signal: controller.signal });
|
|
501
|
+
} catch (err) {
|
|
502
|
+
// The reroll gate closes on `signaled` like the nudge gate below: a delivered pick
|
|
503
|
+
// means the workflow already holds this turn's signal, so replaying the identical
|
|
504
|
+
// prompt would re-deliver it (a targetless pick keeps the actor alive through the
|
|
505
|
+
// settlement). It also closes when restore dropped `prompt` (`attach` rides instead):
|
|
506
|
+
// there is nothing identical to replay, and the thrown settlement still carries the
|
|
507
|
+
// legible runaway reason.
|
|
508
|
+
const reason = stopped || signaled ? undefined : runawayReason(err);
|
|
509
|
+
if (reason === undefined || input.continuation || input.prompt === undefined || rerolls >= runawayBudget)
|
|
510
|
+
throw err;
|
|
511
|
+
rerolls++;
|
|
512
|
+
binding.telemetry?.({
|
|
513
|
+
kind: "retry",
|
|
514
|
+
child: self._parent?.id ?? self.id,
|
|
515
|
+
attempt: rerolls,
|
|
516
|
+
reason: "runaway reroll",
|
|
517
|
+
});
|
|
518
|
+
// Deterministically derived, so a reroll never mints identity (ADR-0016: minting
|
|
519
|
+
// lives in the input mapper). Surface FIRST: the fresh conversation's menu dials
|
|
520
|
+
// `/mcp/<currentIid>`, so its address must be live before the Harness can run it.
|
|
521
|
+
currentIid = `${instanceId}-r${rerolls}`;
|
|
522
|
+
registerSurface(currentIid);
|
|
523
|
+
admission = await client.admit(
|
|
524
|
+
{ ...input, attach: undefined, instanceId: currentIid },
|
|
525
|
+
{ definition, signal: controller.signal },
|
|
526
|
+
);
|
|
527
|
+
ledger(admission);
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
if (stopped || signaled || input.tools.length === 0) return; // the turn ended as intended
|
|
531
|
+
if (nudges >= nudgeBudget) {
|
|
532
|
+
fault(`agent completed its turn without calling any of: ${input.tools.join(", ")}`);
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
nudges++;
|
|
536
|
+
binding.telemetry?.({
|
|
537
|
+
kind: "retry",
|
|
538
|
+
child: self._parent?.id ?? self.id,
|
|
539
|
+
attempt: nudges,
|
|
540
|
+
reason: "no-signal nudge",
|
|
541
|
+
});
|
|
542
|
+
admission = await client.admit(
|
|
543
|
+
{ ...input, attach: undefined, instanceId: currentIid, prompt: nudgePrompt(input.tools) },
|
|
544
|
+
{ definition, signal: controller.signal },
|
|
545
|
+
);
|
|
546
|
+
ledger(admission);
|
|
547
|
+
}
|
|
548
|
+
} catch (err) {
|
|
549
|
+
if (stopped) return;
|
|
550
|
+
fault(err instanceof Error ? err.message : String(err));
|
|
551
|
+
}
|
|
552
|
+
})();
|
|
553
|
+
|
|
554
|
+
// Stop (parent stopped the child) == abandon. Idempotent with an explicit CANCEL.
|
|
555
|
+
return abandon;
|
|
556
|
+
});
|
|
557
|
+
// The brand (ADR-0049): a readable property, so `isAgent` is a plain shape test and a reader —
|
|
558
|
+
// `jr2 up`'s model preflight, a Machine doc — can name what this slot runs without invoking it.
|
|
559
|
+
// The DECLARATION, not the narrowed definition: an Open model is exactly what those readers
|
|
560
|
+
// must be able to see and refuse (ADR-0054).
|
|
561
|
+
return Object.assign(logic, { definition: declaration });
|
|
562
|
+
}
|