diff --git a/scripts/fixtures/agent-output-server.py b/scripts/fixtures/agent-output-server.py
index 4cf95f2..b27a1ac 100644
--- a/scripts/fixtures/agent-output-server.py
+++ b/scripts/fixtures/agent-output-server.py
@@ -1,6 +1,7 @@
"""Real local agent HTTP fixture with persistent journals and scripted models."""
import asyncio
import json
+import os
from pathlib import Path
import sys
@@ -8,6 +9,7 @@
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
@@ -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. ',
+ '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())
diff --git a/scripts/test-agent-tool-catalog-native.cjs b/scripts/test-agent-tool-catalog-native.cjs
new file mode 100644
index 0000000..9d9648f
--- /dev/null
+++ b/scripts/test-agent-tool-catalog-native.cjs
@@ -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.
',{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,[]);
+});
diff --git a/src/agents/AgentTools.tsx b/src/agents/AgentTools.tsx
new file mode 100644
index 0000000..35e0260
--- /dev/null
+++ b/src/agents/AgentTools.tsx
@@ -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
No application tools configured.
; + returnThese tools are configured by the host and stay the same when you change models.
+{tool.description}
Revision {tool.revision} +This agent must finish without handing off.
} ; diff --git a/src/agents/agents.css b/src/agents/agents.css index 77d9e70..7893103 100644 --- a/src/agents/agents.css +++ b/src/agents/agents.css @@ -12,3 +12,10 @@ .agent-output-requirements p{font-size:.85rem;line-height:1.5;color:var(--muted,#777);margin:0} .agent-output-requirements .agent-fields{margin-bottom:0} .agent-output-requirements textarea[spellcheck=false]{font-family:ui-monospace,SFMono-Regular,monospace;font-size:.85rem;line-height:1.5} + +.agent-tools {padding:12px 0;overflow-wrap:anywhere;} +.agent-tools summary {cursor:pointer;font-weight:600;} +.agent-tools ul {padding-left:20px;} +.agent-tools li {margin:12px 0;} +.agent-tools li p {white-space:pre-wrap;margin:4px 0;} +.agent-tools small,.agent-tools-empty {color:var(--muted);} diff --git a/src/agents/plans.ts b/src/agents/plans.ts index 0793412..94bd5da 100644 --- a/src/agents/plans.ts +++ b/src/agents/plans.ts @@ -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; @@ -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