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
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
6 changes: 5 additions & 1 deletion scripts/fixtures/agent-output-server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
68 changes: 68 additions & 0 deletions scripts/test-handoff-output-native.cjs
Original file line number Diff line number Diff line change
@@ -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,[]);
});
6 changes: 3 additions & 3 deletions src/agents/AgentsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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:[]});
Expand Down Expand Up @@ -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<unknown>('/v1/agents/catalog',options).then(parseCatalog),api.request<unknown>('/v1/agent-plans?limit=20',options).then(value=>parsePlanPage(value,status.space as string))]);
const maxParallel=caps.features['agents.parallel']?parseRunPolicy(await api.request<unknown>('/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]);
Expand All @@ -97,7 +97,7 @@ export function AgentsPage({api,enabled}:{api:ApiClient;enabled:boolean}){
{!data.runsAvailable&&<p className="agent-notice">This server supports saved workflow configuration. Run controls are not enabled.</p>}
<div className="agents-layout"><aside aria-label="Saved workflows"><h2>Saved in {data.space}</h2><button onClick={()=>{if(discard()){setDirty(false);setSelection(value=>({id:value.id+1,plan:null}));}}}>New workflow</button>{data.handoffsAvailable&&<button onClick={()=>{if(discard()){setDirty(false);setSelection(value=>({id:value.id+1,plan:null,handoff:true}));}}}>New handoff workflow</button>}<ul>{data.items.map(saved=><li key={saved.plan.workflow_id}><button onClick={()=>{if(discard()){setDirty(false);setSelection(value=>({id:value.id+1,plan:saved}));}}}>{saved.plan.workflow_id}<small>Revision {saved.revision} · {isHandoffPlan(saved.plan)?`${saved.plan.agents.length} agents · Handoffs`: `${saved.plan.tasks.length} tasks`}{!saved.configuration_current?' · Review required':''}</small></button></li>)}</ul>{!data.items.length&&<p>No saved workflows yet.</p>}{data.next_after&&<button disabled={paging} onClick={()=>void page()}>{paging?'Loading…':'Load more'}</button>}<button onClick={()=>{if(discard())setRefresh(n=>n+1);}}>Reload saved workflows</button></aside>
{(()=>{
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?<HandoffEditor key={selection.id} {...props}/>:<p role="alert">This server does not support handoff configuration.</p>:selection.plan&&isInteractivePlan(selection.plan.plan)&&!data.inputsAvailable?<p role="alert">This server does not support human input workflows.</p>:<Editor key={selection.id} {...props}/>;
})()}</div>{data.runsAvailable&&<RunPanel key={data.space} api={api} space={data.space} plan={selection.plan} dirty={dirty} maxParallel={data.maxParallel} handoffsAvailable={data.handoffsAvailable} inputsAvailable={data.inputsAvailable} usageAvailable={data.usageAvailable}/>} {error&&<p role="alert">{error}</p>}
</>}
Expand Down
Loading
Loading