From 7dd181f4b02efd6e9fec0f9cf284ca25308c7e54 Mon Sep 17 00:00:00 2001 From: Nicholas Tindle Date: Thu, 22 Jan 2026 15:20:12 -0600 Subject: [PATCH] feat(classic): make CWD the default agent workspace for CLI mode In CLI mode, agents now work directly in the current directory instead of being sandboxed to .autogpt/agents/{id}/workspace/. Agent state files are still stored in .autogpt/agents/{id}/state.json. Server mode retains the original sandboxed behavior for isolation. Changes: - Add workspace_root parameter to FileManagerComponent to detect CLI mode - Update Agent to pass workspace_root when file_storage is rooted at workspace - Adjust save_state paths based on mode (CLI uses .autogpt/ prefix) - Add use_tools field to ActionProposal for parallel tool execution - Support parallel tool execution in Agent.execute() Co-Authored-By: Claude Opus 4.5 --- .../components/file_manager/file_manager.py | 39 ++++-- classic/forge/forge/models/action.py | 16 ++- .../original_autogpt/autogpt/agents/agent.py | 132 ++++++++++++++---- 3 files changed, 149 insertions(+), 38 deletions(-) diff --git a/classic/forge/forge/components/file_manager/file_manager.py b/classic/forge/forge/components/file_manager/file_manager.py index 3d2c049926..d062238207 100644 --- a/classic/forge/forge/components/file_manager/file_manager.py +++ b/classic/forge/forge/components/file_manager/file_manager.py @@ -52,6 +52,7 @@ class FileManagerComponent( file_storage: FileStorage, agent_state: BaseAgentSettings, config: Optional[FileManagerConfiguration] = None, + workspace_root: Optional[Path] = None, ): """Initialise the FileManagerComponent. Either `agent_id` or `config` must be provided. @@ -61,15 +62,26 @@ class FileManagerComponent( state (BaseAgentSettings): The agent's state. config (FileManagerConfiguration, optional): The configuration for the file manager. Defaults to None. + workspace_root (Path, optional): When provided (CLI mode), indicates that + file_storage is rooted at the workspace directory. Agent files go to + .autogpt/agents/{id}/ and the workspace is the current directory. + When None (server mode), uses sandboxed paths under agents/{id}/. """ if not agent_state.agent_id: raise ValueError("Agent must have an ID.") self.agent_state = agent_state + self._workspace_root = workspace_root if not config: - storage_path = f"agents/{self.agent_state.agent_id}/" - workspace_path = f"agents/{self.agent_state.agent_id}/workspace" + if workspace_root: + # CLI mode: file_storage root = workspace, agent works in cwd + storage_path = f".autogpt/agents/{self.agent_state.agent_id}/" + workspace_path = "." + else: + # Server mode: file_storage root = .autogpt, sandboxed workspace + storage_path = f"agents/{self.agent_state.agent_id}/" + workspace_path = f"agents/{self.agent_state.agent_id}/workspace" ConfigurableComponent.__init__( self, FileManagerConfiguration( @@ -90,17 +102,26 @@ class FileManagerComponent( async def save_state(self, save_as_id: Optional[str] = None) -> None: """Save the agent's data and state.""" if save_as_id: - self._file_storage.make_dir(f"agents/{save_as_id}") + # Determine path prefix based on mode + if self._workspace_root: + # CLI mode: file storage is rooted at workspace, state goes to .autogpt/ + agents_prefix = ".autogpt/agents" + else: + # Server mode: file storage is rooted at .autogpt/ + agents_prefix = "agents" + + self._file_storage.make_dir(f"{agents_prefix}/{save_as_id}") # Save state await self._file_storage.write_file( - f"agents/{save_as_id}/{self.STATE_FILE}", + f"{agents_prefix}/{save_as_id}/{self.STATE_FILE}", self.agent_state.model_dump_json(), ) - # Copy workspace - self._file_storage.copy( - self.config.workspace_path, - f"agents/{save_as_id}/workspace", - ) + # Copy workspace (only in server mode, each agent has its own sandbox) + if not self._workspace_root: + self._file_storage.copy( + self.config.workspace_path, + f"{agents_prefix}/{save_as_id}/workspace", + ) else: await self.storage.write_file( self.storage.root / self.STATE_FILE, self.agent_state.model_dump_json() diff --git a/classic/forge/forge/models/action.py b/classic/forge/forge/models/action.py index a87f9bb6e3..075e3167f7 100644 --- a/classic/forge/forge/models/action.py +++ b/classic/forge/forge/models/action.py @@ -17,12 +17,23 @@ from .utils import ModelWithSummary class ActionProposal(BaseModel): thoughts: str | ModelWithSummary use_tool: AssistantFunctionCall + use_tools: Optional[list[AssistantFunctionCall]] = None + """ + List of tools to execute in parallel. If None, only use_tool is executed. + When set, use_tool should be the first item for backwards compatibility. + """ raw_message: AssistantChatMessage = None # type: ignore """ The message from which the action proposal was parsed. To be set by the parser. """ + def get_tools(self) -> list[AssistantFunctionCall]: + """Get all tools to execute. Returns use_tools if set, otherwise [use_tool].""" + if self.use_tools: + return self.use_tools + return [self.use_tool] + @classmethod def model_json_schema( cls, @@ -42,8 +53,11 @@ class ActionProposal(BaseModel): mode=mode, **kwargs, ) - if "raw_message" in schema["properties"]: # must check because schema is cached + # Exclude internal fields from schema (must check because schema is cached) + if "raw_message" in schema["properties"]: del schema["properties"]["raw_message"] + if "use_tools" in schema["properties"]: + del schema["properties"]["use_tools"] return schema diff --git a/classic/original_autogpt/autogpt/agents/agent.py b/classic/original_autogpt/autogpt/agents/agent.py index 5febafa31b..76233579f8 100644 --- a/classic/original_autogpt/autogpt/agents/agent.py +++ b/classic/original_autogpt/autogpt/agents/agent.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import inspect import logging from typing import TYPE_CHECKING, Any, ClassVar, Optional @@ -175,7 +176,14 @@ class Agent(BaseAgent[AnyActionProposal], Configurable[AgentSettings]): ) if not app_config.noninteractive_mode: self.user_interaction = UserInteractionComponent() - self.file_manager = FileManagerComponent(file_storage, settings) + # CLI mode: file storage rooted at workspace (not .autogpt) + # Agents work directly in cwd; in server mode, they're sandboxed + cli_mode = file_storage.root == app_config.workspace + self.file_manager = FileManagerComponent( + file_storage, + settings, + workspace_root=app_config.workspace if cli_mode else None, + ) self.code_executor = CodeExecutorComponent( self.file_manager.workspace, CodeExecutorConfiguration( @@ -352,46 +360,54 @@ class Agent(BaseAgent[AnyActionProposal], Configurable[AgentSettings]): proposal: ActionProposal, user_feedback: str = "", ) -> ActionResult: - tool = proposal.use_tool + # Get all tools to execute (supports parallel execution) + tools = proposal.get_tools() # Get commands self.commands = await self.run_pipeline(CommandProvider.get_commands) self._remove_disabled_commands() - # Check permissions before execution + # Check permissions for all tools before execution + feedback_to_append = None if self.permission_manager: - perm_result = self.permission_manager.check_command( - tool.name, tool.arguments - ) - if not perm_result.allowed: - # Permission denied - pass feedback to agent if provided - if perm_result.feedback: - return await self.do_not_execute(proposal, perm_result.feedback) - return ActionErrorResult( - reason=f"Permission denied for command '{tool.name}'", + for tool in tools: + perm_result = self.permission_manager.check_command( + tool.name, tool.arguments ) + if not perm_result.allowed: + # Permission denied - pass feedback to agent if provided + if perm_result.feedback: + return await self.do_not_execute(proposal, perm_result.feedback) + return ActionErrorResult( + reason=f"Permission denied for command '{tool.name}'", + ) + # Permission granted - save feedback if any + if perm_result.feedback: + feedback_to_append = perm_result.feedback - # Permission granted - execute command, then handle feedback if any - feedback_to_append = perm_result.feedback + # Execute tool(s) + if len(tools) == 1: + # Single tool - original behavior + tool = tools[0] + try: + return_value = await self._execute_tool(tool) + result = ActionSuccessResult(outputs=return_value) + except AgentTerminated: + raise + except AgentException as e: + result = ActionErrorResult.from_exception(e) + logger.warning(f"{tool} raised an error: {e}") + sentry_sdk.capture_exception(e) else: - feedback_to_append = None - - try: - return_value = await self._execute_tool(tool) - - result = ActionSuccessResult(outputs=return_value) - except AgentTerminated: - raise - except AgentException as e: - result = ActionErrorResult.from_exception(e) - logger.warning(f"{tool} raised an error: {e}") - sentry_sdk.capture_exception(e) + # Multiple tools - execute in parallel + logger.info(f"Executing {len(tools)} tools in parallel") + result = await self._execute_tools_parallel(tools) result_tlength = self.llm_provider.count_tokens(str(result), self.llm.name) if result_tlength > self.send_token_limit // 3: result = ActionErrorResult( - reason=f"Command {tool.name} returned too much output. " - "Do not execute this command again with the same arguments." + reason="Command(s) returned too much output. " + "Do not execute these commands again with the same arguments." ) # Notify ReWOO strategy of execution result for variable tracking @@ -458,6 +474,66 @@ class Agent(BaseAgent[AnyActionProposal], Configurable[AgentSettings]): except Exception as e: raise CommandExecutionError(str(e)) + async def _execute_tools_parallel( + self, tools: list[AssistantFunctionCall] + ) -> ActionResult: + """Execute multiple tools in parallel and combine results. + + Args: + tools: List of tool calls to execute in parallel + + Returns: + Combined ActionResult with all outputs or errors + """ + + async def execute_single(tool: AssistantFunctionCall) -> tuple[str, Any, str]: + """Execute a single tool and return (name, result, error).""" + try: + result = await self._execute_tool(tool) + return (tool.name, result, "") + except AgentTerminated: + raise + except AgentException as e: + logger.warning(f"{tool} raised an error: {e}") + sentry_sdk.capture_exception(e) + return (tool.name, None, str(e)) + + # Execute all tools in parallel + results = await asyncio.gather( + *[execute_single(tool) for tool in tools], + return_exceptions=True, + ) + + # Process results + outputs: dict[str, Any] = {} + errors: list[str] = [] + + for i, res in enumerate(results): + tool = tools[i] + if isinstance(res, BaseException): + # Unexpected exception from gather + errors.append(f"{tool.name}: {res}") + logger.warning(f"{tool} raised unexpected error: {res}") + sentry_sdk.capture_exception(res) + elif isinstance(res, tuple): + name, output, error = res + if error: + errors.append(f"{name}: {error}") + else: + outputs[name] = output + + # Return combined result + if errors and not outputs: + # All failed + return ActionErrorResult(reason="; ".join(errors)) + elif errors: + # Partial success - include errors in output + outputs["_errors"] = errors + return ActionSuccessResult(outputs=outputs) + else: + # All succeeded + return ActionSuccessResult(outputs=outputs) + def _get_command(self, command_name: str) -> Command: for command in reversed(self.commands): if command_name in command.names: