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
34 changes: 32 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -472,8 +472,8 @@ replace the selected model or task graph.
Saved plans belong to the authenticated space and are loaded without browser
storage. The server enforces write permissions; read-only users can inspect plans.
The Python host must configure `AgentCatalog` and `AgentPlanStore` in `create_app`.
This editor saves configuration; starting, cancelling and inspecting runs in the
browser remains separate work. Native execution uses `AgentWorkflow`.
Hosts with run capabilities also support starting, cancelling and inspecting
saved workflow runs in the browser. Native execution uses `AgentWorkflow`.

Run `node --test scripts/test-agents.cjs` with the same Playwright environment
variables used above. It verifies packaged desktop/mobile editing, persistence,
Expand Down Expand Up @@ -649,3 +649,33 @@ reopening it. A schema checks shape, not factual accuracy.
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.

### Exact tool approvals

When the host advertises `agents.approvals`, run inspection shows guarded calls
with their agent, selected model, workflow step, tool revision and exact JSON
arguments. Large integers retain their original digits. Directional Unicode
controls appear as explicit Unicode escapes in both pending requests and history;
the bound arguments remain unchanged.

**Approve** or **Deny** saves a decision without executing a tool or calling a
model. Select individual saved decisions and choose **Continue with selected
decisions** to advance those calls. A denial skips that tool handler and lets the
agent receive the denial when explicitly continued. Decisions require a review
or full key; continuation requires a write or full key. The host enforces these
permissions and binds each decision to the saved call and revision.

An unconfirmed continuation retains its identifier and selected batch across
status refreshes and reopening that run in the current panel. Retrying is always
explicit. A continuation already saved by the host can also be recovered after
reloading the browser. Nothing is automatically resubmitted. Cancelled runs and
runs with unknown outcomes cannot continue; admitted-call history alone does not
prove an effect completed. Read the run status and verified results.

After `pnpm build`, `pnpm test:agent-approvals:browser` exercises the packaged
console against an isolated local HTTP fixture. It checks ambiguous-response
recovery across status refresh and visible directional controls without changing
argument values. It requires an installed Playwright module and local browser;
set `SCONE_PLAYWRIGHT_MODULE` to its module path and
`SCONE_BROWSER_EXECUTABLE` to the browser executable when they are not available
through Playwright's defaults. The command does not install or download either.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"typecheck": "tsc --noEmit",
"check:assets": "node scripts/package.mjs --check",
"test": "node --test tests/*.test.mjs tests/*.test.ts",
"test:agent-approvals:browser": "node scripts/check-agent-approvals.mjs",
"start": "node scripts/host.mjs"
},
"dependencies": {
Expand Down
57 changes: 57 additions & 0 deletions scripts/check-agent-approvals.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/** Real packaged React regressions; configure a locally installed browser and Playwright. */
import fs from 'node:fs';
import http from 'node:http';
import assert from 'node:assert/strict';
import {fileURLToPath} from 'node:url';
const {chromium}=await import(process.env.SCONE_PLAYWRIGHT_MODULE || 'playwright');
const root=fileURLToPath(new URL('..',import.meta.url));
const html=fs.readFileSync(root+'/dist/console.html','utf8').replaceAll('__SCONE_TOKEN__','review');
assert(html.includes('Tool approval requests'));
const time='2026-09-13T10:00:00Z';
const call={step_id:'send',selection_id:'send',agent_id:'worker',model_id:'careful',binding:'a'.repeat(64),tool_name:'send_note',tool_revision:'.',tool_digest:'b'.repeat(64),arguments_json:'{"message":"hello"}',operation_digest:'c'.repeat(64)};
const decided={space:'alpha',run_id:'one',request_id:'d'.repeat(64),call,revision:2,created_at:time,decision:'approve',decided_by:'key:owner',decided_at:time,decision_digest:'e'.repeat(64),activation_id:null,activated_at:null,consumed_at:null};
const status={space:'alpha',run_id:'one',created_at:time,workflow_id:'job',plan_revision:1,status:'paused',active_local:false,completed_steps:[],inflight:null,outcome_unknown:false,error_class:null,paused_steps:['send']};
const request={space:'alpha',run_id:'one',created_at:time,question:'Hello',plan:{space:'alpha',revision:1,updated_at:time,plan:{workflow_id:'job',tasks:[{task_id:'send',agent_id:'worker',model_id:'careful',prompt:'Send a note',depends_on:[]}]},bindings:{send:'a'.repeat(64)}}};
const caps=JSON.parse(fs.readFileSync(root+'/tests/fixtures/http-capabilities.json')).python;
for(const k of Object.keys(caps.features))if(k.startsWith('agents.'))caps.features[k]=false;
for(const k of ['catalog','plans','runs','approvals'])caps.features['agents.'+k]=true;
const posts=[],unexpected=[];let records=[decided];
const server=http.createServer(async(req,res)=>{
const path=new URL(req.url,'http://localhost').pathname;const json=value=>{res.setHeader('Content-Type','application/json');res.end(JSON.stringify(value));};
if(path==='/agents'||path==='/'){res.setHeader('Content-Type','text/html');return res.end(html);}
if(path==='/v1/status')return json({space:'alpha',episodes:0});
if(path==='/v1/capabilities')return json(caps);
if(path==='/v1/agents/catalog')return json({agents:[{agent_id:'worker',default_model:'careful',models:[{model_id:'careful',label:'Careful',revision:'1'}]}]});
if(path==='/v1/agent-plans'||path==='/v1/agent-runs')return json({items:[],next_after:null});
if(path==='/v1/agent-runs/one/request'){await new Promise(resolve=>setTimeout(resolve,100));return json(request);}
if(path==='/v1/agent-runs/one')return json(status);
if(path==='/v1/agent-runs/one/approvals')return json({space:'alpha',run_id:'one',items:records});
if(path==='/v1/agent-runs/one/approval-continuations') {let body='';for await(const chunk of req)body+=chunk;posts.push(JSON.parse(body));res.statusCode=503;return json({error:"acknowledgement unavailable"});}
unexpected.push(path);res.statusCode=404;json({error:'missing'});
});
(async()=>{let browser;try{
await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));browser=await chromium.launch({headless:true,...(process.env.SCONE_BROWSER_EXECUTABLE?{executablePath:process.env.SCONE_BROWSER_EXECUTABLE}:{})});
const page=await browser.newPage();page.setDefaultTimeout(7000);
await page.goto('http://127.0.0.1:'+server.address().port+'/agents');
await page.getByLabel('Find run by identifier').fill('one');await page.getByRole('button',{name:'Open run',exact:true}).click();
await page.getByLabel('Continue with this approval').check();await page.getByRole('button',{name:'Continue with selected decisions (1)',exact:true}).click();
await page.getByText(/Continuation not confirmed/).waitFor();assert.equal(posts.length,1);const first=posts[0].continuation_id;
assert.equal(await page.getByRole('button',{name:'Retry same tool continuation',exact:true}).count(),1);
await page.getByRole('button',{name:'Check status',exact:true}).click();await page.getByLabel('Continue with this approval').waitFor({state:'hidden'});await page.getByLabel('Continue with this approval').waitFor();
const retrySurvived=await page.getByRole('button',{name:'Retry same tool continuation',exact:true}).count();
assert.equal(retrySurvived,1,'Check status must preserve the exact pending continuation');
await page.getByRole('button',{name:'Retry same tool continuation',exact:true}).click();
await page.getByText(/Continuation not confirmed/).waitFor();assert.equal(posts.length,2);
assert.deepEqual(posts[1],posts[0]);
console.log('Status refresh preserves the exact ambiguous continuation ID and batch.');
const raw=JSON.stringify({destination:'\u202emoc.elpmaxe@nimda\u202c'});
const bidi={...decided,call:{...call,arguments_json:raw}};
records=[bidi,{...bidi,request_id:'f'.repeat(64),revision:4,activation_id:'history',activated_at:time,consumed_at:time}];
await page.getByRole('button',{name:'Check status',exact:true}).click();
await page.getByText('Directional control characters are shown as Unicode escapes.',{exact:true}).first().waitFor();
const displayed=await page.locator('.agent-arguments').allTextContents();
assert.equal(displayed.length,2);
for(const value of displayed){assert.ok(value.includes('\\u202e'));assert.ok(!/[\u202a-\u202e]/.test(value));assert.deepEqual(JSON.parse(value),JSON.parse(raw));}
assert.equal(posts.length,2,'Displaying bidi requests must not submit decisions or continuations');
console.log('Pending and admitted argument displays reveal directional controls without changing their values.');
}catch(error){console.error(error);process.exitCode=1;}finally{await browser?.close();await new Promise(resolve=>server.close(resolve));}})();
6 changes: 3 additions & 3 deletions src/agents/AgentsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {AgentTools} from './AgentTools';

const secureRequest={cache:'no-store',redirect:'error',credentials:'omit',referrerPolicy:'no-referrer'} as const;

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};
type Ready={approvalsAvailable:boolean;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 @@ -80,7 +80,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:{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}});
if(!controller.signal.aborted)setLoaded({api,data:{approvalsAvailable:caps.features['agents.approvals'],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 @@ -101,7 +101,7 @@ export function AgentsPage({api,enabled}:{api:ApiClient;enabled:boolean}){
{(()=>{
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>}
})()}</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} approvalsAvailable={data.approvalsAvailable}/>} {error&&<p role="alert">{error}</p>}
</>}
</main>;
}
Loading
Loading