@eir-labs/coltrane 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +85 -0
- package/agents/anticipation-mapper.json +34 -0
- package/agents/claim-amender.json +34 -0
- package/agents/claim-architect.json +34 -0
- package/agents/claim-rewriter.json +32 -0
- package/agents/delivery-finalizer.json +40 -0
- package/agents/diamond-cutter.json +33 -0
- package/agents/disclosure-analyst.json +26 -0
- package/agents/domain-explorer.json +37 -0
- package/agents/e2e-runner.json +34 -0
- package/agents/e2e-suite-judge.json +26 -0
- package/agents/event-clusterer.json +29 -0
- package/agents/novelty-searcher.json +40 -0
- package/agents/patent-browser-scout.json +49 -0
- package/agents/patent-examiner.json +36 -0
- package/agents/phase_agents/claim-rewriter.md +44 -0
- package/agents/phase_agents/delivery-finalizer.md +30 -0
- package/agents/phase_agents/diamond-cutter.md +47 -0
- package/agents/phase_agents/domain-explorer.md +27 -0
- package/agents/phase_agents/novelty-searcher.md +50 -0
- package/agents/phase_agents/problem-definer.md +32 -0
- package/agents/phase_agents/solution-developer.md +26 -0
- package/agents/phase_agents/verdict-judger.md +70 -0
- package/agents/players/audience-modeler.md +40 -0
- package/agents/players/chain-audit-keeper.md +41 -0
- package/agents/players/illumination-reviewer.md +42 -0
- package/agents/players/methodology-cadence-keeper.md +42 -0
- package/agents/players/substrate-edge-keeper.md +45 -0
- package/agents/prior-art-scout.json +45 -0
- package/agents/problem-definer.json +35 -0
- package/agents/seeds/steve_seed_a.json +12 -0
- package/agents/seeds/steve_seed_b.json +12 -0
- package/agents/seeds/steve_seed_c.json +12 -0
- package/agents/seeds/steve_seed_d.json +12 -0
- package/agents/sensor.json +22 -0
- package/agents/solution-developer.json +35 -0
- package/agents/source-walker.json +38 -0
- package/agents/spec-drafter.json +31 -0
- package/agents/sub_thread_soft_judge.json +26 -0
- package/agents/sub_thread_trace_sensor.json +22 -0
- package/agents/summarizer.json +25 -0
- package/agents/synthesis-writer.json +29 -0
- package/agents/triage-judge.json +39 -0
- package/agents/user_flow_judge.json +26 -0
- package/agents/user_flow_sensor.json +22 -0
- package/agents/verdict-judger.json +39 -0
- package/core_types/artifact.json +17 -0
- package/core_types/interpretation.json +16 -0
- package/core_types/judgment.json +16 -0
- package/core_types/plan.json +16 -0
- package/core_types/signal.json +16 -0
- package/core_types/verdict.json +27 -0
- package/dist/src/access_grant.d.ts +46 -0
- package/dist/src/access_grant.js +76 -0
- package/dist/src/access_grant.js.map +1 -0
- package/dist/src/acoustics.d.ts +9 -0
- package/dist/src/acoustics.js +65 -0
- package/dist/src/acoustics.js.map +1 -0
- package/dist/src/agent_profile.d.ts +36 -0
- package/dist/src/agent_profile.js +57 -0
- package/dist/src/agent_profile.js.map +1 -0
- package/dist/src/bifrost_invoker.d.ts +33 -0
- package/dist/src/bifrost_invoker.js +85 -0
- package/dist/src/bifrost_invoker.js.map +1 -0
- package/dist/src/canonical_core_types.d.ts +2 -0
- package/dist/src/canonical_core_types.js +111 -0
- package/dist/src/canonical_core_types.js.map +1 -0
- package/dist/src/canonical_form.d.ts +55 -0
- package/dist/src/canonical_form.js +93 -0
- package/dist/src/canonical_form.js.map +1 -0
- package/dist/src/charter.d.ts +37 -0
- package/dist/src/charter.js +86 -0
- package/dist/src/charter.js.map +1 -0
- package/dist/src/circle_of_fifths.d.ts +27 -0
- package/dist/src/circle_of_fifths.js +78 -0
- package/dist/src/circle_of_fifths.js.map +1 -0
- package/dist/src/claude_invoker.d.ts +102 -0
- package/dist/src/claude_invoker.js +760 -0
- package/dist/src/claude_invoker.js.map +1 -0
- package/dist/src/coltrane_profile.d.ts +87 -0
- package/dist/src/coltrane_profile.js +132 -0
- package/dist/src/coltrane_profile.js.map +1 -0
- package/dist/src/composition.d.ts +57 -0
- package/dist/src/composition.js +447 -0
- package/dist/src/composition.js.map +1 -0
- package/dist/src/core_types.d.ts +6 -0
- package/dist/src/core_types.js +25 -0
- package/dist/src/core_types.js.map +1 -0
- package/dist/src/document_factory.d.ts +89 -0
- package/dist/src/document_factory.js +176 -0
- package/dist/src/document_factory.js.map +1 -0
- package/dist/src/genome_schema.d.ts +512 -0
- package/dist/src/genome_schema.js +168 -0
- package/dist/src/genome_schema.js.map +1 -0
- package/dist/src/genome_writer.d.ts +67 -0
- package/dist/src/genome_writer.js +179 -0
- package/dist/src/genome_writer.js.map +1 -0
- package/dist/src/gig_song.d.ts +29 -0
- package/dist/src/gig_song.js +56 -0
- package/dist/src/gig_song.js.map +1 -0
- package/dist/src/gig_tracker.d.ts +54 -0
- package/dist/src/gig_tracker.js +109 -0
- package/dist/src/gig_tracker.js.map +1 -0
- package/dist/src/harmonic_validation.d.ts +11 -0
- package/dist/src/harmonic_validation.js +35 -0
- package/dist/src/harmonic_validation.js.map +1 -0
- package/dist/src/hooks.d.ts +27 -0
- package/dist/src/hooks.js +2 -0
- package/dist/src/hooks.js.map +1 -0
- package/dist/src/index.d.ts +36 -0
- package/dist/src/index.js +37 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/jsong.d.ts +47 -0
- package/dist/src/jsong.js +130 -0
- package/dist/src/jsong.js.map +1 -0
- package/dist/src/judges/user_flow_judge.d.ts +89 -0
- package/dist/src/judges/user_flow_judge.js +200 -0
- package/dist/src/judges/user_flow_judge.js.map +1 -0
- package/dist/src/learner.d.ts +37 -0
- package/dist/src/learner.js +30 -0
- package/dist/src/learner.js.map +1 -0
- package/dist/src/ledger.d.ts +225 -0
- package/dist/src/ledger.js +306 -0
- package/dist/src/ledger.js.map +1 -0
- package/dist/src/loader.d.ts +87 -0
- package/dist/src/loader.js +504 -0
- package/dist/src/loader.js.map +1 -0
- package/dist/src/mcp.d.ts +23 -0
- package/dist/src/mcp.js +125 -0
- package/dist/src/mcp.js.map +1 -0
- package/dist/src/modulation_path.d.ts +42 -0
- package/dist/src/modulation_path.js +60 -0
- package/dist/src/modulation_path.js.map +1 -0
- package/dist/src/output_validation.d.ts +19 -0
- package/dist/src/output_validation.js +51 -0
- package/dist/src/output_validation.js.map +1 -0
- package/dist/src/outputs.d.ts +125 -0
- package/dist/src/outputs.js +452 -0
- package/dist/src/outputs.js.map +1 -0
- package/dist/src/overtones.d.ts +42 -0
- package/dist/src/overtones.js +98 -0
- package/dist/src/overtones.js.map +1 -0
- package/dist/src/player_to_claude_code.d.ts +17 -0
- package/dist/src/player_to_claude_code.js +141 -0
- package/dist/src/player_to_claude_code.js.map +1 -0
- package/dist/src/playwright_cage.d.ts +28 -0
- package/dist/src/playwright_cage.js +47 -0
- package/dist/src/playwright_cage.js.map +1 -0
- package/dist/src/polyphony.d.ts +46 -0
- package/dist/src/polyphony.js +91 -0
- package/dist/src/polyphony.js.map +1 -0
- package/dist/src/portfolio.d.ts +52 -0
- package/dist/src/portfolio.js +186 -0
- package/dist/src/portfolio.js.map +1 -0
- package/dist/src/pricing.d.ts +23 -0
- package/dist/src/pricing.js +16 -0
- package/dist/src/pricing.js.map +1 -0
- package/dist/src/registry.d.ts +46 -0
- package/dist/src/registry.js +199 -0
- package/dist/src/registry.js.map +1 -0
- package/dist/src/runtime.d.ts +304 -0
- package/dist/src/runtime.js +1099 -0
- package/dist/src/runtime.js.map +1 -0
- package/dist/src/server.d.ts +88 -0
- package/dist/src/server.js +1692 -0
- package/dist/src/server.js.map +1 -0
- package/dist/src/server_entry.d.ts +2 -0
- package/dist/src/server_entry.js +40 -0
- package/dist/src/server_entry.js.map +1 -0
- package/dist/src/server_relay.d.ts +82 -0
- package/dist/src/server_relay.js +341 -0
- package/dist/src/server_relay.js.map +1 -0
- package/dist/src/simulate.d.ts +60 -0
- package/dist/src/simulate.js +72 -0
- package/dist/src/simulate.js.map +1 -0
- package/dist/src/skill_runner.mjs +34 -0
- package/dist/src/skill_subprocess.d.ts +65 -0
- package/dist/src/skill_subprocess.js +132 -0
- package/dist/src/skill_subprocess.js.map +1 -0
- package/dist/src/skills.d.ts +85 -0
- package/dist/src/skills.js +258 -0
- package/dist/src/skills.js.map +1 -0
- package/dist/src/subthread_recorder.d.ts +68 -0
- package/dist/src/subthread_recorder.js +166 -0
- package/dist/src/subthread_recorder.js.map +1 -0
- package/dist/src/tensor_read.d.ts +38 -0
- package/dist/src/tensor_read.js +60 -0
- package/dist/src/tensor_read.js.map +1 -0
- package/dist/src/test_honest_broker.d.ts +34 -0
- package/dist/src/test_honest_broker.js +155 -0
- package/dist/src/test_honest_broker.js.map +1 -0
- package/dist/src/test_topology.d.ts +22 -0
- package/dist/src/test_topology.js +137 -0
- package/dist/src/test_topology.js.map +1 -0
- package/dist/src/tones.d.ts +20 -0
- package/dist/src/tones.js +81 -0
- package/dist/src/tones.js.map +1 -0
- package/dist/src/tool_providers.d.ts +45 -0
- package/dist/src/tool_providers.js +104 -0
- package/dist/src/tool_providers.js.map +1 -0
- package/dist/src/type_versioning.d.ts +23 -0
- package/dist/src/type_versioning.js +31 -0
- package/dist/src/type_versioning.js.map +1 -0
- package/dist/src/version.d.ts +28 -0
- package/dist/src/version.js +86 -0
- package/dist/src/version.js.map +1 -0
- package/domain_types/claim-draft.json +34 -0
- package/domain_types/coverage-report.json +31 -0
- package/domain_types/draft-agent-profile.json +31 -0
- package/domain_types/draft-domain-type.json +28 -0
- package/domain_types/draft-standard.json +46 -0
- package/domain_types/e2e-observation.json +32 -0
- package/domain_types/e2e-report.json +30 -0
- package/domain_types/e2e-target.json +26 -0
- package/domain_types/e2e-verdict.json +26 -0
- package/domain_types/examine-round-record.json +40 -0
- package/domain_types/examiner-rejection.json +28 -0
- package/domain_types/failure-modes.json +31 -0
- package/domain_types/invention-analysis.json +34 -0
- package/domain_types/invention-spec.json +34 -0
- package/domain_types/novelty-analysis.json +28 -0
- package/domain_types/novelty-verdict.json +35 -0
- package/domain_types/parsed-conversation-trace.json +38 -0
- package/domain_types/pattern-extraction.json +62 -0
- package/domain_types/prior-art-hit.json +50 -0
- package/domain_types/project-charter.json +71 -0
- package/domain_types/provisional-draft.json +34 -0
- package/domain_types/raw-note.json +19 -0
- package/domain_types/repo-survey.json +28 -0
- package/domain_types/seeding-verdict.json +70 -0
- package/domain_types/soft-verdict.json +55 -0
- package/domain_types/summary.json +19 -0
- package/domain_types/triage-verdict.json +59 -0
- package/domain_types/user-flow-transcript.json +43 -0
- package/domain_types/user-flow-verdict.json +60 -0
- package/domain_types/verdict-record.json +39 -0
- package/package.json +82 -0
- package/skills/citation-verify/fixtures/basic.json +14 -0
- package/skills/citation-verify/meta.json +10 -0
- package/skills/citation-verify/skill.mjs +26 -0
- package/skills/claim-element-decompose/fixtures/basic.json +11 -0
- package/skills/claim-element-decompose/meta.json +10 -0
- package/skills/claim-element-decompose/skill.mjs +14 -0
- package/skills/diamond-cutting-discipline/fixtures/basic.json +13 -0
- package/skills/diamond-cutting-discipline/meta.json +19 -0
- package/skills/diamond-cutting-discipline/skill.md +46 -0
- package/skills/element-mapping-matrix/fixtures/basic.json +13 -0
- package/skills/element-mapping-matrix/meta.json +10 -0
- package/skills/element-mapping-matrix/skill.mjs +20 -0
- package/skills/number-adder/fixtures/basic.json +9 -0
- package/skills/number-adder/meta.json +10 -0
- package/skills/number-adder/skill.mjs +12 -0
- package/skills/patent-fetch/fixtures/parse.json +18 -0
- package/skills/patent-fetch/meta.json +11 -0
- package/skills/patent-fetch/skill.mjs +34 -0
- package/skills/query-expand/fixtures/basic.json +14 -0
- package/skills/query-expand/meta.json +10 -0
- package/skills/query-expand/skill.mjs +29 -0
- package/skills/run-vitest-band/fixtures/basic.json +9 -0
- package/skills/run-vitest-band/meta.json +18 -0
- package/skills/run-vitest-band/skill.mjs +22 -0
- package/skills/statutory-checklist/fixtures/basic.json +16 -0
- package/skills/statutory-checklist/meta.json +10 -0
- package/skills/statutory-checklist/skill.mjs +25 -0
- package/skills/summarize-tight/fixtures/basic.json +13 -0
- package/skills/summarize-tight/meta.json +20 -0
- package/skills/summarize-tight/skill.md +1 -0
- package/skills/verdict-gate/fixtures/basic.json +9 -0
- package/skills/verdict-gate/meta.json +10 -0
- package/skills/verdict-gate/skill.mjs +82 -0
- package/standards/e2e-suite-v0.json +44 -0
- package/standards/patent-triage-v0.json +98 -0
- package/standards/patent-triage-v1.json +84 -0
- package/standards/seed-from-local-repos-v0.json +115 -0
- package/standards/sub_thread_invocation.json +50 -0
- package/standards/summarize.json +40 -0
- package/standards/synthesis-walk-v0.json +108 -0
- package/standards/user_flow_correctness.json +57 -0
|
@@ -0,0 +1,760 @@
|
|
|
1
|
+
// The real AgentInvoker: builds the 5-layer prompt and runs cognition via the
|
|
2
|
+
// `claude` CLI (Claude Code IS the cognition — the prime directive's "depend on
|
|
3
|
+
// nothing but Claude Code"). buildPrompt is pure + testable; runClaude is the one
|
|
4
|
+
// non-deterministic seam (spawns the CLI, parses structured output).
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { writeFileSync, unlinkSync } from "node:fs";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { randomUUID } from "node:crypto";
|
|
10
|
+
import { abortReasonText } from "./runtime.js";
|
|
11
|
+
import { assertToolGrantsResolvable } from "./tool_providers.js";
|
|
12
|
+
import { playwrightServerFor } from "./playwright_cage.js";
|
|
13
|
+
const EMPTY_TOOL_REGISTRY = new Map();
|
|
14
|
+
// Per-tier model resolution (the old MODEL_TIER_MAP: economy/standard/premium →
|
|
15
|
+
// haiku/sonnet/opus). An agent's model_tier picks the concrete spawn model; falls back to
|
|
16
|
+
// the invoker's static default only when the agent declares no tier.
|
|
17
|
+
export const MODEL_TIER_MAP = {
|
|
18
|
+
economy: "claude-haiku-4-5",
|
|
19
|
+
standard: "claude-sonnet-4-6",
|
|
20
|
+
premium: "claude-opus-4-8",
|
|
21
|
+
};
|
|
22
|
+
function resolveModel(tier, fallback) {
|
|
23
|
+
return tier ? MODEL_TIER_MAP[tier] : fallback;
|
|
24
|
+
}
|
|
25
|
+
// code_tool_access → the built-in code tools the cage denies. none denies all; read keeps
|
|
26
|
+
// Read; write keeps Read/Write/Edit; full denies none; unset adds no denial layer.
|
|
27
|
+
const CODE_TOOLS = ["Read", "Write", "Edit", "Bash"];
|
|
28
|
+
function codeToolDenials(access) {
|
|
29
|
+
switch (access) {
|
|
30
|
+
case "none": return [...CODE_TOOLS];
|
|
31
|
+
case "read": return ["Write", "Edit", "Bash"];
|
|
32
|
+
case "write": return ["Bash"];
|
|
33
|
+
default: return []; // "full" or unset → no code-tool denial
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// Belbin cognitive-role descriptions for the Disposition layer (the agent's stance, 2
|
|
37
|
+
// "in tension"). Strings match the old runtime verbatim so a restored prompt reaches
|
|
38
|
+
// parity with the baseline fixtures. Reference data; buildPrompt wires it in.
|
|
39
|
+
export const BELBIN_DESCRIPTIONS = {
|
|
40
|
+
explorer: "Navigates unknown territory, discovers structure, maps the landscape.",
|
|
41
|
+
analyst: "Finds patterns, extracts meaning, builds structured understanding from raw data.",
|
|
42
|
+
critic: "Challenges assumptions, finds weaknesses, demands evidence for every claim.",
|
|
43
|
+
synthesizer: "Combines disparate inputs into coherent wholes, resolves contradictions.",
|
|
44
|
+
planner: "Decomposes goals into sequences, allocates resources, designs strategies.",
|
|
45
|
+
executor: "Produces concrete artifacts, writes code, builds deliverables.",
|
|
46
|
+
audience_modeler: "Understands user perspectives, models personas, anticipates needs.",
|
|
47
|
+
};
|
|
48
|
+
// #237 — what a dispatch-time depth actually ASKS FOR. The prompt half of the lever.
|
|
49
|
+
export const DEPTH_GUIDANCE = {
|
|
50
|
+
skim: " — this is a cheap iteration pass. Do the minimum that produces a well-formed, valid output. Do not explore, do not use tools you do not strictly need, do not elaborate.",
|
|
51
|
+
quick: " — favour speed over exhaustiveness. Cover the obvious ground and stop.",
|
|
52
|
+
standard: "",
|
|
53
|
+
deep: " — be exhaustive. Chase the non-obvious and justify every claim.",
|
|
54
|
+
};
|
|
55
|
+
// #237 — the SPEND half of the lever. `--max-turns` is the only hard per-chair cost bound the
|
|
56
|
+
// cage has, so a shallow depth caps it: a skim run that can still take 100 tool turns is a full
|
|
57
|
+
// run wearing a label. Only ever TIGHTENS an agent's own declared cap, never widens it. Depths
|
|
58
|
+
// with no entry leave the agent's cap exactly as declared.
|
|
59
|
+
export const DEPTH_MAX_TOOL_CALLS = { skim: 8, quick: 16 };
|
|
60
|
+
// The 5-layer prompt hierarchy: Disposition → Identity → Skills → Context → Task.
|
|
61
|
+
// Pure: same context in, same prompt out. Hashable, reviewable, testable.
|
|
62
|
+
// Layer 3 (Skills) is emitted when the AgentInvocationContext carries resolved
|
|
63
|
+
// SkillRecords — the runtime resolves the agent's `skill_slugs` against the
|
|
64
|
+
// genome's skills map and passes the records through. Empty/absent → the Skills
|
|
65
|
+
// section is omitted entirely (no empty header, no noise) so the model only
|
|
66
|
+
// sees skills the agent actually declared.
|
|
67
|
+
export function buildPrompt(ctx, outputSchema,
|
|
68
|
+
// Per-type schemas for a MULTI-output agent (slug → schema). When the agent declares
|
|
69
|
+
// more than one output type, the Task layer asks for a blob keyed by type rather than a
|
|
70
|
+
// single object — the runtime then seals one record per key.
|
|
71
|
+
outputSchemas) {
|
|
72
|
+
const a = ctx.agent;
|
|
73
|
+
const layers = [];
|
|
74
|
+
// 1. Disposition — the Belbin cognitive-role pairing, held in tension (how you think).
|
|
75
|
+
const dispo = a.behavioral_primitives.map((r) => `- **${r}**: ${BELBIN_DESCRIPTIONS[r] ?? r}`).join("\n");
|
|
76
|
+
layers.push(`# Disposition\nYou hold these cognitive modes in equal tension:\n${dispo}\nHold every mode active throughout your work; none dominates.`);
|
|
77
|
+
// 2. Identity — who you are: the slug line plus the agent's own prose.
|
|
78
|
+
layers.push(`# Identity\nYou are the agent "${a.slug}"${a.domain ? ` in the "${a.domain}" domain` : ""}.\n\n${a.identity}`);
|
|
79
|
+
// 3. Method — how THIS agent does its job, the step-by-step.
|
|
80
|
+
layers.push(`# Method\n${a.method}`);
|
|
81
|
+
// 4. Skills — content the agent's bound skills contribute to the prompt. Each
|
|
82
|
+
// skill renders as `## <slug>` + its text payload. We pick the first non-empty
|
|
83
|
+
// string from the conventional content keys (`md`, then `text`, then `body`);
|
|
84
|
+
// a slug-only SkillRecord still renders its slug so the model knows it's bound.
|
|
85
|
+
const resolved = ctx.skills ?? [];
|
|
86
|
+
const skillBlocks = resolved.length > 0
|
|
87
|
+
? resolved.map((s) => {
|
|
88
|
+
const text = (typeof s["md"] === "string" && s["md"]) ||
|
|
89
|
+
(typeof s["text"] === "string" && s["text"]) ||
|
|
90
|
+
(typeof s["body"] === "string" && s["body"]) ||
|
|
91
|
+
"";
|
|
92
|
+
return `## ${s.slug}${text ? `\n${text}` : ""}`;
|
|
93
|
+
})
|
|
94
|
+
// No resolved content this gig — still name the bound skills so the model knows it has
|
|
95
|
+
// them (matches the old runtime's skills index). #241: NEVER name a slug the runtime
|
|
96
|
+
// resolved to no package. An all-dangling agent used to render `# Skills` / `## <slug>`
|
|
97
|
+
// with zero content — the prompt ASSERTING to the model that it holds a discipline that
|
|
98
|
+
// does not exist. An ABSENT `missing_skills` means resolution was never attempted (no
|
|
99
|
+
// skills map), so nothing is known-unresolved and the legacy index behaviour stands.
|
|
100
|
+
: (a.skill_slugs ?? [])
|
|
101
|
+
.filter((slug) => !(ctx.missing_skills ?? []).includes(slug))
|
|
102
|
+
.map((slug) => `## ${slug}`);
|
|
103
|
+
if (skillBlocks.length > 0) {
|
|
104
|
+
layers.push(`# Skills\n${skillBlocks.join("\n\n")}`);
|
|
105
|
+
}
|
|
106
|
+
// 5. Constraints — the negative space (never-invent / cite-sources). Omitted when empty.
|
|
107
|
+
if (a.constraints.length > 0) {
|
|
108
|
+
layers.push(`# Constraints\n${a.constraints.map((c) => `- ${c}`).join("\n")}`);
|
|
109
|
+
}
|
|
110
|
+
// 6. Available Tools — name every granted tool so the model knows it has them and uses
|
|
111
|
+
// them (the cage grants access; the prompt must grant awareness, or the tools sit unused).
|
|
112
|
+
if (a.allowed_tools && a.allowed_tools.length > 0) {
|
|
113
|
+
layers.push(`# Available Tools\nThese tools are available to you — call them directly:\n${a.allowed_tools.map((t) => `- ${t}`).join("\n")}`);
|
|
114
|
+
}
|
|
115
|
+
// 7. Context — the gig input + the upstream typed outputs you consume + depth tuning.
|
|
116
|
+
const inputsBlock = ctx.inputs.length
|
|
117
|
+
? ctx.inputs.map((o) => `- ${o.domain_type} (from ${o.agent_slug}): ${JSON.stringify(o.data)}`).join("\n")
|
|
118
|
+
: "(none — you are a root agent)";
|
|
119
|
+
// #237 — a dispatch-time depth OVERRIDES the agent's static depth_profile, and carries an
|
|
120
|
+
// instruction with it. A depth that only gets recorded is not a cost lever; the model has to
|
|
121
|
+
// be told to do less, or "skim first" stays a slogan and every iteration pays full price.
|
|
122
|
+
const runDepth = ctx.depth ?? a.depth_profile;
|
|
123
|
+
const depthLine = runDepth
|
|
124
|
+
? `Depth: ${runDepth}${ctx.depth ? DEPTH_GUIDANCE[ctx.depth] : ""}\n`
|
|
125
|
+
: "";
|
|
126
|
+
layers.push(`# Context\n${depthLine}Gig input: ${JSON.stringify(ctx.gig_input)}\nUpstream outputs:\n${inputsBlock}`);
|
|
127
|
+
// 5. Task — produce the types THIS CHAIR promises as JSON. #174: the chair's output_contract
|
|
128
|
+
// (threaded as ctx.output_types) is the selector — a multi-capability agent at a single-purpose
|
|
129
|
+
// chair is asked for only its promised subset, not its whole catalogue. Legacy ctx without it
|
|
130
|
+
// falls back to the agent's full output_types.
|
|
131
|
+
const sealTypes = ctx.output_types?.length ? ctx.output_types : a.output_types;
|
|
132
|
+
if (sealTypes.length > 1) {
|
|
133
|
+
// multi-output: one JSON object keyed by each output-type slug; each value is that
|
|
134
|
+
// type's data. The runtime seals one record per key (a SENSE+JUDGE agent yields its
|
|
135
|
+
// Signal and its Judgment in one pass).
|
|
136
|
+
const perType = sealTypes
|
|
137
|
+
.map((t) => {
|
|
138
|
+
const s = outputSchemas?.[t];
|
|
139
|
+
return ` "${t}": <object${s ? ` matching ${JSON.stringify(s)}` : ""}>`;
|
|
140
|
+
})
|
|
141
|
+
.join(",\n");
|
|
142
|
+
layers.push(`# Task\nProduce one object for EACH of your output types: ${sealTypes.map((t) => `"${t}"`).join(", ")}.\n` +
|
|
143
|
+
`Respond with ONLY a single JSON object keyed by output-type name — no prose, no code fence:\n{\n${perType}\n}`);
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
const outType = sealTypes[0] ?? "output";
|
|
147
|
+
const schemaHint = outputSchema ? `\nIt must match this JSON schema:\n${JSON.stringify(outputSchema)}` : "";
|
|
148
|
+
layers.push(`# Task\nProduce exactly one "${outType}".${schemaHint}\n` +
|
|
149
|
+
`Respond with ONLY a single JSON object (the output's data) — no prose, no code fence.`);
|
|
150
|
+
}
|
|
151
|
+
return layers.join("\n\n");
|
|
152
|
+
}
|
|
153
|
+
// ───────────────────────── JSON extraction (#221, #226) ─────────────────────────
|
|
154
|
+
//
|
|
155
|
+
// The old implementation took "the first balanced brace run" — string-blind, anchored on
|
|
156
|
+
// the first `{` and never re-anchored, with exactly one candidate ever handed to
|
|
157
|
+
// JSON.parse. It mis-sliced valid output (a `}` inside a string value truncated the slice)
|
|
158
|
+
// and, worse, silently returned an illustrative preamble object in place of the answer.
|
|
159
|
+
// That wrong object then sealed with a real content_sha and genuine provenance edges, so
|
|
160
|
+
// `output_trace` reported an intact chain over garbage.
|
|
161
|
+
//
|
|
162
|
+
// This is now ONE implementation shared by all four production call sites (:319, :325,
|
|
163
|
+
// bifrost_invoker.ts, document_factory.ts) — see #226; the judge's half-fixed duplicate is
|
|
164
|
+
// gone.
|
|
165
|
+
/** Bound on the raw-output excerpt a parse failure carries, so no blob lands in a log line. */
|
|
166
|
+
const EXCERPT_MAX_CHARS = 500;
|
|
167
|
+
/**
|
|
168
|
+
* A typed extraction failure. Carries the number of balanced JSON objects found and a
|
|
169
|
+
* bounded excerpt of the raw text — previously both throws were bare `Error`s with no
|
|
170
|
+
* sample, so the operator's entire diagnostic was a V8 offset into a string never
|
|
171
|
+
* surfaced. The type is also the prerequisite for any future retry policy: runtime.ts
|
|
172
|
+
* cannot currently tell a retryable parse failure from a non-retryable contract failure.
|
|
173
|
+
*/
|
|
174
|
+
export class ModelOutputParseError extends Error {
|
|
175
|
+
candidateCount;
|
|
176
|
+
excerpt;
|
|
177
|
+
constructor(message, candidateCount, raw) {
|
|
178
|
+
super(`${message} (candidates: ${candidateCount})`);
|
|
179
|
+
this.name = "ModelOutputParseError";
|
|
180
|
+
this.candidateCount = candidateCount;
|
|
181
|
+
this.excerpt =
|
|
182
|
+
raw.length > EXCERPT_MAX_CHARS ? `${raw.slice(0, EXCERPT_MAX_CHARS)}…` : raw;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Walk forward from `start` (which must be a `{`) honouring JSON string literals and
|
|
187
|
+
* backslash escapes, so only STRUCTURAL braces move the depth counter. Returns the index
|
|
188
|
+
* of the matching `}`, or -1 if the object never closes.
|
|
189
|
+
*
|
|
190
|
+
* Escape handling is the half of this that is easiest to get wrong, and two guards pin
|
|
191
|
+
* it: `{"path":"C:\\","v":1}` (an escaped backslash immediately before the closing quote
|
|
192
|
+
* — a naive `inString = !inString` toggle breaks it) and `{"note":"use {slug} here"}`
|
|
193
|
+
* (balanced in-string braces, which worked by accident before and must keep working).
|
|
194
|
+
*/
|
|
195
|
+
function scanBalanced(text, start) {
|
|
196
|
+
let depth = 0;
|
|
197
|
+
let inString = false;
|
|
198
|
+
let escaped = false;
|
|
199
|
+
for (let i = start; i < text.length; i++) {
|
|
200
|
+
const ch = text[i];
|
|
201
|
+
if (escaped) {
|
|
202
|
+
escaped = false;
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (inString) {
|
|
206
|
+
if (ch === "\\") {
|
|
207
|
+
escaped = true;
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
if (ch === '"')
|
|
211
|
+
inString = false;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (ch === '"') {
|
|
215
|
+
inString = true;
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (ch === "{")
|
|
219
|
+
depth++;
|
|
220
|
+
else if (ch === "}") {
|
|
221
|
+
depth--;
|
|
222
|
+
if (depth === 0)
|
|
223
|
+
return i;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return -1;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Every balanced, parseable JSON object in the text, in document order.
|
|
230
|
+
*
|
|
231
|
+
* Enumerates at EVERY `{` start position — the old code fixed `start` at the first one,
|
|
232
|
+
* so a brace run in the prose (`The set {a,b} matters.`) sank the whole extraction. A
|
|
233
|
+
* start that fails to parse is skipped and the scan re-anchors on the next `{`.
|
|
234
|
+
*
|
|
235
|
+
* Starts INSIDE an accepted candidate are skipped, so `{"a":{"b":1},"c":2}` yields the
|
|
236
|
+
* outer object rather than also offering its own nested `{"b":1}` as a rival.
|
|
237
|
+
*/
|
|
238
|
+
function enumerateCandidates(text) {
|
|
239
|
+
const found = [];
|
|
240
|
+
let i = 0;
|
|
241
|
+
while (i < text.length) {
|
|
242
|
+
if (text[i] !== "{") {
|
|
243
|
+
i++;
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
const end = scanBalanced(text, i);
|
|
247
|
+
if (end === -1) {
|
|
248
|
+
i++;
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
let parsed;
|
|
252
|
+
try {
|
|
253
|
+
parsed = JSON.parse(text.slice(i, end + 1));
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
i++;
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
260
|
+
found.push({ start: i, end, value: parsed });
|
|
261
|
+
i = end + 1;
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
i++;
|
|
265
|
+
}
|
|
266
|
+
return found;
|
|
267
|
+
}
|
|
268
|
+
// A fenced block, tagged (```json) or bare (```). Non-greedy so consecutive fences are
|
|
269
|
+
// separate spans rather than one span swallowing the prose between them.
|
|
270
|
+
const FENCE_RE = /```[ \t]*[A-Za-z0-9_+-]*[ \t]*\r?\n([\s\S]*?)```/g;
|
|
271
|
+
/** Character spans of every fenced block's BODY. */
|
|
272
|
+
function fenceSpans(text) {
|
|
273
|
+
const spans = [];
|
|
274
|
+
FENCE_RE.lastIndex = 0;
|
|
275
|
+
let m;
|
|
276
|
+
while ((m = FENCE_RE.exec(text)) !== null) {
|
|
277
|
+
const body = m[1] ?? "";
|
|
278
|
+
const bodyStart = m.index + m[0].length - 3 - body.length;
|
|
279
|
+
spans.push({ start: bodyStart, end: bodyStart + body.length - 1 });
|
|
280
|
+
}
|
|
281
|
+
return spans;
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Extract the model's answer object from its output.
|
|
285
|
+
*
|
|
286
|
+
* Selection policy (a deliberate contract change from "the first balanced object"):
|
|
287
|
+
* 1. A candidate inside a fenced block beats one outside; among fenced, prefer the last.
|
|
288
|
+
* 2. Then `expectKeys` — a candidate must contain ALL expected keys to qualify.
|
|
289
|
+
* 3. Then the LAST surviving candidate, not the first. The prompt demands a single
|
|
290
|
+
* object (buildPrompt :146/:153), so an earlier object is evidence of scaffolding.
|
|
291
|
+
*
|
|
292
|
+
* A whole-text top-level array is handled explicitly: one element unwraps, more than one
|
|
293
|
+
* throws rather than silently discarding the array framing and every later element.
|
|
294
|
+
*/
|
|
295
|
+
export function extractJson(text, opts = {}) {
|
|
296
|
+
const trimmed = text.trim();
|
|
297
|
+
if (trimmed.startsWith("[")) {
|
|
298
|
+
let arr;
|
|
299
|
+
try {
|
|
300
|
+
arr = JSON.parse(trimmed);
|
|
301
|
+
}
|
|
302
|
+
catch { /* not a clean array — fall through */ }
|
|
303
|
+
if (Array.isArray(arr)) {
|
|
304
|
+
const only = arr.length === 1 ? arr[0] : undefined;
|
|
305
|
+
if (only && typeof only === "object" && !Array.isArray(only)) {
|
|
306
|
+
return only;
|
|
307
|
+
}
|
|
308
|
+
throw new ModelOutputParseError(`model output is a ${arr.length}-element JSON array where a single object was required`, arr.length, text);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
const candidates = enumerateCandidates(text);
|
|
312
|
+
if (candidates.length === 0) {
|
|
313
|
+
throw new ModelOutputParseError("no JSON object in model output — the model produced no answer", 0, text);
|
|
314
|
+
}
|
|
315
|
+
// 1. Fenced candidates win outright when any exist.
|
|
316
|
+
const spans = fenceSpans(text);
|
|
317
|
+
const fenced = candidates.filter((c) => spans.some((s) => c.start >= s.start && c.end <= s.end));
|
|
318
|
+
let pool = fenced.length > 0 ? fenced : candidates;
|
|
319
|
+
// 2. Expected keys — ALL must be present. If nothing matches, the signal simply does not
|
|
320
|
+
// narrow (it never widens, and it never picks a partial match).
|
|
321
|
+
const keys = opts.expectKeys ?? [];
|
|
322
|
+
if (keys.length > 0) {
|
|
323
|
+
const matching = pool.filter((c) => keys.every((k) => Object.prototype.hasOwnProperty.call(c.value, k)));
|
|
324
|
+
if (matching.length > 0)
|
|
325
|
+
pool = matching;
|
|
326
|
+
}
|
|
327
|
+
// 3. Refuse to guess when the caller had nothing to score against.
|
|
328
|
+
if (pool.length > 1 && opts.requireUnambiguous === true) {
|
|
329
|
+
throw new ModelOutputParseError("ambiguous model output — several JSON objects and no output schema to choose between them", pool.length, text);
|
|
330
|
+
}
|
|
331
|
+
return pool[pool.length - 1].value;
|
|
332
|
+
}
|
|
333
|
+
/** Property names declared by a resolved output schema (the single-output key signal). */
|
|
334
|
+
function schemaPropertyNames(schema) {
|
|
335
|
+
const props = schema?.["properties"];
|
|
336
|
+
return props && typeof props === "object" ? Object.keys(props) : [];
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Build the extractor's options for a chair from what the invoker already resolved.
|
|
340
|
+
* Shared by the Claude and Bifrost invokers so the key signal reaches every call site —
|
|
341
|
+
* behaviour propagates through the shared import, but `expectKeys` does not unless each
|
|
342
|
+
* site passes it (#221 policy 5).
|
|
343
|
+
*
|
|
344
|
+
* The two prompt shapes are mutually exclusive, so the derivation is per-shape:
|
|
345
|
+
* - MULTI-output chair — buildPrompt :138-147 asks for a blob keyed by type slug, so the
|
|
346
|
+
* slugs are the expected keys.
|
|
347
|
+
* - SINGLE-output chair — buildPrompt :148-155 asks for the bare data object, never
|
|
348
|
+
* wrapped in {"<type-slug>": …}, so the resolved schema's property names are the
|
|
349
|
+
* signal. Unioning the slug in here would make the set unsatisfiable under
|
|
350
|
+
* all-must-match semantics and destroy the signal entirely.
|
|
351
|
+
* - Neither available (bare core type, or a domain type absent from the registry) —
|
|
352
|
+
* no signal, so refuse to guess between rival candidates.
|
|
353
|
+
*/
|
|
354
|
+
export function extractOptionsForChair(sealTypes, schema) {
|
|
355
|
+
if (sealTypes.length > 1)
|
|
356
|
+
return { expectKeys: [...sealTypes] };
|
|
357
|
+
const props = schemaPropertyNames(schema);
|
|
358
|
+
return props.length > 0 ? { expectKeys: props } : { requireUnambiguous: true };
|
|
359
|
+
}
|
|
360
|
+
// The wall-clock bound on one chair's spawn. A tool-granted child has no inherent
|
|
361
|
+
// terminus (it can search/loop), and the gig runs the spawn synchronously — so without
|
|
362
|
+
// this bound one wedged child wedges the whole server. SIGKILL, not SIGTERM: a
|
|
363
|
+
// signal-trapping child can't outlive its budget. Long enough for a tool-using chair
|
|
364
|
+
// (a capped search agent runs minutes), far below an operator-visible hang.
|
|
365
|
+
export const DEFAULT_CHAIR_TIMEOUT_MS = 10 * 60_000;
|
|
366
|
+
// How long a cancelled chair child gets to shut down politely before it is killed outright.
|
|
367
|
+
// SIGTERM first (a `claude` child spawns its own MCP servers; a cooperative exit gives it a
|
|
368
|
+
// chance to take them with it), SIGKILL after. Deliberately NOT `detached: true` +
|
|
369
|
+
// process.kill(-pid): that is the only airtight answer to grandchild orphaning, but it takes
|
|
370
|
+
// children OUT of the server's process group, so an operator's Ctrl-C stops reaching them —
|
|
371
|
+
// which makes #252 worse, not better. Process-group kill is a separate decision.
|
|
372
|
+
export const DEFAULT_ABORT_GRACE_MS = 2_000;
|
|
373
|
+
// #250/#252 — every chair child the invoker spawns, so something can reach them.
|
|
374
|
+
// Before this, `child` was a const inside spawnStreaming's promise executor: never returned,
|
|
375
|
+
// never registered, never exposed, and the ONLY path to child.kill was the timeout closure.
|
|
376
|
+
// A server told to shut down could not stop its own grandchildren, which kept running,
|
|
377
|
+
// orphaned, still billing — with gig tracking dropped, so nothing recorded they existed.
|
|
378
|
+
const LIVE_CHAIR_CHILDREN = new Set();
|
|
379
|
+
/** How many chair children are alive right now (observability for the shutdown path). */
|
|
380
|
+
export function liveChairChildCount() {
|
|
381
|
+
return LIVE_CHAIR_CHILDREN.size;
|
|
382
|
+
}
|
|
383
|
+
/** SIGTERM every live chair child, escalating to SIGKILL after `graceMs`. Returns the count
|
|
384
|
+
* signalled. Called on server shutdown so a restart is not an orphaning. */
|
|
385
|
+
export function killLiveChairChildren(graceMs = DEFAULT_ABORT_GRACE_MS) {
|
|
386
|
+
const victims = [...LIVE_CHAIR_CHILDREN];
|
|
387
|
+
for (const c of victims)
|
|
388
|
+
terminateChild(c, graceMs);
|
|
389
|
+
return victims.length;
|
|
390
|
+
}
|
|
391
|
+
function terminateChild(child, graceMs) {
|
|
392
|
+
const hardKill = () => { try {
|
|
393
|
+
child.kill("SIGKILL");
|
|
394
|
+
}
|
|
395
|
+
catch { /* already gone */ } };
|
|
396
|
+
try {
|
|
397
|
+
child.kill("SIGTERM");
|
|
398
|
+
}
|
|
399
|
+
catch { /* already gone */ }
|
|
400
|
+
if (graceMs <= 0) {
|
|
401
|
+
hardKill();
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
const t = setTimeout(() => {
|
|
405
|
+
if (child.exitCode === null && child.signalCode === null)
|
|
406
|
+
hardKill();
|
|
407
|
+
}, graceMs);
|
|
408
|
+
// never hold the event loop open just to escalate a kill
|
|
409
|
+
t.unref?.();
|
|
410
|
+
}
|
|
411
|
+
// The blast-radius cage, PURE. Given the agent's tool grant + a per-gig mcp-config path,
|
|
412
|
+
// build the claude CLI args. Two halves: `--strict-mcp-config` + `--mcp-config <path>`
|
|
413
|
+
// means the spawn loads ONLY the servers in that file (never the host's ambient MCP) —
|
|
414
|
+
// deny-by-default. `--allowedTools`/`--disallowedTools` scope the tool surface to the
|
|
415
|
+
// agent's declared grant. Ports OG's claude-launcher 4-flag cage.
|
|
416
|
+
export function buildInvokerArgs(prompt, mcpConfigPath, opts) {
|
|
417
|
+
const args = ["-p", prompt];
|
|
418
|
+
if (opts.model)
|
|
419
|
+
args.push("--model", opts.model);
|
|
420
|
+
// per-agent blast-radius cap: a runaway agent can't burn past its own turn budget.
|
|
421
|
+
if (opts.max_tool_calls !== undefined)
|
|
422
|
+
args.push("--max-turns", String(opts.max_tool_calls));
|
|
423
|
+
// the cage floor: no ambient MCP servers leak into the spawn, ever.
|
|
424
|
+
args.push("--mcp-config", mcpConfigPath, "--strict-mcp-config");
|
|
425
|
+
if (opts.allowed_tools && opts.allowed_tools.length > 0)
|
|
426
|
+
args.push("--allowedTools", opts.allowed_tools.join(","));
|
|
427
|
+
if (opts.disallowed_tools && opts.disallowed_tools.length > 0)
|
|
428
|
+
args.push("--disallowedTools", opts.disallowed_tools.join(","));
|
|
429
|
+
return args;
|
|
430
|
+
}
|
|
431
|
+
// The production AgentInvoker. Writes a per-gig mcp-config (the permitted servers only),
|
|
432
|
+
// spawns `claude -p` inside the cage, parses the JSON. The spawn is the non-deterministic
|
|
433
|
+
// seam (inject `run` to test the cage args + parse without the CLI). When a
|
|
434
|
+
// parent_session_id is provided, every spawned MCP server in this child receives it via
|
|
435
|
+
// env so the recorder seals the parent → child lineage edge on the child's first turn.
|
|
436
|
+
export function makeClaudeInvoker(opts = {}) {
|
|
437
|
+
const bin = opts.bin ?? "claude";
|
|
438
|
+
// Injected run (tests) short-circuits the spawn: plain mode, returns the JSON blob directly.
|
|
439
|
+
// Absent → the default streaming spawn below runs the real CLI with stream-json.
|
|
440
|
+
const customRun = opts.run;
|
|
441
|
+
const spawnBounds = { timeout: opts.timeout_ms ?? DEFAULT_CHAIR_TIMEOUT_MS, killSignal: "SIGKILL" };
|
|
442
|
+
// #185 — grant resolution is enabled once the deployment wires a provider registry (either map
|
|
443
|
+
// present). Until then the invoker keeps its legacy pass-through (tools listed, no resolution) so
|
|
444
|
+
// a bare/test invoker is unaffected. bootstrapServerDeps always supplies mcpServerConfigs, so the
|
|
445
|
+
// running engine always resolves + fails closed.
|
|
446
|
+
const resolutionEnabled = opts.toolProviders !== undefined || opts.mcpServerConfigs !== undefined;
|
|
447
|
+
const abortGraceMs = opts.abort_grace_ms ?? DEFAULT_ABORT_GRACE_MS;
|
|
448
|
+
return async (ctx) => {
|
|
449
|
+
// #250 — a chair whose gig is already cancelled spends nothing: no prompt, no mcp-config,
|
|
450
|
+
// no spawn. This is the cheapest point on the whole cancellation chain.
|
|
451
|
+
if (ctx.signal?.aborted) {
|
|
452
|
+
throw new Error(`chair "${ctx.agent.slug}" not started — gig aborted (${abortReasonText(ctx.signal)})`);
|
|
453
|
+
}
|
|
454
|
+
// Resolve THIS agent's grants → the MCP servers it needs, FIRST: a grant with no resolvable
|
|
455
|
+
// provider is a dead name, so fail the chair closed before we build a prompt or spawn a child
|
|
456
|
+
// that advertises a tool it can't call.
|
|
457
|
+
let resolvedMcpServers = {};
|
|
458
|
+
// The grants as the SPAWN must see them in --allowedTools. Default to the raw grant list (the
|
|
459
|
+
// legacy pass-through invoker); when resolution is on, use the resolved names — an in-house engine
|
|
460
|
+
// tool granted by bare slug becomes mcp__<server>__<tool>, the name its server advertises (#204).
|
|
461
|
+
let effectiveAllowed = ctx.agent.allowed_tools;
|
|
462
|
+
if (resolutionEnabled) {
|
|
463
|
+
// The caged browser: if this agent declares a browser_grant, coltrane builds a deny-by-default
|
|
464
|
+
// Playwright server scoped to exactly its allowed origins and offers it as the "playwright"
|
|
465
|
+
// provider. An agent that grants mcp__playwright__* tools but declares NO browser_grant has no
|
|
466
|
+
// playwright config → its grant is unresolvable → fails closed (no uncaged browser, ever).
|
|
467
|
+
const browserCage = playwrightServerFor(ctx.agent.browser_grant);
|
|
468
|
+
const effectiveConfigs = browserCage
|
|
469
|
+
? { ...(opts.mcpServerConfigs ?? {}), playwright: browserCage }
|
|
470
|
+
: (opts.mcpServerConfigs ?? {});
|
|
471
|
+
// assertToolGrantsResolvable is the single source of the fail-closed guard (it throws on a
|
|
472
|
+
// dead name) AND returns the resolved servers — no duplicated inline throw.
|
|
473
|
+
const resolved = assertToolGrantsResolvable(ctx.agent.slug, ctx.agent.allowed_tools ?? [], opts.toolProviders ?? EMPTY_TOOL_REGISTRY, effectiveConfigs);
|
|
474
|
+
resolvedMcpServers = resolved.mcpServers;
|
|
475
|
+
effectiveAllowed = resolved.effectiveAllowed;
|
|
476
|
+
}
|
|
477
|
+
const types = opts.registry?.listTypes() ?? [];
|
|
478
|
+
const schemaOf = (slug) => types.find((t) => t.slug === slug)?.schema;
|
|
479
|
+
// #174 — schemas follow the chair's promised subset (ctx.output_types), not the agent's
|
|
480
|
+
// whole catalogue; legacy ctx without it falls back to the agent's full output_types.
|
|
481
|
+
const sealTypes = ctx.output_types?.length ? ctx.output_types : ctx.agent.output_types;
|
|
482
|
+
const outType = sealTypes[0];
|
|
483
|
+
const schema = schemaOf(outType);
|
|
484
|
+
// For a multi-output chair, resolve every promised type's schema so the Task layer can
|
|
485
|
+
// ask for a blob keyed by type; the runtime seals one record per key.
|
|
486
|
+
const outputSchemas = sealTypes.length > 1
|
|
487
|
+
? Object.fromEntries(sealTypes.map((t) => [t, schemaOf(t)]))
|
|
488
|
+
: undefined;
|
|
489
|
+
const prompt = buildPrompt(ctx, schema, outputSchemas);
|
|
490
|
+
// #221 — the key signal for candidate selection, derived from what we just resolved.
|
|
491
|
+
// Threaded into BOTH extract calls below; threading only the injected-run one would
|
|
492
|
+
// leave every real chair unscored.
|
|
493
|
+
const extractOpts = extractOptionsForChair(sealTypes, schema);
|
|
494
|
+
// per-gig mcp-config: only the deployment-permitted servers (empty by default).
|
|
495
|
+
const cfgPath = join(tmpdir(), `coltrane-mcp-${randomUUID()}.json`);
|
|
496
|
+
// the base map (opts.mcpServers) + the per-agent servers its grants resolved to (#185).
|
|
497
|
+
const servers = { ...(opts.mcpServers ?? {}), ...resolvedMcpServers };
|
|
498
|
+
const parent = opts.parent_session_id;
|
|
499
|
+
// Inject parent_session_id env into every named server so children seal lineage.
|
|
500
|
+
const enriched = parent
|
|
501
|
+
? Object.fromEntries(Object.entries(servers).map(([name, def]) => {
|
|
502
|
+
const d = (def && typeof def === "object" ? def : {});
|
|
503
|
+
const env = (d["env"] && typeof d["env"] === "object" ? d["env"] : {});
|
|
504
|
+
return [name, { ...d, env: { ...env, COLTRANE_PARENT_SESSION_ID: parent } }];
|
|
505
|
+
}))
|
|
506
|
+
: servers;
|
|
507
|
+
writeFileSync(cfgPath, JSON.stringify({ mcpServers: enriched }));
|
|
508
|
+
try {
|
|
509
|
+
const a = ctx.agent;
|
|
510
|
+
// #237 — a shallow run depth tightens the turn cap; it never widens the agent's own.
|
|
511
|
+
const depthCap = ctx.depth ? DEPTH_MAX_TOOL_CALLS[ctx.depth] : undefined;
|
|
512
|
+
const maxToolCalls = depthCap === undefined
|
|
513
|
+
? a.max_tool_calls
|
|
514
|
+
: Math.min(depthCap, a.max_tool_calls ?? depthCap);
|
|
515
|
+
const baseArgs = buildInvokerArgs(prompt, cfgPath, {
|
|
516
|
+
model: resolveModel(a.model_tier, opts.model),
|
|
517
|
+
allowed_tools: effectiveAllowed,
|
|
518
|
+
disallowed_tools: [...(a.disallowed_tools ?? []), ...codeToolDenials(a.code_tool_access)],
|
|
519
|
+
max_tool_calls: maxToolCalls,
|
|
520
|
+
});
|
|
521
|
+
// Custom run (tests): plain mode, the returned string IS the JSON blob — no streaming.
|
|
522
|
+
if (customRun)
|
|
523
|
+
return extractJson(await customRun(bin, baseArgs, spawnBounds), extractOpts);
|
|
524
|
+
// Default: stream-json so the child's tool calls / reasoning are observable LIVE. Each
|
|
525
|
+
// event is forwarded to ctx.onEvent (the runtime tees it to the gig's per-chair log);
|
|
526
|
+
// the final result text is extracted from the stream and parsed into the typed output.
|
|
527
|
+
const args = [...baseArgs, "--output-format", "stream-json", "--verbose"];
|
|
528
|
+
// #250 level 3 — the cancellation signal and grace window reach the spawn, so an
|
|
529
|
+
// aborted gig actually kills its chair child (and never spawns one if already aborted).
|
|
530
|
+
const stdout = await spawnStreaming(bin, args, spawnBounds, ctx.onEvent, ctx.signal, abortGraceMs);
|
|
531
|
+
const outcome = finalText(stdout);
|
|
532
|
+
// #223 — the child reported an error result. Both discriminators are required, and
|
|
533
|
+
// both are verified against the CLI (see the note on StreamOutcome): `subtype` for a
|
|
534
|
+
// run that did not complete, `is_error` for an API-error payload riding subtype
|
|
535
|
+
// "success". Neither is a chair answer, and the CLI exits 0 for the subtype cases —
|
|
536
|
+
// so without this the partial reasoning seals as if it had succeeded.
|
|
537
|
+
if (outcome.errorSubtype !== undefined) {
|
|
538
|
+
throw new Error(`claude ended with result subtype "${outcome.errorSubtype}" — the run did not ` +
|
|
539
|
+
`complete, so any text it emitted is partial reasoning, not an answer`);
|
|
540
|
+
}
|
|
541
|
+
if (outcome.apiErrorText !== undefined) {
|
|
542
|
+
throw new Error(`claude flagged its result with is_error — the payload is an error message, not an ` +
|
|
543
|
+
`answer: ${outcome.apiErrorText.slice(0, 300)}`);
|
|
544
|
+
}
|
|
545
|
+
// #222 — the stream parsed but carried no answer at all (e.g. only a system/init
|
|
546
|
+
// event). Report THAT, with the raw stdout as evidence, instead of blaming the model
|
|
547
|
+
// for emitting no JSON.
|
|
548
|
+
if (outcome.text.trim() === "") {
|
|
549
|
+
throw new ModelOutputParseError("the model produced no answer — the stream carried no result text and no assistant text", 0, stdout);
|
|
550
|
+
}
|
|
551
|
+
return extractJson(outcome.text, extractOpts);
|
|
552
|
+
}
|
|
553
|
+
finally {
|
|
554
|
+
try {
|
|
555
|
+
unlinkSync(cfgPath);
|
|
556
|
+
}
|
|
557
|
+
catch { /* best-effort cleanup */ }
|
|
558
|
+
}
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
// Spawn a child and stream its stdout line-by-line. Each complete line is parsed as a
|
|
562
|
+
// stream-json event and forwarded (granularly) to onEvent as it arrives — this is the
|
|
563
|
+
// agent-layer observability seam. Returns the full stdout on clean exit; rejects on
|
|
564
|
+
// non-zero exit (with stderr), timeout (SIGKILL, so a signal-trapping child can't survive),
|
|
565
|
+
// or cancellation via `signal` (SIGTERM → grace → SIGKILL).
|
|
566
|
+
//
|
|
567
|
+
// The child is REGISTERED in LIVE_CHAIR_CHILDREN for its whole lifetime (#250/#252): a handle
|
|
568
|
+
// nothing holds is a process nothing can stop.
|
|
569
|
+
function spawnStreaming(bin, args, bounds, onEvent, signal, abortGraceMs = DEFAULT_ABORT_GRACE_MS) {
|
|
570
|
+
return new Promise((resolve, reject) => {
|
|
571
|
+
if (signal?.aborted) {
|
|
572
|
+
reject(new Error(`chair child not spawned — gig aborted (${abortReasonText(signal)})`));
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
const child = spawn(bin, [...args], { stdio: ["ignore", "pipe", "pipe"] });
|
|
576
|
+
LIVE_CHAIR_CHILDREN.add(child);
|
|
577
|
+
let stdout = "";
|
|
578
|
+
let stderr = "";
|
|
579
|
+
let buf = "";
|
|
580
|
+
let timer;
|
|
581
|
+
let onAbort;
|
|
582
|
+
const release = () => {
|
|
583
|
+
if (timer)
|
|
584
|
+
clearTimeout(timer);
|
|
585
|
+
LIVE_CHAIR_CHILDREN.delete(child);
|
|
586
|
+
if (onAbort && signal)
|
|
587
|
+
signal.removeEventListener("abort", onAbort);
|
|
588
|
+
};
|
|
589
|
+
if (signal) {
|
|
590
|
+
onAbort = () => {
|
|
591
|
+
const reason = abortReasonText(signal);
|
|
592
|
+
terminateChild(child, abortGraceMs);
|
|
593
|
+
release();
|
|
594
|
+
reject(new Error(`chair child aborted: ${reason}`));
|
|
595
|
+
};
|
|
596
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
597
|
+
}
|
|
598
|
+
timer = setTimeout(() => {
|
|
599
|
+
child.kill(bounds.killSignal);
|
|
600
|
+
release();
|
|
601
|
+
reject(new Error(`chair child timed out after ${bounds.timeout}ms (${bounds.killSignal})`));
|
|
602
|
+
}, bounds.timeout);
|
|
603
|
+
const forwardLine = (line) => {
|
|
604
|
+
if (!line || !onEvent)
|
|
605
|
+
return;
|
|
606
|
+
try {
|
|
607
|
+
forwardStreamEvent(JSON.parse(line), onEvent);
|
|
608
|
+
}
|
|
609
|
+
catch { /* non-json line */ }
|
|
610
|
+
};
|
|
611
|
+
child.stdout.on("data", (chunk) => {
|
|
612
|
+
const s = chunk.toString();
|
|
613
|
+
stdout += s;
|
|
614
|
+
buf += s;
|
|
615
|
+
let nl;
|
|
616
|
+
while ((nl = buf.indexOf("\n")) >= 0) {
|
|
617
|
+
const line = buf.slice(0, nl).trim();
|
|
618
|
+
buf = buf.slice(nl + 1);
|
|
619
|
+
forwardLine(line);
|
|
620
|
+
}
|
|
621
|
+
});
|
|
622
|
+
child.stderr.on("data", (chunk) => { stderr += chunk.toString(); });
|
|
623
|
+
child.on("error", (e) => { release(); reject(e); });
|
|
624
|
+
child.on("close", (code) => {
|
|
625
|
+
// #250/#252 — release() subsumes the old clearTimeout: it also deregisters the child
|
|
626
|
+
// from LIVE_CHAIR_CHILDREN and drops the abort listener. It runs FIRST so a throwing
|
|
627
|
+
// onEvent in the flush below cannot leak the registration or the timer.
|
|
628
|
+
release();
|
|
629
|
+
// #224 — the read loop only drains on "\n", so a final line with no trailing newline
|
|
630
|
+
// was never forwarded. The usage sink (runtime.ts makeUsageSink) reads total_cost_usd
|
|
631
|
+
// ONLY from result events, so that chair's spend silently vanished from GigResult.usage
|
|
632
|
+
// and the per-chair jsonl lost its last event. finalText was unaffected (it re-splits
|
|
633
|
+
// the whole stdout), which is exactly why it was silent: the run succeeded and only
|
|
634
|
+
// the accounting was wrong. Flush before settling, on the failure path too — a chair
|
|
635
|
+
// that failed still spent money.
|
|
636
|
+
const tail = buf.trim();
|
|
637
|
+
buf = "";
|
|
638
|
+
forwardLine(tail);
|
|
639
|
+
if (code !== 0)
|
|
640
|
+
reject(new Error(`claude exited ${code}: ${stderr.slice(0, 500)}`));
|
|
641
|
+
else
|
|
642
|
+
resolve(stdout);
|
|
643
|
+
});
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
// Map a child stream-json event to granular AgentStreamEvents. assistant content explodes
|
|
647
|
+
// into per-block tool_use / text events (so a monitor sees each tool call); result passes
|
|
648
|
+
// its text; everything else passes its type + raw.
|
|
649
|
+
function forwardStreamEvent(evt, onEvent) {
|
|
650
|
+
const type = String(evt["type"] ?? "event");
|
|
651
|
+
if (type === "assistant" && evt["message"] && typeof evt["message"] === "object") {
|
|
652
|
+
const content = evt["message"].content ?? [];
|
|
653
|
+
for (const b of content) {
|
|
654
|
+
const bt = String(b["type"] ?? "");
|
|
655
|
+
if (bt === "tool_use")
|
|
656
|
+
onEvent({ type: "tool_use", tool: String(b["name"] ?? ""), raw: b });
|
|
657
|
+
else if (bt === "text")
|
|
658
|
+
onEvent({ type: "assistant", text: String(b["text"] ?? ""), raw: b });
|
|
659
|
+
}
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
if (type === "result") {
|
|
663
|
+
onEvent({ type: "result", text: typeof evt["result"] === "string" ? evt["result"] : undefined, raw: evt });
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
onEvent({ type, raw: evt });
|
|
667
|
+
}
|
|
668
|
+
// The `type` values the CLI's stream-json actually emits (SDKMessage, sdk.d.ts:370). A line
|
|
669
|
+
// that merely PARSES as JSON is NOT a stream event — that conflation is #222: the model's own
|
|
670
|
+
// answer parses and carries no `type`, which flipped the old `parsedAny` flag and made the
|
|
671
|
+
// raw-stdout fallback unreachable for the very payload it existed to rescue.
|
|
672
|
+
const STREAM_EVENT_TYPES = new Set([
|
|
673
|
+
"assistant",
|
|
674
|
+
"user",
|
|
675
|
+
"result",
|
|
676
|
+
"system",
|
|
677
|
+
"stream_event",
|
|
678
|
+
]);
|
|
679
|
+
/**
|
|
680
|
+
* Pick the assistant text block that IS the answer.
|
|
681
|
+
*
|
|
682
|
+
* Concatenating every block across the run (the old behaviour) glues intermediate
|
|
683
|
+
* reasoning in front of the answer, and the extractor then has to choose between the
|
|
684
|
+
* reasoning's objects and the real one. Prefer the LAST block that is nothing but a JSON
|
|
685
|
+
* object — that is precisely what buildPrompt asks for ("Respond with ONLY a single JSON
|
|
686
|
+
* object — no prose, no code fence", :153), so such a block is the model complying, while
|
|
687
|
+
* a block with chatter wrapped around an object is commentary. Falls back to the final
|
|
688
|
+
* block when no block is a bare object (then the extractor's own policy decides).
|
|
689
|
+
*/
|
|
690
|
+
function answerBlock(blocks) {
|
|
691
|
+
for (let i = blocks.length - 1; i >= 0; i--) {
|
|
692
|
+
const b = (blocks[i] ?? "").trim();
|
|
693
|
+
if (b.startsWith("{") && b.endsWith("}")) {
|
|
694
|
+
try {
|
|
695
|
+
const v = JSON.parse(b);
|
|
696
|
+
if (v && typeof v === "object" && !Array.isArray(v))
|
|
697
|
+
return b;
|
|
698
|
+
}
|
|
699
|
+
catch { /* not a bare object — keep looking */ }
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
return blocks.length > 0 ? (blocks[blocks.length - 1] ?? "") : "";
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* Read a stream-json stdout into the answer text (plus any error the CLI reported).
|
|
706
|
+
* Falls back to the raw stdout when no recognized stream event appeared at all — the
|
|
707
|
+
* plain `-p` shape the old `parsedAny` check claimed to handle and did not.
|
|
708
|
+
*/
|
|
709
|
+
function finalText(stdout) {
|
|
710
|
+
const lines = stdout.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
711
|
+
let result;
|
|
712
|
+
let errorSubtype;
|
|
713
|
+
let apiErrorText;
|
|
714
|
+
const assistant = [];
|
|
715
|
+
let sawStreamEvent = false;
|
|
716
|
+
for (const l of lines) {
|
|
717
|
+
let e;
|
|
718
|
+
try {
|
|
719
|
+
e = JSON.parse(l);
|
|
720
|
+
}
|
|
721
|
+
catch {
|
|
722
|
+
continue; /* non-json */
|
|
723
|
+
}
|
|
724
|
+
const type = typeof e["type"] === "string" ? e["type"] : "";
|
|
725
|
+
if (!STREAM_EVENT_TYPES.has(type))
|
|
726
|
+
continue;
|
|
727
|
+
sawStreamEvent = true;
|
|
728
|
+
if (type === "result") {
|
|
729
|
+
const subtype = typeof e["subtype"] === "string" ? e["subtype"] : "";
|
|
730
|
+
if (subtype !== "" && subtype !== "success") {
|
|
731
|
+
errorSubtype = subtype;
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
if (e["is_error"] === true) {
|
|
735
|
+
apiErrorText = typeof e["result"] === "string" ? e["result"] : "";
|
|
736
|
+
continue;
|
|
737
|
+
}
|
|
738
|
+
if (typeof e["result"] === "string")
|
|
739
|
+
result = e["result"];
|
|
740
|
+
}
|
|
741
|
+
else if (type === "assistant" && e["message"] && typeof e["message"] === "object") {
|
|
742
|
+
const content = e["message"].content ?? [];
|
|
743
|
+
for (const b of content)
|
|
744
|
+
if (b["type"] === "text")
|
|
745
|
+
assistant.push(String(b["text"] ?? ""));
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
if (errorSubtype !== undefined)
|
|
749
|
+
return { text: "", errorSubtype };
|
|
750
|
+
if (apiErrorText !== undefined)
|
|
751
|
+
return { text: "", apiErrorText };
|
|
752
|
+
if (!sawStreamEvent)
|
|
753
|
+
return { text: stdout };
|
|
754
|
+
// #222 — `""` IS a string, so `result ?? assistant.join("\n")` returned the empty result
|
|
755
|
+
// and beat real assistant text. Nullish coalescing was the bug; emptiness is the test.
|
|
756
|
+
if (result !== undefined && result.trim() !== "")
|
|
757
|
+
return { text: result };
|
|
758
|
+
return { text: answerBlock(assistant) };
|
|
759
|
+
}
|
|
760
|
+
//# sourceMappingURL=claude_invoker.js.map
|