@@ -23,7 +23,7 @@ import { type } from "arktype";
2323import { createPosixTools } from "@intx/tools-posix" ;
2424import { createDynamicToolRunner } from "../tui/dynamic-tool-runner.js" ;
2525import 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
2828import { seedPricingMetadataFromCache } from "../cost/pricing-metadata.js" ;
2929import { defaultPricingCachePath } from "../cost/pricing-fetcher.js" ;
@@ -105,6 +105,11 @@ import {
105105} from "./stop-policy.js" ;
106106import { SubAgentDirector } from "./nudge-director.js" ;
107107import { assertTierMayMountFleetVerb } from "./authority.js" ;
108+ import {
109+ createSubmitResultState ,
110+ evaluateSubmitResult ,
111+ SUBMIT_RESULT_MAX_CORRECTIONS ,
112+ } from "./submit-result.js" ;
108113import {
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
0 commit comments