@jr2/orchestrator 0.1.4 → 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/package.json +3 -3
- package/src/actor.ts +149 -59
- package/src/agent.ts +8 -7
- package/src/gate.ts +1 -1
- package/src/harness-client.ts +33 -6
- package/src/registration.ts +38 -0
- package/src/run-host.ts +35 -3
- package/src/setup.ts +110 -35
- package/src/stub-harness.ts +21 -7
- package/src/vocabulary.ts +2 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jr2/orchestrator",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
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.
|
|
45
|
+
"@jr2/agent-protocol": "0.2.0"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
48
|
"@types/node": "^26.0.1",
|
|
49
|
-
"@jr2/harness": "0.
|
|
49
|
+
"@jr2/harness": "0.2.0"
|
|
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. */
|
|
@@ -277,6 +303,50 @@ function nudgePrompt(tools: readonly string[]): string {
|
|
|
277
303
|
);
|
|
278
304
|
}
|
|
279
305
|
|
|
306
|
+
/**
|
|
307
|
+
* Where this Turn works — the Frame's other half (ADR-0057), resolved before admission.
|
|
308
|
+
*
|
|
309
|
+
* The state's own `cwd` IS the Frame and wins outright, for every Agent: what is resolved here is
|
|
310
|
+
* an ABSENT one, and a directory a state named is never second-guessed — a Menu-only Agent handed
|
|
311
|
+
* one simply has no Working tools to root there (ADR-0028/0031).
|
|
312
|
+
*
|
|
313
|
+
* Absent, a Menu-only Agent frames no directory; a Workspace with ONE Repo Slot frames that
|
|
314
|
+
* slot's Worktree, which privileges nothing because there is nothing to choose between (ADR-0051);
|
|
315
|
+
* a Workspace with more REFUSES the Turn, naming the slots in declaration order and the line that
|
|
316
|
+
* ends it, because the kit gives no slot a meaning and a wrong guess is the silent failure
|
|
317
|
+
* ADR-0057 was written for; and a run outside any Workspace frames nothing, so the Harness keeps
|
|
318
|
+
* `/work`.
|
|
319
|
+
*
|
|
320
|
+
* Resolution counts SLOTS, and a slot's Worktree is the writable branch checkout. The detached
|
|
321
|
+
* review Worktree (`AmbientHandles.review`, ADR-0028's reviewer seat) is never resolved to: it is
|
|
322
|
+
* per review round, so only the reviewing state knows which one, and it frames it (ADR-0028 hands
|
|
323
|
+
* the reviewer its path as "cwd and prompt"). A `workspace: "read"` Agent that frames nothing
|
|
324
|
+
* under a one-slot Workspace therefore works in the branch checkout with `write`/`edit` withheld
|
|
325
|
+
* and `bash` kept — the tool layer's hint, not containment (ADR-0028): containment is the
|
|
326
|
+
* detached review Worktree, and only a reviewing state that requested one can frame it.
|
|
327
|
+
*
|
|
328
|
+
* The refusal throws at invoke, like the Open-model fence: loudly, at the first Turn, before a
|
|
329
|
+
* surface is registered or a pod is spent.
|
|
330
|
+
*/
|
|
331
|
+
function frameCwd(
|
|
332
|
+
instanceId: string,
|
|
333
|
+
input: AgentRunInput,
|
|
334
|
+
workspace: WorkspaceAccess,
|
|
335
|
+
ambient: AmbientHandles | undefined,
|
|
336
|
+
): string | undefined {
|
|
337
|
+
if (input.cwd !== undefined) return input.cwd;
|
|
338
|
+
if (workspace === "none") return undefined;
|
|
339
|
+
const slots = Object.keys(ambient?.repos ?? {});
|
|
340
|
+
if (slots.length === 0) return undefined;
|
|
341
|
+
if (slots.length === 1) return ambient!.repos[slots[0]!];
|
|
342
|
+
throw new Error(
|
|
343
|
+
`turn ${instanceId}: agent "${input.agentName}" works under a Workspace carrying more than one ` +
|
|
344
|
+
`Repo Slot (${slots.join(", ")}) and its Turn framed no \`cwd\` — say which Worktree it works ` +
|
|
345
|
+
`in (\`cwd: context.workspace.repos.<slot>\`, one of those), because the kit gives no slot a ` +
|
|
346
|
+
`meaning (ADR-0051) and a Turn's Frame is where it works (ADR-0057)`,
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
280
350
|
/** One Agent slot's logic: the run-lifecycle actor closed over ONE definition and BRANDED with it
|
|
281
351
|
* (ADR-0049). The brand is the whole resolution mechanism — the actor reads its definition off its
|
|
282
352
|
* own closure, `jr2Setup` recognizes the slot by `isAgent`, and a `.provide()` that swaps the slot
|
|
@@ -285,7 +355,10 @@ function nudgePrompt(tools: readonly string[]): string {
|
|
|
285
355
|
* The brand is a RUNTIME property, read back through `isAgent`, and deliberately NOT part of this
|
|
286
356
|
* type: the unit-test seam is `provide({ actors: { coder: fake } })` (ADR-0049), and a required
|
|
287
357
|
* `definition` here would make every fake carry a definition it never uses. */
|
|
288
|
-
export type AgentLogic = CallbackActorLogic<
|
|
358
|
+
export type AgentLogic = CallbackActorLogic<
|
|
359
|
+
AgentRunReceiveEvent,
|
|
360
|
+
AgentTurnInput | (AgentTurnInput & AgentTurnPlacement) | AgentRunInput
|
|
361
|
+
>;
|
|
289
362
|
|
|
290
363
|
/**
|
|
291
364
|
* Build one Agent slot's actor logic over an injected port factory — the seam `agent()` (bound to
|
|
@@ -313,7 +386,10 @@ export function agentActorWith(
|
|
|
313
386
|
// Typed as the union so BOTH shapes typecheck on an invoke: jr2Setup machines write
|
|
314
387
|
// AgentTurnInput (and the config wrapper finalizes it before the actor ever runs); plain
|
|
315
388
|
// setup() machines must pass the finalized shape themselves — checked loudly below.
|
|
316
|
-
const logic = fromCallback<
|
|
389
|
+
const logic = fromCallback<
|
|
390
|
+
AgentRunReceiveEvent,
|
|
391
|
+
AgentTurnInput | (AgentTurnInput & AgentTurnPlacement) | AgentRunInput
|
|
392
|
+
>((args) => {
|
|
317
393
|
const { system, self, sendBack, receive } = args;
|
|
318
394
|
const input = args.input as AgentRunInput;
|
|
319
395
|
const { instanceId } = input;
|
|
@@ -339,6 +415,9 @@ export function agentActorWith(
|
|
|
339
415
|
// are unreachable (ADR-0013).
|
|
340
416
|
const binding = runBindingOf(system);
|
|
341
417
|
const workspace = definition.workspace ?? "write";
|
|
418
|
+
// The enclosing workspace()'s handles, walked structurally up the actor parent chain — the
|
|
419
|
+
// Harness coordinates below and the Frame's `cwd` both read them (ADR-0013/0016/0057).
|
|
420
|
+
const ambient = ambientHandlesFor(self);
|
|
342
421
|
let endpoint: string;
|
|
343
422
|
let sandbox: string | undefined;
|
|
344
423
|
if (input.endpoint) {
|
|
@@ -359,7 +438,6 @@ export function agentActorWith(
|
|
|
359
438
|
// Workspace's. The Instance token still may (it is the operator, tokens.ts).
|
|
360
439
|
sandbox = INSTANCE_HARNESS_SERVICE;
|
|
361
440
|
} else {
|
|
362
|
-
const ambient = ambientHandlesFor(self);
|
|
363
441
|
if (!ambient?.endpoint) {
|
|
364
442
|
throw new Error(
|
|
365
443
|
`turn ${instanceId}: agent "${input.agentName}" has workspace: "${workspace}" — ` +
|
|
@@ -371,6 +449,12 @@ export function agentActorWith(
|
|
|
371
449
|
sandbox = input.sandbox ?? ambient.sandbox;
|
|
372
450
|
}
|
|
373
451
|
|
|
452
|
+
// The Frame's other half (ADR-0057): WHERE this Turn works. Resolved HERE, before any surface
|
|
453
|
+
// is registered and before a pod is spent, because the answer is a fact about the run — a
|
|
454
|
+
// Worktree path exists only once a run has a branch, so no definition could have named it.
|
|
455
|
+
const cwd = frameCwd(instanceId, input, workspace, ambient);
|
|
456
|
+
const framed: AgentRunInput = cwd === undefined ? input : { ...input, cwd };
|
|
457
|
+
|
|
374
458
|
// Register this invocation's event surface (throws on a name outside the INVOKING MACHINE's
|
|
375
459
|
// vocabulary — ADR-0011's invoke-time check, scoped to `self._parent.logic` because names are
|
|
376
460
|
// per-Machine — which errors the run loudly at the invoking state).
|
|
@@ -476,17 +560,23 @@ export function agentActorWith(
|
|
|
476
560
|
// Any budget exhausting emits the ONE terminal `agent.fault { reason }`.
|
|
477
561
|
void (async () => {
|
|
478
562
|
const fault = (reason: string) => {
|
|
479
|
-
if (
|
|
563
|
+
if (stopped) return;
|
|
564
|
+
// Burn the conversation before the fault lands (ADR-0057): the state the fault routes to
|
|
565
|
+
// may invoke this Agent again in the same macrostep, and its mapper must already read the
|
|
566
|
+
// bumped epoch. Continuations only — a fresh invocation mints its own conversation, so
|
|
567
|
+
// there is nothing to burn.
|
|
568
|
+
if (input.continuation) binding.bumpEpoch?.(continuedIid(self._parent, binding.runId, input.agentName));
|
|
569
|
+
sendBack({ type: "agent.fault", instanceId, reason } satisfies FaultTelemetry);
|
|
480
570
|
};
|
|
481
571
|
try {
|
|
482
572
|
// Queue behind any abort still in flight for this iid (ADR-0024). Non-trivial only under
|
|
483
|
-
// `
|
|
573
|
+
// `continue: true`, which is the only way two invocations share an instance id — and
|
|
484
574
|
// there it is mandatory: the Harness queues per conversation and an abort settles what
|
|
485
575
|
// is queued behind it, so losing this race would kill the new turn before it ran, silently.
|
|
486
576
|
await pendingAborts.get(instanceId);
|
|
487
577
|
let admission = input.attach;
|
|
488
578
|
if (!admission) {
|
|
489
|
-
admission = await client.admit(
|
|
579
|
+
admission = await client.admit(framed, { definition, signal: controller.signal });
|
|
490
580
|
ledger(admission);
|
|
491
581
|
// The admission marker (ADR-0023): the Turn and its framing, once — a re-attach
|
|
492
582
|
// continues a Turn already announced, and a nudge (below) is mechanism, not narrative
|
|
@@ -521,7 +611,7 @@ export function agentActorWith(
|
|
|
521
611
|
currentIid = `${instanceId}-r${rerolls}`;
|
|
522
612
|
registerSurface(currentIid);
|
|
523
613
|
admission = await client.admit(
|
|
524
|
-
{ ...
|
|
614
|
+
{ ...framed, attach: undefined, instanceId: currentIid },
|
|
525
615
|
{ definition, signal: controller.signal },
|
|
526
616
|
);
|
|
527
617
|
ledger(admission);
|
|
@@ -540,7 +630,7 @@ export function agentActorWith(
|
|
|
540
630
|
reason: "no-signal nudge",
|
|
541
631
|
});
|
|
542
632
|
admission = await client.admit(
|
|
543
|
-
{ ...
|
|
633
|
+
{ ...framed, attach: undefined, instanceId: currentIid, prompt: nudgePrompt(input.tools) },
|
|
544
634
|
{ definition, signal: controller.signal },
|
|
545
635
|
);
|
|
546
636
|
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;
|
|
@@ -190,9 +195,18 @@ export function createHarnessClient(options: HarnessClientOptions): HarnessClien
|
|
|
190
195
|
headers: { "content-type": "application/json" },
|
|
191
196
|
body: JSON.stringify({
|
|
192
197
|
message: sendOptions.message,
|
|
198
|
+
// The other half of the Frame (ADR-0057): where this Turn works. Omitted only when
|
|
199
|
+
// UNSET, and the Harness keeps `/work`; anything else — including the empty string a
|
|
200
|
+
// miscomputed worktree path would produce — travels, because a `cwd` the Harness
|
|
201
|
+
// cannot read as an absolute path is a 400 there. Dropping it here would hand that
|
|
202
|
+
// Turn the `/work` default instead: the silent wrong directory ADR-0057 exists for.
|
|
203
|
+
...(sendOptions.cwd === undefined ? {} : { cwd: sendOptions.cwd }),
|
|
193
204
|
// The Agent this Turn runs (ADR-0049) — plain data, validated at admission by the
|
|
194
205
|
// Harness, which 400s naming the slot rather than settling a Submission `failed`.
|
|
195
|
-
|
|
206
|
+
// IDENTITY only: `description` is stripped here (ADR-0057). It is Console material —
|
|
207
|
+
// `machine-doc.ts` and the surface read it — and the Harness neither validates nor
|
|
208
|
+
// uses it, so it has no business on the Turn's wire.
|
|
209
|
+
definition: admittedDefinition(sendOptions.definition),
|
|
196
210
|
// Omitted when unset, so an admission with no dials is byte-identical to before.
|
|
197
211
|
...(sendOptions.model ? { model: sendOptions.model } : {}),
|
|
198
212
|
...(sendOptions.thinkingLevel ? { thinkingLevel: sendOptions.thinkingLevel } : {}),
|
|
@@ -271,6 +285,15 @@ export function createHarnessClient(options: HarnessClientOptions): HarnessClien
|
|
|
271
285
|
};
|
|
272
286
|
}
|
|
273
287
|
|
|
288
|
+
/** The definition as the ADMISSION carries it (ADR-0057): identity alone. `description` is
|
|
289
|
+
* Console material the Orchestrator strips before admission — the Harness has no use for it and
|
|
290
|
+
* no longer validates it. Every other field travels, so a field added to the definition reaches
|
|
291
|
+
* the Harness without a change here. */
|
|
292
|
+
function admittedDefinition(definition: AgentDefinition): Omit<AgentDefinition, "description"> {
|
|
293
|
+
const { description: _description, ...identity } = definition;
|
|
294
|
+
return identity;
|
|
295
|
+
}
|
|
296
|
+
|
|
274
297
|
/** Build an `AgentRunPort` over an injected wire client. Stateless — the admission IS the handle. */
|
|
275
298
|
export function harnessAgentRunPort(client: HarnessClient): AgentRunPort {
|
|
276
299
|
return {
|
|
@@ -280,6 +303,10 @@ export function harnessAgentRunPort(client: HarnessClient): AgentRunPort {
|
|
|
280
303
|
}
|
|
281
304
|
return await client.send(input.agentName, input.instanceId, {
|
|
282
305
|
message: input.prompt,
|
|
306
|
+
// The Frame's other half (ADR-0057) — absent on a workspace-less run, where `/work` is
|
|
307
|
+
// what the Harness keeps. Absent means UNSET: a cwd the mapper computed travels as it is,
|
|
308
|
+
// to the Harness's refusal if it is not a path.
|
|
309
|
+
...(input.cwd === undefined ? {} : { cwd: input.cwd }),
|
|
283
310
|
definition: opts.definition,
|
|
284
311
|
model: input.model,
|
|
285
312
|
thinkingLevel: input.thinkingLevel,
|
package/src/registration.ts
CHANGED
|
@@ -156,6 +156,22 @@ export function actorPath(ref: AnyActorRef): string[] {
|
|
|
156
156
|
return segments;
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
/**
|
|
160
|
+
* The Instance ID of an Agent's ONE continued conversation in one Machine instance (ADR-0057):
|
|
161
|
+
* `<runId>/<machine actor path>/<agent>`, which is also its epoch-0 spelling. `machine` is the
|
|
162
|
+
* actor of the INVOKING Machine — the invoke's own leaf id is deliberately absent, because every
|
|
163
|
+
* state of one Machine that says `continue` means the same conversation, and two Machine
|
|
164
|
+
* instances (two Pool children, whose ids are the items') can never meet.
|
|
165
|
+
*
|
|
166
|
+
* One derivation, two readers, so they cannot drift: `mintIid` builds every iid on it (a fresh
|
|
167
|
+
* Turn adds a random suffix, a burned conversation's successor its epoch), and the Agent actor
|
|
168
|
+
* names the conversation it burns with it.
|
|
169
|
+
*/
|
|
170
|
+
export function continuedIid(machine: AnyActorRef | undefined, runId: string, agentName: string): string {
|
|
171
|
+
const path = (machine ? actorPath(machine).join(".") : "") || "root";
|
|
172
|
+
return `${runId}/${path}/${agentName}`;
|
|
173
|
+
}
|
|
174
|
+
|
|
159
175
|
/** The address of a run's gate: gate ids are run-scoped by construction (ADR-0011). */
|
|
160
176
|
export function gateAddress(runId: string, gate: string): string {
|
|
161
177
|
return `gate/${runId}/${gate}`;
|
|
@@ -237,6 +253,22 @@ export type RunBinding = {
|
|
|
237
253
|
* durable.
|
|
238
254
|
*/
|
|
239
255
|
recordAdmission?: (instanceId: string, admission: AgentAdmission) => void;
|
|
256
|
+
/**
|
|
257
|
+
* The epoch ledger's READ half (ADR-0057): the current epoch of a continued conversation,
|
|
258
|
+
* keyed by its base Instance ID ({@link continuedIid}). The input mapper reads it AT MINT TIME,
|
|
259
|
+
* so the minted id persists with the input — a restore re-attaches the same conversation, and
|
|
260
|
+
* only a fresh transition (which re-runs the mapper) sees a bump. Optional: a bare unit-test
|
|
261
|
+
* binding has no burned conversations, so every conversation reads epoch 0.
|
|
262
|
+
*/
|
|
263
|
+
epochOf?: (conversation: string) => number;
|
|
264
|
+
/**
|
|
265
|
+
* The epoch ledger's WRITE half (ADR-0057): the terminal `agent.fault` burns the conversation
|
|
266
|
+
* it ended, and the next `continue` on that Agent mints `<conversation>/<epoch>` — a virgin
|
|
267
|
+
* conversation. No fault leaves a conversation worth continuing (ADR-0035): a runaway either
|
|
268
|
+
* had its reroll or holds a poisoned context, and infra and no-signal mean the Harness that
|
|
269
|
+
* held it is unreachable. Persisted beside the snapshot with the admission ledger.
|
|
270
|
+
*/
|
|
271
|
+
bumpEpoch?: (conversation: string) => void;
|
|
240
272
|
/**
|
|
241
273
|
* Surface absorbed-retry telemetry on the run feed (ADR-0016): attempts are observable, but
|
|
242
274
|
* as `{ child, attempt }` — state-key-class data, never iids (ADR-0014's line holds on the
|
|
@@ -303,6 +335,12 @@ export function boundRunId(system: AnyActorSystem): string | undefined {
|
|
|
303
335
|
return bindings.get(system)?.runId;
|
|
304
336
|
}
|
|
305
337
|
|
|
338
|
+
/** The epoch of a continued conversation, or 0 outside a jr2 host (and for one nothing has
|
|
339
|
+
* burned) — the SOFT read `jr2Setup`'s iid minting uses, beside {@link boundRunId}. */
|
|
340
|
+
export function boundEpoch(system: AnyActorSystem, conversation: string): number {
|
|
341
|
+
return bindings.get(system)?.epochOf?.(conversation) ?? 0;
|
|
342
|
+
}
|
|
343
|
+
|
|
306
344
|
/** Actor-side: resolve the run this actor tree belongs to. Throws outside a jr2 host. */
|
|
307
345
|
export function runBindingOf(system: AnyActorSystem): RunBinding {
|
|
308
346
|
const binding = bindings.get(system);
|
package/src/run-host.ts
CHANGED
|
@@ -334,6 +334,19 @@ type RunBlob = {
|
|
|
334
334
|
machine?: string;
|
|
335
335
|
snapshot: unknown;
|
|
336
336
|
agents?: Record<string, AgentAdmission>;
|
|
337
|
+
/**
|
|
338
|
+
* The EPOCH ledger (ADR-0057): a continued conversation's base Instance ID
|
|
339
|
+
* (`<runId>/<machine actor path>/<agent>`) → the epoch the next `continue` on it lands on. An
|
|
340
|
+
* entry appears only once a terminal `agent.fault` has burned that conversation; everything
|
|
341
|
+
* else reads 0, which is the unsuffixed id itself.
|
|
342
|
+
*
|
|
343
|
+
* It rides THIS blob rather than a store of its own for the same reasons `agents` does: it is
|
|
344
|
+
* per-run state with the run's exact lifetime, and saving it in the same write gives it the
|
|
345
|
+
* snapshot's atomicity — a restore that re-attached a Turn but forgot which conversations were
|
|
346
|
+
* burned would hand the next `continue` a dead one. Keyed by the BASE id (never the minted
|
|
347
|
+
* `<id>/<epoch>`), so one conversation has exactly one counter however often it burns.
|
|
348
|
+
*/
|
|
349
|
+
epochs?: Record<string, number>;
|
|
337
350
|
fault?: string;
|
|
338
351
|
};
|
|
339
352
|
|
|
@@ -346,6 +359,8 @@ type LiveRun = {
|
|
|
346
359
|
binding: RunBinding;
|
|
347
360
|
/** The live admission ledger (ADR-0016): persisted as `RunBlob.agents`, seeded on restore. */
|
|
348
361
|
agents: Record<string, AgentAdmission>;
|
|
362
|
+
/** The live epoch ledger (ADR-0057): persisted as `RunBlob.epochs`, seeded on restore. */
|
|
363
|
+
epochs: Record<string, number>;
|
|
349
364
|
/** The error that killed the run, if it errored (xstate serializes Error to `{}`, so the
|
|
350
365
|
* message is captured here at the observer and persisted onto the blob for `read`). */
|
|
351
366
|
fault?: string;
|
|
@@ -534,6 +549,9 @@ export class RunHost {
|
|
|
534
549
|
record,
|
|
535
550
|
def,
|
|
536
551
|
agents,
|
|
552
|
+
// The burned conversations come back with the admissions (ADR-0057): a restored run's
|
|
553
|
+
// next `continue` must land where this run's last one did, not on a dead conversation.
|
|
554
|
+
blob.epochs ?? {},
|
|
537
555
|
);
|
|
538
556
|
actor.start();
|
|
539
557
|
reattached.push(stored.runId);
|
|
@@ -608,7 +626,7 @@ export class RunHost {
|
|
|
608
626
|
// `sendBack` synchronously, so a pick that moved the Machine out of that state has already
|
|
609
627
|
// destroyed this registration by now.
|
|
610
628
|
//
|
|
611
|
-
// IDENTITY, not existence: under `
|
|
629
|
+
// IDENTITY, not existence: under `continue: true` the next state re-registers the SAME
|
|
612
630
|
// address for its own turn, and that is a new turn — this one still ended.
|
|
613
631
|
return {
|
|
614
632
|
delivered: true,
|
|
@@ -929,6 +947,7 @@ export class RunHost {
|
|
|
929
947
|
record: RunRecord,
|
|
930
948
|
def: WorkflowDef,
|
|
931
949
|
agents: Record<string, AgentAdmission> = {},
|
|
950
|
+
epochs: Record<string, number> = {},
|
|
932
951
|
): AnyActor {
|
|
933
952
|
let live: LiveRun | undefined;
|
|
934
953
|
let scheduled = false;
|
|
@@ -955,6 +974,17 @@ export class RunHost {
|
|
|
955
974
|
const run = this.runs.get(record.runId);
|
|
956
975
|
if (run && run.agents === agents) this.persist(run);
|
|
957
976
|
},
|
|
977
|
+
// The epoch ledger's two halves (ADR-0057). The read is what the input mapper mints from;
|
|
978
|
+
// the write is the terminal `agent.fault` burning the conversation it ended, saved in the
|
|
979
|
+
// same RunBlob as the admission ledger — and, like it, saved AT THE WRITE rather than
|
|
980
|
+
// waiting for the next transition, because a crash between the fault and the next state's
|
|
981
|
+
// invoke would otherwise restore into the burned conversation.
|
|
982
|
+
epochOf: (conversation) => epochs[conversation] ?? 0,
|
|
983
|
+
bumpEpoch: (conversation) => {
|
|
984
|
+
epochs[conversation] = (epochs[conversation] ?? 0) + 1;
|
|
985
|
+
const run = this.runs.get(record.runId);
|
|
986
|
+
if (run && run.epochs === epochs) this.persist(run);
|
|
987
|
+
},
|
|
958
988
|
// Absorbed-retry attempts go straight to the run's observers (SSE/CLI watch) — they are
|
|
959
989
|
// feed events, not machine events (ADR-0016: the workflow sees only the terminal fault).
|
|
960
990
|
telemetry: (event) => {
|
|
@@ -1002,7 +1032,7 @@ export class RunHost {
|
|
|
1002
1032
|
if (ev.type === "@xstate.snapshot") schedule();
|
|
1003
1033
|
},
|
|
1004
1034
|
});
|
|
1005
|
-
live = this.track(record, actor, def, agents, binding);
|
|
1035
|
+
live = this.track(record, actor, def, agents, epochs, binding);
|
|
1006
1036
|
return actor;
|
|
1007
1037
|
}
|
|
1008
1038
|
|
|
@@ -1011,9 +1041,10 @@ export class RunHost {
|
|
|
1011
1041
|
actor: AnyActor,
|
|
1012
1042
|
def: WorkflowDef,
|
|
1013
1043
|
agents: Record<string, AgentAdmission>,
|
|
1044
|
+
epochs: Record<string, number>,
|
|
1014
1045
|
binding: RunBinding,
|
|
1015
1046
|
): LiveRun {
|
|
1016
|
-
const run: LiveRun = { record, actor, def, agents, binding, listeners: new Set(), feedSoFar: [] };
|
|
1047
|
+
const run: LiveRun = { record, actor, def, agents, epochs, binding, listeners: new Set(), feedSoFar: [] };
|
|
1017
1048
|
this.runs.set(record.runId, run);
|
|
1018
1049
|
// Ordinary persistence rides the inspection stream (see `spawn`); the subscription exists
|
|
1019
1050
|
// for the ERROR channel: an errored actor (an invoke threw — e.g. ADR-0011's invoke-time
|
|
@@ -1068,6 +1099,7 @@ export class RunHost {
|
|
|
1068
1099
|
machine: fingerprintOf(run.def.machine),
|
|
1069
1100
|
snapshot: serialized,
|
|
1070
1101
|
agents: run.agents,
|
|
1102
|
+
epochs: run.epochs,
|
|
1071
1103
|
fault: run.fault,
|
|
1072
1104
|
};
|
|
1073
1105
|
const status = terminal ?? (machineStatus === "active" ? "live" : machineStatus);
|
package/src/setup.ts
CHANGED
|
@@ -46,7 +46,7 @@ import { eventMap, type EventDef, type EventFrom } from "@jr2/agent-protocol";
|
|
|
46
46
|
import type { AgentRunInput, AgentTurnInput, FaultTelemetry } from "./actor.ts";
|
|
47
47
|
import { isAgent } from "./agent.ts";
|
|
48
48
|
import { gate } from "./gate.ts";
|
|
49
|
-
import {
|
|
49
|
+
import { boundEpoch, boundRunId, continuedIid } from "./registration.ts";
|
|
50
50
|
import { attachInputSchema, attachVocabulary } from "./vocabulary.ts";
|
|
51
51
|
|
|
52
52
|
/**
|
|
@@ -264,7 +264,9 @@ function deriveMenus(config: unknown, defs: Map<string, EventDef>, actors: Recor
|
|
|
264
264
|
const wrapOne = (inv: LooseInvoke): LooseInvoke => {
|
|
265
265
|
// The slot key IS the Agent name (ADR-0049) — read off the declaration, never authored.
|
|
266
266
|
if (typeof inv?.src === "string" && isAgent(actors[inv.src])) {
|
|
267
|
-
|
|
267
|
+
const derived = pick(names, "agent");
|
|
268
|
+
if (derived.length === 0) refuseBuriedPicks(node, inv.src, path, pick);
|
|
269
|
+
return { ...inv, input: wrapAgentInput(inv.input, derived, inv.src) };
|
|
268
270
|
}
|
|
269
271
|
if (inv?.src === "gate") {
|
|
270
272
|
const wrapped: LooseInvoke = { ...inv, input: wrapGateInput(inv.input, pick(names, "external")) };
|
|
@@ -291,32 +293,106 @@ function deriveMenus(config: unknown, defs: Map<string, EventDef>, actors: Recor
|
|
|
291
293
|
return walk(config as LooseState, new Set(), []);
|
|
292
294
|
}
|
|
293
295
|
|
|
296
|
+
/**
|
|
297
|
+
* Refuse the one shape that LOOKS like a Menu and is not one (ADR-0015, ADR-0057): an Agent
|
|
298
|
+
* invoked on a state whose picks are written in its SUBSTATES.
|
|
299
|
+
*
|
|
300
|
+
* A Menu derives from the invoking state's own + ANCESTOR transitions, per statechart semantics,
|
|
301
|
+
* so a pick written below that state is one the Turn is never offered — the model is handed a
|
|
302
|
+
* Menu it cannot end its turn with, and the actor reads an empty Menu as a Turn that ended as
|
|
303
|
+
* intended, so nothing nudges and nothing faults: the run parks. Until ADR-0057 the `tools:`
|
|
304
|
+
* override papered over it; with the override gone the kit says it here, at build, where `jr2 up`
|
|
305
|
+
* walks every registered Machine.
|
|
306
|
+
*
|
|
307
|
+
* An EMPTY Menu is not itself wrong — a state moved by a Gate or a timer asks its Agent for text
|
|
308
|
+
* and nothing else — so only the contradiction is refused: no Menu here, and picks below.
|
|
309
|
+
*/
|
|
310
|
+
function refuseBuriedPicks(
|
|
311
|
+
node: LooseState,
|
|
312
|
+
agentName: string,
|
|
313
|
+
path: readonly string[],
|
|
314
|
+
pick: (names: Set<string>, kind: "agent" | "external") => string[],
|
|
315
|
+
): void {
|
|
316
|
+
const below = new Set<string>();
|
|
317
|
+
const descend = (n: LooseState): void => {
|
|
318
|
+
for (const key of Object.keys(n.on ?? {})) if (!key.includes(".") && key !== "*") below.add(key);
|
|
319
|
+
for (const child of Object.values(n.states ?? {})) descend(child);
|
|
320
|
+
};
|
|
321
|
+
for (const child of Object.values(node.states ?? {})) descend(child);
|
|
322
|
+
|
|
323
|
+
const buried = pick(below, "agent");
|
|
324
|
+
if (buried.length === 0) return;
|
|
325
|
+
const state = path.length ? path.join(".") : "(the machine root)";
|
|
326
|
+
throw new Error(
|
|
327
|
+
`agent "${agentName}" is invoked on state "${state}", which derives an EMPTY Menu while the ` +
|
|
328
|
+
`states BELOW it handle ${buried.join(", ")} — a Menu is the invoking state's own plus its ` +
|
|
329
|
+
`ancestors' transitions (ADR-0015), so a pick written below is one the Turn can never be ` +
|
|
330
|
+
`offered, and a Turn with no Menu ends with no pick at all. Move it onto "${state}" (guard ` +
|
|
331
|
+
`it if it must not always be offered — ADR-0029), or, if this Turn is deliberately ` +
|
|
332
|
+
`menu-less, the pick belongs to another invoke.`,
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
294
336
|
const resolveInput = (orig: unknown, args: InputArgs): Record<string, unknown> =>
|
|
295
337
|
(typeof orig === "function" ? (orig as (a: InputArgs) => unknown)(args) : (orig ?? {})) as Record<string, unknown>;
|
|
296
338
|
|
|
339
|
+
/**
|
|
340
|
+
* Every key a Turn's input may carry (ADR-0057): the Frame (`prompt`, `cwd`), the Dials (`model`,
|
|
341
|
+
* `thinkingLevel`), `continue`, and the Placement seat the stub tier states (`endpoint`,
|
|
342
|
+
* `sandbox`). Anything else is refused at RUNTIME, because no type can catch it: an invoke's input
|
|
343
|
+
* is a function returning a union, and the conditional spreads authors write inside it defeat
|
|
344
|
+
* excess-property checking — so a misspelled `continue` or a key from some other surface would
|
|
345
|
+
* compile and then silently take a fresh conversation every Turn, the class of quiet wrong
|
|
346
|
+
* behavior ADR-0057 was written to end.
|
|
347
|
+
*/
|
|
348
|
+
const TURN_INPUT_KEYS: ReadonlySet<string> = new Set([
|
|
349
|
+
"prompt",
|
|
350
|
+
"cwd",
|
|
351
|
+
"continue",
|
|
352
|
+
"model",
|
|
353
|
+
"thinkingLevel",
|
|
354
|
+
"endpoint",
|
|
355
|
+
"sandbox",
|
|
356
|
+
]);
|
|
357
|
+
|
|
358
|
+
/** Refuse a Turn whose input carries a key a Turn has no use for, before anything is minted or
|
|
359
|
+
* admitted. The message names the slot, the keys found, and the whole surface (ADR-0057). */
|
|
360
|
+
function refuseUnknownKeys(consumer: object, agentName: string): void {
|
|
361
|
+
const unknown = Object.keys(consumer).filter((key) => !TURN_INPUT_KEYS.has(key));
|
|
362
|
+
if (unknown.length === 0) return;
|
|
363
|
+
throw new Error(
|
|
364
|
+
`agent "${agentName}": the Turn's input carries ${unknown.map((k) => `\`${k}\``).join(", ")}, which a ` +
|
|
365
|
+
`Turn has no use for — its input is its Frame (\`prompt\`, \`cwd\`), its Dials (\`model\`, ` +
|
|
366
|
+
`\`thinkingLevel\`), and \`continue\` (ADR-0057)`,
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
297
370
|
/** Wrap an Agent slot's invoke input: name the Agent (the slot key), append the derived menu, and
|
|
298
371
|
* finalize the mechanism fields. */
|
|
299
372
|
function wrapAgentInput(orig: unknown, derived: string[], agentName: string) {
|
|
300
373
|
return (args: InputArgs): AgentRunInput => {
|
|
301
374
|
const consumer = resolveInput(orig, args) as Partial<AgentTurnInput & AgentRunInput>;
|
|
375
|
+
refuseUnknownKeys(consumer, agentName);
|
|
302
376
|
return {
|
|
303
377
|
agentName,
|
|
304
|
-
|
|
378
|
+
// jr2 mints every id (ADR-0016, ADR-0057): the Turn's input carries no id to honor, so
|
|
379
|
+
// nothing a Machine writes can address a conversation jr2 did not derive.
|
|
380
|
+
instanceId: mintIid(consumer, agentName, args.self),
|
|
305
381
|
endpoint: consumer.endpoint,
|
|
306
382
|
sandbox: consumer.sandbox,
|
|
307
383
|
prompt: consumer.prompt,
|
|
384
|
+
// The Frame's other half (ADR-0057) — WHERE this Turn works. Passed through as written; the
|
|
385
|
+
// actor resolves an ABSENT one against the enclosing Workspace, which only it can see.
|
|
386
|
+
cwd: consumer.cwd,
|
|
308
387
|
// This turn's dials (ADR-0018) — passed straight through; the Harness layers
|
|
309
388
|
// them over the definition when the Submission starts.
|
|
310
389
|
model: consumer.model,
|
|
311
390
|
thinkingLevel: consumer.thinkingLevel,
|
|
312
|
-
// ADR-0035's reroll gate: closed to `
|
|
313
|
-
// runaway recovery is a
|
|
314
|
-
|
|
315
|
-
//
|
|
316
|
-
|
|
317
|
-
? { continuation: true }
|
|
318
|
-
: {}),
|
|
319
|
-
tools: consumer.tools ?? derived,
|
|
391
|
+
// ADR-0035's reroll gate: closed to `continue: true`, which names an EXISTING conversation
|
|
392
|
+
// while the runaway's one recovery is a fresh one — exactly what it opted out of.
|
|
393
|
+
...(consumer.continue === true ? { continuation: true } : {}),
|
|
394
|
+
// The Menu (ADR-0015): always the derived one, since ADR-0057 retired the override.
|
|
395
|
+
tools: derived,
|
|
320
396
|
};
|
|
321
397
|
};
|
|
322
398
|
}
|
|
@@ -330,31 +406,30 @@ function wrapGateInput(orig: unknown, derived: string[]) {
|
|
|
330
406
|
}
|
|
331
407
|
|
|
332
408
|
/**
|
|
333
|
-
* Mint the instance id (ADR-0016). It is minted in the INPUT MAPPER — not the actor —
|
|
334
|
-
* the input is the persistence vehicle: restore re-spawns from the persisted input
|
|
335
|
-
* re-running the mapper (same conversation), while a fresh transition re-runs it
|
|
409
|
+
* Mint the instance id (ADR-0016, ADR-0057). It is minted in the INPUT MAPPER — not the actor —
|
|
410
|
+
* because the input is the persistence vehicle: restore re-spawns from the persisted input
|
|
411
|
+
* without re-running the mapper (same conversation), while a fresh transition re-runs it.
|
|
336
412
|
*
|
|
337
|
-
*
|
|
338
|
-
*
|
|
339
|
-
*
|
|
340
|
-
*
|
|
341
|
-
*
|
|
342
|
-
*
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
*
|
|
346
|
-
*
|
|
347
|
-
*
|
|
413
|
+
* Both spellings are built on {@link continuedIid}, `<runId>/<machine actor path>/<agent>` — the
|
|
414
|
+
* Agent's ONE conversation in this Machine instance. The machine actor path excludes the root
|
|
415
|
+
* actor (its id is generated per process) and the invoke's own leaf id (states of one Machine
|
|
416
|
+
* share the conversation, ADR-0057), so two Pool children — whose ids are their items' — can
|
|
417
|
+
* never meet.
|
|
418
|
+
*
|
|
419
|
+
* - Default (FRESH, jr's lossy handoff): a new conversation per invocation — that id plus a
|
|
420
|
+
* random suffix.
|
|
421
|
+
* - `continue: true`: that id exactly, at epoch 0 — the author writes a boolean, because in a
|
|
422
|
+
* state machine the identity (the run, the Machine instance, the Agent slot) is already on the
|
|
423
|
+
* page. Once jr2 has faulted the conversation, the ledger's epoch suffixes it (`<id>/1`,
|
|
424
|
+
* `<id>/2`, …): a virgin conversation, since no fault leaves one worth continuing (ADR-0035).
|
|
425
|
+
* **Epoch 0 is never spelled** — the unsuffixed id IS the first conversation, which keeps the
|
|
426
|
+
* common case readable on the Harness route, in the ledger and in the logs. Invoking a live
|
|
427
|
+
* continued id fails loudly at the registration table (one live surface per address).
|
|
348
428
|
*/
|
|
349
|
-
function mintIid(
|
|
350
|
-
consumer: { session?: "continue"; scope?: string; conversation?: string },
|
|
351
|
-
agentName: string,
|
|
352
|
-
self: AnyActorRef,
|
|
353
|
-
): string {
|
|
429
|
+
function mintIid(consumer: { continue?: true }, agentName: string, self: AnyActorRef): string {
|
|
354
430
|
const runId = boundRunId(self.system) ?? "local";
|
|
355
|
-
const
|
|
356
|
-
if (consumer.
|
|
357
|
-
const
|
|
358
|
-
|
|
359
|
-
return `${runId}/${path}/${agentName}${scope}/${randomUUID().slice(0, 8)}`;
|
|
431
|
+
const conversation = continuedIid(self, runId, agentName);
|
|
432
|
+
if (!consumer.continue) return `${conversation}/${randomUUID().slice(0, 8)}`;
|
|
433
|
+
const epoch = boundEpoch(self.system, conversation);
|
|
434
|
+
return epoch === 0 ? conversation : `${conversation}/${epoch}`;
|
|
360
435
|
}
|
package/src/stub-harness.ts
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
// silent real Harness, and e2e drives it by playing the agent against `/mcp/<iid>` instead.
|
|
7
7
|
//
|
|
8
8
|
// Wire (ADR-0027 — the normative model of the five endpoints; `@jr2/harness` serves it for real):
|
|
9
|
-
// POST /agents/:name/:id {message, definition, model?, thinkingLevel?} → 200 { streamUrl, offset, submissionId }
|
|
9
|
+
// POST /agents/:name/:id {message, cwd?, definition, model?, thinkingLevel?} → 200 { streamUrl, offset, submissionId }
|
|
10
|
+
// (`message` and `cwd` are the Turn's Frame — what it is about and where it works, ADR-0057)
|
|
10
11
|
// GET /agents/:name/:id?offset=…[&view=updates] → 200 `[]` + Stream-Next-Offset/Up-To-Date
|
|
11
12
|
// GET /agents/:name/:id?…&live=long-poll → parked; 204 + same headers on timeout
|
|
12
13
|
// POST /agents/:name/:id/abort → 200 { aborted }
|
|
@@ -32,15 +33,19 @@
|
|
|
32
33
|
import { createServer } from "node:http";
|
|
33
34
|
import type { AddressInfo, Socket } from "node:net";
|
|
34
35
|
|
|
35
|
-
/** One admission of an Agent: which slot, which durable exchange,
|
|
36
|
-
*
|
|
37
|
-
* mechanics-tier test can assert which Agent a
|
|
38
|
-
*
|
|
39
|
-
*
|
|
36
|
+
/** One admission of an Agent: which slot, which durable exchange, this Turn's FRAME (the prompt
|
|
37
|
+
* and where it works — ADR-0057), the DEFINITION the Machine's slot carried here (ADR-0049), and
|
|
38
|
+
* this Submission's dials (ADR-0018) — captured so a mechanics-tier test can assert which Agent a
|
|
39
|
+
* state ran, at what settings and in which directory, exactly as it asserts the prompt. The stub
|
|
40
|
+
* is inert and never READS any of it: a real Harness runs the Turn, this one only proves each
|
|
41
|
+
* piece rode the wire. */
|
|
40
42
|
export type Admission = {
|
|
41
43
|
agentName: string;
|
|
42
44
|
instanceId: string;
|
|
43
45
|
message?: string;
|
|
46
|
+
/** Where the Working tools would be rooted (ADR-0057). Absent when the Turn carried no `cwd`,
|
|
47
|
+
* which is the workspace-less run a real Harness answers with `/work`. */
|
|
48
|
+
cwd?: string;
|
|
44
49
|
definition?: Record<string, unknown>;
|
|
45
50
|
model?: string;
|
|
46
51
|
thinkingLevel?: string;
|
|
@@ -122,12 +127,21 @@ export async function startStubHarness(opts: StubHarnessOptions = {}): Promise<R
|
|
|
122
127
|
req.on("data", (chunk: Buffer) => (body += chunk));
|
|
123
128
|
req.on("end", () => {
|
|
124
129
|
const sent = safeParse(body) as
|
|
125
|
-
| {
|
|
130
|
+
| {
|
|
131
|
+
message?: string;
|
|
132
|
+
cwd?: string;
|
|
133
|
+
definition?: Record<string, unknown>;
|
|
134
|
+
model?: string;
|
|
135
|
+
thinkingLevel?: string;
|
|
136
|
+
}
|
|
126
137
|
| undefined;
|
|
127
138
|
const admission: Admission = {
|
|
128
139
|
agentName: decodeURIComponent(agentName),
|
|
129
140
|
instanceId: decodeURIComponent(instanceId),
|
|
130
141
|
message: sent?.message,
|
|
142
|
+
// Recorded only when the Frame carried one, so an admission without a `cwd` reads as
|
|
143
|
+
// the absence it is rather than a directory the state never named (ADR-0057).
|
|
144
|
+
...(sent?.cwd === undefined ? {} : { cwd: sent.cwd }),
|
|
131
145
|
...(sent?.definition ? { definition: sent.definition } : {}),
|
|
132
146
|
...(sent?.model ? { model: sent.model } : {}),
|
|
133
147
|
...(sent?.thinkingLevel ? { thinkingLevel: sent.thinkingLevel } : {}),
|
package/src/vocabulary.ts
CHANGED
|
@@ -82,7 +82,8 @@ export function inputSchemaOf(machine: AnyStateMachine): z.ZodObject | undefined
|
|
|
82
82
|
/**
|
|
83
83
|
* What the HOST adds to the input of the machine a run STARTS with, beside the door
|
|
84
84
|
* (`RunHost.start`). One field today: the run's seed Instance ID, minted with the run and
|
|
85
|
-
* reported by `jr2 status
|
|
85
|
+
* reported by `jr2 status`. It names the RUN, never a conversation — jr2 mints every Agent
|
|
86
|
+
* Instance ID in the input mapper (ADR-0016, ADR-0057), and a Machine cannot pass one in.
|
|
86
87
|
*
|
|
87
88
|
* It is deliberately NOT door material (ADR-0033): no caller sends it — `start` overwrites
|
|
88
89
|
* whatever arrived, after the parse — and it is never served as JSON Schema, so putting it in a
|