diff --git a/CHANGELOG.md b/CHANGELOG.md index ae7fc9e9..0c1d84ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,9 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename ### Agent +- Tier 3 leaf workers can now report via `submit_result`, a typed channel alongside + the markdown envelope that validates against a director-declared JSON Schema + and returns a correction (capped at 3 rounds) on an invalid submission. - **Fleet authority tiers are now runtime-enforced, not documented in a prompt.** Every director package carries a required `tier` (`orchestrator` / `nested-orchestrator` / `leaf`): skywalker gets full fleet control, greybeard diff --git a/bun.lock b/bun.lock index dbebe7cc..322671bc 100644 --- a/bun.lock +++ b/bun.lock @@ -17,6 +17,7 @@ "@opentui/core": "0.5.1", "@opentui/keymap": "0.5.1", "@opentui/solid": "0.5.1", + "ajv": "catalog:", "arktype": "catalog:", "highlight.js": "^11.11.1", "solid-js": "1.9.14", @@ -98,6 +99,7 @@ }, "catalog": { "@types/semver": "^7.7.1", + "ajv": "^8.17.1", "arktype": "^2.1.29", "better-auth": "^1.4.18", "drizzle-orm": "^0.45.1", @@ -306,7 +308,7 @@ "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], - "ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], @@ -570,7 +572,7 @@ "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], - "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], @@ -814,7 +816,7 @@ "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], - "@modelcontextprotocol/sdk/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + "@eslint/eslintrc/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.6", "", {}, "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw=="], @@ -822,12 +824,12 @@ "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], - "ajv-formats/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], - "babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="], "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "eslint/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], + "glob/minimatch": ["minimatch@8.0.7", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg=="], "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], @@ -842,14 +844,14 @@ "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], - "@modelcontextprotocol/sdk/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "@eslint/eslintrc/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="], - "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "eslint/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + "glob/minimatch/brace-expansion": ["brace-expansion@2.1.4", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="], "pkg-up/find-up/locate-path": ["locate-path@3.0.0", "", { "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" } }, "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A=="], diff --git a/package.json b/package.json index 9bf1bdfb..5b5b6377 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ }, "catalog": { "@types/semver": "^7.7.1", + "ajv": "^8.17.1", "arktype": "^2.1.29", "better-auth": "^1.4.18", "drizzle-orm": "^0.45.1", @@ -76,6 +77,7 @@ "@opentui/core": "0.5.1", "@opentui/keymap": "0.5.1", "@opentui/solid": "0.5.1", + "ajv": "catalog:", "arktype": "catalog:", "highlight.js": "^11.11.1", "solid-js": "1.9.14" diff --git a/src/agent/directors/types.ts b/src/agent/directors/types.ts index 7b974f09..f237dc05 100644 --- a/src/agent/directors/types.ts +++ b/src/agent/directors/types.ts @@ -60,6 +60,18 @@ export interface NudgePolicy { readonly stallMs?: number; } +/** + * Optional structured-output contract for a director's worker (CL-6946). + * Additive alongside the markdown envelope (Summary/Findings/Blockers/Paths, + * see subagent/report.ts) — declaring `outputSchema` lets a Tier 3 leaf also + * submit a JSON payload via `submit_result`, validated against this schema. + * Omit entirely to keep a director on the markdown-only path. + */ +export interface ReportContract { + /** JSON Schema for submit_result's payload, validated with ajv (see subagent/submit-result.ts). */ + readonly outputSchema?: Record; +} + /** * One shipped director: hard primary intent + package fields. * Packages land in later levels; registry holds the closed set. @@ -81,6 +93,8 @@ export interface DirectorPackage { readonly modelRole: ModelRole; /** Fleet authority tier — data on the package, gated at mount, not prose. */ readonly tier: SubagentTier; + /** Optional typed output contract (CL-6946); Tier 3 leaves only. */ + readonly reportContract?: ReportContract; } export interface ResolveDirectorInput { diff --git a/src/subagent/report.ts b/src/subagent/report.ts index e300afa5..8983f67e 100644 --- a/src/subagent/report.ts +++ b/src/subagent/report.ts @@ -47,6 +47,8 @@ export interface DispatchBrief { successCriteria?: readonly string[]; doNot?: readonly string[]; reportFocus?: string; + /** Turn token (CL-6946) a leaf must echo back to `submit_result`. Leaf-tier dispatches only. */ + turnToken?: string; } export function buildDispatchBrief(brief: DispatchBrief): string { @@ -85,6 +87,14 @@ export function buildDispatchBrief(brief: DispatchBrief): string { reportLines.push(`Focus Findings on: ${brief.reportFocus.trim()}`); } parts.push("", "## Report shape", ...reportLines); + if (brief.turnToken !== undefined && brief.turnToken.length > 0) { + parts.push( + "", + "## Turn token", + brief.turnToken, + `If you call submit_result, pass turn_token="${brief.turnToken}" exactly. A mismatched token means this turn was superseded — do not resubmit under it.`, + ); + } return parts.join("\n"); } diff --git a/src/subagent/run.ts b/src/subagent/run.ts index 336e5e72..942070ad 100644 --- a/src/subagent/run.ts +++ b/src/subagent/run.ts @@ -23,7 +23,7 @@ import { type } from "arktype"; import { createPosixTools } from "@intx/tools-posix"; import { createDynamicToolRunner } from "../tui/dynamic-tool-runner.js"; import type { ReactorEmittedEvent } from "@intx/inference"; -import type { BlobReader, InboundMessage } from "@intx/types/runtime"; +import type { BlobReader, InboundMessage, ToolDefinition } from "@intx/types/runtime"; import { seedPricingMetadataFromCache } from "../cost/pricing-metadata.js"; import { defaultPricingCachePath } from "../cost/pricing-fetcher.js"; @@ -106,6 +106,11 @@ import { import { SubAgentDirector } from "./nudge-director.js"; import { assertTierMayMountFleetVerb } from "./authority.js"; import { createReadAgentTraceTool } from "./trace-tool.js"; +import { + createSubmitResultState, + evaluateSubmitResult, + SUBMIT_RESULT_MAX_CORRECTIONS, +} from "./submit-result.js"; import { abortError, createSubAgentSpawnRegistryPlugin, @@ -296,6 +301,24 @@ export function shouldRequireEvidence(input: { return input.directorId === "critique"; } +const submitResultDefinition: ToolDefinition = { + name: "submit_result", + description: + "Submit your structured result for this turn. Requires the turn_token from your dispatch " + + "brief's Turn token section. If a JSON Schema is declared for this job, result is validated " + + "against it; an invalid submission returns a correction so you can fix and resubmit (capped " + + `at ${SUBMIT_RESULT_MAX_CORRECTIONS} corrections). This does not replace the markdown report ` + + "envelope — still finish with it.", + inputSchema: { + type: "object", + properties: { + turn_token: { type: "string", description: "Turn token from the dispatch brief." }, + result: { description: "The structured result payload." }, + }, + required: ["turn_token", "result"], + }, +}; + // Spin up an isolated, autonomous agent loop, hand it one task, and return // its final report. `params.cwd` is either the dispatcher's own cwd (shared // mode) or a worktree snapshotted from the dispatcher's last commit @@ -308,6 +331,12 @@ export async function runSubAgent(params: RunSubAgentParams): Promise { }); const permissionGate = params.permissionGate; + // Turn token (CL-6946): identifies this dispatch to submit_result so a + // submission survives only for the turn it was spawned under — if the + // orchestrator redirects/steers away, a stale submit_result call (echoing + // an old token) is rejected rather than silently accepted. + const turnToken = params.tier === "leaf" ? generateSessionId() : undefined; + const submitResultState = createSubmitResultState(); const spawnRegistry = createSubAgentSpawnRegistryPlugin(); // Child tools resolve spills against the child's own store first, then the // parent's (CL-4323): parent tool-output:// URIs handed in the brief must @@ -423,6 +452,27 @@ export async function runSubAgent(params: RunSubAgentParams): Promise { }), ]; + // submit_result (CL-6946): typed reporting channel, Tier 3 leaves only. + // Gated by the existing tier machinery — never invent a parallel check. + if (params.tier === "leaf") { + tools = [ + ...tools, + stringTool({ + definition: submitResultDefinition, + handler: async (rawArgs: Record): Promise => { + const outcome = evaluateSubmitResult({ + turnToken: turnToken!, + submittedToken: rawArgs.turn_token, + result: rawArgs.result, + ...(params.reportSchema !== undefined ? { schema: params.reportSchema } : {}), + state: submitResultState, + }); + return outcome.message; + }, + }), + ]; + } + // Orchestrators need task + search_agents installed, not just mentioned in // the prompt. Nested dispatch always forbids further orchestration so the // tree bottoms out after one hop. @@ -846,6 +896,7 @@ export async function runSubAgent(params: RunSubAgentParams): Promise { ...(params.reportFocus !== undefined && params.reportFocus.trim().length > 0 ? { reportFocus: params.reportFocus } : {}), + ...(turnToken !== undefined ? { turnToken } : {}), }); const ensureNotAborted = (): void => { // Re-read .aborted after await — control-flow narrowing would wrongly diff --git a/src/subagent/submit-result.test.ts b/src/subagent/submit-result.test.ts new file mode 100644 index 00000000..a79ad18d --- /dev/null +++ b/src/subagent/submit-result.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, test } from "bun:test"; + +import { createSubmitResultState, evaluateSubmitResult } from "./submit-result.js"; + +const TOKEN = "turn-abc123"; + +describe("evaluateSubmitResult", () => { + test("a valid submission against a declared schema succeeds", () => { + const state = createSubmitResultState(); + const outcome = evaluateSubmitResult({ + turnToken: TOKEN, + submittedToken: TOKEN, + result: { verdict: "pass", score: 5 }, + schema: { + type: "object", + required: ["verdict", "score"], + properties: { + verdict: { type: "string", enum: ["pass", "fail"] }, + score: { type: "number", minimum: 0, maximum: 10 }, + }, + }, + state, + }); + expect(outcome.ok).toBe(true); + expect(outcome.message).toBe("Result accepted."); + expect(state.corrections).toBe(0); + }); + + test("an invalid submission returns a correction and a resubmit then succeeds", () => { + const state = createSubmitResultState(); + const schema = { + type: "object" as const, + required: ["verdict"], + properties: { verdict: { type: "string", enum: ["pass", "fail"] } }, + }; + + const first = evaluateSubmitResult({ + turnToken: TOKEN, + submittedToken: TOKEN, + result: { verdict: "maybe" }, + schema, + state, + }); + expect(first.ok).toBe(false); + expect(first.message).toContain("Invalid submission"); + expect(state.corrections).toBe(1); + + const second = evaluateSubmitResult({ + turnToken: TOKEN, + submittedToken: TOKEN, + result: { verdict: "pass" }, + schema, + state, + }); + expect(second.ok).toBe(true); + expect(second.message).toBe("Result accepted."); + }); + + test("a stale/mismatched turn token is rejected", () => { + const state = createSubmitResultState(); + const outcome = evaluateSubmitResult({ + turnToken: TOKEN, + submittedToken: "some-other-turn-token", + result: { verdict: "pass" }, + state, + }); + expect(outcome.ok).toBe(false); + expect(outcome.message).toContain("turn_token does not match"); + expect(state.corrections).toBe(0); + }); + + test("correction cap refuses further attempts once reached", () => { + const state = createSubmitResultState(); + const schema = { type: "object" as const, required: ["x"] }; + for (let i = 0; i < 3; i++) { + evaluateSubmitResult({ + turnToken: TOKEN, + submittedToken: TOKEN, + result: {}, + schema, + state, + }); + } + const capped = evaluateSubmitResult({ + turnToken: TOKEN, + submittedToken: TOKEN, + result: { x: 1 }, + schema, + state, + }); + expect(capped.ok).toBe(false); + expect(capped.message).toContain("correction cap"); + }); +}); diff --git a/src/subagent/submit-result.ts b/src/subagent/submit-result.ts new file mode 100644 index 00000000..a75e11e5 --- /dev/null +++ b/src/subagent/submit-result.ts @@ -0,0 +1,71 @@ +/** + * submit_result evaluation (CL-6946): pure logic, unit-testable without + * spinning up a full agent loop. run.ts wires this into the tool handler and + * owns the per-turn `SubmitResultState` (one instance per runSubAgent call). + */ + +import Ajv, { type Schema } from "ajv"; + +const ajv = new Ajv({ allErrors: true, strict: false }); + +export type JsonSchema = Schema; + +export const SUBMIT_RESULT_MAX_CORRECTIONS = 3; + +export interface SubmitResultState { + corrections: number; +} + +export function createSubmitResultState(): SubmitResultState { + return { corrections: 0 }; +} + +export interface SubmitResultInput { + /** This turn's token, generated by runSubAgent at dispatch time. */ + turnToken: string; + /** The turn_token argument the worker actually passed. */ + submittedToken: unknown; + /** The result argument the worker passed. */ + result: unknown; + /** Declared output schema, if the director's report contract has one. */ + schema?: JsonSchema; + state: SubmitResultState; + maxCorrections?: number; +} + +/** Non-terminal by design: an invalid submission returns `ok: false` so the worker can retry, not a thrown error. */ +export function evaluateSubmitResult(input: SubmitResultInput): { + ok: boolean; + message: string; +} { + const cap = input.maxCorrections ?? SUBMIT_RESULT_MAX_CORRECTIONS; + if (typeof input.submittedToken !== "string" || input.submittedToken !== input.turnToken) { + return { + ok: false, + message: + "Error: turn_token does not match this turn — this dispatch was superseded. Do not resubmit.", + }; + } + if (input.state.corrections >= cap) { + return { + ok: false, + message: `Error: submit_result correction cap (${cap}) reached for this turn. No further attempts accepted — finish with the markdown report envelope instead.`, + }; + } + if (input.schema !== undefined) { + const validate = ajv.compile(input.schema); + const valid = validate(input.result); + if (!valid) { + input.state.corrections += 1; + return { + ok: false, + message: [ + `Invalid submission (${input.state.corrections}/${cap} corrections used):`, + ajv.errorsText(validate.errors, { separator: "\n", dataVar: "result" }), + "Fix and call submit_result again with the same turn_token.", + ].join("\n"), + }; + } + } + return { ok: true, message: "Result accepted." }; +} diff --git a/src/subagent/task-tool.ts b/src/subagent/task-tool.ts index d1d8707c..18e7dd83 100644 --- a/src/subagent/task-tool.ts +++ b/src/subagent/task-tool.ts @@ -834,6 +834,12 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { : {}), maxTurns: resolvedMaxTurns, ...(deps.deadlineMs !== undefined ? { deadlineMs: deps.deadlineMs } : {}), + // submit_result mount gate (CL-6946): only a resolved Tier 3 leaf + // director gets tier here, and only if it declared an outputSchema. + ...(resolvedPackage !== undefined ? { tier: resolvedPackage.tier } : {}), + ...(resolvedPackage?.reportContract?.outputSchema !== undefined + ? { reportSchema: resolvedPackage.reportContract.outputSchema } + : {}), }; const result = await run(params); // Operator cancel may race after run resolves. Keep strip status cancelled diff --git a/src/subagent/types.ts b/src/subagent/types.ts index 4ce43b3e..3f54c414 100644 --- a/src/subagent/types.ts +++ b/src/subagent/types.ts @@ -136,4 +136,14 @@ export type RunSubAgentParams = { * and operator cancel alone. */ deadlineMs?: number; + /** + * Resolved director tier (CL-6946), independent of `orchestratorTier` (which + * is only ever set when `orchestrator` is true). Set by task-tool.ts from + * `DirectorPackage.tier`. runSubAgent mounts `submit_result` only when this + * is `"leaf"` — the existing tier machinery (authority.ts / directors/types.ts) + * gates it, not a new mechanism. + */ + tier?: SubagentTier; + /** DirectorPackage.reportContract.outputSchema, when the resolved leaf declares one. */ + reportSchema?: Record; } & SubAgentSandboxDeps;