diff --git a/app/components/ChatInput.vue b/app/components/ChatInput.vue index 6d466b9..6bd39e9 100644 --- a/app/components/ChatInput.vue +++ b/app/components/ChatInput.vue @@ -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() }" /> +
diff --git a/app/components/SearchSelector.vue b/app/components/SearchSelector.vue index f91ecd6..630d819 100644 --- a/app/components/SearchSelector.vue +++ b/app/components/SearchSelector.vue @@ -83,7 +83,7 @@ const openSettings = () => { - Search the web automatically when needed + Search the web and fetch URLs when needed
diff --git a/app/components/ToolSelector.vue b/app/components/ToolSelector.vue new file mode 100644 index 0000000..0930b47 --- /dev/null +++ b/app/components/ToolSelector.vue @@ -0,0 +1,45 @@ + + + diff --git a/drizzle/schema.ts b/drizzle/schema.ts index 33edae8..5e787f3 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -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) => [ diff --git a/server/api/agent/[id]/index.patch.ts b/server/api/agent/[id]/index.patch.ts index 3d9aef4..cae71e6 100644 --- a/server/api/agent/[id]/index.patch.ts +++ b/server/api/agent/[id]/index.patch.ts @@ -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), diff --git a/server/api/topic/[topicId]/chat/index.post.ts b/server/api/topic/[topicId]/chat/index.post.ts index c26a042..49dd853 100644 --- a/server/api/topic/[topicId]/chat/index.post.ts +++ b/server/api/topic/[topicId]/chat/index.post.ts @@ -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 => { + 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(); const nativeToDbToolCallId = new Map(); - // TODO: somehow let the user turn on and off tools - const tools: Record = { - // 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 = {}; + + 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);