Skip to content

Commit b0f0a04

Browse files
Add submit_result typed reporting channel for Tier 3 leaves (#602)
* Add submit_result typed reporting channel for Tier 3 leaves 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. * 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.
1 parent 7562482 commit b0f0a04

10 files changed

Lines changed: 272 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
1515

1616
### Agent
1717

18+
- Tier 3 leaf workers can now report via `submit_result`, a typed channel alongside
19+
the markdown envelope that validates against a director-declared JSON Schema
20+
and returns a correction (capped at 3 rounds) on an invalid submission.
1821
- **Fleet authority tiers are now runtime-enforced, not documented in a prompt.**
1922
Every director package carries a required `tier` (`orchestrator` /
2023
`nested-orchestrator` / `leaf`): skywalker gets full fleet control, greybeard

bun.lock

Lines changed: 10 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
},
5555
"catalog": {
5656
"@types/semver": "^7.7.1",
57+
"ajv": "^8.17.1",
5758
"arktype": "^2.1.29",
5859
"better-auth": "^1.4.18",
5960
"drizzle-orm": "^0.45.1",
@@ -76,6 +77,7 @@
7677
"@opentui/core": "0.5.1",
7778
"@opentui/keymap": "0.5.1",
7879
"@opentui/solid": "0.5.1",
80+
"ajv": "catalog:",
7981
"arktype": "catalog:",
8082
"highlight.js": "^11.11.1",
8183
"solid-js": "1.9.14"

src/agent/directors/types.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,18 @@ export interface NudgePolicy {
6060
readonly stallMs?: number;
6161
}
6262

63+
/**
64+
* Optional structured-output contract for a director's worker (CL-6946).
65+
* Additive alongside the markdown envelope (Summary/Findings/Blockers/Paths,
66+
* see subagent/report.ts) — declaring `outputSchema` lets a Tier 3 leaf also
67+
* submit a JSON payload via `submit_result`, validated against this schema.
68+
* Omit entirely to keep a director on the markdown-only path.
69+
*/
70+
export interface ReportContract {
71+
/** JSON Schema for submit_result's payload, validated with ajv (see subagent/submit-result.ts). */
72+
readonly outputSchema?: Record<string, unknown>;
73+
}
74+
6375
/**
6476
* One shipped director: hard primary intent + package fields.
6577
* Packages land in later levels; registry holds the closed set.
@@ -81,6 +93,8 @@ export interface DirectorPackage {
8193
readonly modelRole: ModelRole;
8294
/** Fleet authority tier — data on the package, gated at mount, not prose. */
8395
readonly tier: SubagentTier;
96+
/** Optional typed output contract (CL-6946); Tier 3 leaves only. */
97+
readonly reportContract?: ReportContract;
8498
}
8599

86100
export interface ResolveDirectorInput {

src/subagent/report.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ export interface DispatchBrief {
4747
successCriteria?: readonly string[];
4848
doNot?: readonly string[];
4949
reportFocus?: string;
50+
/** Turn token (CL-6946) a leaf must echo back to `submit_result`. Leaf-tier dispatches only. */
51+
turnToken?: string;
5052
}
5153

5254
export function buildDispatchBrief(brief: DispatchBrief): string {
@@ -85,6 +87,14 @@ export function buildDispatchBrief(brief: DispatchBrief): string {
8587
reportLines.push(`Focus Findings on: ${brief.reportFocus.trim()}`);
8688
}
8789
parts.push("", "## Report shape", ...reportLines);
90+
if (brief.turnToken !== undefined && brief.turnToken.length > 0) {
91+
parts.push(
92+
"",
93+
"## Turn token",
94+
brief.turnToken,
95+
`If you call submit_result, pass turn_token="${brief.turnToken}" exactly. A mismatched token means this turn was superseded — do not resubmit under it.`,
96+
);
97+
}
8898
return parts.join("\n");
8999
}
90100

src/subagent/run.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import { type } from "arktype";
2323
import { createPosixTools } from "@intx/tools-posix";
2424
import { createDynamicToolRunner } from "../tui/dynamic-tool-runner.js";
2525
import type { ReactorEmittedEvent } from "@intx/inference";
26-
import type { BlobReader, InboundMessage } from "@intx/types/runtime";
26+
import type { BlobReader, InboundMessage, ToolDefinition } from "@intx/types/runtime";
2727

2828
import { seedPricingMetadataFromCache } from "../cost/pricing-metadata.js";
2929
import { defaultPricingCachePath } from "../cost/pricing-fetcher.js";
@@ -106,6 +106,11 @@ import {
106106
import { SubAgentDirector } from "./nudge-director.js";
107107
import { assertTierMayMountFleetVerb } from "./authority.js";
108108
import { createReadAgentTraceTool } from "./trace-tool.js";
109+
import {
110+
createSubmitResultState,
111+
evaluateSubmitResult,
112+
SUBMIT_RESULT_MAX_CORRECTIONS,
113+
} from "./submit-result.js";
109114
import {
110115
abortError,
111116
createSubAgentSpawnRegistryPlugin,
@@ -296,6 +301,24 @@ export function shouldRequireEvidence(input: {
296301
return input.directorId === "critique";
297302
}
298303

304+
const submitResultDefinition: ToolDefinition = {
305+
name: "submit_result",
306+
description:
307+
"Submit your structured result for this turn. Requires the turn_token from your dispatch " +
308+
"brief's Turn token section. If a JSON Schema is declared for this job, result is validated " +
309+
"against it; an invalid submission returns a correction so you can fix and resubmit (capped " +
310+
`at ${SUBMIT_RESULT_MAX_CORRECTIONS} corrections). This does not replace the markdown report ` +
311+
"envelope — still finish with it.",
312+
inputSchema: {
313+
type: "object",
314+
properties: {
315+
turn_token: { type: "string", description: "Turn token from the dispatch brief." },
316+
result: { description: "The structured result payload." },
317+
},
318+
required: ["turn_token", "result"],
319+
},
320+
};
321+
299322
// Spin up an isolated, autonomous agent loop, hand it one task, and return
300323
// its final report. `params.cwd` is either the dispatcher's own cwd (shared
301324
// mode) or a worktree snapshotted from the dispatcher's last commit
@@ -308,6 +331,12 @@ export async function runSubAgent(params: RunSubAgentParams): Promise<string> {
308331
});
309332

310333
const permissionGate = params.permissionGate;
334+
// Turn token (CL-6946): identifies this dispatch to submit_result so a
335+
// submission survives only for the turn it was spawned under — if the
336+
// orchestrator redirects/steers away, a stale submit_result call (echoing
337+
// an old token) is rejected rather than silently accepted.
338+
const turnToken = params.tier === "leaf" ? generateSessionId() : undefined;
339+
const submitResultState = createSubmitResultState();
311340
const spawnRegistry = createSubAgentSpawnRegistryPlugin();
312341
// Child tools resolve spills against the child's own store first, then the
313342
// parent's (CL-4323): parent tool-output:// URIs handed in the brief must
@@ -423,6 +452,27 @@ export async function runSubAgent(params: RunSubAgentParams): Promise<string> {
423452
}),
424453
];
425454

455+
// submit_result (CL-6946): typed reporting channel, Tier 3 leaves only.
456+
// Gated by the existing tier machinery — never invent a parallel check.
457+
if (params.tier === "leaf") {
458+
tools = [
459+
...tools,
460+
stringTool({
461+
definition: submitResultDefinition,
462+
handler: async (rawArgs: Record<string, unknown>): Promise<string> => {
463+
const outcome = evaluateSubmitResult({
464+
turnToken: turnToken!,
465+
submittedToken: rawArgs.turn_token,
466+
result: rawArgs.result,
467+
...(params.reportSchema !== undefined ? { schema: params.reportSchema } : {}),
468+
state: submitResultState,
469+
});
470+
return outcome.message;
471+
},
472+
}),
473+
];
474+
}
475+
426476
// Orchestrators need task + search_agents installed, not just mentioned in
427477
// the prompt. Nested dispatch always forbids further orchestration so the
428478
// tree bottoms out after one hop.
@@ -846,6 +896,7 @@ export async function runSubAgent(params: RunSubAgentParams): Promise<string> {
846896
...(params.reportFocus !== undefined && params.reportFocus.trim().length > 0
847897
? { reportFocus: params.reportFocus }
848898
: {}),
899+
...(turnToken !== undefined ? { turnToken } : {}),
849900
});
850901
const ensureNotAborted = (): void => {
851902
// Re-read .aborted after await — control-flow narrowing would wrongly

src/subagent/submit-result.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { describe, expect, test } from "bun:test";
2+
3+
import { createSubmitResultState, evaluateSubmitResult } from "./submit-result.js";
4+
5+
const TOKEN = "turn-abc123";
6+
7+
describe("evaluateSubmitResult", () => {
8+
test("a valid submission against a declared schema succeeds", () => {
9+
const state = createSubmitResultState();
10+
const outcome = evaluateSubmitResult({
11+
turnToken: TOKEN,
12+
submittedToken: TOKEN,
13+
result: { verdict: "pass", score: 5 },
14+
schema: {
15+
type: "object",
16+
required: ["verdict", "score"],
17+
properties: {
18+
verdict: { type: "string", enum: ["pass", "fail"] },
19+
score: { type: "number", minimum: 0, maximum: 10 },
20+
},
21+
},
22+
state,
23+
});
24+
expect(outcome.ok).toBe(true);
25+
expect(outcome.message).toBe("Result accepted.");
26+
expect(state.corrections).toBe(0);
27+
});
28+
29+
test("an invalid submission returns a correction and a resubmit then succeeds", () => {
30+
const state = createSubmitResultState();
31+
const schema = {
32+
type: "object" as const,
33+
required: ["verdict"],
34+
properties: { verdict: { type: "string", enum: ["pass", "fail"] } },
35+
};
36+
37+
const first = evaluateSubmitResult({
38+
turnToken: TOKEN,
39+
submittedToken: TOKEN,
40+
result: { verdict: "maybe" },
41+
schema,
42+
state,
43+
});
44+
expect(first.ok).toBe(false);
45+
expect(first.message).toContain("Invalid submission");
46+
expect(state.corrections).toBe(1);
47+
48+
const second = evaluateSubmitResult({
49+
turnToken: TOKEN,
50+
submittedToken: TOKEN,
51+
result: { verdict: "pass" },
52+
schema,
53+
state,
54+
});
55+
expect(second.ok).toBe(true);
56+
expect(second.message).toBe("Result accepted.");
57+
});
58+
59+
test("a stale/mismatched turn token is rejected", () => {
60+
const state = createSubmitResultState();
61+
const outcome = evaluateSubmitResult({
62+
turnToken: TOKEN,
63+
submittedToken: "some-other-turn-token",
64+
result: { verdict: "pass" },
65+
state,
66+
});
67+
expect(outcome.ok).toBe(false);
68+
expect(outcome.message).toContain("turn_token does not match");
69+
expect(state.corrections).toBe(0);
70+
});
71+
72+
test("correction cap refuses further attempts once reached", () => {
73+
const state = createSubmitResultState();
74+
const schema = { type: "object" as const, required: ["x"] };
75+
for (let i = 0; i < 3; i++) {
76+
evaluateSubmitResult({
77+
turnToken: TOKEN,
78+
submittedToken: TOKEN,
79+
result: {},
80+
schema,
81+
state,
82+
});
83+
}
84+
const capped = evaluateSubmitResult({
85+
turnToken: TOKEN,
86+
submittedToken: TOKEN,
87+
result: { x: 1 },
88+
schema,
89+
state,
90+
});
91+
expect(capped.ok).toBe(false);
92+
expect(capped.message).toContain("correction cap");
93+
});
94+
});

0 commit comments

Comments
 (0)