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
12 changes: 10 additions & 2 deletions scripts/fixtures/agent-output-server.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
"""Real local agent HTTP fixture with persistent journals and scripted models."""
import asyncio
import json
import os
from pathlib import Path
import sys

import uvicorn
from fastapi.responses import HTMLResponse
from scone_memory import HashEmbedder, InMemoryDocumentStore, InMemoryVectorIndex, MemoryEngine
from scone_memory.agents.catalog import AgentCatalog, AgentDefinition, AgentModel
from scone_memory.agents.custom_tools import AgentTool
from scone_memory.agents.evidence_loop import ToolStep
from scone_memory.agents.usage import ModelTokenUsage
from scone_memory.agents.plan_store import AgentPlanStore
Expand All @@ -31,10 +33,16 @@ async def complete(self, messages, tools):
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))
application_tools = []
if os.environ.get('SCONE_TEST_AGENT_TOOLS') == '1':
def forbidden_tool(arguments, context):
raise AssertionError('Catalog views must not execute application tools')
application_tools = [AgentTool('count_records', 'Count local records. <img src=x onerror=alert(1)>',
'fixture-1', {'type': 'object'}, forbidden_tool)]
catalog = AgentCatalog(tools=application_tools, 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), AgentDefinition(agent_id='writer', instructions='Write the final answer.', models=('fast', 'careful'), default_model='careful', initial_search=False)])
default_model='fast', initial_search=False, tools=tuple(tool.name for tool in application_tools)), 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
49 changes: 49 additions & 0 deletions scripts/test-agent-tool-catalog-native.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
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 mode of ['desktop','mobile','legacy'])test(`native agent tool catalog remains scoped in ${mode} editor`,{timeout:60000},async t=>{
const state=fs.mkdtempSync(path.join(os.tmpdir(),'scone-tool-catalog-browser-')),port=await availablePort(),base=`http://127.0.0.1:${port}`;
let browser,logs='';
const server=spawn(testPython(),['-u',path.join(__dirname,'fixtures/agent-output-server.py'),state,path.join(__dirname,'../dist/console.html'),String(port)],{env:{...process.env,SCONE_TEST_AGENT_TOOLS:'1'},stdio:['pipe','pipe','pipe']});
const closed=once(server,'close');server.stderr.on('data',part=>{logs=(logs+part).slice(-8000);});server.stdout.resume();
t.after(async()=>{await browser?.close();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);fs.rmSync(state,{recursive:true,force:true});}assert.equal(server.exitCode,0,logs);});
let ready=false;
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){ready=true;break;}}catch{}await new Promise(resolve=>setTimeout(resolve,50));}
assert(ready,logs);
browser=await engines[process.env.SCONE_BROWSER_ENGINE||'chromium'].launch({headless:true,executablePath:process.env.SCONE_BROWSER_PATH});
const page=await browser.newPage({viewport:mode==='mobile'?{width:390,height:844}:{width:1380,height:1000}});page.setDefaultTimeout(7000);
const errors=[],writes=[];page.on('pageerror',error=>errors.push(error.message));page.on('request',request=>{if(['POST','PUT'].includes(request.method()))writes.push(request.url());});
if(mode==='legacy')await page.route('**/v1/agents/catalog',async route=>{const response=await route.fetch(),body=await response.json();delete body.tools;body.agents.forEach(agent=>delete agent.tools);await route.fulfill({response,json:body});});
await page.goto(base+'/agents');await page.getByLabel('Agent',{exact:true}).waitFor();
if(mode==='legacy'){
assert.equal(await page.locator('.agent-tools,.agent-tools-empty').count(),0);
await page.getByRole('button',{name:'New handoff workflow',exact:true}).click();
assert.equal(await page.locator('.agent-tools,.agent-tools-empty').count(),0);
}else{
await page.locator('.agent-tools summary').click();
await page.getByText('Count local records. <img src=x onerror=alert(1)>',{exact:true}).waitFor();
assert.equal(await page.locator('.agent-tools img').count(),0);
await page.getByLabel('Model',{exact:true}).selectOption('careful');
assert.equal(await page.getByLabel('Model',{exact:true}).inputValue(),'careful');
assert.equal(await page.locator('.agent-tools strong').textContent(),'count_records');
if(process.env.SCONE_TOOL_SCREENSHOTS)await page.screenshot({path:path.join(process.env.SCONE_TOOL_SCREENSHOTS,`tools-task-${mode}.png`),fullPage:true});
await page.getByLabel('Agent',{exact:true}).selectOption('writer');
await page.getByText('No application tools configured.',{exact:true}).waitFor();
assert.equal(await page.locator('.agent-tools').count(),0);
page.once('dialog',dialog=>dialog.accept());
await page.getByRole('button',{name:'New handoff workflow',exact:true}).click();
await page.locator('.agent-tools summary').click();
await page.getByLabel('Add agent',{exact:true}).selectOption('writer');
await page.getByRole('region',{name:'Handoff agent writer',exact:true}).getByText('No application tools configured.',{exact:true}).waitFor();
await page.getByLabel('Model for worker',{exact:true}).selectOption('careful');
assert.equal(await page.getByRole('region',{name:'Handoff agent worker',exact:true}).locator('.agent-tools strong').textContent(),'count_records');
if(process.env.SCONE_TOOL_SCREENSHOTS)await page.screenshot({path:path.join(process.env.SCONE_TOOL_SCREENSHOTS,`tools-handoff-${mode}.png`),fullPage:true});
}
assert.equal(writes.length,0);assert(!fs.existsSync(path.join(state,'calls.jsonl')));
assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));assert.deepEqual(errors,[]);
});
13 changes: 13 additions & 0 deletions src/agents/AgentTools.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type {ToolChoice} from './plans';

export function AgentTools({tools}:{tools:readonly ToolChoice[]|undefined}){
if(tools===undefined)return null;
if(!tools.length)return <p className="agent-tools-empty">No application tools configured.</p>;
return <details className="agent-tools">
<summary>Application tools ({tools.length})</summary>
<p>These tools are configured by the host and stay the same when you change models.</p>
<ul>{tools.map(tool=><li key={tool.name}>
<strong>{tool.name}</strong><p>{tool.description}</p><small>Revision {tool.revision}</small>
</li>)}</ul>
</details>;
}
2 changes: 2 additions & 0 deletions src/agents/AgentsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {requireOutputCapabilities} from './output-requirements';
import {RunPanel} from './RunPanel';
import {HandoffEditor} from './HandoffEditor';
import {parseRunPolicy} from './runs';
import {AgentTools} from './AgentTools';

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

Expand Down Expand Up @@ -54,6 +55,7 @@ function Editor({api,space,catalog,initial,onSave,onDirty,inputsAvailable,requir
<label>Model<select aria-label="Model" value={task.model_id} onChange={event=>update(index,{...task,model_id:event.target.value})}>
{!agent?.models.some(model=>model.model_id===task.model_id)&&<option value={task.model_id}>{task.model_id||'Choose a model'} (unavailable)</option>}{agent?.models.map(model=><option key={model.model_id} value={model.model_id}>{model.label} · {model.model_id}</option>)}
</select></label></>}{isInputTask(task)&&<label>Maximum reply bytes<input type="number" min={1} max={4000} value={task.max_response_bytes} onChange={event=>update(index,{...task,max_response_bytes:Number(event.target.value)})}/></label>}</div>
{!isInputTask(task)&&<AgentTools tools={agent?.tools}/>}
<label>{isInputTask(task)?'Question for the user':'Task instructions'}<textarea aria-label={isInputTask(task)?'Question for the user':'Task instructions'} required maxLength={2000} rows={3} value={task.prompt} onChange={event=>update(index,{...task,prompt:event.target.value})}/></label>
{!isInputTask(task)&&<OutputRequirementsEditor value={task.answer_requirements} available={requirementsAvailable} schemaAvailable={schemaAvailable} onChange={value=>update(index,{...task,answer_requirements:value})}/>}
<fieldset className="agent-dependencies"><legend>Receive outputs from</legend>{plan.tasks.filter((_,i)=>i!==index).map((other,i)=><label key={i}><input type="checkbox" checked={task.depends_on.includes(other.task_id)} onChange={event=>update(index,{...task,depends_on:event.target.checked?[...task.depends_on,other.task_id]:task.depends_on.filter(id=>id!==other.task_id)})}/>{other.task_id||'Unnamed task'}</label>)}{plan.tasks.length===1&&<p>No other tasks yet.</p>}</fieldset>
Expand Down
2 changes: 2 additions & 0 deletions src/agents/HandoffEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ 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';
import {AgentTools} from './AgentTools';
const secureRequest={cache:'no-store',redirect:'error',credentials:'omit',referrerPolicy:'no-referrer'} as const;
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];
Expand Down Expand Up @@ -45,6 +46,7 @@ export function HandoffEditor({api,space,catalog,initial,onSave,onDirty,handoffR
<label>Model<select aria-label={`Model for ${selected.agent_id}`} value={selected.model_id} onChange={event=>update(index,{model_id:event.target.value})}>
{!agent?.models.some(model=>model.model_id===selected.model_id)&&<option value={selected.model_id}>{selected.model_id} (unavailable)</option>}{agent?.models.map(model=><option key={model.model_id} value={model.model_id}>{model.label} · {model.model_id}</option>)}
</select></label>
<AgentTools tools={agent?.tools}/>
<fieldset className="agent-dependencies"><legend>Allowed handoff targets</legend>{plan.agents.map(target=><label key={target.agent_id}><input type="checkbox" checked={selected.can_handoff_to.includes(target.agent_id)} onChange={event=>update(index,{can_handoff_to:event.target.checked?[...selected.can_handoff_to,target.agent_id]:selected.can_handoff_to.filter(id=>id!==target.agent_id)})}/>{target.agent_id}{target.agent_id===selected.agent_id?' (repeat this agent)':''}</label>)}</fieldset>
{!selected.can_handoff_to.length&&<p>This agent must finish without handing off.</p>}
</section>;
Expand Down
7 changes: 7 additions & 0 deletions src/agents/agents.css

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

32 changes: 29 additions & 3 deletions src/agents/plans.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {parseOutputRequirements,type OutputRequirements} from './output-requirements.ts';
export interface ModelChoice {model_id:string;label:string;revision:string}
export interface AgentChoice {agent_id:string;default_model:string;models:ModelChoice[]}
export interface ToolChoice {name:string;description:string;revision:string}
export interface AgentChoice {agent_id:string;default_model:string;models:ModelChoice[];tools?:ToolChoice[]}
export interface AgentTask {task_id:string;agent_id:string;model_id:string;prompt:string;depends_on:string[];answer_requirements?:OutputRequirements}
export interface HumanInputTask {kind:'input';task_id:string;prompt:string;depends_on:string[];max_response_bytes:number}
export type TaskNode=AgentTask|HumanInputTask;
Expand Down Expand Up @@ -43,13 +44,38 @@ function list(value:unknown,max:number):unknown[]{
return value as unknown[];
}
function unique(values:string[]):void{if(new Set(values).size!==values.length)throw Error('Duplicate agent configuration identifiers.');}
const reservedTools=new Set(['search_memory','trace_memory','read_memory','compute_memory','answer','unknown_tool','custom_tool']);
function toolName(value:unknown):string{
const name=text(value,64);
if(!/^[A-Za-z0-9_-]+$/.test(name)||reservedTools.has(name))throw Error('Invalid application tool name.');
return name;
}
function toolTable(value:unknown):Map<string,ToolChoice>{
const entries=list(value,32).map(value=>{
const row=record(value);
if(Object.keys(row).some(key=>!['name','description','revision'].includes(key)))throw Error('Invalid application tool metadata.');
const name=toolName(row.name),description=text(row.description,4000),revision=text(row.revision,128);
if(/[\uD800-\uDFFF]/u.test(description)||new TextEncoder().encode(description).length>4000||!/^[A-Za-z0-9._:-]+$/.test(revision))throw Error('Invalid application tool metadata.');
return {name,description,revision};
});
unique(entries.map(tool=>tool.name));
if(new TextEncoder().encode(JSON.stringify(entries)).length>128000)throw Error('Application tool metadata exceeds its byte limit.');
return new Map(entries.map(tool=>[tool.name,tool]));
}
function selectedTools(row:Record<string,unknown>,table:Map<string,ToolChoice>|undefined):ToolChoice[]|undefined{
if(!table){if('tools' in row)throw Error('Application tool descriptions are missing.');return undefined;}
const names=list(row.tools,32).map(toolName);unique(names);
return names.map(name=>{const tool=table.get(name);if(!tool)throw Error('Selected application tool is unavailable.');return {...tool};});
}
export function parseCatalog(value:unknown):AgentChoice[]{
const agents=list(record(value).agents,32).map(value=>{
const packet=record(value),table='tools' in packet?toolTable(packet.tools):undefined;
const agents=list(packet.agents,32).map(value=>{
const row=record(value),models=list(row.models,64).map(value=>{const model=record(value);return {model_id:identifier(model.model_id),label:text(model.label,256),revision:identifier(model.revision)};});
unique(models.map(model=>model.model_id));
const default_model=identifier(row.default_model);
if(!models.some(model=>model.model_id===default_model))throw Error('Agent default model is unavailable.');
return {agent_id:identifier(row.agent_id),default_model,models};
const tools=selectedTools(row,table);
return {agent_id:identifier(row.agent_id),default_model,models,...(tools===undefined?{}:{tools})};
});
unique(agents.map(agent=>agent.agent_id));return agents;
}
Expand Down
42 changes: 42 additions & 0 deletions tests/agent-tool-catalog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {parseCatalog} from '../src/agents/plans.ts';

const agent={agent_id:'worker',default_model:'local',models:[{model_id:'local',label:'Local',revision:'1'}]};
const tool={name:'count',description:'Count local records.',revision:'1'};
const packet=()=>({agents:[{...agent,tools:['count']}],tools:[{...tool}]});

test('legacy unknown and explicit empty application tools remain distinct',()=>{
assert.equal(parseCatalog({agents:[agent]})[0].tools,undefined);
assert.deepEqual(parseCatalog({agents:[{...agent,tools:[]}],tools:[]})[0].tools,[]);
});
test('shared tool metadata resolves per agent and stays detached',()=>{
const source=packet();
const result=parseCatalog({...source,agents:[source.agents[0],{...agent,agent_id:'second',tools:['count']}]});
assert.deepEqual(result[0].tools,[tool]);
source.tools[0].description='Changed';
assert.equal(result[0].tools?.[0].description,tool.description);
});
test('partial new catalog shapes and malformed metadata fail closed',()=>{
for(const value of [
{agents:[{...agent,tools:[]}]},{agents:[agent],tools:[]},
{...packet(),tools:null},{...packet(),tools:false},{...packet(),tools:[tool,tool]},
{...packet(),agents:[{...agent,tools:null}]},{...packet(),agents:[{...agent,tools:['missing']}]},
{...packet(),agents:[{...agent,tools:['count','count']}]},
...['answer','search_memory','bad.name','x'.repeat(65)].map(name=>({...packet(),tools:[{...tool,name}]})),
...[' ', 'é'.repeat(2001),'\ud800'].map(description=>({...packet(),tools:[{...tool,description}]})),
{...packet(),tools:[{...tool,parameters:{}}]}, {...packet(),tools:[{...tool,revision:false}]},
])assert.throws(()=>parseCatalog(value));
});
test('metadata has bounded counts and aggregate UTF-8 bytes',()=>{
const tools=Array.from({length:32},(_,i)=>({...tool,name:'tool_'+i,description:'x'.repeat(3900)}));
const agents=[{...agent,tools:tools.map(tool=>tool.name)}];
assert.equal(parseCatalog({agents,tools})[0].tools?.length,32);
assert.throws(()=>parseCatalog({agents,tools:[...tools,{...tool,name:'extra'}]}));
assert.throws(()=>parseCatalog({agents,tools:tools.map(tool=>({...tool,description:'x'.repeat(4000)}))}));
});
test('prototype-like tool names use ordinary entries and descriptions stay literal',()=>{
const description='<img src=x onerror=alert(1)> 😀';
const result=parseCatalog({agents:[{...agent,tools:['__proto__']}],tools:[{...tool,name:'__proto__',description}]});
assert.equal(result[0].tools?.[0].description,description);
});
Loading