From b60bb93e9b88aa96a49c3e4eaf901b45bca4f373 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 23 Aug 2026 22:33:30 -0700 Subject: [PATCH 1/2] Add submit_result typed reporting channel for Tier 3 leaves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mounts on leaf-tier workers only (existing authority.ts/tier gate). DirectorPackage.reportContract.outputSchema is an optional JSON Schema; an invalid submit_result payload returns a correction (non-terminal, capped at 3 rounds) instead of failing the run. Requires a per-turn token generated at dispatch and echoed back, rejecting stale/superseded turns. Purely additive — the markdown report envelope path is untouched. --- CHANGELOG.md | 4 ++ src/agent/directors/types.ts | 14 ++++ src/subagent/json-schema-lite.ts | 105 +++++++++++++++++++++++++++++ src/subagent/report.ts | 10 +++ src/subagent/run.ts | 53 ++++++++++++++- src/subagent/submit-result.test.ts | 94 ++++++++++++++++++++++++++ src/subagent/submit-result.ts | 66 ++++++++++++++++++ src/subagent/task-tool.ts | 6 ++ src/subagent/types.ts | 10 +++ 9 files changed, 361 insertions(+), 1 deletion(-) create mode 100644 src/subagent/json-schema-lite.ts create mode 100644 src/subagent/submit-result.test.ts create mode 100644 src/subagent/submit-result.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ae7fc9e9c..ac360c877 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ 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: a director package may declare a JSON Schema on + `DirectorPackage.reportContract.outputSchema`, and an invalid submission returns + a correction (capped at 3 rounds) instead of failing the run. - **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/src/agent/directors/types.ts b/src/agent/directors/types.ts index 7b974f090..d2313fff0 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 (draft-07 subset, see subagent/json-schema-lite.ts) for submit_result's payload. */ + 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/json-schema-lite.ts b/src/subagent/json-schema-lite.ts new file mode 100644 index 000000000..87e0ea41f --- /dev/null +++ b/src/subagent/json-schema-lite.ts @@ -0,0 +1,105 @@ +/** + * Minimal JSON Schema (draft-07 subset) validator for submit_result payloads + * (CL-6946). No JSON-Schema validation library is in the dependency tree + * (arktype validates its own type language, not arbitrary JSON Schema + * documents) — this covers the subset director packages need to declare a + * structured output shape: type, required, properties, items, enum, and the + * common string/number bounds. Not a general-purpose validator. + */ + +export type JsonSchema = Record; + +function typeOf(value: unknown): string { + if (value === null) return "null"; + if (Array.isArray(value)) return "array"; + return typeof value; +} + +function matchesType(value: unknown, expected: string): boolean { + if (expected === "integer") return typeof value === "number" && Number.isInteger(value); + return typeOf(value) === expected; +} + +/** Validate `value` against `schema`, returning human-readable error strings (empty = valid). */ +export function validateJsonSchema(schema: JsonSchema, value: unknown, path = "result"): string[] { + const errors: string[] = []; + + const expectedType = schema.type; + if (typeof expectedType === "string" && !matchesType(value, expectedType)) { + errors.push(`${path}: expected type "${expectedType}", got "${typeOf(value)}"`); + return errors; // further checks are meaningless on the wrong type + } + + const enumValues = schema.enum; + if (Array.isArray(enumValues) && !enumValues.some((v) => deepEqual(v, value))) { + errors.push(`${path}: value is not one of the allowed enum values`); + } + + if (typeOf(value) === "object" && value !== null) { + const obj = value as Record; + const required = schema.required; + if (Array.isArray(required)) { + for (const key of required) { + if (typeof key === "string" && !(key in obj)) { + errors.push(`${path}: missing required property "${key}"`); + } + } + } + const properties = schema.properties; + if (properties !== null && typeof properties === "object") { + for (const [key, subSchema] of Object.entries(properties as Record)) { + if (key in obj && subSchema !== null && typeof subSchema === "object") { + errors.push(...validateJsonSchema(subSchema as JsonSchema, obj[key], `${path}.${key}`)); + } + } + } + if (schema.additionalProperties === false) { + const allowed = new Set( + properties !== null && typeof properties === "object" + ? Object.keys(properties as Record) + : [], + ); + for (const key of Object.keys(obj)) { + if (!allowed.has(key)) { + errors.push(`${path}: unexpected property "${key}" (additionalProperties: false)`); + } + } + } + } + + if (typeOf(value) === "array" && Array.isArray(value)) { + const items = schema.items; + if (items !== null && typeof items === "object") { + value.forEach((item, i) => { + errors.push(...validateJsonSchema(items as JsonSchema, item, `${path}[${i}]`)); + }); + } + } + + if (typeof value === "string") { + if (typeof schema.minLength === "number" && value.length < schema.minLength) { + errors.push(`${path}: length ${value.length} is below minLength ${schema.minLength}`); + } + if (typeof schema.maxLength === "number" && value.length > schema.maxLength) { + errors.push(`${path}: length ${value.length} exceeds maxLength ${schema.maxLength}`); + } + } + + if (typeof value === "number") { + if (typeof schema.minimum === "number" && value < schema.minimum) { + errors.push(`${path}: ${value} is below minimum ${schema.minimum}`); + } + if (typeof schema.maximum === "number" && value > schema.maximum) { + errors.push(`${path}: ${value} exceeds maximum ${schema.maximum}`); + } + } + + return errors; +} + +function deepEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (typeof a !== typeof b) return false; + if (typeof a !== "object" || a === null || b === null) return false; + return JSON.stringify(a) === JSON.stringify(b); +} diff --git a/src/subagent/report.ts b/src/subagent/report.ts index e300afa58..8983f67e6 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 336e5e729..942070ad4 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 000000000..a79ad18df --- /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 000000000..2f7464694 --- /dev/null +++ b/src/subagent/submit-result.ts @@ -0,0 +1,66 @@ +/** + * 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 { validateJsonSchema, type JsonSchema } from "./json-schema-lite.js"; + +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 errors = validateJsonSchema(input.schema, input.result); + if (errors.length > 0) { + input.state.corrections += 1; + return { + ok: false, + message: [ + `Invalid submission (${input.state.corrections}/${cap} corrections used):`, + ...errors.map((e) => `- ${e}`), + "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 d1d8707c0..18e7dd83f 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 4ce43b3e1..3f54c414b 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; From 4244d37c00ccf1c29e48ff609e5431a3713c4c46 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 23 Aug 2026 22:41:16 -0700 Subject: [PATCH 2/2] Validate submit_result payloads with ajv instead of a hand-rolled subset Replaces the draft-07 partial validator with ajv (already resolved transitively via @modelcontextprotocol/sdk) and surfaces its error output directly in the correction message sent back to the worker. --- CHANGELOG.md | 5 +- bun.lock | 18 +++--- package.json | 2 + src/agent/directors/types.ts | 2 +- src/subagent/json-schema-lite.ts | 105 ------------------------------- src/subagent/submit-result.ts | 13 ++-- 6 files changed, 24 insertions(+), 121 deletions(-) delete mode 100644 src/subagent/json-schema-lite.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ac360c877..0c1d84ca9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,9 +16,8 @@ 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: a director package may declare a JSON Schema on - `DirectorPackage.reportContract.outputSchema`, and an invalid submission returns - a correction (capped at 3 rounds) instead of failing the run. + 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 dbebe7ccc..322671bc8 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 9bf1bdfbb..5b5b6377f 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 d2313fff0..f237dc059 100644 --- a/src/agent/directors/types.ts +++ b/src/agent/directors/types.ts @@ -68,7 +68,7 @@ export interface NudgePolicy { * Omit entirely to keep a director on the markdown-only path. */ export interface ReportContract { - /** JSON Schema (draft-07 subset, see subagent/json-schema-lite.ts) for submit_result's payload. */ + /** JSON Schema for submit_result's payload, validated with ajv (see subagent/submit-result.ts). */ readonly outputSchema?: Record; } diff --git a/src/subagent/json-schema-lite.ts b/src/subagent/json-schema-lite.ts deleted file mode 100644 index 87e0ea41f..000000000 --- a/src/subagent/json-schema-lite.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * Minimal JSON Schema (draft-07 subset) validator for submit_result payloads - * (CL-6946). No JSON-Schema validation library is in the dependency tree - * (arktype validates its own type language, not arbitrary JSON Schema - * documents) — this covers the subset director packages need to declare a - * structured output shape: type, required, properties, items, enum, and the - * common string/number bounds. Not a general-purpose validator. - */ - -export type JsonSchema = Record; - -function typeOf(value: unknown): string { - if (value === null) return "null"; - if (Array.isArray(value)) return "array"; - return typeof value; -} - -function matchesType(value: unknown, expected: string): boolean { - if (expected === "integer") return typeof value === "number" && Number.isInteger(value); - return typeOf(value) === expected; -} - -/** Validate `value` against `schema`, returning human-readable error strings (empty = valid). */ -export function validateJsonSchema(schema: JsonSchema, value: unknown, path = "result"): string[] { - const errors: string[] = []; - - const expectedType = schema.type; - if (typeof expectedType === "string" && !matchesType(value, expectedType)) { - errors.push(`${path}: expected type "${expectedType}", got "${typeOf(value)}"`); - return errors; // further checks are meaningless on the wrong type - } - - const enumValues = schema.enum; - if (Array.isArray(enumValues) && !enumValues.some((v) => deepEqual(v, value))) { - errors.push(`${path}: value is not one of the allowed enum values`); - } - - if (typeOf(value) === "object" && value !== null) { - const obj = value as Record; - const required = schema.required; - if (Array.isArray(required)) { - for (const key of required) { - if (typeof key === "string" && !(key in obj)) { - errors.push(`${path}: missing required property "${key}"`); - } - } - } - const properties = schema.properties; - if (properties !== null && typeof properties === "object") { - for (const [key, subSchema] of Object.entries(properties as Record)) { - if (key in obj && subSchema !== null && typeof subSchema === "object") { - errors.push(...validateJsonSchema(subSchema as JsonSchema, obj[key], `${path}.${key}`)); - } - } - } - if (schema.additionalProperties === false) { - const allowed = new Set( - properties !== null && typeof properties === "object" - ? Object.keys(properties as Record) - : [], - ); - for (const key of Object.keys(obj)) { - if (!allowed.has(key)) { - errors.push(`${path}: unexpected property "${key}" (additionalProperties: false)`); - } - } - } - } - - if (typeOf(value) === "array" && Array.isArray(value)) { - const items = schema.items; - if (items !== null && typeof items === "object") { - value.forEach((item, i) => { - errors.push(...validateJsonSchema(items as JsonSchema, item, `${path}[${i}]`)); - }); - } - } - - if (typeof value === "string") { - if (typeof schema.minLength === "number" && value.length < schema.minLength) { - errors.push(`${path}: length ${value.length} is below minLength ${schema.minLength}`); - } - if (typeof schema.maxLength === "number" && value.length > schema.maxLength) { - errors.push(`${path}: length ${value.length} exceeds maxLength ${schema.maxLength}`); - } - } - - if (typeof value === "number") { - if (typeof schema.minimum === "number" && value < schema.minimum) { - errors.push(`${path}: ${value} is below minimum ${schema.minimum}`); - } - if (typeof schema.maximum === "number" && value > schema.maximum) { - errors.push(`${path}: ${value} exceeds maximum ${schema.maximum}`); - } - } - - return errors; -} - -function deepEqual(a: unknown, b: unknown): boolean { - if (a === b) return true; - if (typeof a !== typeof b) return false; - if (typeof a !== "object" || a === null || b === null) return false; - return JSON.stringify(a) === JSON.stringify(b); -} diff --git a/src/subagent/submit-result.ts b/src/subagent/submit-result.ts index 2f7464694..a75e11e52 100644 --- a/src/subagent/submit-result.ts +++ b/src/subagent/submit-result.ts @@ -4,7 +4,11 @@ * owns the per-turn `SubmitResultState` (one instance per runSubAgent call). */ -import { validateJsonSchema, type JsonSchema } from "./json-schema-lite.js"; +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; @@ -49,14 +53,15 @@ export function evaluateSubmitResult(input: SubmitResultInput): { }; } if (input.schema !== undefined) { - const errors = validateJsonSchema(input.schema, input.result); - if (errors.length > 0) { + 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):`, - ...errors.map((e) => `- ${e}`), + ajv.errorsText(validate.errors, { separator: "\n", dataVar: "result" }), "Fix and call submit_result again with the same turn_token.", ].join("\n"), };