Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 10 additions & 8 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
Expand Down
14 changes: 14 additions & 0 deletions src/agent/directors/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
}

/**
* One shipped director: hard primary intent + package fields.
* Packages land in later levels; registry holds the closed set.
Expand All @@ -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 {
Expand Down
10 changes: 10 additions & 0 deletions src/subagent/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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");
}

Expand Down
53 changes: 52 additions & 1 deletion src/subagent/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -308,6 +331,12 @@ export async function runSubAgent(params: RunSubAgentParams): Promise<string> {
});

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
Expand Down Expand Up @@ -423,6 +452,27 @@ export async function runSubAgent(params: RunSubAgentParams): Promise<string> {
}),
];

// 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<string, unknown>): Promise<string> => {
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.
Expand Down Expand Up @@ -846,6 +896,7 @@ export async function runSubAgent(params: RunSubAgentParams): Promise<string> {
...(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
Expand Down
94 changes: 94 additions & 0 deletions src/subagent/submit-result.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading
Loading