From f96f7275e1fe712d5f923c57f846eb8a9b720354 Mon Sep 17 00:00:00 2001 From: DrDrewCain Date: Sun, 13 Sep 2026 00:59:13 -0500 Subject: [PATCH] Edit final output contracts for handoff workflows --- README.md | 20 +++++++ scripts/fixtures/agent-output-server.py | 6 +- scripts/test-handoff-output-native.cjs | 68 +++++++++++++++++++++++ src/agents/AgentsPage.tsx | 6 +- src/agents/HandoffEditor.tsx | 12 +++- src/agents/OutputRequirementsEditor.tsx | 2 +- src/agents/output-requirements.ts | 9 ++- src/agents/plans.ts | 6 +- src/capabilities.ts | 6 +- tests/capabilities.test.ts | 8 +++ tests/handoff-output-requirements.test.ts | 32 +++++++++++ 11 files changed, 159 insertions(+), 16 deletions(-) create mode 100644 scripts/test-handoff-output-native.cjs create mode 100644 tests/handoff-output-requirements.test.ts diff --git a/README.md b/README.md index 443460a..c09565e 100644 --- a/README.md +++ b/README.md @@ -629,3 +629,23 @@ actual local native HTTP and encrypted journals at desktop and mobile widths, including malformed drafts, selected models, invalid answer withholding and process restart without additional inference. It uses the same explicit local Python/browser environment variables as the other native browser fixtures. + +### Handoff final output requirements + +When the host advertises `agents.handoffs.output_requirements`, a handoff workflow +can set a final text or JSON-object contract, including byte/line limits, +instructions and an optional schema. Requirements apply when an agent finishes; +intermediate agents can exchange ordinary prose notes. Each agent retains its +selected host-approved model and allowed targets. + +Schema authoring additionally requires `agents.output_schema`. The console keeps +authored local references and refuses invalid drafts before saving. An invalid +schema draft also marks the workflow unsaved, preventing a run against stale +saved settings. Changing the contract requires a new plan revision and run. +The native host validates the final result before persistence and again when +reopening it. A schema checks shape, not factual accuracy. + +`scripts/test-handoff-output-native.cjs` exercises real two-agent HTTP execution, +selected models, exact save/reload, invalid drafts and final answers, and an +encrypted journal restart without model replay, at desktop and mobile widths. +Use the same isolated Python/browser settings as the task contract fixture. diff --git a/scripts/fixtures/agent-output-server.py b/scripts/fixtures/agent-output-server.py index 18eacef..4cf95f2 100644 --- a/scripts/fixtures/agent-output-server.py +++ b/scripts/fixtures/agent-output-server.py @@ -26,11 +26,15 @@ async def complete(self, messages, tools): with (state / 'calls.jsonl').open('a') as output: output.write(json.dumps({'model': self.name, 'messages': messages}) + '\n') usage = ModelTokenUsage(prompt_tokens=120, completion_tokens=18, total_tokens=138) if self.name == 'careful' else ModelTokenUsage(prompt_tokens=25) + if 'Return answer and handoff_to.' in json.dumps(messages): + writing = any(message.get('content', '').startswith('Write the final answer.') for message in messages) + value = {'answer': {'name': 'Juniper' if self.name == 'careful' else 2}, 'handoff_to': None} if writing else {'answer': 'Ordinary research notes', 'handoff_to': 'writer'} + return ToolStep(content=json.dumps(value), usage=usage) return ToolStep(content='{"name":"Juniper"}' if self.name == 'careful' else 'Invalid plain-text result', usage=usage) catalog = AgentCatalog(models=[AgentModel(name, name.title() + ' local', '1', lambda name=name: Model(name)) for name in ('fast', 'careful')], agents=[AgentDefinition( agent_id='worker', instructions='Use the provided direction.', models=('fast', 'careful'), - default_model='fast', initial_search=False)]) + default_model='fast', initial_search=False), AgentDefinition(agent_id='writer', instructions='Write the final answer.', models=('fast', 'careful'), default_model='careful', initial_search=False)]) plans = AgentPlanStore(state / 'plans.sqlite', key=b'k' * 32) service = AgentRunService(state / 'runs', key=b'k' * 32, catalog=catalog, plans=plans, memory=memory, scope_for=lambda space: RecallScope.validated()) diff --git a/scripts/test-handoff-output-native.cjs b/scripts/test-handoff-output-native.cjs new file mode 100644 index 0000000..992b195 --- /dev/null +++ b/scripts/test-handoff-output-native.cjs @@ -0,0 +1,68 @@ +const {test}=require('node:test'); +const assert=require('node:assert/strict'); +const {spawn}=require('node:child_process'); +const {once}=require('node:events'); +const fs=require('node:fs'),os=require('node:os'),path=require('node:path'),net=require('node:net'); +const engines=require(process.env.SCONE_PLAYWRIGHT_MODULE||'playwright'); +const {testPython}=require('./fixture-host.cjs'); +async function availablePort(){const server=net.createServer();await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));const port=server.address().port;await new Promise(resolve=>server.close(resolve));return port;} +for(const mobile of [false,true])test(`handoff final schemas survive two hops and restart, mobile=${mobile}`,{timeout:60000},async t=>{ + const state=fs.mkdtempSync(path.join(os.tmpdir(),'scone-handoff-contract-browser-')),port=await availablePort(),base=`http://127.0.0.1:${port}`; + let server,closed,logs='',browser; + const stop=async()=>{if(!server)return;if(server.exitCode===null&&server.signalCode===null)server.stdin.end('stop\n');const timer=setTimeout(()=>server.kill('SIGKILL'),5000);try{await closed;}finally{clearTimeout(timer);}assert.notEqual(server.signalCode,'SIGKILL',logs);assert.equal(server.exitCode,0,logs);server=null;}; + const start=async()=>{ + server=spawn(testPython(),['-u',path.join(__dirname,'fixtures/agent-output-server.py'),state,path.join(__dirname,'../dist/console.html'),String(port)],{stdio:['pipe','pipe','pipe']}); + closed=once(server,'close');server.stderr.on('data',part=>{logs=(logs+part).slice(-8000);});server.stdout.resume(); + for(let i=0;i<150;i++){if(server.exitCode!==null)throw Error(logs);try{if((await fetch(base+'/healthz',{signal:AbortSignal.timeout(300)})).ok)return;}catch{}await new Promise(resolve=>setTimeout(resolve,50));}throw Error('Fixture did not become ready: '+logs); + }; + const calls=()=>fs.existsSync(path.join(state,'calls.jsonl'))?fs.readFileSync(path.join(state,'calls.jsonl'),'utf8').trim().split('\n').filter(Boolean).map(JSON.parse):[]; + t.after(async()=>{await browser?.close();await stop();fs.rmSync(state,{recursive:true,force:true});}); + await start();browser=await engines[process.env.SCONE_BROWSER_ENGINE||'chromium'].launch({headless:true,executablePath:process.env.SCONE_BROWSER_PATH}); + const page=await browser.newPage({viewport:mobile?{width:390,height:844}:{width:1380,height:1100}});page.setDefaultTimeout(7000); + const errors=[],writes=[];page.on('pageerror',error=>errors.push(error.message));page.on('request',request=>{if(request.method()==='PUT')writes.push(request.postDataJSON());}); + await page.goto(base+'/agents'); + await page.getByRole('button',{name:'New handoff workflow',exact:true}).click(); + await page.getByLabel('Workflow identifier',{exact:true}).fill('handoffs'); + await page.getByLabel('Model for worker',{exact:true}).selectOption('careful'); + await page.getByLabel('Add agent',{exact:true}).selectOption('writer'); + await page.getByRole('region',{name:'Handoff agent worker',exact:true}).getByRole('checkbox',{name:'writer',exact:true}).check(); + await page.getByLabel('Answer format',{exact:true}).selectOption('json_object'); + await page.getByLabel('Maximum answer bytes',{exact:true}).fill('256'); + await page.getByLabel('Maximum answer lines (optional)',{exact:true}).fill('1'); + const field=page.getByLabel('JSON schema (optional)',{exact:true}); + await field.fill('{'); + await page.getByRole('button',{name:'Save workflow',exact:true}).click(); + assert.equal(writes.length,0);assert.equal(calls().length,0); + const schema={$defs:{name:{type:'string',minLength:1}},properties:{name:{$ref:'#/$defs/name'}},required:['name'],additionalProperties:false}; + await field.fill(JSON.stringify(schema,null,2)); + await page.getByRole('button',{name:'Save workflow',exact:true}).click(); + await page.getByText('Saved revision 1.',{exact:true}).waitFor(); + assert.deepEqual(writes[0].plan.answer_requirements.output_schema,schema); + await field.evaluate(element=>{element.scrollTop=0;element.blur();});await page.evaluate(()=>scrollTo(0,0)); + if(process.env.SCONE_CONTRACT_SCREENSHOTS)await page.screenshot({path:path.join(process.env.SCONE_CONTRACT_SCREENSHOTS,`handoffs-${mobile?'mobile':'desktop'}.png`),fullPage:true}); + await page.getByLabel('Run identifier',{exact:true}).fill('valid');await page.getByLabel('Question',{exact:true}).fill('What is the name?'); + await page.getByRole('button',{name:'Start run',exact:true}).click(); + const view=page.getByRole('region',{name:'Selected run',exact:true}); + await view.getByRole('status').filter({hasText:'completed'}).waitFor(); + await page.getByText('{"name":"Juniper"}',{exact:true}).first().waitFor(); + assert.equal(calls().length,2);assert(calls().every(call=>call.model==='careful')); + await stop();await start();await page.reload(); + await page.getByRole('button',{name:/handoffs.*Revision 1/}).first().click(); + assert.deepEqual(JSON.parse(await field.inputValue()),schema); + await page.getByRole('button',{name:/valid · handoffs/}).click(); + await page.getByText('{"name":"Juniper"}',{exact:true}).first().waitFor();assert.equal(calls().length,2); + await field.fill('{'); + await page.getByText('Save your workflow changes before starting a run.',{exact:true}).waitFor(); + let discardQuestion='';page.once('dialog',async dialog=>{discardQuestion=dialog.message();await dialog.dismiss();}); + await page.getByRole('button',{name:'Reload saved workflows',exact:true}).click(); + assert.match(discardQuestion,/Discard your unsaved/);assert.equal(await field.inputValue(),'{'); + await field.fill(JSON.stringify(schema)); + await page.getByLabel('Model for writer',{exact:true}).selectOption('fast'); + await page.getByRole('button',{name:'Save workflow',exact:true}).click();await page.getByText('Saved revision 2.',{exact:true}).waitFor(); + const prepare=page.getByRole('button',{name:'Prepare a new run',exact:true});if(await prepare.count())await prepare.click(); + await page.getByLabel('Run identifier',{exact:true}).fill('invalid');await page.getByLabel('Question',{exact:true}).fill('What is the name?'); + await page.getByRole('button',{name:'Start run',exact:true}).click(); + await view.getByRole('status').filter({hasText:'failed'}).waitFor(); + assert.equal(calls().length,4);assert.equal(await page.getByText('{"name":2}',{exact:true}).count(),0); + assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));assert.deepEqual(errors,[]); +}); diff --git a/src/agents/AgentsPage.tsx b/src/agents/AgentsPage.tsx index 6bd419f..21e38cd 100644 --- a/src/agents/AgentsPage.tsx +++ b/src/agents/AgentsPage.tsx @@ -12,7 +12,7 @@ import {parseRunPolicy} from './runs'; const secureRequest={cache:'no-store',redirect:'error',credentials:'omit',referrerPolicy:'no-referrer'} as const; -type Ready={requirementsAvailable:boolean;schemaAvailable:boolean;usageAvailable:boolean;inputsAvailable:boolean;handoffsAvailable:boolean;runsAvailable:boolean;maxParallel:number;space:string;catalog:AgentChoice[];items:SavedPlan[];next_after:string|null}; +type Ready={handoffRequirementsAvailable:boolean;requirementsAvailable:boolean;schemaAvailable:boolean;usageAvailable:boolean;inputsAvailable:boolean;handoffsAvailable:boolean;runsAvailable:boolean;maxParallel:number;space:string;catalog:AgentChoice[];items:SavedPlan[];next_after:string|null}; function Editor({api,space,catalog,initial,onSave,onDirty,inputsAvailable,requirementsAvailable,schemaAvailable}:{api:ApiClient;space:string;catalog:AgentChoice[];initial:SavedPlan|null;onSave:(plan:SavedPlan)=>void;onDirty:()=>void;inputsAvailable:boolean;requirementsAvailable:boolean;schemaAvailable:boolean}){ const first=catalog[0]; const newTask=(id:string):AgentTask=>({task_id:id,agent_id:first?.agent_id??'',model_id:first?.default_model??'',prompt:'',depends_on:[]}); @@ -78,7 +78,7 @@ export function AgentsPage({api,enabled}:{api:ApiClient;enabled:boolean}){ if(!caps.features['agents.catalog']||!caps.features['agents.plans'])throw Error('Agent workflow configuration is not enabled on this server.'); const [catalog,page]=await Promise.all([api.request('/v1/agents/catalog',options).then(parseCatalog),api.request('/v1/agent-plans?limit=20',options).then(value=>parsePlanPage(value,status.space as string))]); const maxParallel=caps.features['agents.parallel']?parseRunPolicy(await api.request('/v1/agents/run-policy',options),status.space):1; - if(!controller.signal.aborted)setLoaded({api,data:{requirementsAvailable:caps.features['agents.output_requirements'],schemaAvailable:caps.features['agents.output_schema'],usageAvailable:caps.features['agents.usage'],inputsAvailable:caps.features['agents.inputs'],handoffsAvailable:caps.features['agents.handoffs'],maxParallel,runsAvailable:caps.features['agents.runs'],space:status.space,catalog,...page}}); + if(!controller.signal.aborted)setLoaded({api,data:{handoffRequirementsAvailable:caps.features['agents.handoffs.output_requirements'],requirementsAvailable:caps.features['agents.output_requirements'],schemaAvailable:caps.features['agents.output_schema'],usageAvailable:caps.features['agents.usage'],inputsAvailable:caps.features['agents.inputs'],handoffsAvailable:caps.features['agents.handoffs'],maxParallel,runsAvailable:caps.features['agents.runs'],space:status.space,catalog,...page}}); })().catch(error=>{if(!controller.signal.aborted)setError(error instanceof Error?error.message:'Agent configuration could not be loaded.');}).finally(()=>{if(!controller.signal.aborted)setLoading(false);}); return()=>controller.abort(); },[api,enabled,refresh]); @@ -97,7 +97,7 @@ export function AgentsPage({api,enabled}:{api:ApiClient;enabled:boolean}){ {!data.runsAvailable&&

This server supports saved workflow configuration. Run controls are not enabled.

}
{(()=>{ - const props={api,requirementsAvailable:data.requirementsAvailable,schemaAvailable:data.schemaAvailable,inputsAvailable:data.inputsAvailable,space:data.space,catalog:data.catalog,initial:selection.plan,onDirty:()=>setDirty(true),onSave:(saved:SavedPlan)=>{setDirty(false);setSelection(current=>({...current,plan:saved}));setLoaded(current=>current?.api===api?{api,data:{...current.data,items:[saved,...current.data.items.filter(item=>item.plan.workflow_id!==saved.plan.workflow_id)]}}:current);}}; + const props={api,handoffRequirementsAvailable:data.handoffRequirementsAvailable,requirementsAvailable:data.requirementsAvailable,schemaAvailable:data.schemaAvailable,inputsAvailable:data.inputsAvailable,space:data.space,catalog:data.catalog,initial:selection.plan,onDirty:()=>setDirty(true),onSave:(saved:SavedPlan)=>{setDirty(false);setSelection(current=>({...current,plan:saved}));setLoaded(current=>current?.api===api?{api,data:{...current.data,items:[saved,...current.data.items.filter(item=>item.plan.workflow_id!==saved.plan.workflow_id)]}}:current);}}; return (selection.plan?isHandoffPlan(selection.plan.plan):selection.handoff)?data.handoffsAvailable?:

This server does not support handoff configuration.

:selection.plan&&isInteractivePlan(selection.plan.plan)&&!data.inputsAvailable?

This server does not support human input workflows.

:; })()}
{data.runsAvailable&&} {error&&

{error}

} } diff --git a/src/agents/HandoffEditor.tsx b/src/agents/HandoffEditor.tsx index 442eefe..6e83497 100644 --- a/src/agents/HandoffEditor.tsx +++ b/src/agents/HandoffEditor.tsx @@ -1,8 +1,10 @@ import {useEffect,useRef,useState} from 'react'; import {ApiError,type ApiClient} from '../api'; import {isHandoffPlan,parseSavedEdit,planAddress,validatePlan,type AgentChoice,type HandoffAgent,type HandoffPlan,type SavedPlan} from './plans'; +import {OutputRequirementsEditor} from './OutputRequirementsEditor'; +import {requireOutputCapabilities} from './output-requirements'; const secureRequest={cache:'no-store',redirect:'error',credentials:'omit',referrerPolicy:'no-referrer'} as const; -export function HandoffEditor({api,space,catalog,initial,onSave,onDirty}:{api:ApiClient;space:string;catalog:AgentChoice[];initial:SavedPlan|null;onSave:(plan:SavedPlan)=>void;onDirty:()=>void}){ +export function HandoffEditor({api,space,catalog,initial,onSave,onDirty,handoffRequirementsAvailable,schemaAvailable}:{handoffRequirementsAvailable:boolean;schemaAvailable:boolean;api:ApiClient;space:string;catalog:AgentChoice[];initial:SavedPlan|null;onSave:(plan:SavedPlan)=>void;onDirty:()=>void}){ const first=catalog[0]; const [plan,setPlan]=useState(initial&&isHandoffPlan(initial.plan)?initial.plan:{workflow_id:'',root_agent:first?.agent_id??'',max_handoffs:3,agents:first?[{agent_id:first.agent_id,model_id:first.default_model,can_handoff_to:[]}]:[]}); const [revision,setRevision]=useState(initial?.revision??0),[busy,setBusy]=useState(false),[issue,setIssue]=useState(''),[notice,setNotice]=useState(''),[reviewRequired,setReviewRequired]=useState(initial?.configuration_current===false); @@ -12,7 +14,7 @@ export function HandoffEditor({api,space,catalog,initial,onSave,onDirty}:{api:Ap const save=async()=>{ if(busy)return;setIssue('');setNotice('');const controller=new AbortController();active.current=controller; try{ - const valid=validatePlan(plan,catalog);setBusy(true); + const valid=validatePlan(plan,catalog);requireOutputCapabilities(valid,false,schemaAvailable,handoffRequirementsAvailable);setBusy(true); const saved=parseSavedEdit(await api.request(planAddress(valid.workflow_id),{...secureRequest,method:'PUT',body:JSON.stringify({expected_revision:revision,plan:valid}),signal:AbortSignal.any([controller.signal,AbortSignal.timeout(15000)])}),space,valid,revision); if(!isHandoffPlan(saved.plan))throw Error('The server returned a different workflow type.'); if(!controller.signal.aborted){setPlan(saved.plan);setRevision(saved.revision);setReviewRequired(!saved.configuration_current);onSave(saved);setNotice(`Saved revision ${saved.revision}.`);} @@ -20,7 +22,7 @@ export function HandoffEditor({api,space,catalog,initial,onSave,onDirty}:{api:Ap finally{if(!controller.signal.aborted)setBusy(false);} }; const unused=catalog.filter(choice=>!plan.agents.some(agent=>agent.agent_id===choice.agent_id)); - return
{event.preventDefault();void save();}}> + return {event.preventDefault();void save();}}>

{initial?'Edit handoff workflow':'New handoff workflow'}

Agents choose when to finish or pass work to an allowed agent. Each keeps the model you select.

{reviewRequired&&

The host changed an agent or model configuration. Review every agent and save a new revision before running it.

} @@ -29,6 +31,10 @@ export function HandoffEditor({api,space,catalog,initial,onSave,onDirty}:{api:Ap

Up to {Number.isFinite(plan.max_handoffs)?plan.max_handoffs+1:'—'} agent steps. Repeated agents are allowed only through the targets below. Reaching the limit keeps partial work and returns no final answer.

+ {(handoffRequirementsAvailable||plan.answer_requirements)&&
+

Final answer

Requirements apply when an agent finishes the workflow. Agents can pass ordinary text notes between steps.

+ edit(value=>({...value,answer_requirements}))}/> +
} {plan.agents.map((selected,index)=>{ const agent=catalog.find(choice=>choice.agent_id===selected.agent_id); return
diff --git a/src/agents/OutputRequirementsEditor.tsx b/src/agents/OutputRequirementsEditor.tsx index c1ff40d..c348b84 100644 --- a/src/agents/OutputRequirementsEditor.tsx +++ b/src/agents/OutputRequirementsEditor.tsx @@ -31,7 +31,7 @@ export function OutputRequirementsEditor({value,onChange,available,schemaAvailab catch(error){const message=error instanceof Error?error.message:'Check the JSON schema.';setIssue(message);event.target.setCustomValidity(message);} }}/> {!schemaAvailable&&

This server does not support JSON schemas.

} -

The server validates schema fields and values. An invalid answer stops the task; it is not automatically rewritten.

+

The server validates schema fields and values. An invalid answer stops execution; it is not automatically rewritten.

}

Output requirements do not establish factual accuracy. The host’s existing answer limits still apply.

} diff --git a/src/agents/output-requirements.ts b/src/agents/output-requirements.ts index c46a0c4..3a6c4ad 100644 --- a/src/agents/output-requirements.ts +++ b/src/agents/output-requirements.ts @@ -82,8 +82,13 @@ export function parseSchemaDraft(text:string):Readonly> } return schemaSnapshot(parsed); } -export function requireOutputCapabilities(plan:WorkflowPlan,requirements:boolean,schema:boolean):void{ - if('agents' in plan)return; +export function requireOutputCapabilities(plan:WorkflowPlan,requirements:boolean,schema:boolean,handoffs=false):void{ + if('agents' in plan){ + if(!plan.answer_requirements)return; + if(!handoffs)fail('This server does not support handoff output requirements.'); + if(plan.answer_requirements.output_schema&&!schema)fail('This server does not support output schemas.'); + return; + } for(const task of plan.tasks){ if('kind' in task||!task.answer_requirements)continue; if(!requirements)fail('This server does not support task output requirements.'); diff --git a/src/agents/plans.ts b/src/agents/plans.ts index 39ff28f..0793412 100644 --- a/src/agents/plans.ts +++ b/src/agents/plans.ts @@ -20,7 +20,7 @@ export function isInputTask(task:TaskNode):task is HumanInputTask{return 'kind' export function isInteractivePlan(plan:WorkflowPlan):plan is InteractivePlan{return 'kind' in plan&&plan.kind==='interactive';} export interface AgentPlan {workflow_id:string;tasks:AgentTask[]} export interface HandoffAgent {agent_id:string;model_id:string;can_handoff_to:string[]} -export interface HandoffPlan {workflow_id:string;root_agent:string;max_handoffs:number;agents:HandoffAgent[]} +export interface HandoffPlan {workflow_id:string;root_agent:string;max_handoffs:number;agents:HandoffAgent[];answer_requirements?:OutputRequirements} export type WorkflowPlan=AgentPlan|HandoffPlan|InteractivePlan; export interface SavedPlan {space:string;revision:number;plan:WorkflowPlan;configuration_current:boolean;updated_at:string;bindings:Record} export function isHandoffPlan(plan:WorkflowPlan):plan is HandoffPlan{return 'agents' in plan;} @@ -56,7 +56,7 @@ export function parseCatalog(value:unknown):AgentChoice[]{ function parsePlan(value:unknown):WorkflowPlan{ const row=record(value); if('agents' in row){ - if(Object.keys(row).some(key=>!['workflow_id','root_agent','max_handoffs','agents'].includes(key)))throw Error('Invalid handoff plan fields.'); + if(Object.keys(row).some(key=>!['workflow_id','root_agent','max_handoffs','agents','answer_requirements'].includes(key)))throw Error('Invalid handoff plan fields.'); const agents=list(row.agents,32).map(value=>{ const agent=record(value),can_handoff_to=list(agent.can_handoff_to,32).map(identifier);unique(can_handoff_to); if(Object.keys(agent).some(key=>!['agent_id','model_id','can_handoff_to'].includes(key)))throw Error('Invalid handoff agent fields.'); @@ -65,7 +65,7 @@ function parsePlan(value:unknown):WorkflowPlan{ unique(agents.map(agent=>agent.agent_id));const known=new Set(agents.map(agent=>agent.agent_id)),root_agent=identifier(row.root_agent); if(!agents.length||!known.has(root_agent)||agents.some(agent=>agent.can_handoff_to.some(id=>!known.has(id))))throw Error('Choose a known root agent and permitted handoff targets.'); if(typeof row.max_handoffs!=='number'||!Number.isInteger(row.max_handoffs)||row.max_handoffs<0||row.max_handoffs>31)throw Error('Allow between 0 and 31 handoffs.'); - return {workflow_id:identifier(row.workflow_id),root_agent,max_handoffs:row.max_handoffs,agents}; + return {workflow_id:identifier(row.workflow_id),root_agent,max_handoffs:row.max_handoffs,agents,...(row.answer_requirements==null?{}:{answer_requirements:parseOutputRequirements(row.answer_requirements)})}; } const interactive=row.kind==='interactive'; if(('kind' in row&&!interactive)||Object.keys(row).some(key=>!['workflow_id','tasks',...(interactive?['kind']:[])].includes(key)))throw Error('Invalid task plan fields.'); diff --git a/src/capabilities.ts b/src/capabilities.ts index 6c7f764..2d13cbe 100644 --- a/src/capabilities.ts +++ b/src/capabilities.ts @@ -1,7 +1,7 @@ export const FEATURE_KEYS = ['recall', 'facts.read', 'facts.review', 'facts.close', 'facts.exclude', 'facts.include', 'events.read', 'metrics.read', 'scopes.read', 'status.read'] as const; export type Feature = typeof FEATURE_KEYS[number]; -type OptionalFeature = 'agents.output_requirements' | 'agents.output_schema' | 'agents.usage' | 'documents.video.understand' | 'documents.sync' | 'agents.inputs' | 'recall.parts' | 'agents.handoffs' | 'agents.parallel' | 'agents.runs' | 'agents.catalog' | 'agents.plans' | 'episodes.forget' | 'documents.ocr.tables' | 'documents.jobs' | 'documents.files' | 'graph.knowledge_walk' | 'documents.provenance' | 'recall.graph_boost' | 'graph.knowledge_seeds' | 'graph.knowledge_paging' | 'graph.timeline' | 'graph.sources' | 'graph.export' | 'graph.path' | 'graph.knowledge' | 'entities.read' | 'graph.report' | 'images.understand' | 'models.manage' | 'episodes.attachments' | 'episodes.list' | 'episodes.read' | 'facts.links' | 'integrity.read' | 'profile.read' | 'processing.distill' | 'processing.derive' | 'jobs.read' | 'recall.conditions'; +type OptionalFeature = 'agents.handoffs.output_requirements' | 'agents.output_requirements' | 'agents.output_schema' | 'agents.usage' | 'documents.video.understand' | 'documents.sync' | 'agents.inputs' | 'recall.parts' | 'agents.handoffs' | 'agents.parallel' | 'agents.runs' | 'agents.catalog' | 'agents.plans' | 'episodes.forget' | 'documents.ocr.tables' | 'documents.jobs' | 'documents.files' | 'graph.knowledge_walk' | 'documents.provenance' | 'recall.graph_boost' | 'graph.knowledge_seeds' | 'graph.knowledge_paging' | 'graph.timeline' | 'graph.sources' | 'graph.export' | 'graph.path' | 'graph.knowledge' | 'entities.read' | 'graph.report' | 'images.understand' | 'models.manage' | 'episodes.attachments' | 'episodes.list' | 'episodes.read' | 'facts.links' | 'integrity.read' | 'profile.read' | 'processing.distill' | 'processing.derive' | 'jobs.read' | 'recall.conditions'; export interface Capabilities { schema_version: 1; implementation: string; @@ -14,7 +14,7 @@ export function parseCapabilities(value: unknown): Capabilities { if (data.schema_version !== 1 || typeof data.implementation !== 'string' || !data.implementation.trim() || !data.features || typeof data.features !== 'object' || Array.isArray(data.features)) throw Error('Unsupported capability response'); const features = data.features as Record; - for(const key of ['agents.output_requirements','agents.output_schema','agents.usage','documents.video.understand','documents.sync','documents.ocr.tables','agents.inputs','recall.parts','agents.handoffs','agents.parallel','agents.runs','agents.catalog','agents.plans','episodes.forget','documents.jobs','documents.files','documents.provenance','processing.distill','processing.derive','graph.knowledge','entities.read','graph.report','graph.path','graph.export','graph.sources','graph.timeline','graph.knowledge_paging','graph.knowledge_seeds','graph.knowledge_walk','recall.graph_boost']){ + for(const key of ['agents.handoffs.output_requirements','agents.output_requirements','agents.output_schema','agents.usage','documents.video.understand','documents.sync','documents.ocr.tables','agents.inputs','recall.parts','agents.handoffs','agents.parallel','agents.runs','agents.catalog','agents.plans','episodes.forget','documents.jobs','documents.files','documents.provenance','processing.distill','processing.derive','graph.knowledge','entities.read','graph.report','graph.path','graph.export','graph.sources','graph.timeline','graph.knowledge_paging','graph.knowledge_seeds','graph.knowledge_walk','recall.graph_boost']){ if(features[key]!==undefined&&typeof features[key]!=='boolean')throw Error('Invalid processing capability flag'); } if (FEATURE_KEYS.some(key => typeof features[key] !== 'boolean')) throw Error('Incomplete or invalid capability flags'); @@ -28,5 +28,5 @@ export function parseCapabilities(value: unknown): Capabilities { if(features['recall.conditions']!==undefined&&typeof features['recall.conditions']!=='boolean')throw Error('Invalid recall conditions capability'); if(features['images.understand']!==undefined&&typeof features['images.understand']!=='boolean')throw Error('Invalid image understanding capability'); if(features['models.manage']!==undefined&&typeof features['models.manage']!=='boolean')throw Error('Invalid model management capability'); - return {schema_version:1, implementation:data.implementation, features:{'agents.output_requirements':features['agents.output_requirements']===true,'agents.output_schema':features['agents.output_schema']===true,'agents.usage':features['agents.usage']===true,'documents.video.understand':features['documents.video.understand']===true,'documents.sync':features['documents.sync']===true,'documents.ocr.tables':features['documents.ocr.tables']===true,'agents.inputs':features['agents.inputs']===true,'recall.parts':features['recall.parts']===true,'agents.handoffs':features['agents.handoffs']===true,'agents.parallel':features['agents.parallel']===true,'agents.runs':features['agents.runs']===true,'agents.catalog':features['agents.catalog']===true,'agents.plans':features['agents.plans']===true,'episodes.forget':features['episodes.forget']===true,'documents.jobs':features['documents.jobs']===true,'documents.files':features['documents.files']===true,'graph.knowledge_walk':features['graph.knowledge_walk']===true,'documents.provenance':features['documents.provenance']===true,'recall.graph_boost':features['recall.graph_boost']===true,'graph.knowledge_seeds':features['graph.knowledge_seeds']===true,'graph.knowledge_paging':features['graph.knowledge_paging']===true,'graph.timeline':features['graph.timeline']===true,'graph.sources':features['graph.sources']===true,'graph.export':features['graph.export']===true,'graph.path':features['graph.path']===true,'graph.report':features['graph.report']===true,'graph.knowledge':features['graph.knowledge']===true,'entities.read':features['entities.read']===true,...Object.fromEntries(FEATURE_KEYS.map(key => [key, features[key]])), 'episodes.attachments':features['episodes.attachments'] === true, 'episodes.list':features['episodes.list'] === true, 'episodes.read':features['episodes.read'] === true, 'facts.links':features['facts.links'] === true, 'integrity.read':features['integrity.read'] === true, 'profile.read':features['profile.read'] === true, 'processing.distill':features['processing.distill']===true, 'processing.derive':features['processing.derive']===true, 'jobs.read':features['jobs.read']===true, 'recall.conditions':features['recall.conditions']===true, 'models.manage':features['models.manage']===true, 'images.understand':features['images.understand']===true} as Capabilities['features']}; + return {schema_version:1, implementation:data.implementation, features:{'agents.handoffs.output_requirements':features['agents.handoffs.output_requirements']===true,'agents.output_requirements':features['agents.output_requirements']===true,'agents.output_schema':features['agents.output_schema']===true,'agents.usage':features['agents.usage']===true,'documents.video.understand':features['documents.video.understand']===true,'documents.sync':features['documents.sync']===true,'documents.ocr.tables':features['documents.ocr.tables']===true,'agents.inputs':features['agents.inputs']===true,'recall.parts':features['recall.parts']===true,'agents.handoffs':features['agents.handoffs']===true,'agents.parallel':features['agents.parallel']===true,'agents.runs':features['agents.runs']===true,'agents.catalog':features['agents.catalog']===true,'agents.plans':features['agents.plans']===true,'episodes.forget':features['episodes.forget']===true,'documents.jobs':features['documents.jobs']===true,'documents.files':features['documents.files']===true,'graph.knowledge_walk':features['graph.knowledge_walk']===true,'documents.provenance':features['documents.provenance']===true,'recall.graph_boost':features['recall.graph_boost']===true,'graph.knowledge_seeds':features['graph.knowledge_seeds']===true,'graph.knowledge_paging':features['graph.knowledge_paging']===true,'graph.timeline':features['graph.timeline']===true,'graph.sources':features['graph.sources']===true,'graph.export':features['graph.export']===true,'graph.path':features['graph.path']===true,'graph.report':features['graph.report']===true,'graph.knowledge':features['graph.knowledge']===true,'entities.read':features['entities.read']===true,...Object.fromEntries(FEATURE_KEYS.map(key => [key, features[key]])), 'episodes.attachments':features['episodes.attachments'] === true, 'episodes.list':features['episodes.list'] === true, 'episodes.read':features['episodes.read'] === true, 'facts.links':features['facts.links'] === true, 'integrity.read':features['integrity.read'] === true, 'profile.read':features['profile.read'] === true, 'processing.distill':features['processing.distill']===true, 'processing.derive':features['processing.derive']===true, 'jobs.read':features['jobs.read']===true, 'recall.conditions':features['recall.conditions']===true, 'models.manage':features['models.manage']===true, 'images.understand':features['images.understand']===true} as Capabilities['features']}; } diff --git a/tests/capabilities.test.ts b/tests/capabilities.test.ts index 985302d..2f47566 100644 --- a/tests/capabilities.test.ts +++ b/tests/capabilities.test.ts @@ -151,3 +151,11 @@ test('video inference requires an explicit boolean independently of frame viewin assert.equal(parseCapabilities({...fixtures.python,features:{...features,'documents.video.understand':true}}).features['documents.video.understand'],true); for(const value of ['true',1,null])assert.throws(()=>parseCapabilities({...fixtures.python,features:{...features,'documents.video.understand':value}})); }); + +test('handoff output contracts require their own explicit boolean capability',()=>{ + const features={...fixtures.python.features,'agents.output_requirements':true,'agents.output_schema':true}; + delete features['agents.handoffs.output_requirements']; + assert.equal(parseCapabilities({...fixtures.python,features}).features['agents.handoffs.output_requirements'],false); + assert.equal(parseCapabilities({...fixtures.python,features:{...features,'agents.handoffs.output_requirements':true}}).features['agents.handoffs.output_requirements'],true); + for(const value of ['true',1,null])assert.throws(()=>parseCapabilities({...fixtures.python,features:{...features,'agents.handoffs.output_requirements':value}})); +}); diff --git a/tests/handoff-output-requirements.test.ts b/tests/handoff-output-requirements.test.ts new file mode 100644 index 0000000..3890421 --- /dev/null +++ b/tests/handoff-output-requirements.test.ts @@ -0,0 +1,32 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {parseOutputRequirements,requireOutputCapabilities} from '../src/agents/output-requirements.ts'; +import {parseSavedEdit,validatePlan} from '../src/agents/plans.ts'; +const catalog=[{agent_id:'worker',default_model:'local',models:[{model_id:'local',label:'Local',revision:'1'}]}]; +const legacy={workflow_id:'handoffs',root_agent:'worker',max_handoffs:3,agents:[{agent_id:'worker',model_id:'local',can_handoff_to:[]}]}; +const schema={$defs:{name:{type:'string'}},properties:{name:{$ref:'#/$defs/name'}},required:['name']}; +test('handoff requirements preserve authored schema and legacy omitted shape',()=>{ + assert.deepEqual(validatePlan(legacy,catalog),legacy); + const plan=validatePlan({...legacy,answer_requirements:parseOutputRequirements({format:'json_object',output_schema:schema})},catalog); + assert.deepEqual(plan.answer_requirements.output_schema,schema); + const saved={space:'alpha',revision:1,plan,configuration_current:true,updated_at:'2026-09-13T00:00:00Z',bindings:{worker:'a'.repeat(64)}}; + assert.deepEqual(parseSavedEdit(saved,'alpha',plan,0).plan,plan); + const changed=structuredClone(saved);changed.plan.answer_requirements.output_schema.properties.name={type:'number'}; + assert.throws(()=>parseSavedEdit(changed,'alpha',plan,0)); + delete changed.plan.answer_requirements;assert.throws(()=>parseSavedEdit(changed,'alpha',plan,0)); +}); +test('task support never implies handoff final contract support',()=>{ + const basic=validatePlan({...legacy,answer_requirements:parseOutputRequirements({max_lines:1})},catalog); + assert.doesNotThrow(()=>requireOutputCapabilities(validatePlan(legacy,catalog),false,false)); + assert.throws(()=>requireOutputCapabilities(basic,true,true)); + assert.doesNotThrow(()=>requireOutputCapabilities(basic,false,false,true)); + const typed=validatePlan({...legacy,answer_requirements:parseOutputRequirements({format:'json_object',output_schema:schema})},catalog); + assert.throws(()=>requireOutputCapabilities(typed,true,true,false)); + assert.throws(()=>requireOutputCapabilities(typed,false,false,true)); + assert.doesNotThrow(()=>requireOutputCapabilities(typed,false,true,true)); +}); +test('malformed contracts are rejected in loaded handoff plans',()=>{ + for(const answer_requirements of [[],{max_bytes:true},{format:'json_object',output_schema:[]},{PRIVATE:'PRIVATE'}]){ + assert.throws(()=>validatePlan({...legacy,answer_requirements},catalog)); + } +});