@eir-labs/coltrane 0.8.0 → 0.9.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/agents/change-verifier.json +40 -0
- package/agents/code-implementer.json +42 -0
- package/agents/contract-definer.json +31 -0
- package/agents/defect-investigator.json +60 -0
- package/agents/grounder.json +38 -0
- package/agents/pr-publisher.json +48 -0
- package/agents/red-spec-drafter.json +46 -0
- package/agents/spec-publisher.json +48 -0
- package/agents/spec-reviewer.json +32 -0
- package/dist/src/bifrost_invoker.js +53 -52
- package/dist/src/bifrost_invoker.js.map +1 -1
- package/dist/src/change_set_branch.d.ts +131 -0
- package/dist/src/change_set_branch.js +179 -0
- package/dist/src/change_set_branch.js.map +1 -0
- package/dist/src/claude_invoker.d.ts +79 -2
- package/dist/src/claude_invoker.js +377 -36
- package/dist/src/claude_invoker.js.map +1 -1
- package/dist/src/cli.d.ts +1 -1
- package/dist/src/cli.js +26 -3
- package/dist/src/cli.js.map +1 -1
- package/dist/src/committed_work.d.ts +206 -0
- package/dist/src/committed_work.js +0 -0
- package/dist/src/committed_work.js.map +1 -0
- package/dist/src/composition.d.ts +11 -0
- package/dist/src/composition.js +3 -1
- package/dist/src/composition.js.map +1 -1
- package/dist/src/genome_schema.d.ts +803 -10
- package/dist/src/genome_schema.js +413 -7
- package/dist/src/genome_schema.js.map +1 -1
- package/dist/src/genome_store.d.ts +16 -1
- package/dist/src/genome_store.js +68 -0
- package/dist/src/genome_store.js.map +1 -1
- package/dist/src/gig_tracker.js +4 -0
- package/dist/src/gig_tracker.js.map +1 -1
- package/dist/src/hosted_tools.js +19 -0
- package/dist/src/hosted_tools.js.map +1 -1
- package/dist/src/institution_enforcement.d.ts +77 -0
- package/dist/src/institution_enforcement.js +442 -0
- package/dist/src/institution_enforcement.js.map +1 -0
- package/dist/src/institution_loader.d.ts +63 -0
- package/dist/src/institution_loader.js +239 -0
- package/dist/src/institution_loader.js.map +1 -0
- package/dist/src/loader.d.ts +4 -1
- package/dist/src/loader.js +19 -1
- package/dist/src/loader.js.map +1 -1
- package/dist/src/mcp.js +8 -1
- package/dist/src/mcp.js.map +1 -1
- package/dist/src/runtime.d.ts +66 -0
- package/dist/src/runtime.js +202 -2
- package/dist/src/runtime.js.map +1 -1
- package/dist/src/server.d.ts +7 -0
- package/dist/src/server.js +90 -1
- package/dist/src/server.js.map +1 -1
- package/dist/src/venue_realize.d.ts +90 -0
- package/dist/src/venue_realize.js +129 -0
- package/dist/src/venue_realize.js.map +1 -0
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/dist/src/worker.d.ts +45 -3
- package/dist/src/worker.js +67 -2
- package/dist/src/worker.js.map +1 -1
- package/domain_types/change-request.json +2 -1
- package/domain_types/class-sweep.json +34 -0
- package/domain_types/defect-class.json +25 -0
- package/domain_types/defect-location.json +28 -0
- package/domain_types/defect-report.json +28 -0
- package/domain_types/fix-spec.json +28 -0
- package/domain_types/fix-verification.json +28 -0
- package/domain_types/grounding-dossier.json +95 -0
- package/domain_types/pull-request.json +43 -0
- package/domain_types/red-spec.json +76 -0
- package/domain_types/reproduction.json +28 -0
- package/domain_types/root-cause.json +28 -0
- package/domain_types/subsystem-contract.json +106 -0
- package/institutions/coltrane.json +166 -0
- package/institutions/quartet.json +143 -26
- package/package.json +2 -1
- package/standards/defect-investigation-v1.json +156 -0
- package/standards/software-change-pr-v1.json +151 -0
- package/standards/spec-drafting-v1.json +125 -0
|
@@ -8,8 +8,12 @@ import { tmpdir } from "node:os";
|
|
|
8
8
|
import { join } from "node:path";
|
|
9
9
|
import { randomUUID } from "node:crypto";
|
|
10
10
|
import { abortReasonText } from "./runtime.js";
|
|
11
|
-
import { resolveAgentGrants } from "./tool_providers.js";
|
|
11
|
+
import { resolveAgentGrants, ENGINE_MCP_SERVER } from "./tool_providers.js";
|
|
12
|
+
import { venueEffectiveTools } from "./chart.js";
|
|
13
|
+
import { CORE_TYPES } from "./core_types.js";
|
|
12
14
|
const EMPTY_TOOL_REGISTRY = new Map();
|
|
15
|
+
/** The tool name a chair's in-band `output_write` seal is advertised under in the spawn. */
|
|
16
|
+
const OUTPUT_WRITE_TOOL = `mcp__${ENGINE_MCP_SERVER}__output_write`;
|
|
13
17
|
// Per-tier model resolution (the old MODEL_TIER_MAP: economy/standard/premium →
|
|
14
18
|
// haiku/sonnet/opus). An agent's model_tier picks the concrete spawn model; falls back to
|
|
15
19
|
// the invoker's static default only when the agent declares no tier.
|
|
@@ -88,7 +92,7 @@ export function buildPrompt(ctx, outputSchema,
|
|
|
88
92
|
// Per-type schemas for a MULTI-output agent (slug → schema). When the agent declares
|
|
89
93
|
// more than one output type, the Task layer asks for a blob keyed by type rather than a
|
|
90
94
|
// single object — the runtime then seals one record per key.
|
|
91
|
-
outputSchemas) {
|
|
95
|
+
outputSchemas, seal) {
|
|
92
96
|
const a = ctx.agent;
|
|
93
97
|
const layers = [];
|
|
94
98
|
// 1. Disposition — the Belbin cognitive-role pairing, held in tension (how you think).
|
|
@@ -149,7 +153,26 @@ outputSchemas) {
|
|
|
149
153
|
// chair is asked for only its promised subset, not its whole catalogue. Legacy ctx without it
|
|
150
154
|
// falls back to the agent's full output_types.
|
|
151
155
|
const sealTypes = ctx.output_types?.length ? ctx.output_types : a.output_types;
|
|
152
|
-
if (
|
|
156
|
+
if (seal) {
|
|
157
|
+
// IN-BAND WRITE-BOUNDARY SEAL. Each output is sealed by an `output_write` call whose payload
|
|
158
|
+
// the engine adjudicates against the FULL contract; a rejection returns in-band and the agent
|
|
159
|
+
// fixes `data` and calls again — its own single run self-corrects, no invoker re-prompt.
|
|
160
|
+
const perType = sealTypes
|
|
161
|
+
.map((t) => {
|
|
162
|
+
const s = outputSchemas?.[t] ?? (sealTypes.length === 1 ? outputSchema : undefined);
|
|
163
|
+
const core = seal.core_by_type[t] ?? "";
|
|
164
|
+
return (`- output_write({ "core_type": "${core}", "domain_type": "${t}", ` +
|
|
165
|
+
`"gig_id": "${seal.gig_id}", "phase": "${seal.phase}", "agent_slug": "${seal.agent_slug}", ` +
|
|
166
|
+
`"data": <object${s ? ` matching ${JSON.stringify(s)}` : ""}> })`);
|
|
167
|
+
})
|
|
168
|
+
.join("\n");
|
|
169
|
+
layers.push(`# Task\nSeal each of your output types by calling the \`output_write\` tool — one call per type:\n${perType}\n\n` +
|
|
170
|
+
`The tool validates your \`data\` against the complete output contract and returns ` +
|
|
171
|
+
`\`{ ok: false, error }\` if it does not pass. When that happens, read the error, correct the ` +
|
|
172
|
+
`\`data\`, and call \`output_write\` again — repeat until it returns \`ok: true\`. The successful ` +
|
|
173
|
+
`call IS the seal; do NOT print the output as text, and do not stop until every type is sealed.`);
|
|
174
|
+
}
|
|
175
|
+
else if (sealTypes.length > 1) {
|
|
153
176
|
// multi-output: one JSON object keyed by each output-type slug; each value is that
|
|
154
177
|
// type's data. The runtime seals one record per key (a SENSE+JUDGE agent yields its
|
|
155
178
|
// Signal and its Judgment in one pass).
|
|
@@ -202,6 +225,28 @@ export class ModelOutputParseError extends Error {
|
|
|
202
225
|
raw.length > EXCERPT_MAX_CHARS ? `${raw.slice(0, EXCERPT_MAX_CHARS)}…` : raw;
|
|
203
226
|
}
|
|
204
227
|
}
|
|
228
|
+
/**
|
|
229
|
+
* A chair completed its run but sealed NONE of its promised outputs through `output_write`.
|
|
230
|
+
* Distinct from ModelOutputParseError (a text-seal chair that emitted no parseable answer): this
|
|
231
|
+
* is the output_write-seal path, where the boundary that adjudicates a payload against its full
|
|
232
|
+
* output contract is the chair's own in-band `output_write` call (validated by the engine's
|
|
233
|
+
* checkWritable, corrected in-band by the agent), NOT a re-prompt from this invoker. A chair that
|
|
234
|
+
* never gets a single write past that boundary produced nothing this invoker can hand back.
|
|
235
|
+
*
|
|
236
|
+
* The runtime's own floor check (executeChair, `missingRequired`) also catches a shortfall and is
|
|
237
|
+
* the authority on which promised types were merely optional; this error is the earlier, chair-
|
|
238
|
+
* local signal that the write boundary sealed nothing at all.
|
|
239
|
+
*/
|
|
240
|
+
export class ModelOutputContractError extends Error {
|
|
241
|
+
slug;
|
|
242
|
+
reason;
|
|
243
|
+
constructor(slug, reason) {
|
|
244
|
+
super(`chair "${slug}" sealed no output through its write boundary: ${reason}`);
|
|
245
|
+
this.name = "ModelOutputContractError";
|
|
246
|
+
this.slug = slug;
|
|
247
|
+
this.reason = reason;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
205
250
|
/**
|
|
206
251
|
* Walk forward from `start` (which must be a `{`) honouring JSON string literals and
|
|
207
252
|
* backslash escapes, so only STRUCTURAL braces move the depth counter. Returns the index
|
|
@@ -387,6 +432,120 @@ export function extractOptionsForChair(sealTypes, schema) {
|
|
|
387
432
|
const props = schemaPropertyNames(schema);
|
|
388
433
|
return props.length > 0 ? { expectKeys: props } : { requireUnambiguous: true };
|
|
389
434
|
}
|
|
435
|
+
// ───────────────────── output_write capture (the write-boundary seal path) ─────────────────────
|
|
436
|
+
//
|
|
437
|
+
// A model chair on the output_write-seal path does NOT print a final-text answer — it SEALS each
|
|
438
|
+
// output in-band by calling `output_write`, whose payload the engine adjudicates against the full
|
|
439
|
+
// contract (validate-mode, returning the verdict in-band so the agent self-corrects). This reads
|
|
440
|
+
// those calls back out of the child's stream-json stdout: the payload of each SUCCESSFUL
|
|
441
|
+
// output_write call (a tool_use whose tool_result was not an error) is what the chair sealed, and
|
|
442
|
+
// the runtime's own boundary seals it exactly once. Because the agent corrects a rejected write by
|
|
443
|
+
// calling again, the LAST non-errored call per type is the one that passed.
|
|
444
|
+
/** The name a chair's `output_write` grant is advertised under in the spawn (mcp__<server>__<tool>),
|
|
445
|
+
* plus the bare slug for a legacy pass-through invoker. Matched by suffix so either resolves. */
|
|
446
|
+
function isOutputWriteToolName(name) {
|
|
447
|
+
return name === "output_write" || name.endsWith("__output_write");
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Extract the chair's sealed payloads from a stream-json stdout, keyed by the chair's seal types.
|
|
451
|
+
* Returns the blob shape the runtime already consumes (a key per domain_type, or the bare data for
|
|
452
|
+
* a lone single-output write), so executeChair seals it through its one boundary unchanged.
|
|
453
|
+
*/
|
|
454
|
+
/** A child that exited non-zero, carrying the stdout it produced before dying.
|
|
455
|
+
*
|
|
456
|
+
* The stream is not incidental to the error — for a chair stopped by its tool budget it holds
|
|
457
|
+
* validated payloads, and discarding it destroys work the engine had already adjudicated as good.
|
|
458
|
+
* Exported because the injected-run seam (`opts.run`) is how tests reproduce a non-zero exit. */
|
|
459
|
+
export class ChildExitError extends Error {
|
|
460
|
+
stdout;
|
|
461
|
+
constructor(message, stdout) {
|
|
462
|
+
super(message);
|
|
463
|
+
this.stdout = stdout;
|
|
464
|
+
this.name = "ChildExitError";
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
/** The result subtype the CLI reports when `--max-turns` cut the run off.
|
|
468
|
+
*
|
|
469
|
+
* This is the ONE non-success subtype whose stream is still worth reading, and the distinction is
|
|
470
|
+
* the whole of the policy: a budget stop says "the agent was interrupted", while every other error
|
|
471
|
+
* subtype says "what the agent produced is unreliable". Writes survive the first and must not
|
|
472
|
+
* survive the second. */
|
|
473
|
+
const BUDGET_STOP_SUBTYPE = "error_max_turns";
|
|
474
|
+
/** Rewrite `--max-turns` to the reserve. The continuation gets the EXTENSION as its whole budget,
|
|
475
|
+
* not a second full allowance — otherwise the grant silently doubles the chair's cost ceiling. */
|
|
476
|
+
function withMaxTurns(args, turns) {
|
|
477
|
+
const out = [...args];
|
|
478
|
+
const i = out.indexOf("--max-turns");
|
|
479
|
+
if (i >= 0 && i + 1 < out.length)
|
|
480
|
+
out[i + 1] = String(turns);
|
|
481
|
+
else
|
|
482
|
+
out.push("--max-turns", String(turns));
|
|
483
|
+
return out;
|
|
484
|
+
}
|
|
485
|
+
/** Swap the prompt a built arg list carries.
|
|
486
|
+
*
|
|
487
|
+
* `-p` is a boolean flag with the prompt as a POSITIONAL that follows it — except on the
|
|
488
|
+
* large-prompt path, where the positional is dropped and the text goes to stdin instead. A
|
|
489
|
+
* continuation has to replace whichever form is in play, and the two are not interchangeable:
|
|
490
|
+
* editing only the stdin text would re-send the ORIGINAL prompt to a small-prompt chair, which
|
|
491
|
+
* reads as the engine ignoring its own grant. */
|
|
492
|
+
function withPrompt(args, prompt) {
|
|
493
|
+
const out = [...args];
|
|
494
|
+
const i = out.indexOf("-p");
|
|
495
|
+
if (i < 0)
|
|
496
|
+
return out;
|
|
497
|
+
const positionalFollows = i + 1 < out.length && !out[i + 1].startsWith("-");
|
|
498
|
+
if (positionalFollows)
|
|
499
|
+
out[i + 1] = prompt;
|
|
500
|
+
return out;
|
|
501
|
+
}
|
|
502
|
+
export function captureOutputWrites(stdout, sealTypes) {
|
|
503
|
+
const writes = [];
|
|
504
|
+
const errored = new Set();
|
|
505
|
+
for (const raw of stdout.split("\n")) {
|
|
506
|
+
const line = raw.trim();
|
|
507
|
+
if (!line)
|
|
508
|
+
continue;
|
|
509
|
+
let e;
|
|
510
|
+
try {
|
|
511
|
+
e = JSON.parse(line);
|
|
512
|
+
}
|
|
513
|
+
catch {
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
const type = typeof e["type"] === "string" ? e["type"] : "";
|
|
517
|
+
const msg = e["message"];
|
|
518
|
+
if ((type === "assistant" || type === "user") && msg && typeof msg === "object") {
|
|
519
|
+
const content = msg.content ?? [];
|
|
520
|
+
for (const b of content) {
|
|
521
|
+
const bt = String(b["type"] ?? "");
|
|
522
|
+
if (bt === "tool_use" && isOutputWriteToolName(String(b["name"] ?? ""))) {
|
|
523
|
+
const input = (b["input"] && typeof b["input"] === "object" ? b["input"] : {});
|
|
524
|
+
writes.push({ id: String(b["id"] ?? ""), domain_type: String(input["domain_type"] ?? ""), data: input["data"] });
|
|
525
|
+
}
|
|
526
|
+
else if (bt === "tool_result" && b["is_error"] === true) {
|
|
527
|
+
errored.add(String(b["tool_use_id"] ?? ""));
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
const passed = writes.filter((w) => !w.id || !errored.has(w.id));
|
|
533
|
+
const byType = new Map();
|
|
534
|
+
for (const w of passed)
|
|
535
|
+
byType.set(w.domain_type, w.data); // last non-errored write per type wins
|
|
536
|
+
const blob = {};
|
|
537
|
+
for (const t of sealTypes) {
|
|
538
|
+
if (byType.has(t))
|
|
539
|
+
blob[t] = byType.get(t);
|
|
540
|
+
}
|
|
541
|
+
// Single-output chairs may seal with an empty/other domain_type (buildPrompt names it, but a
|
|
542
|
+
// model can still omit it). If nothing matched by name and exactly one output was sealed, that
|
|
543
|
+
// lone payload IS the single output — key it under the promised type.
|
|
544
|
+
if (sealTypes.length === 1 && blob[sealTypes[0]] === undefined && passed.length > 0) {
|
|
545
|
+
blob[sealTypes[0]] = passed[passed.length - 1].data;
|
|
546
|
+
}
|
|
547
|
+
return blob;
|
|
548
|
+
}
|
|
390
549
|
// The wall-clock bound on one chair's spawn. A tool-granted child has no inherent
|
|
391
550
|
// terminus (it can search/loop), and the gig runs the spawn synchronously — so without
|
|
392
551
|
// this bound one wedged child wedges the whole server. SIGKILL, not SIGTERM: a
|
|
@@ -515,6 +674,24 @@ export function makeClaudeInvoker(opts = {}) {
|
|
|
515
674
|
// running engine always resolves + fails closed.
|
|
516
675
|
const resolutionEnabled = opts.toolProviders !== undefined || opts.mcpServerConfigs !== undefined;
|
|
517
676
|
const abortGraceMs = opts.abort_grace_ms ?? DEFAULT_ABORT_GRACE_MS;
|
|
677
|
+
const sealViaOutputWrite = opts.sealVia === "output_write";
|
|
678
|
+
// A reserve is a grant, so an absent or nonsensical one grants nothing rather than defaulting to
|
|
679
|
+
// some house number — an extension the author did not ask for is spend they did not authorise.
|
|
680
|
+
// #turn-budget — resolved PER INVOCATION so the chair-scoped reserve (ctx.turn_reserve, the pool-
|
|
681
|
+
// capped offer the runtime threads) wins over the invoker-level default (opts.turn_reserve). Both
|
|
682
|
+
// pass through the identical > 0 floor. ctx.turn_reserve === 0 (a declared chair whose pool was
|
|
683
|
+
// dry) is a hard zero, NOT a fall-through to opts — 0 is not nullish, so `??` stops there.
|
|
684
|
+
const resolveReserveTurns = (ctxReserve) => {
|
|
685
|
+
const src = ctxReserve ?? opts.turn_reserve;
|
|
686
|
+
return Number.isFinite(src) && (src ?? 0) > 0 ? Math.floor(src) : 0;
|
|
687
|
+
};
|
|
688
|
+
// The core type each domain type extends — the `core_type` the agent must pass to output_write.
|
|
689
|
+
const coreTypeOf = (slug) => {
|
|
690
|
+
if (CORE_TYPES.includes(slug))
|
|
691
|
+
return slug;
|
|
692
|
+
const dt = opts.registry?.listTypes().find((t) => t.slug === slug);
|
|
693
|
+
return dt ? dt.extends : "";
|
|
694
|
+
};
|
|
518
695
|
return async (ctx) => {
|
|
519
696
|
// #250 — a chair whose gig is already cancelled spends nothing: no prompt, no mcp-config,
|
|
520
697
|
// no spawn. This is the cheapest point on the whole cancellation chain.
|
|
@@ -546,6 +723,34 @@ export function makeClaudeInvoker(opts = {}) {
|
|
|
546
723
|
resolvedMcpServers = resolved.mcpServers;
|
|
547
724
|
effectiveAllowed = resolved.effectiveAllowed;
|
|
548
725
|
}
|
|
726
|
+
// VENUE CONFINEMENT BY CONSTRUCTION. When the dispatch path resolved a room for this chair
|
|
727
|
+
// (ctx.realization + ctx.venue both threaded by runGig), the spawn reflects the realization:
|
|
728
|
+
// - `--allowedTools` carries EXACTLY venueEffectiveTools(agent, venue) — the SAME shared oracle
|
|
729
|
+
// the compose-time R10 check refuses against (src/chart.ts), never a re-inlined intersection
|
|
730
|
+
// and never the un-intersected grant, so runtime enforcement and compose-time refusal cannot
|
|
731
|
+
// drift.
|
|
732
|
+
// - the child env is the realization's deny-by-default allowlist (SeatRealization.env, `{}` when
|
|
733
|
+
// the surface admits nothing), so an undeclared ambient credential never reaches the child.
|
|
734
|
+
// Narrowed BEFORE the in-band-seal block below, so the engine's own output_write grant — engine
|
|
735
|
+
// mechanism, not an optional capability — is re-added on top of the room's ceiling. Absent on
|
|
736
|
+
// either field → the un-narrowed path above stands and no child env is constructed (INV10).
|
|
737
|
+
let childEnv;
|
|
738
|
+
if (ctx.realization && ctx.venue) {
|
|
739
|
+
effectiveAllowed = venueEffectiveTools(ctx.agent, ctx.venue);
|
|
740
|
+
const seat = ctx.realization.seats.find((s) => s.agent_slug === ctx.agent.slug);
|
|
741
|
+
childEnv = seat?.env ?? {};
|
|
742
|
+
}
|
|
743
|
+
// WIRE THE IN-BAND SEAL. A model chair on the output_write-seal path must be able to CALL
|
|
744
|
+
// output_write regardless of what it declared in allowed_tools — the seal is engine mechanism,
|
|
745
|
+
// not an optional capability. Bridge the engine's own MCP server into the spawn and add the
|
|
746
|
+
// output_write grant so the child can reach it. Gated on the engine server config being wired
|
|
747
|
+
// (bootstrapServerDeps always supplies it); a bare/test invoker without it captures from the
|
|
748
|
+
// injected stream instead of a real spawn, so it needs no grant.
|
|
749
|
+
const engineServerCfg = (opts.mcpServerConfigs ?? {})[ENGINE_MCP_SERVER];
|
|
750
|
+
if (sealViaOutputWrite && engineServerCfg !== undefined) {
|
|
751
|
+
resolvedMcpServers = { ...resolvedMcpServers, [ENGINE_MCP_SERVER]: engineServerCfg };
|
|
752
|
+
effectiveAllowed = [...new Set([...(effectiveAllowed ?? []), OUTPUT_WRITE_TOOL])];
|
|
753
|
+
}
|
|
549
754
|
const schemaOf = (slug) => promptSchemaFor(opts.registry, slug);
|
|
550
755
|
// #174 — schemas follow the chair's promised subset (ctx.output_types), not the agent's
|
|
551
756
|
// whole catalogue; legacy ctx without it falls back to the agent's full output_types.
|
|
@@ -557,7 +762,18 @@ export function makeClaudeInvoker(opts = {}) {
|
|
|
557
762
|
const outputSchemas = sealTypes.length > 1
|
|
558
763
|
? Object.fromEntries(sealTypes.map((t) => [t, schemaOf(t)]))
|
|
559
764
|
: undefined;
|
|
560
|
-
|
|
765
|
+
// The in-band seal directive: present only when this invoker seals via output_write AND the
|
|
766
|
+
// runtime threaded a gig_id (the write needs it). Absent → the legacy text-seal Task layer.
|
|
767
|
+
const seal = sealViaOutputWrite && ctx.gig_id
|
|
768
|
+
? {
|
|
769
|
+
via: "output_write",
|
|
770
|
+
gig_id: ctx.gig_id,
|
|
771
|
+
agent_slug: ctx.agent.slug,
|
|
772
|
+
phase: ctx.phase,
|
|
773
|
+
core_by_type: Object.fromEntries(sealTypes.map((t) => [t, coreTypeOf(t)])),
|
|
774
|
+
}
|
|
775
|
+
: undefined;
|
|
776
|
+
const prompt = buildPrompt(ctx, schema, outputSchemas, seal);
|
|
561
777
|
// #221 — the key signal for candidate selection, derived from what we just resolved.
|
|
562
778
|
// Threaded into BOTH extract calls below; threading only the injected-run one would
|
|
563
779
|
// leave every real chair unscored.
|
|
@@ -567,45 +783,131 @@ export function makeClaudeInvoker(opts = {}) {
|
|
|
567
783
|
// the base map (opts.mcpServers) + the per-agent servers its grants resolved to (#185).
|
|
568
784
|
const servers = { ...(opts.mcpServers ?? {}), ...resolvedMcpServers };
|
|
569
785
|
const parent = opts.parent_session_id;
|
|
570
|
-
//
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
})
|
|
577
|
-
:
|
|
786
|
+
// Per-server env additions: parent_session_id into every server (so children seal lineage), and
|
|
787
|
+
// COLTRANE_OUTPUT_WRITE_MODE=validate into the ENGINE server on the output_write-seal path — so
|
|
788
|
+
// the child's coltrane server ADJUDICATES the chair's in-band output_write calls against the
|
|
789
|
+
// full seal predicate and returns the verdict, without persisting (the runtime is the one
|
|
790
|
+
// sealer, so this is what keeps the output sealed exactly once).
|
|
791
|
+
const envFor = (name) => ({
|
|
792
|
+
...(parent ? { COLTRANE_PARENT_SESSION_ID: parent } : {}),
|
|
793
|
+
...(sealViaOutputWrite && name === ENGINE_MCP_SERVER ? { COLTRANE_OUTPUT_WRITE_MODE: "validate" } : {}),
|
|
794
|
+
});
|
|
795
|
+
const enriched = Object.fromEntries(Object.entries(servers).map(([name, def]) => {
|
|
796
|
+
const additions = envFor(name);
|
|
797
|
+
if (Object.keys(additions).length === 0)
|
|
798
|
+
return [name, def];
|
|
799
|
+
const d = (def && typeof def === "object" ? def : {});
|
|
800
|
+
const env = (d["env"] && typeof d["env"] === "object" ? d["env"] : {});
|
|
801
|
+
return [name, { ...d, env: { ...env, ...additions } }];
|
|
802
|
+
}));
|
|
578
803
|
writeFileSync(cfgPath, JSON.stringify({ mcpServers: enriched }));
|
|
579
804
|
try {
|
|
580
805
|
const a = ctx.agent;
|
|
806
|
+
// #turn-budget — the reserve the invoker will grant on a budget stop, chair-scoped first.
|
|
807
|
+
const reserveTurns = resolveReserveTurns(ctx.turn_reserve);
|
|
581
808
|
// #237 — a shallow run depth tightens the turn cap; it never widens the agent's own.
|
|
582
809
|
const depthCap = ctx.depth ? DEPTH_MAX_TOOL_CALLS[ctx.depth] : undefined;
|
|
810
|
+
// #turn-budget — resolve chair > agent > engine default, THEN let a shallow depth cap tighten
|
|
811
|
+
// (never widen). `ctx.turn_budget === 0` is a deliberate hard floor and does NOT fall through
|
|
812
|
+
// (0 is not nullish); absent falls to the agent's own cap, then the CLI default (undefined →
|
|
813
|
+
// no --max-turns emitted at all).
|
|
814
|
+
const resolvedBudget = ctx.turn_budget ?? a.max_tool_calls;
|
|
583
815
|
const maxToolCalls = depthCap === undefined
|
|
584
|
-
?
|
|
585
|
-
: Math.min(depthCap,
|
|
816
|
+
? resolvedBudget
|
|
817
|
+
: Math.min(depthCap, resolvedBudget ?? depthCap);
|
|
818
|
+
// Run ONE invocation and return the child's raw stdout. Custom run (tests): the returned
|
|
819
|
+
// string IS the transcript (a bare JSON blob on the text path, a stream-json transcript on
|
|
820
|
+
// the output_write path). Default: stream-json so the child's tool calls / reasoning are
|
|
821
|
+
// observable LIVE, teed to ctx.onEvent.
|
|
586
822
|
const baseArgs = buildInvokerArgs(prompt, cfgPath, {
|
|
587
823
|
model: resolveModel(a.model_tier, opts.model),
|
|
588
824
|
allowed_tools: effectiveAllowed,
|
|
589
825
|
disallowed_tools: [...(a.disallowed_tools ?? []), ...codeToolDenials(a.code_tool_access)],
|
|
590
826
|
max_tool_calls: maxToolCalls,
|
|
591
827
|
});
|
|
592
|
-
//
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
//
|
|
596
|
-
//
|
|
597
|
-
//
|
|
598
|
-
|
|
599
|
-
//
|
|
600
|
-
//
|
|
601
|
-
|
|
828
|
+
// ONE invocation only — on the output_write path the agent self-corrects a rejected write
|
|
829
|
+
// WITHIN this single run (each output_write rejection returns in-band and it calls again),
|
|
830
|
+
// and on the text path there is a single answer. Either way, the invoker never re-prompts.
|
|
831
|
+
// A chair stopped by its TOOL BUDGET is not a chair that failed. `--max-turns` cuts the run
|
|
832
|
+
// off mid-flight, and everything it had already written through the in-band boundary was
|
|
833
|
+
// adjudicated against the full seal predicate at the moment of writing — those are validated
|
|
834
|
+
// payloads, not the partial reasoning the error subtypes exist to catch. Discarding them
|
|
835
|
+
// destroyed real work and billed for it: one observed sweep landed nine sealed lineage-hits,
|
|
836
|
+
// satisfied its output_contract, and was reported as a failure with nothing kept.
|
|
837
|
+
//
|
|
838
|
+
// So the budget stop is caught HERE, and only here: the stream is recovered, and the decision
|
|
839
|
+
// about whether anything survives is left to the seal path below, which is the only place that
|
|
840
|
+
// knows what passed. Every other non-zero exit still propagates untouched.
|
|
841
|
+
const runOnce = async (args, text) => customRun
|
|
842
|
+
? await customRun(bin, [...args], spawnBounds, childEnv)
|
|
843
|
+
: await spawnStreaming(bin, [...args, "--output-format", "stream-json", "--verbose"], spawnBounds, ctx.onEvent, ctx.signal, abortGraceMs, promptViaStdin(text) ? text : undefined, childEnv);
|
|
844
|
+
/** Run, and hand back the stream even when the child died on its turn cap. */
|
|
845
|
+
const runTolerantOfBudgetStop = async (args, text) => {
|
|
846
|
+
try {
|
|
847
|
+
return { stdout: await runOnce(args, text), budgetStopped: false };
|
|
848
|
+
}
|
|
849
|
+
catch (e) {
|
|
850
|
+
const recoverable = seal !== undefined &&
|
|
851
|
+
e instanceof ChildExitError &&
|
|
852
|
+
finalText(e.stdout).errorSubtype === BUDGET_STOP_SUBTYPE;
|
|
853
|
+
if (!recoverable)
|
|
854
|
+
throw e;
|
|
855
|
+
return { stdout: e.stdout, budgetStopped: true };
|
|
856
|
+
}
|
|
857
|
+
};
|
|
858
|
+
let { stdout, budgetStopped } = await runTolerantOfBudgetStop(baseArgs, prompt);
|
|
859
|
+
// Every stream whose writes count toward the seal. Diverges from `stdout` only when a reserve
|
|
860
|
+
// was granted, which is the one case where a chair's output spans more than one invocation.
|
|
861
|
+
let sealStdout = stdout;
|
|
862
|
+
// THE RESERVE GRANT. The chair spent its declared budget; rather than losing whatever it was
|
|
863
|
+
// mid-way through, it is told where it stands and given a bounded extension to close out.
|
|
864
|
+
// Once. The continuation names what already sealed so the chair does not redo it, and says
|
|
865
|
+
// plainly that nothing follows — a chair that believes another extension is coming will spend
|
|
866
|
+
// this one reaching rather than landing.
|
|
867
|
+
if (budgetStopped && reserveTurns > 0 && seal !== undefined) {
|
|
868
|
+
const sealedSoFar = captureOutputWrites(stdout, sealTypes);
|
|
869
|
+
const already = Object.keys(sealedSoFar);
|
|
870
|
+
ctx.onEvent?.({
|
|
871
|
+
type: "budget_reserve_granted",
|
|
872
|
+
raw: { agent: a.slug, reserve_turns: reserveTurns, sealed_before_grant: already },
|
|
873
|
+
});
|
|
874
|
+
const continuation = `You reached your turn budget and were stopped mid-run. You are now in RESERVE: ` +
|
|
875
|
+
`${reserveTurns} turns remain and this is the LAST extension — it will not be extended ` +
|
|
876
|
+
`again, so land the work rather than reaching for more.\n\n` +
|
|
877
|
+
(already.length > 0
|
|
878
|
+
? `Already sealed through the write boundary, do NOT redo: [${already.join(", ")}].\n\n`
|
|
879
|
+
: `Nothing sealed yet.\n\n`) +
|
|
880
|
+
`Close out now: seal what you already have, and state plainly what you did NOT reach so ` +
|
|
881
|
+
`the record shows the boundary instead of implying coverage.\n\n${prompt}`;
|
|
882
|
+
const reserveArgs = withPrompt(withMaxTurns(baseArgs, reserveTurns), continuation);
|
|
883
|
+
const second = await runTolerantOfBudgetStop(reserveArgs, continuation);
|
|
884
|
+
// Two streams, two different questions, and conflating them is a bug: the OUTCOME (did the
|
|
885
|
+
// run complete?) is the last pass's to answer, while the WRITES are cumulative — the first
|
|
886
|
+
// pass's payloads passed the boundary too, and a continuation that sealed nothing must not
|
|
887
|
+
// erase them. Concatenating for both would let the first pass's error_max_turns result event
|
|
888
|
+
// outrank the second's success and fail a run that finished.
|
|
889
|
+
sealStdout = `${stdout}\n${second.stdout}`;
|
|
890
|
+
stdout = second.stdout;
|
|
891
|
+
budgetStopped = second.budgetStopped;
|
|
892
|
+
}
|
|
893
|
+
else if (budgetStopped && reserveTurns === 0 && seal !== undefined) {
|
|
894
|
+
// #turn-budget — the chair hit its budget and the pool had nothing left to extend it (an
|
|
895
|
+
// empty reserve). Keep-sealed-writes below is unchanged, but the starvation is now VISIBLE:
|
|
896
|
+
// emit a denial so a parent watching the stream — and the runtime's draw ledger — can
|
|
897
|
+
// attribute it, rather than a silent no-op that reads like a chair that simply finished.
|
|
898
|
+
ctx.onEvent?.({
|
|
899
|
+
type: "budget_reserve_denied",
|
|
900
|
+
raw: { agent: a.slug, requested: ctx.turn_reserve ?? opts.turn_reserve ?? 0, pool_remaining: 0 },
|
|
901
|
+
});
|
|
902
|
+
}
|
|
602
903
|
const outcome = finalText(stdout);
|
|
603
|
-
// #223 — the child reported an error result.
|
|
604
|
-
//
|
|
605
|
-
//
|
|
606
|
-
//
|
|
607
|
-
//
|
|
608
|
-
|
|
904
|
+
// #223 — the child reported an error result. `subtype` catches a run that did not complete;
|
|
905
|
+
// `is_error` catches an API-error payload riding subtype "success". The CLI exits 0 for both,
|
|
906
|
+
// so without this the partial reasoning seals as if it had succeeded. Applies to both paths.
|
|
907
|
+
// A recovered budget stop reaches here with its subtype still set; that is expected and is
|
|
908
|
+
// not an error for the seal path. The text path gets no such reprieve — its payload IS the
|
|
909
|
+
// final answer text, and a truncated run's text is exactly the partial reasoning this guards.
|
|
910
|
+
if (outcome.errorSubtype !== undefined && !budgetStopped) {
|
|
609
911
|
throw new Error(`claude ended with result subtype "${outcome.errorSubtype}" — the run did not ` +
|
|
610
912
|
`complete, so any text it emitted is partial reasoning, not an answer`);
|
|
611
913
|
}
|
|
@@ -613,9 +915,41 @@ export function makeClaudeInvoker(opts = {}) {
|
|
|
613
915
|
throw new Error(`claude flagged its result with is_error — the payload is an error message, not an ` +
|
|
614
916
|
`answer: ${outcome.apiErrorText.slice(0, 300)}`);
|
|
615
917
|
}
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
918
|
+
if (seal) {
|
|
919
|
+
// THE IN-BAND WRITE BOUNDARY. The chair sealed each output by calling output_write, whose
|
|
920
|
+
// payload the engine adjudicated against the FULL seal predicate (checkWritable, run in
|
|
921
|
+
// validate-mode) and whose rejection it corrected in-band. Capture the payloads that
|
|
922
|
+
// PASSED; the runtime (executeChair) then seals them through its own boundary exactly once.
|
|
923
|
+
// A chair that got nothing past the boundary produced nothing — fail here, legibly.
|
|
924
|
+
const blob = captureOutputWrites(sealStdout, sealTypes);
|
|
925
|
+
if (Object.keys(blob).length === 0) {
|
|
926
|
+
throw new ModelOutputContractError(a.slug, budgetStopped
|
|
927
|
+
? `ran out of tool budget (max_tool_calls) before any output_write passed the write ` +
|
|
928
|
+
`boundary for [${sealTypes.join(", ")}] — nothing was salvageable`
|
|
929
|
+
: `no output_write call passed the write boundary for [${sealTypes.join(", ")}]`);
|
|
930
|
+
}
|
|
931
|
+
// The stop is REPORTED, never swallowed. What survived is real and sealed; what the agent
|
|
932
|
+
// would have gone on to find is unknown, and a caller reading this chair's output as a
|
|
933
|
+
// complete sweep would be reading a truncation as a finding.
|
|
934
|
+
if (budgetStopped) {
|
|
935
|
+
ctx.onEvent?.({
|
|
936
|
+
type: "budget_stop",
|
|
937
|
+
raw: {
|
|
938
|
+
agent: a.slug,
|
|
939
|
+
max_tool_calls: a.max_tool_calls,
|
|
940
|
+
sealed_types: Object.keys(blob),
|
|
941
|
+
note: "the chair exhausted its tool budget; the outputs it had already passed through " +
|
|
942
|
+
"the write boundary were kept, and the sweep is TRUNCATED, not complete",
|
|
943
|
+
},
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
return blob;
|
|
947
|
+
}
|
|
948
|
+
// TEXT-SEAL PATH (default; every injected-run test). No in-band tool surface, so the payload
|
|
949
|
+
// is the child's final answer text; the runtime's own seal is this path's write boundary and
|
|
950
|
+
// its full checkWritable adjudicates the extracted payload.
|
|
951
|
+
// #222 — the stream parsed but carried no answer at all. Report THAT, with the raw stdout as
|
|
952
|
+
// evidence, rather than blaming the model for emitting no JSON.
|
|
619
953
|
if (outcome.text.trim() === "") {
|
|
620
954
|
throw new ModelOutputParseError("the model produced no answer — the stream carried no result text and no assistant text", 0, stdout);
|
|
621
955
|
}
|
|
@@ -639,7 +973,10 @@ export function makeClaudeInvoker(opts = {}) {
|
|
|
639
973
|
// nothing holds is a process nothing can stop.
|
|
640
974
|
function spawnStreaming(bin, args, bounds, onEvent, signal, abortGraceMs = DEFAULT_ABORT_GRACE_MS,
|
|
641
975
|
/** The prompt, when it is too large for the command line. Written to the child's stdin. */
|
|
642
|
-
stdinPayload
|
|
976
|
+
stdinPayload,
|
|
977
|
+
/** The venue → dispatch wire's deny-by-default child env. When present it REPLACES the inherited
|
|
978
|
+
* process.env (no ambient credential leaks into a confined child); absent = inherit, unchanged. */
|
|
979
|
+
childEnv) {
|
|
643
980
|
return new Promise((resolve, reject) => {
|
|
644
981
|
if (signal?.aborted) {
|
|
645
982
|
reject(new Error(`chair child not spawned — gig aborted (${abortReasonText(signal)})`));
|
|
@@ -648,7 +985,7 @@ stdinPayload) {
|
|
|
648
985
|
// stdin is a pipe ONLY when a payload is going down it. Leaving it open otherwise would
|
|
649
986
|
// change the child's TTY detection for every existing caller.
|
|
650
987
|
const stdio = [stdinPayload === undefined ? "ignore" : "pipe", "pipe", "pipe"];
|
|
651
|
-
const child = spawn(bin, [...args], { stdio });
|
|
988
|
+
const child = spawn(bin, [...args], { stdio, ...(childEnv ? { env: childEnv } : {}) });
|
|
652
989
|
// Slots 1 and 2 are literally "pipe" above, so both streams exist. Only slot 0 varies,
|
|
653
990
|
// and widening it costs the compiler the overload that proved this.
|
|
654
991
|
const childOut = child.stdout;
|
|
@@ -723,8 +1060,12 @@ stdinPayload) {
|
|
|
723
1060
|
const tail = buf.trim();
|
|
724
1061
|
buf = "";
|
|
725
1062
|
forwardLine(tail);
|
|
1063
|
+
// The stdout travels WITH the failure. A non-zero exit used to discard it, which threw away
|
|
1064
|
+
// the one thing a budget-stopped chair leaves behind: output_write calls that already passed
|
|
1065
|
+
// the engine's write boundary. Whether those are recoverable is a decision for the caller,
|
|
1066
|
+
// which knows the seal mode and the result subtype; it cannot make it without the stream.
|
|
726
1067
|
if (code !== 0)
|
|
727
|
-
reject(new
|
|
1068
|
+
reject(new ChildExitError(`claude exited ${code}: ${stderr.slice(0, 500)}`, stdout));
|
|
728
1069
|
else
|
|
729
1070
|
resolve(stdout);
|
|
730
1071
|
});
|