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 <noreply@anthropic.com>
This commit is contained in:
Nicholas Tindle
2026-01-22 15:20:12 -06:00
parent 114856cef1
commit 7dd181f4b0
3 changed files with 149 additions and 38 deletions

View File

@@ -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()

View File

@@ -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

View File

@@ -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: