Skip to content

Commit 87ccc00

Browse files
committed
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.
1 parent e52cac0 commit 87ccc00

9 files changed

Lines changed: 361 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ 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: a director package may declare a JSON Schema on
20+
`DirectorPackage.reportContract.outputSchema`, and an invalid submission returns
21+
a correction (capped at 3 rounds) instead of failing the run.
1822
- **Fleet authority tiers are now runtime-enforced, not documented in a prompt.**
1923
Every director package carries a required `tier` (`orchestrator` /
2024
`nested-orchestrator` / `leaf`): skywalker gets full fleet control, greybeard

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 (draft-07 subset, see subagent/json-schema-lite.ts) for submit_result's payload. */
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/json-schema-lite.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/**
2+
* Minimal JSON Schema (draft-07 subset) validator for submit_result payloads
3+
* (CL-6946). No JSON-Schema validation library is in the dependency tree
4+
* (arktype validates its own type language, not arbitrary JSON Schema
5+
* documents) — this covers the subset director packages need to declare a
6+
* structured output shape: type, required, properties, items, enum, and the
7+
* common string/number bounds. Not a general-purpose validator.
8+
*/
9+
10+
export type JsonSchema = Record<string, unknown>;
11+
12+
function typeOf(value: unknown): string {
13+
if (value === null) return "null";
14+
if (Array.isArray(value)) return "array";
15+
return typeof value;
16+
}
17+
18+
function matchesType(value: unknown, expected: string): boolean {
19+
if (expected === "integer") return typeof value === "number" && Number.isInteger(value);
20+
return typeOf(value) === expected;
21+
}
22+
23+
/** Validate `value` against `schema`, returning human-readable error strings (empty = valid). */
24+
export function validateJsonSchema(schema: JsonSchema, value: unknown, path = "result"): string[] {
25+
const errors: string[] = [];
26+
27+
const expectedType = schema.type;
28+
if (typeof expectedType === "string" && !matchesType(value, expectedType)) {
29+
errors.push(`${path}: expected type "${expectedType}", got "${typeOf(value)}"`);
30+
return errors; // further checks are meaningless on the wrong type
31+
}
32+
33+
const enumValues = schema.enum;
34+
if (Array.isArray(enumValues) && !enumValues.some((v) => deepEqual(v, value))) {
35+
errors.push(`${path}: value is not one of the allowed enum values`);
36+
}
37+
38+
if (typeOf(value) === "object" && value !== null) {
39+
const obj = value as Record<string, unknown>;
40+
const required = schema.required;
41+
if (Array.isArray(required)) {
42+
for (const key of required) {
43+
if (typeof key === "string" && !(key in obj)) {
44+
errors.push(`${path}: missing required property "${key}"`);
45+
}
46+
}
47+
}
48+
const properties = schema.properties;
49+
if (properties !== null && typeof properties === "object") {
50+
for (const [key, subSchema] of Object.entries(properties as Record<string, unknown>)) {
51+
if (key in obj && subSchema !== null && typeof subSchema === "object") {
52+
errors.push(...validateJsonSchema(subSchema as JsonSchema, obj[key], `${path}.${key}`));
53+
}
54+
}
55+
}
56+
if (schema.additionalProperties === false) {
57+
const allowed = new Set(
58+
properties !== null && typeof properties === "object"
59+
? Object.keys(properties as Record<string, unknown>)
60+
: [],
61+
);
62+
for (const key of Object.keys(obj)) {
63+
if (!allowed.has(key)) {
64+
errors.push(`${path}: unexpected property "${key}" (additionalProperties: false)`);
65+
}
66+
}
67+
}
68+
}
69+
70+
if (typeOf(value) === "array" && Array.isArray(value)) {
71+
const items = schema.items;
72+
if (items !== null && typeof items === "object") {
73+
value.forEach((item, i) => {
74+
errors.push(...validateJsonSchema(items as JsonSchema, item, `${path}[${i}]`));
75+
});
76+
}
77+
}
78+
79+
if (typeof value === "string") {
80+
if (typeof schema.minLength === "number" && value.length < schema.minLength) {
81+
errors.push(`${path}: length ${value.length} is below minLength ${schema.minLength}`);
82+
}
83+
if (typeof schema.maxLength === "number" && value.length > schema.maxLength) {
84+
errors.push(`${path}: length ${value.length} exceeds maxLength ${schema.maxLength}`);
85+
}
86+
}
87+
88+
if (typeof value === "number") {
89+
if (typeof schema.minimum === "number" && value < schema.minimum) {
90+
errors.push(`${path}: ${value} is below minimum ${schema.minimum}`);
91+
}
92+
if (typeof schema.maximum === "number" && value > schema.maximum) {
93+
errors.push(`${path}: ${value} exceeds maximum ${schema.maximum}`);
94+
}
95+
}
96+
97+
return errors;
98+
}
99+
100+
function deepEqual(a: unknown, b: unknown): boolean {
101+
if (a === b) return true;
102+
if (typeof a !== typeof b) return false;
103+
if (typeof a !== "object" || a === null || b === null) return false;
104+
return JSON.stringify(a) === JSON.stringify(b);
105+
}

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";
@@ -105,6 +105,11 @@ import {
105105
} from "./stop-policy.js";
106106
import { SubAgentDirector } from "./nudge-director.js";
107107
import { assertTierMayMountFleetVerb } from "./authority.js";
108+
import {
109+
createSubmitResultState,
110+
evaluateSubmitResult,
111+
SUBMIT_RESULT_MAX_CORRECTIONS,
112+
} from "./submit-result.js";
108113
import {
109114
abortError,
110115
createSubAgentSpawnRegistryPlugin,
@@ -295,6 +300,24 @@ export function shouldRequireEvidence(input: {
295300
return input.directorId === "critique";
296301
}
297302

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

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

454+
// submit_result (CL-6946): typed reporting channel, Tier 3 leaves only.
455+
// Gated by the existing tier machinery — never invent a parallel check.
456+
if (params.tier === "leaf") {
457+
tools = [
458+
...tools,
459+
stringTool({
460+
definition: submitResultDefinition,
461+
handler: async (rawArgs: Record<string, unknown>): Promise<string> => {
462+
const outcome = evaluateSubmitResult({
463+
turnToken: turnToken!,
464+
submittedToken: rawArgs.turn_token,
465+
result: rawArgs.result,
466+
...(params.reportSchema !== undefined ? { schema: params.reportSchema } : {}),
467+
state: submitResultState,
468+
});
469+
return outcome.message;
470+
},
471+
}),
472+
];
473+
}
474+
425475
// Orchestrators need task + search_agents installed, not just mentioned in
426476
// the prompt. Nested dispatch always forbids further orchestration so the
427477
// tree bottoms out after one hop.
@@ -825,6 +875,7 @@ export async function runSubAgent(params: RunSubAgentParams): Promise<string> {
825875
...(params.reportFocus !== undefined && params.reportFocus.trim().length > 0
826876
? { reportFocus: params.reportFocus }
827877
: {}),
878+
...(turnToken !== undefined ? { turnToken } : {}),
828879
});
829880
const ensureNotAborted = (): void => {
830881
// 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)