@jr2/orchestrator 0.1.4 → 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/package.json +3 -3
- package/src/actor.ts +157 -62
- package/src/agent.ts +8 -7
- package/src/gate.ts +1 -1
- package/src/harness-client.ts +61 -14
- package/src/http.ts +8 -5
- package/src/instance.ts +13 -10
- package/src/registration.ts +48 -2
- package/src/run-host.ts +54 -11
- package/src/sandbox-kubectl.ts +19 -11
- package/src/server.ts +1 -12
- package/src/setup.ts +110 -35
- package/src/stub-harness.ts +21 -7
- package/src/tokens.ts +31 -1
- package/src/vocabulary.ts +2 -1
- package/src/workspace.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jr2/orchestrator",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "The jr2 Orchestrator: xstate runtime, HTTP API and Console, and the kit pieces a Workflow imports.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"private": false,
|
|
@@ -42,11 +42,11 @@
|
|
|
42
42
|
"ts-blank-space": "^0.9.0",
|
|
43
43
|
"xstate": "^5.18.0",
|
|
44
44
|
"zod": "^4.4.3",
|
|
45
|
-
"@jr2/agent-protocol": "0.1
|
|
45
|
+
"@jr2/agent-protocol": "0.2.1"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
48
|
"@types/node": "^26.0.1",
|
|
49
|
-
"@jr2/harness": "0.1
|
|
49
|
+
"@jr2/harness": "0.2.1"
|
|
50
50
|
},
|
|
51
51
|
"files": [
|
|
52
52
|
"bin",
|
package/src/actor.ts
CHANGED
|
@@ -43,10 +43,16 @@
|
|
|
43
43
|
// at all, and restore is a fresh process, so there is nothing to infer it from.
|
|
44
44
|
|
|
45
45
|
import { fromCallback, type CallbackActorLogic } from "xstate";
|
|
46
|
-
import {
|
|
47
|
-
|
|
46
|
+
import {
|
|
47
|
+
requireBoundAgent,
|
|
48
|
+
type AgentDeclaration,
|
|
49
|
+
type AgentDefinition,
|
|
50
|
+
type ThinkingLevel,
|
|
51
|
+
type WorkspaceAccess,
|
|
52
|
+
} from "./agent.ts";
|
|
53
|
+
import { ambientHandlesFor, type AmbientHandles } from "./ambient.ts";
|
|
48
54
|
import { INSTANCE_HARNESS_SERVICE } from "./names.ts";
|
|
49
|
-
import { agentAddress, resolveAccepts, runBindingOf } from "./registration.ts";
|
|
55
|
+
import { agentAddress, continuedIid, resolveAccepts, runBindingOf } from "./registration.ts";
|
|
50
56
|
|
|
51
57
|
/**
|
|
52
58
|
* One admitted Submission — the durable re-attach handle (ADR-0016). The wire fields are
|
|
@@ -72,19 +78,33 @@ export type AgentAdmission = {
|
|
|
72
78
|
};
|
|
73
79
|
|
|
74
80
|
/**
|
|
75
|
-
* What a WORKFLOW writes on an Agent slot's invoke (ADR-0015/0016/0049): this
|
|
76
|
-
*
|
|
77
|
-
* (`actors: { coder: agent(def) }`, `src: "coder"`), so a name the Machine
|
|
78
|
-
* compile error on `src` instead of a runtime miss.
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
81
|
+
* What a WORKFLOW writes on an Agent slot's invoke (ADR-0015/0016/0049/0057): this Turn's FRAME,
|
|
82
|
+
* its DIALS, and whether it CONTINUES — nothing else. The AGENT is not written here at all: the
|
|
83
|
+
* slot key is its name (`actors: { coder: agent(def) }`, `src: "coder"`), so a name the Machine
|
|
84
|
+
* does not carry is a compile error on `src` instead of a runtime miss.
|
|
85
|
+
*
|
|
86
|
+
* `jr2Setup.createMachine` wraps the invoke input to finalize it into {@link AgentRunInput}: the
|
|
87
|
+
* slot key lands as `agentName`, the tool menu derives from the invoking state's transitions, the
|
|
88
|
+
* instance id is MINTED (never written here — fresh by default; `continue: true` derives the
|
|
89
|
+
* Agent's one conversation in this Machine instance), and endpoint/sandbox resolve ambiently from
|
|
90
|
+
* the enclosing `workspace()`.
|
|
84
91
|
*/
|
|
85
92
|
export type AgentTurnInput = {
|
|
86
|
-
/**
|
|
93
|
+
/** Half this Turn's FRAME (ADR-0057) — what it is about: the prompt lands as the conversation's
|
|
94
|
+
* next user message. Neither identity nor a Dial. */
|
|
87
95
|
prompt: string;
|
|
96
|
+
/**
|
|
97
|
+
* The other half of the FRAME (ADR-0057) — WHERE this Turn works: the absolute directory the
|
|
98
|
+
* Harness roots the Working tools at. Per Turn by nature, which is why no definition names one:
|
|
99
|
+
* a Worktree path (`/work/<slot>/<branch>`) exists only once a run has a branch.
|
|
100
|
+
*
|
|
101
|
+
* Absent, the actor resolves it before admission: a `workspace: "none"` Agent frames no
|
|
102
|
+
* directory (it has no Working tools to root — ADR-0028), a Workspace with ONE Repo Slot frames
|
|
103
|
+
* that slot's Worktree, a Workspace with more REFUSES the Turn (the kit gives no slot a meaning
|
|
104
|
+
* — ADR-0051 — so the state must say which), and a run with no Workspace at all frames nothing
|
|
105
|
+
* and the Harness keeps `/work`. A relative path is refused at admission, by the Harness.
|
|
106
|
+
*/
|
|
107
|
+
cwd?: string;
|
|
88
108
|
/**
|
|
89
109
|
* This turn's DIALS (ADR-0018) — how hard to run, layered over the definition's own
|
|
90
110
|
* values. One definition value may be carried by several Machines, so the same persona
|
|
@@ -92,38 +112,46 @@ export type AgentTurnInput = {
|
|
|
92
112
|
* and the same reviewer on an architecture change want identical instructions and different
|
|
93
113
|
* effort.
|
|
94
114
|
*
|
|
95
|
-
* IDENTITY is deliberately absent — no `instructions
|
|
96
|
-
*
|
|
115
|
+
* IDENTITY is deliberately absent — no `instructions` or `workspace` here. A call site that
|
|
116
|
+
* rewrote those would make the Agent's name a lie, and `workspace` in particular carries
|
|
97
117
|
* ADR-0028's containment claim, which per-invocation escalation would void.
|
|
98
118
|
*/
|
|
99
119
|
model?: string;
|
|
100
120
|
thinkingLevel?: ThinkingLevel;
|
|
101
121
|
/**
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
*
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
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`.
|
|
122
|
+
* Continue this Agent's ONE conversation in this Machine instance (ADR-0016, ADR-0057) — the
|
|
123
|
+
* whole continuation surface. Absent, every invocation is a FRESH conversation (jr's lossy
|
|
124
|
+
* handoff — revision agents read notes + code, never the prior conversation). Present, the
|
|
125
|
+
* prompt lands as the next user turn of that conversation, whichever state of the Machine
|
|
126
|
+
* invokes it: states differ by Menu (a coder in `implement` offers `finish`; the same coder in
|
|
127
|
+
* `fix` offers `finish` and `dispute`) and the conversation is wanted across them.
|
|
128
|
+
*
|
|
129
|
+
* It names nothing, because the id is STRUCTURAL — `<runId>/<machine actor path>/<agent>` — and
|
|
130
|
+
* a caller-chosen key could only restate that, or contradict it. Two Agents therefore never
|
|
131
|
+
* share a conversation (the name is in the id), and two Pool children never collide (each
|
|
132
|
+
* worker is its own Machine instance). A conversation jr2 has faulted is never continued: the
|
|
133
|
+
* next `continue` lands on a virgin one (ADR-0035, ADR-0057) — and re-briefing that Turn is the
|
|
134
|
+
* author's, because jr2 cannot write that prompt.
|
|
121
135
|
*/
|
|
122
|
-
|
|
123
|
-
|
|
136
|
+
continue?: true;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* MECHANISM, not authoring surface (ADR-0057): where a Turn's Harness is and which Sandbox scopes
|
|
141
|
+
* its deliveries, STATED instead of resolved. It is the seat the stub tier sits in — a
|
|
142
|
+
* mechanics-tier test has no `workspace()` to resolve from, and a dev Harness is just a URL
|
|
143
|
+
* (ADR-0011) — so it is exported for those tests and for nothing else.
|
|
144
|
+
*
|
|
145
|
+
* A real run states neither: a Sandbox Agent resolves both ambiently from the enclosing
|
|
146
|
+
* `workspace()` (ADR-0016), and a `workspace: "none"` Agent lands on the Instance Harness
|
|
147
|
+
* (ADR-0031). Stated, it wins over both, because a URL a test wrote is the one thing no ambient
|
|
148
|
+
* walk can know about.
|
|
149
|
+
*/
|
|
150
|
+
export type AgentTurnPlacement = {
|
|
151
|
+
/** The Harness base URL this Turn is admitted over. */
|
|
124
152
|
endpoint?: string;
|
|
125
|
-
/**
|
|
126
|
-
|
|
153
|
+
/** The Sandbox whose Adapter token may deliver this Turn's picks (ADR-0013). */
|
|
154
|
+
sandbox?: string;
|
|
127
155
|
};
|
|
128
156
|
|
|
129
157
|
/** What the actor is invoked with AFTER jr2Setup finalization: the durable handle, this turn's
|
|
@@ -149,6 +177,9 @@ export type AgentRunInput = {
|
|
|
149
177
|
*/
|
|
150
178
|
sandbox?: string;
|
|
151
179
|
prompt?: string;
|
|
180
|
+
/** The other half of this Turn's Frame (ADR-0057): where the Working tools are rooted. Rides the
|
|
181
|
+
* admit body beside the prompt; absent, the Harness keeps `/work`. */
|
|
182
|
+
cwd?: string;
|
|
152
183
|
/** This turn's dials, passed through from {@link AgentTurnInput}. Plain strings, so they ride
|
|
153
184
|
* the persisted child input; on restore the Submission already exists server-side with its
|
|
154
185
|
* model fixed, so a re-attach never re-resolves them. */
|
|
@@ -160,20 +191,15 @@ export type AgentRunInput = {
|
|
|
160
191
|
*/
|
|
161
192
|
attach?: AgentAdmission;
|
|
162
193
|
/**
|
|
163
|
-
* This invocation
|
|
164
|
-
* `
|
|
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.
|
|
194
|
+
* This invocation CONTINUES an existing conversation — set by the input mapper from
|
|
195
|
+
* `continue: true` alone (ADR-0057), and nothing else. Two things hang off it:
|
|
169
196
|
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
* survives a retune.
|
|
197
|
+
* - it is closed to the ADR-0035 reroll: the runaway's one recovery is a fresh conversation,
|
|
198
|
+
* which is exactly what a continuation opted out of, and a continued Turn's prompt
|
|
199
|
+
* ("Continue.") is meaningless replayed fresh. A gated runaway goes straight to the fault;
|
|
200
|
+
* - a terminal `agent.fault` BURNS the conversation (ADR-0057): the epoch bumps in the host
|
|
201
|
+
* ledger, so the next `continue` on this Agent mints a virgin id. Nothing is burned for a
|
|
202
|
+
* fresh invocation, which already mints its own conversation.
|
|
177
203
|
*/
|
|
178
204
|
continuation?: boolean;
|
|
179
205
|
/** Event names (from the invoking Machine's vocabulary) this invocation accepts over MCP. */
|
|
@@ -231,8 +257,10 @@ export interface AgentRunPort {
|
|
|
231
257
|
* Machine edit that changed it. */
|
|
232
258
|
export type AgentAdmitOptions = { definition: AgentDefinition; signal?: AbortSignal };
|
|
233
259
|
|
|
234
|
-
/** Build a port for one invocation from its serializable input (ADR-0011 static-import doctrine)
|
|
235
|
-
|
|
260
|
+
/** Build a port for one invocation from its serializable input (ADR-0011 static-import doctrine),
|
|
261
|
+
* plus the Harness bearer for the placement that input resolved to (ADR-0058) — absent where no
|
|
262
|
+
* placement is named (the stub path) or the host derives none. */
|
|
263
|
+
export type AgentRunPortFactory = (endpoint: string, bearer?: string) => AgentRunPort;
|
|
236
264
|
|
|
237
265
|
/** Absorbed-turn-mechanics knobs (ADR-0016): defaulted, never Machine context. */
|
|
238
266
|
export type AgentRunOptions = {
|
|
@@ -277,6 +305,50 @@ function nudgePrompt(tools: readonly string[]): string {
|
|
|
277
305
|
);
|
|
278
306
|
}
|
|
279
307
|
|
|
308
|
+
/**
|
|
309
|
+
* Where this Turn works — the Frame's other half (ADR-0057), resolved before admission.
|
|
310
|
+
*
|
|
311
|
+
* The state's own `cwd` IS the Frame and wins outright, for every Agent: what is resolved here is
|
|
312
|
+
* an ABSENT one, and a directory a state named is never second-guessed — a Menu-only Agent handed
|
|
313
|
+
* one simply has no Working tools to root there (ADR-0028/0031).
|
|
314
|
+
*
|
|
315
|
+
* Absent, a Menu-only Agent frames no directory; a Workspace with ONE Repo Slot frames that
|
|
316
|
+
* slot's Worktree, which privileges nothing because there is nothing to choose between (ADR-0051);
|
|
317
|
+
* a Workspace with more REFUSES the Turn, naming the slots in declaration order and the line that
|
|
318
|
+
* ends it, because the kit gives no slot a meaning and a wrong guess is the silent failure
|
|
319
|
+
* ADR-0057 was written for; and a run outside any Workspace frames nothing, so the Harness keeps
|
|
320
|
+
* `/work`.
|
|
321
|
+
*
|
|
322
|
+
* Resolution counts SLOTS, and a slot's Worktree is the writable branch checkout. The detached
|
|
323
|
+
* review Worktree (`AmbientHandles.review`, ADR-0028's reviewer seat) is never resolved to: it is
|
|
324
|
+
* per review round, so only the reviewing state knows which one, and it frames it (ADR-0028 hands
|
|
325
|
+
* the reviewer its path as "cwd and prompt"). A `workspace: "read"` Agent that frames nothing
|
|
326
|
+
* under a one-slot Workspace therefore works in the branch checkout with `write`/`edit` withheld
|
|
327
|
+
* and `bash` kept — the tool layer's hint, not containment (ADR-0028): containment is the
|
|
328
|
+
* detached review Worktree, and only a reviewing state that requested one can frame it.
|
|
329
|
+
*
|
|
330
|
+
* The refusal throws at invoke, like the Open-model fence: loudly, at the first Turn, before a
|
|
331
|
+
* surface is registered or a pod is spent.
|
|
332
|
+
*/
|
|
333
|
+
function frameCwd(
|
|
334
|
+
instanceId: string,
|
|
335
|
+
input: AgentRunInput,
|
|
336
|
+
workspace: WorkspaceAccess,
|
|
337
|
+
ambient: AmbientHandles | undefined,
|
|
338
|
+
): string | undefined {
|
|
339
|
+
if (input.cwd !== undefined) return input.cwd;
|
|
340
|
+
if (workspace === "none") return undefined;
|
|
341
|
+
const slots = Object.keys(ambient?.repos ?? {});
|
|
342
|
+
if (slots.length === 0) return undefined;
|
|
343
|
+
if (slots.length === 1) return ambient!.repos[slots[0]!];
|
|
344
|
+
throw new Error(
|
|
345
|
+
`turn ${instanceId}: agent "${input.agentName}" works under a Workspace carrying more than one ` +
|
|
346
|
+
`Repo Slot (${slots.join(", ")}) and its Turn framed no \`cwd\` — say which Worktree it works ` +
|
|
347
|
+
`in (\`cwd: context.workspace.repos.<slot>\`, one of those), because the kit gives no slot a ` +
|
|
348
|
+
`meaning (ADR-0051) and a Turn's Frame is where it works (ADR-0057)`,
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
280
352
|
/** One Agent slot's logic: the run-lifecycle actor closed over ONE definition and BRANDED with it
|
|
281
353
|
* (ADR-0049). The brand is the whole resolution mechanism — the actor reads its definition off its
|
|
282
354
|
* own closure, `jr2Setup` recognizes the slot by `isAgent`, and a `.provide()` that swaps the slot
|
|
@@ -285,7 +357,10 @@ function nudgePrompt(tools: readonly string[]): string {
|
|
|
285
357
|
* The brand is a RUNTIME property, read back through `isAgent`, and deliberately NOT part of this
|
|
286
358
|
* type: the unit-test seam is `provide({ actors: { coder: fake } })` (ADR-0049), and a required
|
|
287
359
|
* `definition` here would make every fake carry a definition it never uses. */
|
|
288
|
-
export type AgentLogic = CallbackActorLogic<
|
|
360
|
+
export type AgentLogic = CallbackActorLogic<
|
|
361
|
+
AgentRunReceiveEvent,
|
|
362
|
+
AgentTurnInput | (AgentTurnInput & AgentTurnPlacement) | AgentRunInput
|
|
363
|
+
>;
|
|
289
364
|
|
|
290
365
|
/**
|
|
291
366
|
* Build one Agent slot's actor logic over an injected port factory — the seam `agent()` (bound to
|
|
@@ -313,7 +388,10 @@ export function agentActorWith(
|
|
|
313
388
|
// Typed as the union so BOTH shapes typecheck on an invoke: jr2Setup machines write
|
|
314
389
|
// AgentTurnInput (and the config wrapper finalizes it before the actor ever runs); plain
|
|
315
390
|
// setup() machines must pass the finalized shape themselves — checked loudly below.
|
|
316
|
-
const logic = fromCallback<
|
|
391
|
+
const logic = fromCallback<
|
|
392
|
+
AgentRunReceiveEvent,
|
|
393
|
+
AgentTurnInput | (AgentTurnInput & AgentTurnPlacement) | AgentRunInput
|
|
394
|
+
>((args) => {
|
|
317
395
|
const { system, self, sendBack, receive } = args;
|
|
318
396
|
const input = args.input as AgentRunInput;
|
|
319
397
|
const { instanceId } = input;
|
|
@@ -339,6 +417,9 @@ export function agentActorWith(
|
|
|
339
417
|
// are unreachable (ADR-0013).
|
|
340
418
|
const binding = runBindingOf(system);
|
|
341
419
|
const workspace = definition.workspace ?? "write";
|
|
420
|
+
// The enclosing workspace()'s handles, walked structurally up the actor parent chain — the
|
|
421
|
+
// Harness coordinates below and the Frame's `cwd` both read them (ADR-0013/0016/0057).
|
|
422
|
+
const ambient = ambientHandlesFor(self);
|
|
342
423
|
let endpoint: string;
|
|
343
424
|
let sandbox: string | undefined;
|
|
344
425
|
if (input.endpoint) {
|
|
@@ -359,7 +440,6 @@ export function agentActorWith(
|
|
|
359
440
|
// Workspace's. The Instance token still may (it is the operator, tokens.ts).
|
|
360
441
|
sandbox = INSTANCE_HARNESS_SERVICE;
|
|
361
442
|
} else {
|
|
362
|
-
const ambient = ambientHandlesFor(self);
|
|
363
443
|
if (!ambient?.endpoint) {
|
|
364
444
|
throw new Error(
|
|
365
445
|
`turn ${instanceId}: agent "${input.agentName}" has workspace: "${workspace}" — ` +
|
|
@@ -371,6 +451,12 @@ export function agentActorWith(
|
|
|
371
451
|
sandbox = input.sandbox ?? ambient.sandbox;
|
|
372
452
|
}
|
|
373
453
|
|
|
454
|
+
// The Frame's other half (ADR-0057): WHERE this Turn works. Resolved HERE, before any surface
|
|
455
|
+
// is registered and before a pod is spent, because the answer is a fact about the run — a
|
|
456
|
+
// Worktree path exists only once a run has a branch, so no definition could have named it.
|
|
457
|
+
const cwd = frameCwd(instanceId, input, workspace, ambient);
|
|
458
|
+
const framed: AgentRunInput = cwd === undefined ? input : { ...input, cwd };
|
|
459
|
+
|
|
374
460
|
// Register this invocation's event surface (throws on a name outside the INVOKING MACHINE's
|
|
375
461
|
// vocabulary — ADR-0011's invoke-time check, scoped to `self._parent.logic` because names are
|
|
376
462
|
// per-Machine — which errors the run loudly at the invoking state).
|
|
@@ -420,7 +506,10 @@ export function agentActorWith(
|
|
|
420
506
|
);
|
|
421
507
|
registerSurface(currentIid);
|
|
422
508
|
|
|
423
|
-
|
|
509
|
+
// The Harness bearer (ADR-0058) is derived from the SAME name the registration records as its
|
|
510
|
+
// delivery scope: the pod hosting this Turn. One name, both directions — the Adapter there may
|
|
511
|
+
// speak for these picks, and only this bearer may drive the conversation there.
|
|
512
|
+
const client = portFactory(endpoint, sandbox === undefined ? undefined : binding.harnessBearer?.(sandbox));
|
|
424
513
|
const controller = new AbortController();
|
|
425
514
|
// Shared per run, created on demand so the ordering guarantee holds for any binding.
|
|
426
515
|
const pendingAborts = (binding.pendingAborts ??= new Map<string, Promise<void>>());
|
|
@@ -476,17 +565,23 @@ export function agentActorWith(
|
|
|
476
565
|
// Any budget exhausting emits the ONE terminal `agent.fault { reason }`.
|
|
477
566
|
void (async () => {
|
|
478
567
|
const fault = (reason: string) => {
|
|
479
|
-
if (
|
|
568
|
+
if (stopped) return;
|
|
569
|
+
// Burn the conversation before the fault lands (ADR-0057): the state the fault routes to
|
|
570
|
+
// may invoke this Agent again in the same macrostep, and its mapper must already read the
|
|
571
|
+
// bumped epoch. Continuations only — a fresh invocation mints its own conversation, so
|
|
572
|
+
// there is nothing to burn.
|
|
573
|
+
if (input.continuation) binding.bumpEpoch?.(continuedIid(self._parent, binding.runId, input.agentName));
|
|
574
|
+
sendBack({ type: "agent.fault", instanceId, reason } satisfies FaultTelemetry);
|
|
480
575
|
};
|
|
481
576
|
try {
|
|
482
577
|
// Queue behind any abort still in flight for this iid (ADR-0024). Non-trivial only under
|
|
483
|
-
// `
|
|
578
|
+
// `continue: true`, which is the only way two invocations share an instance id — and
|
|
484
579
|
// there it is mandatory: the Harness queues per conversation and an abort settles what
|
|
485
580
|
// is queued behind it, so losing this race would kill the new turn before it ran, silently.
|
|
486
581
|
await pendingAborts.get(instanceId);
|
|
487
582
|
let admission = input.attach;
|
|
488
583
|
if (!admission) {
|
|
489
|
-
admission = await client.admit(
|
|
584
|
+
admission = await client.admit(framed, { definition, signal: controller.signal });
|
|
490
585
|
ledger(admission);
|
|
491
586
|
// The admission marker (ADR-0023): the Turn and its framing, once — a re-attach
|
|
492
587
|
// continues a Turn already announced, and a nudge (below) is mechanism, not narrative
|
|
@@ -521,7 +616,7 @@ export function agentActorWith(
|
|
|
521
616
|
currentIid = `${instanceId}-r${rerolls}`;
|
|
522
617
|
registerSurface(currentIid);
|
|
523
618
|
admission = await client.admit(
|
|
524
|
-
{ ...
|
|
619
|
+
{ ...framed, attach: undefined, instanceId: currentIid },
|
|
525
620
|
{ definition, signal: controller.signal },
|
|
526
621
|
);
|
|
527
622
|
ledger(admission);
|
|
@@ -540,7 +635,7 @@ export function agentActorWith(
|
|
|
540
635
|
reason: "no-signal nudge",
|
|
541
636
|
});
|
|
542
637
|
admission = await client.admit(
|
|
543
|
-
{ ...
|
|
638
|
+
{ ...framed, attach: undefined, instanceId: currentIid, prompt: nudgePrompt(input.tools) },
|
|
544
639
|
{ definition, signal: controller.signal },
|
|
545
640
|
);
|
|
546
641
|
ledger(admission);
|
package/src/agent.ts
CHANGED
|
@@ -30,7 +30,11 @@ export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhi
|
|
|
30
30
|
* (ADR-0031): the value the Agent actor reads off its own slot's definition to place the Turn. */
|
|
31
31
|
export type WorkspaceAccess = "write" | "read" | "none";
|
|
32
32
|
|
|
33
|
-
/** The plain-data Agent definition (ADR-0018).
|
|
33
|
+
/** The plain-data Agent definition (ADR-0018): IDENTITY plus one default. A Turn's Frame — what
|
|
34
|
+
* it is about and where it works — is not here and cannot be (ADR-0057): a Worktree path exists
|
|
35
|
+
* only once a run has a branch, so no definition written before the run could name one.
|
|
36
|
+
*
|
|
37
|
+
* Must stay JSON-serializable: the definition rides
|
|
34
38
|
* the Turn to the Harness (ADR-0049), so anything non-serializable would be silently lost — grow
|
|
35
39
|
* this contract deliberately. ADR-0028 added a restriction vocabulary (`workspace`), not an
|
|
36
40
|
* extension one: custom tool implementations stay out of the contract. */
|
|
@@ -45,9 +49,6 @@ export type AgentDefinition = {
|
|
|
45
49
|
instructions: string;
|
|
46
50
|
/** Optional static description — observability, never sent to the model. */
|
|
47
51
|
description?: string;
|
|
48
|
-
/** Working directory inside the Sandbox. Default `/work` — the pod volume the attach step put
|
|
49
|
-
* the worktrees on (ADR-0005); override only for non-Workspace layouts. */
|
|
50
|
-
cwd?: string;
|
|
51
52
|
/** Reasoning effort. Omitted → the runtime's default. An invocation may override it for one
|
|
52
53
|
* Turn (`AgentTurnInput.thinkingLevel`) — effort is a property of the task's difficulty, so the
|
|
53
54
|
* same persona legitimately runs at different settings in different Machines. */
|
|
@@ -56,9 +57,9 @@ export type AgentDefinition = {
|
|
|
56
57
|
* not `tools` (that names the control-plane Menu, what it may SAY). `"read"` withholds the
|
|
57
58
|
* write/edit Working tools; bash stays, so this states intent and stops the honest path — the
|
|
58
59
|
* detached review worktree is the containment. `"none"` withholds the ENTIRE Working toolset:
|
|
59
|
-
* the Menu-only Agent converses and picks from its Menu, nothing else (
|
|
60
|
-
* Working tools consume
|
|
61
|
-
* Default `"write"`. */
|
|
60
|
+
* the Menu-only Agent converses and picks from its Menu, nothing else (so its Turn frames no
|
|
61
|
+
* directory — only Working tools consume one) — and places the Turn on the Instance Harness
|
|
62
|
+
* (ADR-0031). Default `"write"`. */
|
|
62
63
|
workspace?: WorkspaceAccess;
|
|
63
64
|
};
|
|
64
65
|
|
package/src/gate.ts
CHANGED
|
@@ -46,7 +46,7 @@ export const gate = fromCallback<DeliveredEvent, GateInput | undefined>(({ input
|
|
|
46
46
|
const binding = runBindingOf(system);
|
|
47
47
|
// The derived id is deterministic from machine structure, so recomputing it on every (re)start
|
|
48
48
|
// is restore-stable and needs no input-mapper persistence — the mapper is only load-bearing
|
|
49
|
-
// where minting has a random component (mintIid's fresh-
|
|
49
|
+
// where minting has a random component (mintIid's fresh-Turn suffix, ADR-0016).
|
|
50
50
|
const path = actorPath(self);
|
|
51
51
|
const id = input.gate ?? path.join(".");
|
|
52
52
|
if (!id) {
|
package/src/harness-client.ts
CHANGED
|
@@ -49,16 +49,21 @@ import {
|
|
|
49
49
|
/** The three-verb wire client (the injectable seam — structurally what `@flue/sdk`'s
|
|
50
50
|
* `agents.{send,wait,abort}` was, minus the SDK). */
|
|
51
51
|
export type HarnessClient = {
|
|
52
|
-
/** `POST /agents/:name/:id {message, definition, model?, thinkingLevel?}` → the Admission,
|
|
53
|
-
* `streamUrl` resolved absolute. The
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
52
|
+
/** `POST /agents/:name/:id {message, cwd?, definition, model?, thinkingLevel?}` → the Admission,
|
|
53
|
+
* with `streamUrl` resolved absolute. The FRAME is `message` and `cwd` — what this Turn is about
|
|
54
|
+
* and where it works (ADR-0057), flat siblings on the body. The DEFINITION is the slot's,
|
|
55
|
+
* carried by the Machine and sent with every admission (ADR-0049) — the Harness keeps no roster,
|
|
56
|
+
* so `:name` alone would name nothing there — and it travels as IDENTITY alone, because this
|
|
57
|
+
* verb strips `description` (ADR-0057; see {@link admittedDefinition}). The optional dials are this Submission's override
|
|
58
|
+
* layer (ADR-0018); omitted, the Harness runs the definition's own values. */
|
|
57
59
|
send(
|
|
58
60
|
agentName: string,
|
|
59
61
|
instanceId: string,
|
|
60
62
|
options: {
|
|
61
63
|
message: string;
|
|
64
|
+
/** Where the Working tools are rooted for this Turn (ADR-0057). Omitted, the Harness keeps
|
|
65
|
+
* `/work` — the stub-Harness run with no Workspace. */
|
|
66
|
+
cwd?: string;
|
|
62
67
|
definition: AgentDefinition;
|
|
63
68
|
model?: string;
|
|
64
69
|
thinkingLevel?: ThinkingLevel;
|
|
@@ -75,6 +80,10 @@ export type HarnessClient = {
|
|
|
75
80
|
export type HarnessClientOptions = {
|
|
76
81
|
/** The Harness base URL — what a workspace publishes, or a stub's `url`. */
|
|
77
82
|
baseUrl: string;
|
|
83
|
+
/** The Harness bearer for this placement (ADR-0058; `harnessToken` in tokens.ts) — sent on
|
|
84
|
+
* every call, the long-poll included. Absent only where no placement exists: an explicit-
|
|
85
|
+
* `endpoint` run against the stub Harness, which checks nothing. */
|
|
86
|
+
token?: string;
|
|
78
87
|
/** Injectable for socket-free tests. Default: global `fetch`. */
|
|
79
88
|
fetch?: typeof fetch;
|
|
80
89
|
/** Reconnect backoff floor/ceiling (capped exponential, jittered per rung — see `jittered`;
|
|
@@ -120,6 +129,7 @@ export function createHarnessClient(options: HarnessClientOptions): HarnessClien
|
|
|
120
129
|
const backoffMaxMs = options.backoffMaxMs ?? 5_000;
|
|
121
130
|
const admitWindowMs = options.admitWindowMs ?? 90_000;
|
|
122
131
|
const log = options.log ?? ((line: string) => console.warn(line));
|
|
132
|
+
const authorization: Record<string, string> = options.token ? { authorization: `Bearer ${options.token}` } : {};
|
|
123
133
|
|
|
124
134
|
const conversationUrl = (agentName: string, instanceId: string) =>
|
|
125
135
|
new URL(`/agents/${encodeURIComponent(agentName)}/${encodeURIComponent(instanceId)}`, baseUrl).toString();
|
|
@@ -187,12 +197,21 @@ export function createHarnessClient(options: HarnessClientOptions): HarnessClien
|
|
|
187
197
|
url,
|
|
188
198
|
{
|
|
189
199
|
method: "POST",
|
|
190
|
-
headers: { "content-type": "application/json" },
|
|
200
|
+
headers: { "content-type": "application/json", ...authorization },
|
|
191
201
|
body: JSON.stringify({
|
|
192
202
|
message: sendOptions.message,
|
|
203
|
+
// The other half of the Frame (ADR-0057): where this Turn works. Omitted only when
|
|
204
|
+
// UNSET, and the Harness keeps `/work`; anything else — including the empty string a
|
|
205
|
+
// miscomputed worktree path would produce — travels, because a `cwd` the Harness
|
|
206
|
+
// cannot read as an absolute path is a 400 there. Dropping it here would hand that
|
|
207
|
+
// Turn the `/work` default instead: the silent wrong directory ADR-0057 exists for.
|
|
208
|
+
...(sendOptions.cwd === undefined ? {} : { cwd: sendOptions.cwd }),
|
|
193
209
|
// The Agent this Turn runs (ADR-0049) — plain data, validated at admission by the
|
|
194
210
|
// Harness, which 400s naming the slot rather than settling a Submission `failed`.
|
|
195
|
-
|
|
211
|
+
// IDENTITY only: `description` is stripped here (ADR-0057). It is Console material —
|
|
212
|
+
// `machine-doc.ts` and the surface read it — and the Harness neither validates nor
|
|
213
|
+
// uses it, so it has no business on the Turn's wire.
|
|
214
|
+
definition: admittedDefinition(sendOptions.definition),
|
|
196
215
|
// Omitted when unset, so an admission with no dials is byte-identical to before.
|
|
197
216
|
...(sendOptions.model ? { model: sendOptions.model } : {}),
|
|
198
217
|
...(sendOptions.thinkingLevel ? { thinkingLevel: sendOptions.thinkingLevel } : {}),
|
|
@@ -224,13 +243,21 @@ export function createHarnessClient(options: HarnessClientOptions): HarnessClien
|
|
|
224
243
|
let events: StreamEvent[];
|
|
225
244
|
let nextOffset: string | null;
|
|
226
245
|
try {
|
|
227
|
-
const res = await fetchImpl(url.toString(), { signal });
|
|
246
|
+
const res = await fetchImpl(url.toString(), { headers: authorization, signal });
|
|
228
247
|
if (res.status === 404) {
|
|
229
248
|
throw new SettlementFault(
|
|
230
249
|
`conversation lost: the harness answered 404 for submission "${admission.submissionId}" — ` +
|
|
231
250
|
`a conversation lives as long as its Harness process (ADR-0027)`,
|
|
232
251
|
);
|
|
233
252
|
}
|
|
253
|
+
// Refused, not unreachable: the bearer is derived, so a Harness that rejects it now
|
|
254
|
+
// rejects it on every retry — reconnecting would park the Turn forever, silently.
|
|
255
|
+
if (res.status === 401 || res.status === 403) {
|
|
256
|
+
throw new SettlementFault(
|
|
257
|
+
`harness refused the stream read (${res.status}) for submission "${admission.submissionId}": ` +
|
|
258
|
+
`${await errorDetail(res)} — the bearer is not this placement's (ADR-0058)`,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
234
261
|
if (!res.ok && res.status !== 204) {
|
|
235
262
|
throw new ReconnectableError(`harness stream read failed (${res.status})`);
|
|
236
263
|
}
|
|
@@ -260,6 +287,7 @@ export function createHarnessClient(options: HarnessClientOptions): HarnessClien
|
|
|
260
287
|
async abort(agentName, instanceId, opts) {
|
|
261
288
|
const res = await fetchImpl(`${conversationUrl(agentName, instanceId)}/abort`, {
|
|
262
289
|
method: "POST",
|
|
290
|
+
headers: authorization,
|
|
263
291
|
signal: opts?.signal,
|
|
264
292
|
});
|
|
265
293
|
if (!res.ok) {
|
|
@@ -271,6 +299,15 @@ export function createHarnessClient(options: HarnessClientOptions): HarnessClien
|
|
|
271
299
|
};
|
|
272
300
|
}
|
|
273
301
|
|
|
302
|
+
/** The definition as the ADMISSION carries it (ADR-0057): identity alone. `description` is
|
|
303
|
+
* Console material the Orchestrator strips before admission — the Harness has no use for it and
|
|
304
|
+
* no longer validates it. Every other field travels, so a field added to the definition reaches
|
|
305
|
+
* the Harness without a change here. */
|
|
306
|
+
function admittedDefinition(definition: AgentDefinition): Omit<AgentDefinition, "description"> {
|
|
307
|
+
const { description: _description, ...identity } = definition;
|
|
308
|
+
return identity;
|
|
309
|
+
}
|
|
310
|
+
|
|
274
311
|
/** Build an `AgentRunPort` over an injected wire client. Stateless — the admission IS the handle. */
|
|
275
312
|
export function harnessAgentRunPort(client: HarnessClient): AgentRunPort {
|
|
276
313
|
return {
|
|
@@ -280,6 +317,10 @@ export function harnessAgentRunPort(client: HarnessClient): AgentRunPort {
|
|
|
280
317
|
}
|
|
281
318
|
return await client.send(input.agentName, input.instanceId, {
|
|
282
319
|
message: input.prompt,
|
|
320
|
+
// The Frame's other half (ADR-0057) — absent on a workspace-less run, where `/work` is
|
|
321
|
+
// what the Harness keeps. Absent means UNSET: a cwd the mapper computed travels as it is,
|
|
322
|
+
// to the Harness's refusal if it is not a path.
|
|
323
|
+
...(input.cwd === undefined ? {} : { cwd: input.cwd }),
|
|
283
324
|
definition: opts.definition,
|
|
284
325
|
model: input.model,
|
|
285
326
|
thinkingLevel: input.thinkingLevel,
|
|
@@ -307,16 +348,16 @@ export function createHarnessAgentRunClient(options: HarnessClientOptions): Agen
|
|
|
307
348
|
|
|
308
349
|
/**
|
|
309
350
|
* The run-narrative echo push (ADR-0023): `POST /echo { events }` against one Workspace Harness,
|
|
310
|
-
* bearing
|
|
311
|
-
*
|
|
351
|
+
* bearing that placement's Harness bearer (ADR-0058 — the Harness verifies it against a sha-256,
|
|
352
|
+
* never holding it at rest). The payload is the
|
|
312
353
|
* STRUCTURED feed events; the Harness renders. A non-OK answer rejects, and the CALLER treats
|
|
313
354
|
* that as log-and-continue — fire-and-forget lives in the tee (run-host.ts), not here, so a test
|
|
314
355
|
* can still assert a push failed.
|
|
315
356
|
*/
|
|
316
357
|
export function createEchoPush(options: {
|
|
317
358
|
baseUrl: string;
|
|
318
|
-
/** The
|
|
319
|
-
token
|
|
359
|
+
/** The Workspace's Harness bearer (ADR-0058). Absent only against the stub Harness. */
|
|
360
|
+
token?: string;
|
|
320
361
|
/** Injectable for socket-free tests. Default: global `fetch`. */
|
|
321
362
|
fetch?: typeof fetch;
|
|
322
363
|
}): (events: EchoEvent[]) => Promise<void> {
|
|
@@ -329,7 +370,10 @@ export function createEchoPush(options: {
|
|
|
329
370
|
// `fetch failed` in the pod log is what let that condition hide.
|
|
330
371
|
const res = await fetchImpl(url, {
|
|
331
372
|
method: "POST",
|
|
332
|
-
headers: {
|
|
373
|
+
headers: {
|
|
374
|
+
"content-type": "application/json",
|
|
375
|
+
...(options.token ? { authorization: `Bearer ${options.token}` } : {}),
|
|
376
|
+
},
|
|
333
377
|
body: JSON.stringify({ events }),
|
|
334
378
|
}).catch((err: unknown) => {
|
|
335
379
|
throw new Error(`harness echo to ${url} failed: ${transportDetail(err)}`, { cause: err });
|
|
@@ -359,7 +403,10 @@ export function createEchoPush(options: {
|
|
|
359
403
|
* still an `AgentDefinition`: the actor narrows on start and refuses an Open one there.
|
|
360
404
|
*/
|
|
361
405
|
export function agent(declaration: AgentDeclaration): AgentLogic {
|
|
362
|
-
return agentActorWith(
|
|
406
|
+
return agentActorWith(
|
|
407
|
+
(endpoint, bearer) => createHarnessAgentRunClient({ baseUrl: endpoint, ...(bearer ? { token: bearer } : {}) }),
|
|
408
|
+
declaration,
|
|
409
|
+
);
|
|
363
410
|
}
|
|
364
411
|
|
|
365
412
|
/** A stream read worth retrying (server hiccup) — internal to the reconnect loop, never thrown out. */
|
package/src/http.ts
CHANGED
|
@@ -27,9 +27,10 @@
|
|
|
27
27
|
// control.
|
|
28
28
|
// authenticated any principal we minted a token for. The Agent's surface lives here, scoped
|
|
29
29
|
// further per-registration by `mayDeliverToAgent`.
|
|
30
|
-
// instanceOnly the Instance token ALONE. Run control
|
|
31
|
-
// token authenticates but is refused, because
|
|
32
|
-
//
|
|
30
|
+
// instanceOnly the Instance token ALONE. Run control — starting a run included (ADR-0058) — and
|
|
31
|
+
// full run state (`/runs*`): a Sandbox token authenticates but is refused, because
|
|
32
|
+
// starting work, reading another feature's context, or cancelling a run is not on
|
|
33
|
+
// the Agent's surface any more than a Gate is.
|
|
33
34
|
|
|
34
35
|
import { readFile, stat } from "node:fs/promises";
|
|
35
36
|
import { createRequire } from "node:module";
|
|
@@ -356,8 +357,10 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
|
|
|
356
357
|
// Push work: start a run of a registered workflow. Unknown workflow → host.start throws → 404.
|
|
357
358
|
// A body failing the machine's declared input schema (ADR-0033) → 400 naming the accepted
|
|
358
359
|
// shape — the same error class, and the same wire mapping, as a gate delivery failing its
|
|
359
|
-
// schema (`POST /runs/:id/gates/:gate/events` below).
|
|
360
|
-
|
|
360
|
+
// schema (`POST /runs/:id/gates/:gate/events` below). Instance-band (ADR-0058): starting work is
|
|
361
|
+
// run control, and a Sandbox token's scope is its own agent surface — no Adapter forwarding this
|
|
362
|
+
// route is what keeps an Agent from starting runs today, and that is not a control.
|
|
363
|
+
app.post("/workflows/:name/runs", instanceOnly, async (c) => {
|
|
361
364
|
const name = c.req.param("name");
|
|
362
365
|
const input = await readJson(c.req.text());
|
|
363
366
|
try {
|