diff --git a/frontend/src/app/shell/shell.component.html b/frontend/src/app/shell/shell.component.html index a57e0114..c8ca5250 100644 --- a/frontend/src/app/shell/shell.component.html +++ b/frontend/src/app/shell/shell.component.html @@ -54,7 +54,7 @@ - Code Operations + Code Repositories diff --git a/src/agent/LlmFunctions.ts b/src/agent/LlmFunctions.ts index 7a3c94a6..3f2f5201 100644 --- a/src/agent/LlmFunctions.ts +++ b/src/agent/LlmFunctions.ts @@ -3,10 +3,11 @@ import { FUNC_SEP, FunctionSchema, getFunctionSchemas } from '#functionSchema/fu import { FunctionCall } from '#llm/llm'; import { logger } from '#o11y/logger'; -import { FileSystem } from '#functions/storage/filesystem'; +import { FileSystemService } from '#functions/storage/fileSystemService'; import { GetToolType, ToolType, toolType } from '#functions/toolType'; import { functionFactory } from '#functionSchema/functionDecorators'; +import { FileSystemRead } from '#functions/storage/FileSystemRead'; /** * Holds the instances of the classes with function callable methods. @@ -31,6 +32,8 @@ export class LlmFunctions { for (const functionClassName of functionClassNames) { const ctor = functionFactory()[functionClassName]; if (ctor) this.functionInstances[functionClassName] = new ctor(); + else if (functionClassName === 'FileSystem') + this.functionInstances[FileSystemRead.name] = new FileSystemRead(); // backwards compatability from creating FileSystemRead/Write wrappers else logger.warn(`${functionClassName} not found`); } return this; diff --git a/src/agent/agentContext.test.ts b/src/agent/agentContext.test.ts index 3f6794a3..85327847 100644 --- a/src/agent/agentContext.test.ts +++ b/src/agent/agentContext.test.ts @@ -1,10 +1,12 @@ import { expect } from 'chai'; import sinon from 'sinon'; import { LlmFunctions } from '#agent/LlmFunctions'; -import { createContext, deserializeAgentContext, serializeContext } from '#agent/agentContextLocalStorage'; +import { createContext } from '#agent/agentContextLocalStorage'; import { AgentContext } from '#agent/agentContextTypes'; import { RunAgentConfig } from '#agent/agentRunner'; -import { FileSystem } from '#functions/storage/filesystem'; +import { deserializeAgentContext, serializeContext } from '#agent/agentSerialization'; +import { FileSystemRead } from '#functions/storage/FileSystemRead'; +import { FileSystemService } from '#functions/storage/fileSystemService'; import { LlmTools } from '#functions/util'; import { GPT4o } from '#llm/models/openai'; import { appContext } from '../app'; @@ -25,7 +27,7 @@ describe('agentContext', () => { xhard: GPT4o(), }; // We want to check that the FileSystem gets re-added by the resetFileSystemFunction function - const functions = new LlmFunctions(LlmTools, FileSystem); + const functions = new LlmFunctions(LlmTools, FileSystemRead); const config: RunAgentConfig = { agentName: 'SWE', @@ -36,7 +38,7 @@ describe('agentContext', () => { metadata: { 'metadata-key': 'metadata-value' }, }; const agentContext: AgentContext = createContext(config); - agentContext.fileSystem.setWorkingDirectory('./workingDir'); + agentContext.fileSystem.setWorkingDirectory('./src'); agentContext.memory.memory_key = 'memory_value'; agentContext.functionCallHistory.push({ function_name: 'func', @@ -63,9 +65,6 @@ describe('agentContext', () => { const reserialised = serializeContext(deserialised); expect(serialized).to.be.deep.equal(reserialised); - - // test agentContext.resetFileSystemFunction() - expect(deserialised.fileSystem === deserialised.functions.getFunctionInstanceMap()[FileSystem.name]).to.be.true; }); }); }); diff --git a/src/agent/agentContextLocalStorage.ts b/src/agent/agentContextLocalStorage.ts index 48fef937..756f1247 100644 --- a/src/agent/agentContextLocalStorage.ts +++ b/src/agent/agentContextLocalStorage.ts @@ -4,12 +4,9 @@ import { LlmFunctions } from '#agent/LlmFunctions'; import { ConsoleCompletedHandler } from '#agent/agentCompletion'; import { AgentContext, AgentLLMs } from '#agent/agentContextTypes'; import { RunAgentConfig } from '#agent/agentRunner'; -import { getCompletedHandler } from '#agent/completionHandlerRegistry'; -import { FileSystem } from '#functions/storage/filesystem'; -import { deserializeLLMs } from '#llm/llmFactory'; +import { FileSystemService } from '#functions/storage/fileSystemService'; import { logger } from '#o11y/logger'; import { currentUser } from '#user/userService/userContext'; -import { appContext } from '../app'; export const agentContextStorage = new AsyncLocalStorage(); @@ -44,30 +41,15 @@ export function addNote(note: string): void { /** * @return the filesystem on the current agent context */ -export function getFileSystem(): FileSystem { - if (!agentContextStorage.getStore()) return new FileSystem(); +export function getFileSystem(): FileSystemService { + if (!agentContextStorage.getStore()) return new FileSystemService(); const filesystem = agentContextStorage.getStore()?.fileSystem; if (!filesystem) throw new Error('No file system available on the agent context'); return filesystem; } -/** - * After we have added functions or deserialized an agent, we need to make sure that if the - * agent has the FileSystem function available that it's the same object as the FileSystem on the agent context - * @param agent - */ -function resetFileSystemFunction(agent: AgentContext) { - // Make sure we have the same FileSystem object on the context and in the functions - const functions: LlmFunctions = Array.isArray(agent.functions) ? new LlmFunctions(...agent.functions) : agent.functions; - if (functions.getFunctionClassNames().includes(FileSystem.name)) { - functions.removeFunctionClass(FileSystem.name); - functions.addFunctionInstance(agent.fileSystem, FileSystem.name); - } - agent.functions = functions; -} - export function createContext(config: RunAgentConfig): AgentContext { - const fileSystem = new FileSystem(config.fileSystemPath); + const fileSystem = new FileSystemService(config.fileSystemPath); const hilBudget = config.humanInLoop?.budget ?? (process.env.HIL_BUDGET ? parseFloat(process.env.HIL_BUDGET) : 2); const context: AgentContext = { agentId: config.resumeAgentId || randomUUID(), @@ -98,85 +80,5 @@ export function createContext(config: RunAgentConfig): AgentContext { invoking: [], lastUpdate: Date.now(), }; - resetFileSystemFunction(context); return context; } - -export function serializeContext(context: AgentContext): Record { - const serialized = {}; - - for (const key of Object.keys(context) as Array) { - if (context[key] === undefined) { - // do nothing - } else if (context[key] === null) { - serialized[key] = null; - } - // Copy primitive properties across - else if (typeof context[key] === 'string' || typeof context[key] === 'number' || typeof context[key] === 'boolean') { - serialized[key] = context[key]; - } - // Assume arrays (functionCallHistory) can be directly de(serialised) to JSON - else if (Array.isArray(context[key])) { - serialized[key] = context[key]; - } - // Object type check for a toJSON function - else if (typeof context[key] === 'object' && context[key].toJSON) { - serialized[key] = context[key].toJSON(); - } - // Handle Maps (must only contain primitive/simple object values) - else if (key === 'memory' || key === 'metadata') { - serialized[key] = context[key]; - } else if (key === 'llms') { - serialized[key] = { - easy: context.llms.easy?.getId(), - medium: context.llms.medium?.getId(), - hard: context.llms.hard?.getId(), - xhard: context.llms.xhard?.getId(), - }; - } else if (key === 'user') { - serialized[key] = context.user.id; - } else if (key === 'completedHandler') { - context.completedHandler.agentCompletedHandlerId(); - } - // otherwise throw error - else { - throw new Error(`Cant serialize context property ${key}`); - } - } - return serialized; -} - -export async function deserializeAgentContext(serialized: Record): Promise { - const context: Partial = {}; - - for (const key of Object.keys(serialized)) { - // copy Array and primitive properties across - if (Array.isArray(serialized[key]) || typeof serialized[key] === 'string' || typeof serialized[key] === 'number' || typeof serialized[key] === 'boolean') { - context[key] = serialized[key]; - } - } - - context.fileSystem = new FileSystem().fromJSON(serialized.fileSystem); - context.functions = new LlmFunctions().fromJSON(serialized.functions ?? (serialized as any).toolbox); // toolbox for backward compat - - resetFileSystemFunction(context as AgentContext); // TODO add a test for this - - context.memory = serialized.memory; - context.metadata = serialized.metadata; - context.llms = deserializeLLMs(serialized.llms); - - const user = currentUser(); - if (serialized.user === user.id) context.user = user; - else context.user = await appContext().userService.getUser(serialized.user); - - context.completedHandler = getCompletedHandler(serialized.completedHandler); - - // backwards compatability - if (!context.type) context.type = 'xml'; - if (!context.iterations) context.iterations = 0; - - // Need to default empty parameters. Seems to get lost in Firestore - for (const call of context.functionCallHistory) call.parameters ??= {}; - - return context as AgentContext; -} diff --git a/src/agent/agentContextTypes.ts b/src/agent/agentContextTypes.ts index 950fd244..e5fdd4cf 100644 --- a/src/agent/agentContextTypes.ts +++ b/src/agent/agentContextTypes.ts @@ -1,5 +1,5 @@ import { LlmFunctions } from '#agent/LlmFunctions'; -import { FileSystem } from '#functions/storage/filesystem'; +import { FileSystemService } from '#functions/storage/fileSystemService'; import { FunctionCall, FunctionCallResult, LLM, LlmMessage } from '#llm/llm'; import { User } from '#user/user'; @@ -95,7 +95,7 @@ export interface AgentContext { /** Pre-configured LLMs by task difficulty level for the agent. Specific LLMs can always be instantiated if required. */ llms: AgentLLMs; /** Working filesystem */ - fileSystem?: FileSystem | null; + fileSystem?: FileSystemService | null; /** Memory persisted over the agent's executions */ memory: Record; /** Time of the last database write of the state */ diff --git a/src/agent/agentPromptUtils.ts b/src/agent/agentPromptUtils.ts index 2bb841ea..8816aec3 100644 --- a/src/agent/agentPromptUtils.ts +++ b/src/agent/agentPromptUtils.ts @@ -1,6 +1,6 @@ import { agentContext, getFileSystem } from '#agent/agentContextLocalStorage'; +import { FileSystemService } from '#functions/storage/fileSystemService'; import { FileMetadata, FileStore } from '#functions/storage/filestore'; -import { FileSystem } from '#functions/storage/filesystem'; import { FunctionCallResult } from '#llm/llm'; import { logger } from '#o11y/logger'; @@ -29,7 +29,7 @@ export async function buildToolStatePrompt(): Promise { */ function buildFileSystemPrompt(): string { const functions = agentContext().functions; - if (!functions.getFunctionClassNames().includes(FileSystem.name)) return ''; + if (!functions.getFunctionClassNames().includes(FileSystemService.name)) return ''; const fileSystem = getFileSystem(); return `\n ${fileSystem.basePath} diff --git a/src/agent/agentSerialization.ts b/src/agent/agentSerialization.ts new file mode 100644 index 00000000..7a993b27 --- /dev/null +++ b/src/agent/agentSerialization.ts @@ -0,0 +1,84 @@ +import { LlmFunctions } from '#agent/LlmFunctions'; +import { AgentContext } from '#agent/agentContextTypes'; +import { getCompletedHandler } from '#agent/completionHandlerRegistry'; +import { FileSystemService } from '#functions/storage/fileSystemService'; +import { deserializeLLMs } from '#llm/llmFactory'; +import { currentUser } from '#user/userService/userContext'; +import { appContext } from '../app'; + +export function serializeContext(context: AgentContext): Record { + const serialized = {}; + + for (const key of Object.keys(context) as Array) { + if (context[key] === undefined) { + // do nothing + } else if (context[key] === null) { + serialized[key] = null; + } + // Copy primitive properties across + else if (typeof context[key] === 'string' || typeof context[key] === 'number' || typeof context[key] === 'boolean') { + serialized[key] = context[key]; + } + // Assume arrays (functionCallHistory) can be directly de(serialised) to JSON + else if (Array.isArray(context[key])) { + serialized[key] = context[key]; + } + // Object type check for a toJSON function + else if (typeof context[key] === 'object' && context[key].toJSON) { + serialized[key] = context[key].toJSON(); + } + // Handle Maps (must only contain primitive/simple object values) + else if (key === 'memory' || key === 'metadata') { + serialized[key] = context[key]; + } else if (key === 'llms') { + serialized[key] = { + easy: context.llms.easy?.getId(), + medium: context.llms.medium?.getId(), + hard: context.llms.hard?.getId(), + xhard: context.llms.xhard?.getId(), + }; + } else if (key === 'user') { + serialized[key] = context.user.id; + } else if (key === 'completedHandler') { + context.completedHandler.agentCompletedHandlerId(); + } + // otherwise throw error + else { + throw new Error(`Cant serialize context property ${key}`); + } + } + return serialized; +} + +export async function deserializeAgentContext(serialized: Record): Promise { + const context: Partial = {}; + + for (const key of Object.keys(serialized)) { + // copy Array and primitive properties across + if (Array.isArray(serialized[key]) || typeof serialized[key] === 'string' || typeof serialized[key] === 'number' || typeof serialized[key] === 'boolean') { + context[key] = serialized[key]; + } + } + + context.fileSystem = new FileSystemService().fromJSON(serialized.fileSystem); + context.functions = new LlmFunctions().fromJSON(serialized.functions ?? (serialized as any).toolbox); // toolbox for backward compat + + context.memory = serialized.memory; + context.metadata = serialized.metadata; + context.llms = deserializeLLMs(serialized.llms); + + const user = currentUser(); + if (serialized.user === user.id) context.user = user; + else context.user = await appContext().userService.getUser(serialized.user); + + context.completedHandler = getCompletedHandler(serialized.completedHandler); + + // backwards compatability + if (!context.type) context.type = 'xml'; + if (!context.iterations) context.iterations = 0; + + // Need to default empty parameters. Seems to get lost in Firestore + for (const call of context.functionCallHistory) call.parameters ??= {}; + + return context as AgentContext; +} diff --git a/src/agent/agentStateService/fileAgentStateService.ts b/src/agent/agentStateService/fileAgentStateService.ts index 250365f5..af8bfc81 100644 --- a/src/agent/agentStateService/fileAgentStateService.ts +++ b/src/agent/agentStateService/fileAgentStateService.ts @@ -1,8 +1,8 @@ import { mkdirSync, readFileSync, readdirSync, writeFileSync } from 'fs'; import { unlinkSync } from 'node:fs'; import { LlmFunctions } from '#agent/LlmFunctions'; -import { deserializeAgentContext, serializeContext } from '#agent/agentContextLocalStorage'; import { AgentContext, AgentRunningState } from '#agent/agentContextTypes'; +import { deserializeAgentContext, serializeContext } from '#agent/agentSerialization'; import { AgentStateService } from '#agent/agentStateService/agentStateService'; import { functionFactory } from '#functionSchema/functionDecorators'; import { logger } from '#o11y/logger'; diff --git a/src/agent/agentStateService/inMemoryAgentStateService.ts b/src/agent/agentStateService/inMemoryAgentStateService.ts index aca16d27..8f77f5fd 100644 --- a/src/agent/agentStateService/inMemoryAgentStateService.ts +++ b/src/agent/agentStateService/inMemoryAgentStateService.ts @@ -1,6 +1,6 @@ import { LlmFunctions } from '#agent/LlmFunctions'; -import { deserializeAgentContext, serializeContext } from '#agent/agentContextLocalStorage'; import { AgentContext, AgentRunningState } from '#agent/agentContextTypes'; +import { deserializeAgentContext, serializeContext } from '#agent/agentSerialization'; import { AgentStateService } from '#agent/agentStateService/agentStateService'; import { functionFactory } from '#functionSchema/functionDecorators'; import { logger } from '#o11y/logger'; diff --git a/src/agent/cachingPythonAgentRunner.ts b/src/agent/cachingPythonAgentRunner.ts index 611518e7..1b651676 100644 --- a/src/agent/cachingPythonAgentRunner.ts +++ b/src/agent/cachingPythonAgentRunner.ts @@ -6,7 +6,7 @@ import { AgentContext } from '#agent/agentContextTypes'; import { AGENT_COMPLETED_NAME, AGENT_REQUEST_FEEDBACK, AGENT_SAVE_MEMORY_CONTENT_PARAM_NAME } from '#agent/agentFunctions'; import { buildFunctionCallHistoryPrompt, buildMemoryPrompt, buildToolStatePrompt, updateFunctionSchemas } from '#agent/agentPromptUtils'; import { AgentExecution, formatFunctionError, formatFunctionResult } from '#agent/agentRunner'; -import { agentHumanInTheLoop, notifySupervisor } from '#agent/humanInTheLoop'; +import { humanInTheLoop, notifySupervisor } from '#agent/humanInTheLoop'; import { convertJsonToPythonDeclaration, extractPythonCode } from '#agent/pythonAgentUtils'; import { getServiceName } from '#fastify/trace-init/trace-init'; import { FUNC_SEP, FunctionSchema, getAllFunctionSchemas } from '#functionSchema/functions'; @@ -105,7 +105,7 @@ export async function runCachingPythonAgent(agent: AgentContext): Promise hilBudget) { - await agentHumanInTheLoop(`Agent cost has increased by USD\$${costSinceHil.toFixed(2)}`); + await humanInTheLoop(`Agent cost has increased by USD\$${costSinceHil.toFixed(2)}`); costSinceHil = 0; } diff --git a/src/agent/xmlAgentRunner.ts b/src/agent/xmlAgentRunner.ts index 07e1b729..6a94d384 100644 --- a/src/agent/xmlAgentRunner.ts +++ b/src/agent/xmlAgentRunner.ts @@ -5,7 +5,7 @@ import { AgentContext } from '#agent/agentContextTypes'; import { AGENT_COMPLETED_NAME, AGENT_REQUEST_FEEDBACK } from '#agent/agentFunctions'; import { buildFunctionCallHistoryPrompt, buildMemoryPrompt, buildToolStatePrompt, updateFunctionSchemas } from '#agent/agentPromptUtils'; import { AgentExecution, formatFunctionError, formatFunctionResult, summariseLongFunctionOutput } from '#agent/agentRunner'; -import { agentHumanInTheLoop, notifySupervisor } from '#agent/humanInTheLoop'; +import { humanInTheLoop, notifySupervisor } from '#agent/humanInTheLoop'; import { getServiceName } from '#fastify/trace-init/trace-init'; import { FunctionSchema, getAllFunctionSchemas } from '#functionSchema/functions'; import { FunctionResponse } from '#llm/llm'; @@ -81,7 +81,7 @@ export async function runXmlAgent(agent: AgentContext): Promise if (hilCount && countSinceHil === hilCount) { agent.state = 'hil'; await agentStateService.save(agent); - await agentHumanInTheLoop(`Agent control loop has performed ${hilCount} iterations`); + await humanInTheLoop(`Agent control loop has performed ${hilCount} iterations`); agent.state = 'agent'; await agentStateService.save(agent); countSinceHil = 0; @@ -94,7 +94,7 @@ export async function runXmlAgent(agent: AgentContext): Promise costSinceHil += newCosts; logger.debug(`Spent $${costSinceHil.toFixed(2)} since last input. Total cost $${agentContextStorage.getStore().cost.toFixed(2)}`); if (hilBudget && costSinceHil > hilBudget) { - await agentHumanInTheLoop(`Agent cost has increased by USD\$${costSinceHil.toFixed(2)}`); + await humanInTheLoop(`Agent cost has increased by USD\$${costSinceHil.toFixed(2)}`); costSinceHil = 0; } diff --git a/src/cli/agent.ts b/src/cli/agent.ts index 8f753a30..ca6ee33c 100644 --- a/src/cli/agent.ts +++ b/src/cli/agent.ts @@ -1,7 +1,7 @@ import '#fastify/trace-init/trace-init'; // leave an empty line next so this doesn't get sorted from the first line import { provideFeedback, resumeCompleted, resumeError, resumeHil, startAgentAndWait } from '#agent/agentRunner'; -import { FileSystem } from '#functions/storage/filesystem'; +import { FileSystemRead } from '#functions/storage/FileSystemRead'; import { Perplexity } from '#functions/web/perplexity'; import { PublicWeb } from '#functions/web/web'; import { ClaudeLLMs } from '#llm/models/anthropic'; @@ -20,9 +20,9 @@ export async function main() { } let functions: Array; - functions = [FileSystem, SoftwareDeveloperAgent, Perplexity, PublicWeb]; + functions = [FileSystemRead, SoftwareDeveloperAgent, Perplexity, PublicWeb]; functions = [CodeEditingAgent, Perplexity]; - functions = [FileSystem]; + functions = [FileSystemRead]; const { initialPrompt, resumeAgentId } = parseProcessArgs(); diff --git a/src/cli/gaia.ts b/src/cli/gaia.ts index 1e8c82b6..72144c66 100644 --- a/src/cli/gaia.ts +++ b/src/cli/gaia.ts @@ -4,7 +4,7 @@ import { promises as fs, readFileSync } from 'fs'; import { AgentLLMs } from '#agent/agentContextTypes'; import { AGENT_COMPLETED_PARAM_NAME } from '#agent/agentFunctions'; import { startAgent, startAgentAndWait } from '#agent/agentRunner'; -import { FileSystem } from '#functions/storage/filesystem'; +import { FileSystemRead } from '#functions/storage/FileSystemRead'; import { LlmTools } from '#functions/util'; import { Perplexity } from '#functions/web/perplexity'; import { PublicWeb } from '#functions/web/web'; @@ -101,7 +101,7 @@ async function answerGaiaQuestion(task: GaiaQuestion): Promise { budget, count: 100, }, - functions: [PublicWeb, Perplexity, FileSystem, LlmTools], + functions: [PublicWeb, Perplexity, FileSystemRead, LlmTools], }); const agent = await appContext().agentStateService.load(agentId); diff --git a/src/cli/swe.ts b/src/cli/swe.ts index f3e13dbb..fb3d620d 100644 --- a/src/cli/swe.ts +++ b/src/cli/swe.ts @@ -6,7 +6,7 @@ import { AgentContext, AgentLLMs } from '#agent/agentContextTypes'; import { RunAgentConfig } from '#agent/agentRunner'; import { runAgentWorkflow } from '#agent/agentWorkflowRunner'; import { GitLab } from '#functions/scm/gitlab'; -import { FileSystem } from '#functions/storage/filesystem'; +import { FileSystemRead } from '#functions/storage/FileSystemRead'; import { Perplexity } from '#functions/web/perplexity'; import { ClaudeLLMs } from '#llm/models/anthropic'; import { ClaudeVertexLLMs } from '#llm/models/anthropic-vertex'; @@ -32,7 +32,7 @@ async function main() { const config: RunAgentConfig = { agentName: 'cli-SWE', llms, - functions: [FileSystem, CodeEditingAgent, Perplexity], + functions: [FileSystemRead, CodeEditingAgent, Perplexity], initialPrompt: initialPrompt.trim(), resumeAgentId, }; diff --git a/src/cli/swebench.ts b/src/cli/swebench.ts index 96208131..89aa0bdc 100644 --- a/src/cli/swebench.ts +++ b/src/cli/swebench.ts @@ -7,7 +7,7 @@ import { RunAgentConfig, startAgent, startAgentAndWait } from '#agent/agentRunne import { runAgentWorkflow } from '#agent/agentWorkflowRunner'; import { shutdownTrace } from '#fastify/trace-init/trace-init'; import { GitLab } from '#functions/scm/gitlab'; -import { FileSystem } from '#functions/storage/filesystem'; +import { FileSystemService } from '#functions/storage/fileSystemService'; import { LlmTools } from '#functions/util'; import { Perplexity } from '#functions/web/perplexity'; import { PublicWeb } from '#functions/web/web'; diff --git a/src/cli/util.ts b/src/cli/util.ts index 87caa3dd..bc357cf2 100644 --- a/src/cli/util.ts +++ b/src/cli/util.ts @@ -5,7 +5,7 @@ import { agentContextStorage, createContext, getFileSystem, llms } from '#agent/ import { Jira } from '#functions/jira'; import { GitLab } from '#functions/scm/gitlab'; -import { FileSystem } from '#functions/storage/filesystem'; +import { FileSystemService } from '#functions/storage/fileSystemService'; import { Claude3_Opus, ClaudeLLMs } from '#llm/models/anthropic'; import { Claude3_5_Sonnet_Vertex, Claude3_Haiku_Vertex, Claude3_Sonnet_Vertex, ClaudeVertexLLMs } from '#llm/models/anthropic-vertex'; import { GPT4o } from '#llm/models/openai'; @@ -37,7 +37,7 @@ const utilLLMs: AgentLLMs = { async function main() { await appContext().userService.ensureSingleUser(); const functions = new LlmFunctions(); - functions.addFunctionClass(FileSystem); + functions.addFunctionClass(FileSystemService); const config: RunAgentConfig = { agentName: 'util', diff --git a/src/functionRegistry.ts b/src/functionRegistry.ts index 38d00b84..e4b31024 100644 --- a/src/functionRegistry.ts +++ b/src/functionRegistry.ts @@ -3,7 +3,7 @@ import { ImageGen } from '#functions/image'; import { Jira } from '#functions/jira'; import { GitHub } from '#functions/scm/github'; import { GitLab } from '#functions/scm/gitlab'; -import { FileSystem } from '#functions/storage/filesystem'; +import { FileSystemRead } from '#functions/storage/FileSystemRead'; import { LocalFileStore } from '#functions/storage/localFileStore'; import { LlmTools } from '#functions/util'; import { Perplexity } from '#functions/web/perplexity'; @@ -19,7 +19,8 @@ import { SoftwareDeveloperAgent } from '#swe/softwareDeveloperAgent'; export function functionRegistry(): Array any> { return [ CodeEditingAgent, - FileSystem, + FileSystemRead, + // FileSystemWrite, LocalFileStore, GitLab, // GitHub, // Error: More than one function classes found implementing SourceControlManagement diff --git a/src/functions/cloud/google-cloud.ts b/src/functions/cloud/google-cloud.ts index d42b8670..3a9c2565 100644 --- a/src/functions/cloud/google-cloud.ts +++ b/src/functions/cloud/google-cloud.ts @@ -1,5 +1,5 @@ import { agentContext } from '#agent/agentContextLocalStorage'; -import { waitForConsoleInput } from '#agent/humanInTheLoop'; +import { humanInTheLoop, waitForConsoleInput } from '#agent/humanInTheLoop'; import { func, funcClass } from '#functionSchema/functionDecorators'; import { execCommand, failOnError } from '#utils/exec'; @@ -23,14 +23,29 @@ export class GoogleCloud { /** * Query resource information by executing the gcloud command line tool. This must ONLY be used for querying information, and MUST NOT update or modify resources. * Must have the --project= argument. - * @param gcloudQueryCommand The gcloud query command to execute ( + * @param gcloudQueryCommand The gcloud query command to execute * @returns the console output if the exit code is 0, else throws the console output */ @func() async executeGcloudCommandQuery(gcloudQueryCommand: string): Promise { - await waitForConsoleInput(`Agent "${agentContext().name}" is requesting to run the command ${gcloudQueryCommand}`); if (!gcloudQueryCommand.includes('--project=')) throw new Error('When calling executeGcloudCommandQuery the gcloudQueryCommand parameter must include the --project= argument'); + + // Whitelist list, describe and get-iam-policy commands, otherwise require human-in-the-loop approval + const args = gcloudQueryCommand.split(' '); + if (args[1] === 'alpha' || args[1] === 'beta') { + args.splice(1, 1); + } + + let isQuery = false; + for (const i of [2, 3, 4]) { + if (args[i].startsWith('list') || args[i] === 'describe' || args[i] === 'get-iam-policy') isQuery = true; + } + + if (!isQuery) { + await humanInTheLoop(`Agent "${agentContext().name}" is requesting to run the command ${gcloudQueryCommand}`); + } + const result = await execCommand(gcloudQueryCommand); if (result.exitCode > 0) throw new Error(`Error running ${gcloudQueryCommand}. ${result.stdout}${result.stderr}`); return result.stdout; diff --git a/src/functions/scm/git.ts b/src/functions/scm/git.ts index 91fe2444..ac97fdbe 100644 --- a/src/functions/scm/git.ts +++ b/src/functions/scm/git.ts @@ -1,6 +1,6 @@ import util from 'util'; import { funcClass } from '#functionSchema/functionDecorators'; -import { FileSystem } from '#functions/storage/filesystem'; +import { FileSystemService } from '#functions/storage/fileSystemService'; import { logger } from '#o11y/logger'; import { span } from '#o11y/trace'; import { execCmd, execCommand, failOnError } from '#utils/exec'; @@ -12,7 +12,7 @@ export class Git implements VersionControlSystem { /** The branch name before calling switchToBranch. This enables getting the diff between the current and previous branch */ previousBranch: string | undefined; - constructor(private fileSystem: FileSystem) {} + constructor(private fileSystem: FileSystemService) {} async clone(repoURL: string, commitOrBranch = ''): Promise { const { exitCode, stdout, stderr } = await execCommand(`git clone ${repoURL} ${commitOrBranch}`); diff --git a/src/functions/storage/FileSystemRead.ts b/src/functions/storage/FileSystemRead.ts new file mode 100644 index 00000000..dc716bfe --- /dev/null +++ b/src/functions/storage/FileSystemRead.ts @@ -0,0 +1,179 @@ +import { getFileSystem } from '#agent/agentContextLocalStorage'; +import { func, funcClass } from '#functionSchema/functionDecorators'; + +/** + * Provides functions for LLMs to access the file system. Tools should generally use the functions as + * - They are automatically included in OpenTelemetry tracing + * - They use the working directory, so Sophia can perform its actions outside the process running directory. + * + * The FileSystem is constructed with the basePath property which is like a virtual root. + * Then the workingDirectory property is relative to the basePath. + * + * The functions which list/search filenames should return the paths relative to the workingDirectory. + * + * By default, the basePath is the current working directory of the process. + */ +@funcClass(__filename) +export class FileSystemRead { + /** + * @returns the full path of the working directory on the filesystem + */ + @func() + getWorkingDirectory(): string { + return getFileSystem().getWorkingDirectory(); + } + + /** + * Set the working directory. The dir argument may be an absolute filesystem path, otherwise relative to the current working directory. + * If the dir starts with / it will first be checked as an absolute directory, then as relative path to the working directory. + * @param dir the new working directory + */ + @func() + setWorkingDirectory(dir: string): void { + getFileSystem().setWorkingDirectory(dir); + } + + /** + * Returns the file contents of all the files under the provided directory path + * @param dirPath the directory to return all the files contents under + * @returns the contents of the file(s) as a Map keyed by the file path + */ + @func() + async getFileContentsRecursively(dirPath: string, useGitIgnore = true): Promise> { + return await getFileSystem().getFileContentsRecursively(dirPath, useGitIgnore); + } + + /** + * Returns the file contents of all the files recursively under the provided directory path + * @param dirPath the directory to return all the files contents under + * @param storeToMemory if the file contents should be stored to memory. The key will be in the format file-contents-- + * @returns the contents of the file(s) in format file1 contentsfile2 contents + */ + @func() + async getFileContentsRecursivelyAsXml(dirPath: string, storeToMemory: boolean, filter: (path) => boolean = () => true): Promise { + return await getFileSystem().getFileContentsRecursivelyAsXml(dirPath, storeToMemory, filter); + } + + /** + * Searches for files on the filesystem (using ripgrep) with contents matching the search regex. + * @param contentsRegex the regular expression to search the content all the files recursively for + * @returns the list of filenames (with postfix :) which have contents matching the regular expression. + */ + @func() + async searchFilesMatchingContents(contentsRegex: string): Promise { + return await getFileSystem().searchFilesMatchingContents(contentsRegex); + } + + /** + * Searches for files on the filesystem where the filename matches the regex. + * @param fileNameRegex the regular expression to match the filename. + * @returns the list of filenames matching the regular expression. + */ + @func() + async searchFilesMatchingName(fileNameRegex: string): Promise { + return await getFileSystem().searchFilesMatchingName(fileNameRegex); + } + + /** + * Lists the file and folder names in a single directory. + * Folder names will end with a / + * @param dirPath the folder to list the files in. Defaults to the working directory + * @returns the list of file and folder names + */ + @func() + async listFilesInDirectory(dirPath = '.'): Promise { + return await getFileSystem().listFilesInDirectory(dirPath); + } + + /** + * List all the files recursively under the given path, excluding any paths in a .gitignore file if it exists + * @param dirPath + * @returns the list of files + */ + @func() + async listFilesRecursively(dirPath = './', useGitIgnore = true): Promise { + return await getFileSystem().listFilesRecursively(dirPath, useGitIgnore); + } + + /** + * Gets the contents of a local file on the file system. If the user has only provided a filename you may need to find the full path using the searchFilesMatchingName function. + * @param filePath The file path to read the contents of (e.g. src/index.ts) + * @returns the contents of the file(s) in format file1 contentsfile2 contents + */ + @func() + async readFile(filePath: string): Promise { + return await getFileSystem().readFile(filePath); + } + + /** + * Gets the contents of a local file on the file system and returns it in XML tags + * @param filePath The file path to read the contents of (e.g. src/index.ts) + * @returns the contents of the file(s) in format file1 contents + */ + @func() + async readFileAsXML(filePath: string): Promise { + return await getFileSystem().readFileAsXML(filePath); + } + + /** + * Gets the contents of a list of files, returning a formatted XML string of all file contents + * @param {Array} filePaths The files paths to read the contents of + * @returns {Promise} the contents of the file(s) in format file1 contentsfile2 contents + */ + @func() + async readFilesAsXml(filePaths: string | string[]): Promise { + return await getFileSystem().readFilesAsXml(filePaths); + } + + /** + * Check if a file exists. A filePath starts with / is it relative to FileSystem.basePath, otherwise its relative to FileSystem.workingDirectory + * @param filePath The file path to check + * @returns true if the file exists, else false + */ + @func() + async fileExists(filePath: string): Promise { + return await getFileSystem().fileExists(filePath); + } + + /** + * Generates a textual representation of a directory tree structure. + * + * This function uses listFilesRecursively to get all files and directories, + * respecting .gitignore rules, and produces an indented string representation + * of the file system hierarchy. + * + * @param {string} dirPath - The path of the directory to generate the tree for, defaulting to working directory + * @returns {Promise} A string representation of the directory tree. + * + * @example + * Assuming the following directory structure: + * ./ + * ├── file1.txt + * ├── images/ + * │ ├── logo.png + * └── src/ + * └── utils/ + * └── helper.js + * + * The output would be: + * file1.txt + * images/ + * logo.png + * src/utils/ + * helper.js + */ + @func() + async getFileSystemTree(dirPath = './'): Promise { + return await getFileSystem().getFileSystemTree(dirPath); + } + + /** + * Returns the filesystem structure + * @param dirPath + * @returns a record with the keys as the folders paths, and the list values as the files in the folder + */ + @func() + async getFileSystemTreeStructure(dirPath = './'): Promise> { + return await getFileSystem().getFileSystemTreeStructure(dirPath); + } +} diff --git a/src/functions/storage/FileSystemWrite.ts b/src/functions/storage/FileSystemWrite.ts new file mode 100644 index 00000000..e40a7574 --- /dev/null +++ b/src/functions/storage/FileSystemWrite.ts @@ -0,0 +1,33 @@ +import { getFileSystem } from '#agent/agentContextLocalStorage'; +import { func, funcClass } from '#functionSchema/functionDecorators'; +import { LlmTools } from '#functions/util'; + +/** + * Provides functions for LLMs to write to the file system + */ +@funcClass(__filename) +export class FileSystemWrite { + /** + * Writes to a file. If the file exists it will overwrite the contents. This will create any parent directories required, + * @param filePath The file path (either full filesystem path or relative to current working directory) + * @param contents The contents to write to the file + * @param allowOverwrite if the filePath already exists, then it will overwrite or throw an error based on the allowOverwrite property + */ + @func() + async writeFile(filePath: string, contents: string, allowOverwrite: boolean): Promise { + if ((await getFileSystem().fileExists(filePath)) && !allowOverwrite) throw new Error(`The file ${filePath} already exists`); + await getFileSystem().writeFile(filePath, contents); + } + + /** + * Reads a file, then transforms the contents using a LLM to perform the described changes, then writes back to the file. + * @param {string} filePath The file to update + * @param {string} descriptionOfChanges A natual language description of the changes to make to the file contents + */ + @func() + async editFileContents(filePath: string, descriptionOfChanges: string): Promise { + const contents = await getFileSystem().readFile(filePath); + const updatedContent = await new LlmTools().processText(contents, descriptionOfChanges); + await this.writeFile(filePath, updatedContent, true); + } +} diff --git a/src/functions/storage/filesystem.ts b/src/functions/storage/fileSystemService.ts similarity index 98% rename from src/functions/storage/filesystem.ts rename to src/functions/storage/fileSystemService.ts index 22378003..890c591d 100644 --- a/src/functions/storage/filesystem.ts +++ b/src/functions/storage/fileSystemService.ts @@ -32,6 +32,8 @@ const globAsync = promisify(glob); type FileFilter = (filename: string) => boolean; /** + * Interface to the file system based for an Agent which maintains the state of the working directory. + * * Provides functions for LLMs to access the file system. Tools should generally use the functions as * - They are automatically included in OpenTelemetry tracing * - They use the working directory, so Sophia can perform its actions outside the process running directory. @@ -43,8 +45,7 @@ type FileFilter = (filename: string) => boolean; * * By default, the basePath is the current working directory of the process. */ -@funcClass(__filename) -export class FileSystem { +export class FileSystemService { /** The filesystem path */ private workingDirectory = ''; vcs: VersionControlSystem | null = null; @@ -109,7 +110,7 @@ export class FileSystem { * If the dir starts with / it will first be checked as an absolute directory, then as relative path to the working directory. * @param dir the new working directory */ - @func() setWorkingDirectory(dir: string): void { + setWorkingDirectory(dir: string): void { if (!dir) throw new Error('dir must be provided'); let relativeDir = dir; // Check absolute directory path @@ -148,7 +149,6 @@ export class FileSystem { * @param storeToMemory if the file contents should be stored to memory. The key will be in the format file-contents-- * @returns the contents of the file(s) in format file1 contentsfile2 contents */ - @func() async getFileContentsRecursivelyAsXml(dirPath: string, storeToMemory: boolean, filter: (path) => boolean = () => true): Promise { const filenames = (await this.listFilesRecursively(dirPath)).filter(filter); const contents = await this.readFilesAsXml(filenames); @@ -161,7 +161,6 @@ export class FileSystem { * @param contentsRegex the regular expression to search the content all the files recursively for * @returns the list of filenames (with postfix :) which have contents matching the regular expression. */ - @func() async searchFilesMatchingContents(contentsRegex: string): Promise { // --count Only show count of line matches for each file // rg likes this spawnCommand. Doesn't work it others execs @@ -178,7 +177,6 @@ export class FileSystem { * @param fileNameRegex the regular expression to match the filename. * @returns the list of filenames matching the regular expression. */ - @func() async searchFilesMatchingName(fileNameRegex: string): Promise { const regex = new RegExp(fileNameRegex); const files = await this.listFilesRecursively(); @@ -191,7 +189,6 @@ export class FileSystem { * @param dirPath the folder to list the files in. Defaults to the working directory * @returns the list of file and folder names */ - @func() async listFilesInDirectory(dirPath = '.'): Promise { // const rootPath = path.join(this.basePath, dirPath); const filter: FileFilter = (name) => true; @@ -228,7 +225,6 @@ export class FileSystem { * @param dirPath * @returns the list of files */ - @func() async listFilesRecursively(dirPath = './', useGitIgnore = true): Promise { this.log.debug(`cwd: ${this.workingDirectory}`); @@ -276,7 +272,6 @@ export class FileSystem { * @param filePath The file path to read the contents of (e.g. src/index.ts) * @returns the contents of the file(s) in format file1 contentsfile2 contents */ - @func() async readFile(filePath: string): Promise { logger.debug(`readFile ${filePath}`); let contents: string; @@ -309,7 +304,6 @@ export class FileSystem { * @param filePath The file path to read the contents of (e.g. src/index.ts) * @returns the contents of the file(s) in format file1 contents */ - @func() async readFileAsXML(filePath: string): Promise { return `\n${await this.readFile(filePath)}\n\n`; } @@ -338,7 +332,6 @@ export class FileSystem { * @param {Array} filePaths The files paths to read the contents of * @returns {Promise} the contents of the file(s) in format file1 contentsfile2 contents */ - @func() async readFilesAsXml(filePaths: string | string[]): Promise { if (!Array.isArray(filePaths)) { filePaths = parseArrayParameterValue(filePaths); @@ -364,7 +357,6 @@ export class FileSystem { * @param filePath The file path to check * @returns true if the file exists, else false */ - @func() async fileExists(filePath: string): Promise { // TODO remove the basePath checks. Either absolute or relative to this.cwd logger.debug(`fileExists: ${filePath}`); @@ -394,7 +386,6 @@ export class FileSystem { * @param filePath The file path (either full filesystem path or relative to current working directory) * @param contents The contents to write to the file */ - @func() async writeNewFile(filePath: string, contents: string): Promise { if (await this.fileExists(filePath)) throw new Error(`File ${filePath} already exists. Cannot overwrite`); await this.writeFile(filePath, contents); @@ -405,7 +396,6 @@ export class FileSystem { * @param filePath The file path (either full filesystem path or relative to current working directory) * @param contents The contents to write to the file */ - @func() async writeFile(filePath: string, contents: string): Promise { const fileSystemPath = filePath.startsWith(this.basePath) ? filePath : join(this.getWorkingDirectory(), filePath); logger.debug(`Writing file "${filePath}" to ${fileSystemPath}`); @@ -419,7 +409,6 @@ export class FileSystem { * @param {string} filePath The file to update * @param {string} descriptionOfChanges A natual language description of the changes to make to the file contents */ - // @func() async editFileContents(filePath: string, descriptionOfChanges: string): Promise { const contents = await this.readFile(filePath); const updatedContent = await new LlmTools().processText(contents, descriptionOfChanges); @@ -528,7 +517,6 @@ export class FileSystem { * src/utils/ * helper.js */ - @func() async getFileSystemTree(dirPath = './'): Promise { const files = await this.listFilesRecursively(dirPath); const tree = new Map(); @@ -557,7 +545,6 @@ export class FileSystem { * @param dirPath * @returns a record with the keys as the folders paths, and the list values as the files in the folder */ - @func() async getFileSystemTreeStructure(dirPath = './'): Promise> { const files = await this.listFilesRecursively(dirPath); const tree: Record = {}; diff --git a/src/functions/storage/filesystem.test.ts b/src/functions/storage/filesystem.test.ts index fb44d69f..8e24c5f9 100644 --- a/src/functions/storage/filesystem.test.ts +++ b/src/functions/storage/filesystem.test.ts @@ -1,10 +1,10 @@ import path, { join, resolve } from 'path'; import { expect } from 'chai'; -import { FileSystem } from './filesystem'; +import { FileSystemService } from './fileSystemService'; describe('FileSystem', () => { describe.skip('setWorkingDirectory with fakePath', () => { - const fileSystem = new FileSystem('/basePath'); + const fileSystem = new FileSystemService('/basePath'); it('should be able to set a path from the baseDir when the new working directory starts with /', async () => { fileSystem.setWorkingDirectory('/otherWorkDir'); fileSystem.setWorkingDirectory('/newWorkDir'); @@ -12,21 +12,21 @@ describe('FileSystem', () => { }); it('should be able to set a relative new working directory', async () => { - const fileSystem = new FileSystem('/basePath'); + const fileSystem = new FileSystemService('/basePath'); fileSystem.setWorkingDirectory('dir1'); fileSystem.setWorkingDirectory('dir2'); expect(fileSystem.getWorkingDirectory()).to.equal('/basePath/dir1/dir2'); }); it('should be able to navigate up a directory', async () => { - const fileSystem = new FileSystem('/basePath'); + const fileSystem = new FileSystemService('/basePath'); fileSystem.setWorkingDirectory('dir1/dir2'); fileSystem.setWorkingDirectory('..'); expect(fileSystem.getWorkingDirectory()).to.equal('/basePath/dir1'); }); it('should assume if the new working directory starts with basePath, then its the basePath', async () => { - const fileSystem = new FileSystem('/basePath'); + const fileSystem = new FileSystemService('/basePath'); fileSystem.setWorkingDirectory('/basePath/dir1'); expect(fileSystem.getWorkingDirectory()).to.equal('/basePath/dir1'); }); @@ -45,10 +45,10 @@ describe('FileSystem', () => { }); describe('setWorkingDirectory with real project path', () => { - const fileSystem = new FileSystem(); + const fileSystem = new FileSystemService(); it('should set the real working directory with a relative path', async () => { - const fileSystem = new FileSystem(); + const fileSystem = new FileSystemService(); fileSystem.setWorkingDirectory('frontend'); const exists = await fileSystem.fileExists('angular.json'); expect(exists).to.equal(true); @@ -56,7 +56,7 @@ describe('FileSystem', () => { }); describe('fileExists', () => { - const fileSystem = new FileSystem(); + const fileSystem = new FileSystemService(); it('should return true if a file exists', async () => { expect(await fileSystem.fileExists('package.json')).to.be.true; expect(await fileSystem.fileExists('/package.json')).to.true; @@ -67,7 +67,7 @@ describe('FileSystem', () => { }); it('should return the correct result when the working directory has been set', async () => { - const fileSystem = new FileSystem(); + const fileSystem = new FileSystemService(); fileSystem.setWorkingDirectory('frontend'); let exists = await fileSystem.fileExists('angular.json'); expect(exists).to.equal(true); @@ -78,10 +78,10 @@ describe('FileSystem', () => { describe('listFilesRecursively', () => { describe('test filesystem', () => { - let fileSystem: FileSystem; + let fileSystem: FileSystemService; beforeEach(() => { // set the workingDirectory to test/filesystem - fileSystem = new FileSystem(path.join(process.cwd(), 'test', 'filesystem')); + fileSystem = new FileSystemService(path.join(process.cwd(), 'test', 'filesystem')); }); it('should list all files under the filesystem baseDir honouring .gitignore files in current and sub-directories', async () => { @@ -105,7 +105,7 @@ describe('FileSystem', () => { }); describe('listFilesInDirectory', () => { - const fileSystem = new FileSystem(); + const fileSystem = new FileSystemService(); it('should list files and folders only in the current directory', async () => { const files: string[] = await fileSystem.listFilesInDirectory('./'); expect(files).to.include('package.json'); @@ -130,7 +130,7 @@ describe('FileSystem', () => { }); describe('getMultipleFileContentsAsXml', () => { - const fileSystem = new FileSystem(); + const fileSystem = new FileSystemService(); it('should include files', async () => { const paths = ['package.json', '/README.md', '/src/index.ts']; const contents: string = await fileSystem.readFilesAsXml(paths); @@ -151,7 +151,7 @@ describe('FileSystem', () => { }); describe('readFile', () => { - const fileSystem = new FileSystem(); + const fileSystem = new FileSystemService(); it('should get the file contents for the current directory', async () => { const samplePackageJsonContents = '@opentelemetry/instrumentation-http'; let contents: string = await fileSystem.readFile('package.json'); @@ -180,7 +180,7 @@ describe('FileSystem', () => { */ describe('getFileSystemTree', () => { it('should respect nested .gitignore files', async () => { - const fileSystem = new FileSystem(); + const fileSystem = new FileSystemService(); const tree = await fileSystem.getFileSystemTree(); // Check that root-level .gitignore is respected diff --git a/src/llm/responseParsers.ts b/src/llm/responseParsers.ts index 99c59d32..5866633d 100644 --- a/src/llm/responseParsers.ts +++ b/src/llm/responseParsers.ts @@ -63,40 +63,41 @@ export function parseFunctionCallsXml(response: string): FunctionCalls { */ export function extractJsonResult(rawText: string): any { let text = rawText.trim(); - if ((text.startsWith('```json') || text.startsWith('```JSON')) && text.endsWith('```')) { - // Gemini returns in this format - return JSON.parse(text.slice(7, -3)); - } - if (text.startsWith('```') && text.endsWith('```')) { - // Gemini returns in this format - return JSON.parse(text.slice(3, -3)); - } + try { + if ((text.startsWith('```json') || text.startsWith('```JSON')) && text.endsWith('```')) { + // Gemini returns in this format + return JSON.parse(text.slice(7, -3)); + } + if (text.startsWith('```') && text.endsWith('```')) { + // Gemini returns in this format + return JSON.parse(text.slice(3, -3)); + } - const regex = /```[jJ][sS][oO][nN]\n({.*})\n```/s; - const match = regex.exec(text); - if (match) { - return JSON.parse(match[1]); - } + const regex = /```[jJ][sS][oO][nN]\n({.*})\n```/s; + const match = regex.exec(text); + if (match) { + return JSON.parse(match[1]); + } - const regexXml = /(.*)<\/json>/is; - const matchXml = regexXml.exec(text); - if (matchXml) { - return JSON.parse(matchXml[1]); - } + const regexXml = /(.*)<\/json>/is; + const matchXml = regexXml.exec(text); + if (matchXml) { + return JSON.parse(matchXml[1]); + } + + // Sometimes more than three trailing backticks + while (text.endsWith('`')) { + text = text.slice(0, -1); + } + // If there's some chit-chat before the JSON then remove it. + const firstSquare = text.indexOf('['); + const fistCurly = text.indexOf('{'); + if (fistCurly > 0 || firstSquare > 0) { + if (firstSquare < 0) text = text.slice(fistCurly); + else if (fistCurly < 0) text = text.slice(firstSquare); + else text = text.slice(Math.min(firstSquare, fistCurly)); + } - // Sometimes more than three trailing backticks - while (text.endsWith('`')) { - text = text.slice(0, -1); - } - // If there's some chit-chat before the JSON then remove it. - const firstSquare = text.indexOf('['); - const fistCurly = text.indexOf('{'); - if (fistCurly > 0 || firstSquare > 0) { - if (firstSquare < 0) text = text.slice(fistCurly); - else if (fistCurly < 0) text = text.slice(firstSquare); - else text = text.slice(Math.min(firstSquare, fistCurly)); - } - try { return JSON.parse(text); } catch (e) { logger.error(`Could not parse:\n${text}`); diff --git a/src/modules/firestore/firestoreAgentStateService.ts b/src/modules/firestore/firestoreAgentStateService.ts index 90b978be..9ee78b4c 100644 --- a/src/modules/firestore/firestoreAgentStateService.ts +++ b/src/modules/firestore/firestoreAgentStateService.ts @@ -1,7 +1,7 @@ import { DocumentSnapshot, Firestore } from '@google-cloud/firestore'; import { LlmFunctions } from '#agent/LlmFunctions'; -import { deserializeAgentContext, serializeContext } from '#agent/agentContextLocalStorage'; import { AgentContext, AgentRunningState } from '#agent/agentContextTypes'; +import { deserializeAgentContext, serializeContext } from '#agent/agentSerialization'; import { AgentStateService } from '#agent/agentStateService/agentStateService'; import { functionFactory } from '#functionSchema/functionDecorators'; import { logger } from '#o11y/logger'; diff --git a/src/routes/agent/agent-details-routes.ts b/src/routes/agent/agent-details-routes.ts index 4119520a..13c5d527 100644 --- a/src/routes/agent/agent-details-routes.ts +++ b/src/routes/agent/agent-details-routes.ts @@ -1,8 +1,8 @@ import { Type } from '@sinclair/typebox'; import { FastifyReply } from 'fastify'; -import { serializeContext } from '#agent/agentContextLocalStorage'; import { AgentContext } from '#agent/agentContextTypes'; import { AgentExecution, agentExecutions } from '#agent/agentRunner'; +import { serializeContext } from '#agent/agentSerialization'; import { send, sendBadRequest, sendSuccess } from '#fastify/index'; import { logger } from '#o11y/logger'; import { AppFastifyInstance } from '../../app'; diff --git a/src/swe/codeEditor.ts b/src/swe/aiderCodeEditor.ts similarity index 92% rename from src/swe/codeEditor.ts rename to src/swe/aiderCodeEditor.ts index 9282c642..fa46f807 100644 --- a/src/swe/codeEditor.ts +++ b/src/swe/aiderCodeEditor.ts @@ -17,7 +17,7 @@ import { execCommand } from '#utils/exec'; import { systemDir } from '../appVars'; @funcClass(__filename) -export class CodeEditor { +export class AiderCodeEditor { /** * Makes the changes to the project files to meet the task requirements * @param requirements the complete task requirements with all the supporting documentation and code samples @@ -101,21 +101,22 @@ export class CodeEditor { if (stdout) logger.info(stdout); if (stderr) logger.error(stderr); - // TODO parse $0.12 session from the output - try { + const cost = extractSessionCost(stdout); + addCost(cost); + logger.debug(`Aider cost ${cost}`); + // const costs = llm.calculateCost(parsedInput, parsedOutput); + // addCost(costs[0]); + // logger.debug(`Aider cost ${costs[0]}`); + const llmHistory = readFileSync(llmHistoryFile).toString(); const parsedInput = this.parseAiderInput(llmHistory); const parsedOutput = this.parseAiderOutput(llmHistory); - const costs = llm.calculateCost(parsedInput, parsedOutput); - addCost(costs[0]); - logger.debug(`Aider cost ${costs[0]}`); - span.setAttributes({ inputChars: parsedInput.length, outputChars: parsedOutput.length, - cost: costs[0], + cost: cost, }); // unlinkSync(llmHistoryFile); // TODO should save them as LLMCalls @@ -143,6 +144,17 @@ export class CodeEditor { } } +function extractSessionCost(text: string): number { + const regex = /Cost:.*\$(\d+(?:\.\d+)?) session/; + const match = text.match(regex); + + if (match?.[1]) { + return parseFloat(match[1]); + } + + return 0; // Return null if no match is found +} + export function getPythonPath() { // Read the Sophia .python-version file const pythonVersionFile = path.join(process.cwd(), '.python-version'); diff --git a/src/swe/codeEditingAgent.ts b/src/swe/codeEditingAgent.ts index 9fe094e3..265445c8 100644 --- a/src/swe/codeEditingAgent.ts +++ b/src/swe/codeEditingAgent.ts @@ -1,7 +1,7 @@ import path from 'path'; import { agentContext, getFileSystem, llms } from '#agent/agentContextLocalStorage'; import { func, funcClass } from '#functionSchema/functionDecorators'; -import { FileSystem } from '#functions/storage/filesystem'; +import { FileSystemService } from '#functions/storage/fileSystemService'; import { Perplexity } from '#functions/web/perplexity'; import { logger } from '#o11y/logger'; import { span } from '#o11y/trace'; @@ -12,7 +12,7 @@ import { supportingInformation } from '#swe/supportingInformation'; import { execCommand, runShellCommand } from '#utils/exec'; import { appContext } from '../app'; import { cacheRetry } from '../cache/cacheRetry'; -import { CodeEditor } from './codeEditor'; +import { AiderCodeEditor } from './aiderCodeEditor'; import { ProjectInfo, detectProjectInfo } from './projectDetection'; import { basePrompt } from './prompt'; import { SelectFilesResponse, selectFilesToEdit } from './selectFilesToEdit'; @@ -48,7 +48,7 @@ export class CodeEditingAgent { } logger.info(projectInfo); - const fs: FileSystem = getFileSystem(); + const fs: FileSystemService = getFileSystem(); const git = fs.vcs; fs.setWorkingDirectory(projectInfo.baseDir); @@ -83,7 +83,38 @@ export class CodeEditingAgent { Check if any of the requirements have already been correctly implemented in the code as to not duplicate work. Look at the existing style of the code when producing the requirements. `; - const implementationRequirements = await llms().hard.generateText(implementationDetailsPrompt, null, { id: 'implementationSpecification' }); + let implementationRequirements = await llms().hard.generateText(implementationDetailsPrompt, null, { id: 'implementationSpecification' }); + + const searchPrompt = `${repositoryOverview}${installedPackages}\n\n${implementationRequirements}\n +Given the requirements, if there are any changes which require using open source libraries, provide search queries to look up the API usage online. + +First discuss what 3rd party API usages would be required in the changes, if any. Then taking into account propose queries for online research, which must contain all the required context (e.g. language, library). For example if the requirements were "Update the Bigtable table results to include the table size" and from the repository information we could determine that it is a node.js project, then a suitable query would be "With the Google Cloud Node.js sdk how can I get the size of a Bigtable table?" +(If there is no 3rd party API usage that is not already done in the provided files then return an empty array for the searchQueries property) + +Then respond in following format: + +{ + searchQueries: ["query 1", "query 2"] +} + +`; + try { + const queries = (await llms().medium.generateJson(searchPrompt, null, { id: 'online queries from requirements' })) as { searchQueries: string[] }; + if (queries.searchQueries.length > 0) { + logger.info(`Researching ${queries.searchQueries.join(', ')}`); + const perplexity = new Perplexity(); + + let webResearch = ''; + for (const query of queries.searchQueries) { + const result = await perplexity.research(query, false); + webResearch += `\n${query}\n\n${result}\n`; + } + webResearch += '\n'; + implementationRequirements = webResearch + implementationRequirements; + } + } catch (e) { + logger.error(e, 'Error performing online queries from code requirements'); + } await installPromise; @@ -131,7 +162,7 @@ export class CodeEditingAgent { which don't compile, we can provide the diff since the last good commit to help identify causes of compile issues. */ let compiledCommitSha: string | null = agentContext().memory.compiledCommitSha; - const fs: FileSystem = getFileSystem(); + const fs: FileSystemService = getFileSystem(); const git = fs.vcs; const MAX_ATTEMPTS = 5; @@ -206,7 +237,7 @@ export class CodeEditingAgent { codeEditorRequirements += '\nOnly make changes directly related to these requirements.'; } - await new CodeEditor().editFilesToMeetRequirements(codeEditorRequirements, codeEditorFiles); + await new AiderCodeEditor().editFilesToMeetRequirements(codeEditorRequirements, codeEditorFiles); // The code editor may add new files, so we want to add them to the initial file set const addedFiles: string[] = await git.getAddedFiles(compiledCommitSha); @@ -263,7 +294,7 @@ export class CodeEditingAgent { logger.info(`Static analysis error output: ${staticAnalysisErrorOutput}`); const staticErrorFiles = await this.extractFilenames(`${staticAnalysisErrorOutput}\n\nExtract the filenames from the compile errors.`); - await new CodeEditor().editFilesToMeetRequirements( + await new AiderCodeEditor().editFilesToMeetRequirements( `Static analysis command: ${projectInfo.staticAnalysis}\n${staticAnalysisErrorOutput}\nFix these static analysis errors`, staticErrorFiles, ); @@ -328,7 +359,7 @@ export class CodeEditingAgent { try { let testRequirements = `${requirements}\nSome of the requirements may have already been implemented, so don't duplicate any existing implementation meeting the requirements.\n`; testRequirements += 'Write any additional tests that would be of value.'; - await new CodeEditor().editFilesToMeetRequirements(testRequirements, initialSelectedFiles); + await new AiderCodeEditor().editFilesToMeetRequirements(testRequirements, initialSelectedFiles); await this.compile(projectInfo); await this.runTests(projectInfo); errorAnalysis = null; diff --git a/src/swe/codeEditor.test.ts b/src/swe/codeEditor.test.ts index 1932a3bf..1c5821e2 100644 --- a/src/swe/codeEditor.test.ts +++ b/src/swe/codeEditor.test.ts @@ -1,11 +1,11 @@ import { expect } from 'chai'; -import { CodeEditor } from './codeEditor'; +import { AiderCodeEditor } from './aiderCodeEditor'; describe('CodeEditor', () => { - let codeEditor: CodeEditor; + let codeEditor: AiderCodeEditor; beforeEach(() => { - codeEditor = new CodeEditor(); + codeEditor = new AiderCodeEditor(); }); describe('parseAiderInput', () => { diff --git a/src/swe/codebaseVectorStore.ts b/src/swe/codebaseVectorStore.ts index 35cc4ae8..2d26c624 100644 --- a/src/swe/codebaseVectorStore.ts +++ b/src/swe/codebaseVectorStore.ts @@ -3,7 +3,7 @@ import { Chroma } from '@langchain/community/vectorstores/chroma'; import { Document } from '@langchain/core/documents'; import { OpenAIEmbeddings } from '@langchain/openai'; import { llms } from '#agent/agentContextLocalStorage'; -import { FileSystem } from '#functions/storage/filesystem'; +import { FileSystemService } from '#functions/storage/fileSystemService'; import { LlmTools } from '#functions/util'; import { logger } from '#o11y/logger'; @@ -19,7 +19,7 @@ const vectorStore = new Chroma(embeddings, { }, }); -const fileSystem = new FileSystem(); +const fileSystem = new FileSystemService(); const llmTools = new LlmTools(); async function generateContextualEmbeddings() { diff --git a/src/swe/documentationBuilder.ts b/src/swe/documentationBuilder.ts index 3061c2ef..f07096b7 100644 --- a/src/swe/documentationBuilder.ts +++ b/src/swe/documentationBuilder.ts @@ -111,6 +111,78 @@ The summaries should be in a very terse, gramatically shortened writing style th Note: Avoid duplicating information from parent summaries. Focus on what's unique to this file. + +When the filename is variables.tf or output.tf and just has variable declarations respond like the following. Variables which are common to all (ie. project_id, project_number, region) dont require any description. + +variable "project_id" { + description = "The project id where the resources will be created" + type = string +} + +variable "region" { + description = "The region where all resources will be deployed" + type = string +} + +variable "run_sa" { + description = "Cloud Run Service Account" + type = string +} + + + +{ + "short": "project_id, region, run_sa", + "long": "project_id, region, run_sa: Cloud Run Service Account", +} + + + + + +When a file has terraform resources respond like this example. + +terraform { + required_version = ">= 1.1.4" + required_providers { + google = { + source = "hashicorp/google" + version = "~> 4.46" + } + } +} + +resource "google_cloud_run_service" "affiliate-conversion-importer" { + name = "affiliate-conversion-importer" + location = var.region + project = var.project_id + + template { + spec { + containers { + image = "gcr.io/cloudrun/hello" + } + service_account_name = var.run_sa + } + } + + lifecycle { + ignore_changes = [ + template + ] + } +} + + + +{ + "short": "Cloud run service affiliate-conversion-importer", + "long": "Cloud run service affiliate-conversion-importer with region, project_id, run_sa vars. Ignores changes to template." +} + + + +Note the terse values for short and long in the previous example. Respond with JSON in this format: { @@ -125,6 +197,7 @@ Respond with JSON in this format: // Save the documentation summary files in a parallel directory structure under the .sophia/docs folder await fs.mkdir(join(cwd, sophiaDirName, 'docs', dirname(file)), { recursive: true }); const summaryFilePath = join(cwd, sophiaDirName, 'docs', `${file}.json`); + logger.info(`Writing summary to ${summaryFilePath}`); await fs.writeFile(summaryFilePath, JSON.stringify(doc, null, 2)); } catch (e) { logger.error(e, `Failed to write documentation for file ${file}`); @@ -142,6 +215,7 @@ Respond with JSON in this format: logger.error(e); } logger.info('Files done'); + await sleep(2000); } // ----------------------------------------------------------------------------- diff --git a/src/swe/lang/nodejs/typescriptTools.ts b/src/swe/lang/nodejs/typescriptTools.ts index 4ddf969f..68081a08 100644 --- a/src/swe/lang/nodejs/typescriptTools.ts +++ b/src/swe/lang/nodejs/typescriptTools.ts @@ -19,7 +19,7 @@ export class TypescriptTools implements LanguageTools { */ @func() async runNpmScript(script: string, args: string[] = []): Promise { - const packageJson = JSON.parse(readFileSync('package.json').toString()); + const packageJson = JSON.parse(await getFileSystem().readFile('package.json')); if (!packageJson.scripts[script]) throw new Error(`Npm script ${script} doesn't exist in package.json`); const result = await runShellCommand(`npm run ${script}`); failOnError(`Error running npm run ${script}`, result); diff --git a/src/swe/lang/python/pythonTools.ts b/src/swe/lang/python/pythonTools.ts index a0ced2bb..7764220f 100644 --- a/src/swe/lang/python/pythonTools.ts +++ b/src/swe/lang/python/pythonTools.ts @@ -1,7 +1,7 @@ import { getFileSystem } from '#agent/agentContextLocalStorage'; import { funcClass } from '#functionSchema/functionDecorators'; import { logger } from '#o11y/logger'; -import { getPythonPath } from '#swe/codeEditor'; +import { getPythonPath } from '#swe/aiderCodeEditor'; import { execCmd, execCommand } from '#utils/exec'; import { LanguageTools } from '../languageTools'; diff --git a/src/swe/selectFilesToEdit.test.ts b/src/swe/selectFilesToEdit.test.ts index 0f4a97e1..55c1ad73 100644 --- a/src/swe/selectFilesToEdit.test.ts +++ b/src/swe/selectFilesToEdit.test.ts @@ -1,9 +1,9 @@ import { expect } from 'chai'; -import { FileSystem } from '#functions/storage/filesystem'; +import { FileSystemService } from '#functions/storage/fileSystemService'; import { removeNonExistingFiles } from '#swe/selectFilesToEdit'; describe('removeNonExistingFiles', () => { - const fileSystem = new FileSystem(); + const fileSystem = new FileSystemService(); it('should remove non-existing files from the selection', async () => { const existingFilePath = './package.json'; // assuming package.json exists