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
+34 -4
View File
@@ -50,6 +50,10 @@ const searchConfig = ref({
rerank: props.agent?.config?.search?.rerank ?? false,
});
const toolsConfig = ref({
python: props.agent?.config?.tools?.python ?? false,
});
watch(() => props.agent?.config?.search, (val) => {
searchConfig.value = {
enabled: val?.enabled ?? false,
@@ -58,21 +62,45 @@ watch(() => props.agent?.config?.search, (val) => {
};
}, { deep: true });
const saveSearchConfig = async () => {
watch(() => props.agent?.config?.tools, (val) => {
toolsConfig.value = {
python: val?.python ?? false,
};
}, { deep: true });
const saveAgentConfig = async (partial: {
search?: typeof searchConfig.value;
tools?: typeof toolsConfig.value;
}) => {
if (!props.agent) return;
const currentConfig = props.agent.config ?? {};
const newConfig = {
...currentConfig,
...(partial.search !== undefined ? {
search: {
enabled: searchConfig.value.enabled,
maxResults: searchConfig.value.maxResults,
rerank: searchConfig.value.rerank,
enabled: partial.search.enabled,
maxResults: partial.search.maxResults,
rerank: partial.search.rerank,
},
} : {}),
...(partial.tools !== undefined ? {
tools: {
python: partial.tools.python,
},
} : {}),
};
patchAgentLocally(props.agent.id, { config: newConfig });
updateAgent(props.agent.id, { config: newConfig });
};
const saveSearchConfig = async () => {
await saveAgentConfig({ search: searchConfig.value });
};
const saveToolsConfig = async () => {
await saveAgentConfig({ tools: toolsConfig.value });
};
const isUploading = computed(() => files.value.some((f) => f.status === 'uploading'));
const hasFailedUploads = computed(() => files.value.some((f) => f.status === 'error'));
@@ -386,6 +414,8 @@ onUnmounted(() => {
@update:enabled="(v: boolean) => { searchConfig.enabled = v; saveSearchConfig() }"
@update:max-results="(v: number) => { searchConfig.maxResults = v; saveSearchConfig() }"
@update:rerank="(v: boolean) => { searchConfig.rerank = v; saveSearchConfig() }" />
<ToolSelector v-if="selectedModel?.capabilities.includes('tools')" :python="toolsConfig.python"
@update:python="(v: boolean) => { toolsConfig.python = v; saveToolsConfig() }" />
<FileSelector ref="fileSelectorRef" :selected-model="selectedModel" v-model="files" />
</div>
<div v-if="allowManualRole" class="flex items-center">
+1 -1
View File
@@ -83,7 +83,7 @@ const openSettings = () => {
</span>
<span class="text-xs leading-snug"
:class="enabled ? 'text-[var(--text-secondary)]' : 'text-[var(--text-dim)]'">
Search the web automatically when needed
Search the web and fetch URLs when needed
</span>
</div>
</button>
+45
View File
@@ -0,0 +1,45 @@
<script setup lang="ts">
const props = defineProps<{
python: boolean;
}>();
const emit = defineEmits<{
'update:python': [value: boolean];
}>();
const anyEnabled = computed(() => props.python);
</script>
<template>
<Dropdown dropdownClass="text-sm" placement="top">
<template #default="{ toggle, setRef }">
<button :ref="setRef" @click="toggle"
class="flex items-center justify-center h-8.5 w-8.5 @hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<span class="pointer-events-none i-mynaui-archive text-5 transition-colors duration-200"
:class="anyEnabled ? 'text-[var(--color-accent)]' : 'text-[var(--text-secondary)]'"></span>
</button>
</template>
<template #dropdown>
<div class="flex flex-col gap-1 p-1 min-w-48">
<label
class="flex items-start gap-3 rounded-xl px-3 py-2.5 text-left cursor-pointer transition-colors duration-150 @hover:bg-[var(--color-hover)]"
:class="python ? 'bg-[var(--color-active)]' : ''">
<input type="checkbox" :checked="python"
@change="emit('update:python', ($event.target as HTMLInputElement).checked)"
class="mt-0.5 w-4 h-4 rounded border-[var(--color-border)] bg-transparent text-[var(--color-accent)] focus:ring-[var(--color-accent)] focus:ring-offset-0 cursor-pointer shrink-0" />
<div class="flex flex-col gap-0.5">
<span class="text-sm font-medium"
:class="python ? 'text-[var(--text-primary)]' : 'text-[var(--text-secondary)]'">
Python
</span>
<span class="text-xs leading-snug"
:class="python ? 'text-[var(--text-secondary)]' : 'text-[var(--text-dim)]'">
Run code in a sandbox
</span>
</div>
</label>
</div>
</template>
</Dropdown>
</template>
+4 -1
View File
@@ -112,7 +112,10 @@ export const agents = pgTable('agents', {
enabled: boolean;
rerank: boolean;
maxResults: number;
}
};
tools?: {
python?: boolean;
};
}>().default({}),
createdAt: timestamptz('created_at').notNull().defaultNow(),
}, (table) => [
+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),
+64 -166
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) => {
try {
let stdout = ''
const printCallback = (_: string, text: string) => {
stdout += text
const formatPythonValue = (value: unknown): string => {
if (value === undefined || value === null) {
return '';
}
const m = new Monty(code)
await m.run({ printCallback })
return stdout
if (typeof value === 'string') {
return value;
}
try {
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;
};
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}`;
}
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,95 +470,7 @@ 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',
}));
return {
files,
};
},
}),
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,
};
},
}),
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({
const fetchUrlTool = tool({
description: 'Fetches the content of a URL',
inputSchema: z.object({
url: z.string(),
@@ -546,16 +490,16 @@ const { listDirectoryTool, globTool, readFileTool, readFilesTool, fetchUrlTool,
}),
});
const content = await response.json();
console.log(content);
return {
content: content.data.markdown,
};
},
}),
pythonTool: tool({
description: 'Executes a Python code snippet',
});
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(),
code: z.string().describe('Python code to run. Prefer a final expression over print(), e.g. `2 + 2` returns `4`.'),
}),
outputSchema: z.object({
output: z.string(),
@@ -566,37 +510,7 @@ const { listDirectoryTool, globTool, readFileTool, readFilesTool, fetchUrlTool,
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,
};
},
}),
}
});
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);