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:
@@ -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,
|
||||
search: {
|
||||
enabled: searchConfig.value.enabled,
|
||||
maxResults: searchConfig.value.maxResults,
|
||||
rerank: searchConfig.value.rerank,
|
||||
},
|
||||
...(partial.search !== undefined ? {
|
||||
search: {
|
||||
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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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) => [
|
||||
|
||||
@@ -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