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:
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user