feat: per-agent tools toggle and better python scratchpad

Add ToolSelector for enabling sandboxed python per agent, drop unsafe
filesystem/bash tools, bundle fetchUrl with web search, and return
Monty's last expression value so agents need not print.
This commit is contained in:
Zoe
2026-07-29 14:18:37 -05:00
parent 5aebd30808
commit a836082de8
6 changed files with 182 additions and 203 deletions
+3
View File
@@ -21,6 +21,9 @@ export default defineEventHandler(async (event) => {
rerank: z.boolean(),
maxResults: z.number().min(1).max(50),
}).optional(),
tools: z.object({
python: z.boolean().optional(),
}).optional(),
}).optional(),
})
.safeParse(body),
+93 -195
View File
@@ -2,7 +2,6 @@ import { db } from "~~/server/lib/db";
import * as z from 'zod';
import { type MessageEntity } from '~/composables/useChat';
import { promises as fs } from 'fs';
import { glob } from 'glob';
import { isLoopFinished, type ModelMessage, rerank, type RerankingModel, streamText, type StreamTextTransform, type Tool, tool } from "ai";
import { generations, messageParts, messages, toolCalls, ToolCallType } from "~~/drizzle/schema";
import { topicEvents } from "~~/server/utils/events";
@@ -332,6 +331,9 @@ export default defineEventHandler(async (event) => {
agentmessage as MessageEntity,
{ gateway: gateway.gateway, model, parameters: args },
searchParam,
{
python: topic.agent.config?.tools?.python ?? false,
},
agentmessage.generationId!,
userId,
topicId,
@@ -363,24 +365,54 @@ const formatToolCallId = (nativeId: string) => {
return `veridian__tool-${nativeId.slice(0, 16)}-${nanoid()}`;
};
const evalPython = async (code: string) => {
const formatPythonValue = (value: unknown): string => {
if (value === undefined || value === null) {
return '';
}
if (typeof value === 'string') {
return value;
}
try {
let stdout = ''
return JSON.stringify(value, (_, v) => typeof v === 'bigint' ? v.toString() : v);
} catch {
return String(value);
}
};
const evalPython = async (code: string): Promise<string> => {
try {
let stdout = '';
const printCallback = (_: string, text: string) => {
stdout += text
stdout += text;
};
const m = new Monty(code);
const result = m.run({
printCallback,
limits: {
maxDurationSecs: 10,
maxMemory: 32 * 1024 * 1024,
maxRecursionDepth: 100,
},
});
const expressionOutput = formatPythonValue(result);
if (stdout && expressionOutput) {
return stdout.endsWith('\n')
? `${stdout}${expressionOutput}`
: `${stdout}\n${expressionOutput}`;
}
const m = new Monty(code)
await m.run({ printCallback })
return stdout
return stdout || expressionOutput || '';
} catch (error) {
if (error instanceof MontySyntaxError) {
console.log('Syntax error:', error.message)
} else if (error instanceof MontyRuntimeError) {
console.log('Runtime error:', error.message)
console.log('Traceback:', error.traceback())
} else if (error instanceof MontyTypingError) {
console.log('Type error:', error.displayDiagnostics())
return `SyntaxError: ${error.message}`;
}
if (error instanceof MontyRuntimeError) {
return error.display('traceback') || `RuntimeError: ${error.message}`;
}
if (error instanceof MontyTypingError) {
return error.display('concise') || `TypeError: ${error.message}`;
}
return error instanceof Error ? error.message : 'Python execution failed';
}
};
@@ -438,165 +470,47 @@ export const searchTheWeb = (config: SearchTheWebConfig) => {
};
};
// TODO: obviously come up with a better way for the user to define their own tools
const { listDirectoryTool, globTool, readFileTool, readFilesTool, fetchUrlTool, pythonTool, bashTool } = {
listDirectoryTool: tool({
description: 'Lists the contents of a directory',
inputSchema: z.object({
path: z.string(),
}),
outputSchema: z.object({
files: z.array(z.object({ name: z.string(), type: z.string() })),
}),
execute: async ({ path }) => {
const rawFiles = await fs.readdir(path, { withFileTypes: true });
const files = rawFiles.map((file) => ({
name: file.name,
type: file.isFile() ? 'file' : 'directory',
}));
const fetchUrlTool = tool({
description: 'Fetches the content of a URL',
inputSchema: z.object({
url: z.string(),
}),
outputSchema: z.object({
content: z.string(),
}),
execute: async ({ url }) => {
const response = await fetch('https://api.firecrawl.dev/v2/scrape', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + process.env.FIRECRAWL_API_KEY,
},
body: JSON.stringify({
url,
}),
});
const content = await response.json();
return {
content: content.data.markdown,
};
},
});
return {
files,
};
},
const pythonTool = tool({
description: 'Executes Python in a sandbox. The value of the last expression is returned automatically (no print needed). stdout from print() is also included.',
inputSchema: z.object({
code: z.string().describe('Python code to run. Prefer a final expression over print(), e.g. `2 + 2` returns `4`.'),
}),
globTool: tool({
description: 'Lists files matching a glob pattern',
inputSchema: z.object({
pattern: z.string(),
}),
outputSchema: z.object({
files: z.array(z.object({ path: z.string(), type: z.string() })),
}),
execute: async ({ pattern }) => {
const rawFiles = await glob(pattern, { withFileTypes: true });
const files = rawFiles.map((file) => ({
path: file.parentPath + '/' + file.name,
type: file.isFile() ? 'file' : 'directory',
}));
return {
files,
};
},
outputSchema: z.object({
output: z.string(),
}),
readFileTool: tool({
description: 'Reads the contents of a file',
inputSchema: z.object({
path: z.string(),
}),
outputSchema: z.object({
path: z.string(),
content: z.string(),
}),
execute: async ({ path }) => {
const file = await fs.readFile(path);
return {
path,
content: file.toString(),
};
},
}),
readFilesTool: tool({
description: 'Reads the contents of multiple files',
inputSchema: z.object({
paths: z.array(z.string()).describe('a list of file paths to read'),
}),
outputSchema: z.object({
files: z.array(
z.object({
path: z.string(),
content: z.string(),
}),
),
}),
execute: async ({ paths }) => {
const files = await Promise.all(
paths.map(async (path) => {
const file = await fs.readFile(path);
return {
path: path,
content: file.toString(),
};
}),
);
return {
files,
};
},
}),
fetchUrlTool: tool({
description: 'Fetches the content of a URL',
inputSchema: z.object({
url: z.string(),
}),
outputSchema: z.object({
content: z.string(),
}),
execute: async ({ url }) => {
const response = await fetch('https://api.firecrawl.dev/v2/scrape', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + process.env.FIRECRAWL_API_KEY,
},
body: JSON.stringify({
url,
}),
});
const content = await response.json();
console.log(content);
return {
content: content.data.markdown,
};
},
}),
pythonTool: tool({
description: 'Executes a Python code snippet',
inputSchema: z.object({
code: z.string(),
}),
outputSchema: z.object({
output: z.string(),
}),
execute: async ({ code }) => {
const output = await evalPython(code);
return {
output,
};
},
}),
bashTool: tool({
description: 'Executes a Bash command',
inputSchema: z.object({
code: z.string(),
}),
outputSchema: z.object({
output: z.string(),
}),
execute: async ({ code }) => {
const { exec } = await import('child_process');
const { promisify } = await import('util');
const execAsync = promisify(exec);
async function runCommand(command: string) {
const { stdout, stderr } = await execAsync(command);
if (stderr) {
console.error(`Error: ${stderr}`);
return stderr;
} else {
return stdout;
}
}
const output = await runCommand(code);
return {
output,
};
},
}),
}
execute: async ({ code }) => {
const output = await evalPython(code);
return {
output,
};
},
});
async function generateResponse(
message: MessageEntity,
@@ -608,6 +522,9 @@ async function generateResponse(
search: false | {
config: SearchTheWebConfig,
},
enabledTools: {
python?: boolean;
},
generationId: string,
userId: string,
topicId: string,
@@ -625,31 +542,11 @@ async function generateResponse(
const activeToolCalls = new Set<string>();
const nativeToDbToolCallId = new Map<string, string>();
// TODO: somehow let the user turn on and off tools
const tools: Record<string, Tool> = {
// writeFile: tool({
// inputSchema: z.object({
// path: z.string(),
// content: z.string(),
// }),
// outputSchema: z.object({
// success: z.boolean(),
// }),
// execute: async ({ path, content }) => {
// await fs.writeFile(path, content);
// return {
// success: true,
// };
// }
// }),
listDirectory: listDirectoryTool,
glob: globTool,
readFile: readFileTool,
readFiles: readFilesTool,
fetchUrl: fetchUrlTool,
python: pythonTool,
bash: bashTool,
};
const tools: Record<string, Tool> = {};
if (enabledTools.python) {
tools.python = pythonTool;
}
if (search) {
tools.search = tool({
@@ -669,6 +566,7 @@ async function generateResponse(
return await searchTheWeb(search.config)(query);
},
});
tools.fetchUrl = fetchUrlTool;
}
const response = streamText({
@@ -685,7 +583,7 @@ async function generateResponse(
},
experimental_transform: streamTransoforms,
stopWhen: isLoopFinished(),
tools: model.model.capabilities.includes('tools') ? tools : undefined,
tools: model.model.capabilities.includes('tools') && Object.keys(tools).length > 0 ? tools : undefined,
onError: async (error: any) => {
// TODO: the docs say "The stream processing will pause until the callback promise is resolved." Suggesting that this error might not be fatal?
console.error('generation error', error);