From 66191fdd82ca7c9d8847fbd5a8f1a66b446f9473 Mon Sep 17 00:00:00 2001 From: DrDrewCain Date: Sun, 13 Sep 2026 01:51:42 -0500 Subject: [PATCH] Show configured tools alongside agent model choices --- scripts/fixtures/agent-output-server.py | 12 +++++- scripts/test-agent-tool-catalog-native.cjs | 49 ++++++++++++++++++++++ src/agents/AgentTools.tsx | 13 ++++++ src/agents/AgentsPage.tsx | 2 + src/agents/HandoffEditor.tsx | 2 + src/agents/agents.css | 7 ++++ src/agents/plans.ts | 32 ++++++++++++-- tests/agent-tool-catalog.test.ts | 42 +++++++++++++++++++ 8 files changed, 154 insertions(+), 5 deletions(-) create mode 100644 scripts/test-agent-tool-catalog-native.cjs create mode 100644 src/agents/AgentTools.tsx create mode 100644 tests/agent-tool-catalog.test.ts 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.

; + return
+ Application tools ({tools.length}) +

These tools are configured by the host and stay the same when you change models.

+ +
; +} diff --git a/src/agents/AgentsPage.tsx b/src/agents/AgentsPage.tsx index 21e38cd..c9cd661 100644 --- a/src/agents/AgentsPage.tsx +++ b/src/agents/AgentsPage.tsx @@ -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; @@ -54,6 +55,7 @@ function Editor({api,space,catalog,initial,onSave,onDirty,inputsAvailable,requir }{isInputTask(task)&&} + {!isInputTask(task)&&}