From f9243a73d51b167c8f111c70a2bb3fb90755f59c Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Sat, 21 Mar 2026 23:47:43 +0800 Subject: [PATCH] feat: reduce default max_agent_step to 3 and refactor shell execution - Lower default max_agent_step from 30 to 3 across all agent runners (coze, dashscope, deerflow, dify) for faster responses - Refactor ExecuteShellTool to use plumbum with session-based isolation, maintaining shell state per session (cwd, env vars, etc.) - Remove unused API-specific environment variables from cmd_run - Fix data access bug in shipyard_neo._maybe_model_dump - Add plumbum>=1.10.0 dependency --- .gitignore | 1 + astrbot/cli/commands/cmd_run.py | 12 +- .../agent/runners/coze/coze_agent_runner.py | 2 +- .../dashscope/dashscope_agent_runner.py | 2 +- .../runners/deerflow/deerflow_agent_runner.py | 2 +- .../agent/runners/dify/dify_agent_runner.py | 20 ++-- astrbot/core/astr_agent_run_util.py | 4 +- astrbot/core/astr_agent_tool_exec.py | 4 +- astrbot/core/computer/booters/shipyard_neo.py | 6 +- astrbot/core/computer/tools/shell.py | 113 +++++++++++++++--- dashboard/.gitignore | 1 + docs/zh/dev/astrbot-config.md | 2 +- pyproject.toml | 1 + 13 files changed, 128 insertions(+), 42 deletions(-) diff --git a/.gitignore b/.gitignore index 2cffab039..c71527b6a 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,4 @@ GenieData/ .worktrees/ .env +dashboard/warker.js diff --git a/astrbot/cli/commands/cmd_run.py b/astrbot/cli/commands/cmd_run.py index fa62fa446..ea9a74e85 100644 --- a/astrbot/cli/commands/cmd_run.py +++ b/astrbot/cli/commands/cmd_run.py @@ -306,13 +306,6 @@ def run( "ASTRBOT_SSL_CERT", "ASTRBOT_SSL_KEY", "ASTRBOT_SSL_CA_CERTS", - # API specific envs - "ASTRBOT_API_HOST", - "ASTRBOT_API_PORT", - "ASTRBOT_API_SSL_ENABLE", - "ASTRBOT_API_SSL_CERT", - "ASTRBOT_API_SSL_KEY", - "ASTRBOT_API_SSL_CA_CERTS", "http_proxy", "https_proxy", "no_proxy", @@ -341,6 +334,11 @@ def run( lock_file = astrbot_root / "astrbot.lock" lock = FileLock(lock_file, timeout=5) with lock.acquire(): + click.echo("AstrBot is running...") + if backend_only: + click.echo("Visit the dashboard at : https://dash.astrbot.men/") + click.echo("Backend Requests : localhost or based on https") + asyncio.run(run_astrbot(astrbot_root)) except KeyboardInterrupt: click.echo("AstrBot has been shut down.") diff --git a/astrbot/core/agent/runners/coze/coze_agent_runner.py b/astrbot/core/agent/runners/coze/coze_agent_runner.py index 2cbadfb59..1f2c4b08a 100644 --- a/astrbot/core/agent/runners/coze/coze_agent_runner.py +++ b/astrbot/core/agent/runners/coze/coze_agent_runner.py @@ -107,7 +107,7 @@ class CozeAgentRunner(BaseAgentRunner[TContext]): @override async def step_until_done( - self, max_step: int = 30 + self, max_step: int = 3 ) -> T.AsyncGenerator[AgentResponse, None]: while not self.done(): async for resp in self.step(): diff --git a/astrbot/core/agent/runners/dashscope/dashscope_agent_runner.py b/astrbot/core/agent/runners/dashscope/dashscope_agent_runner.py index 96463f18e..73cb8ffb4 100644 --- a/astrbot/core/agent/runners/dashscope/dashscope_agent_runner.py +++ b/astrbot/core/agent/runners/dashscope/dashscope_agent_runner.py @@ -117,7 +117,7 @@ class DashscopeAgentRunner(BaseAgentRunner[TContext]): @override async def step_until_done( - self, max_step: int = 30 + self, max_step: int = 3 ) -> T.AsyncGenerator[AgentResponse, None]: while not self.done(): async for resp in self.step(): diff --git a/astrbot/core/agent/runners/deerflow/deerflow_agent_runner.py b/astrbot/core/agent/runners/deerflow/deerflow_agent_runner.py index 50ec7c826..ce16a3663 100644 --- a/astrbot/core/agent/runners/deerflow/deerflow_agent_runner.py +++ b/astrbot/core/agent/runners/deerflow/deerflow_agent_runner.py @@ -304,7 +304,7 @@ class DeerFlowAgentRunner(BaseAgentRunner[TContext]): @override async def step_until_done( - self, max_step: int = 30 + self, max_step: int = 3 ) -> T.AsyncGenerator[AgentResponse, None]: if max_step <= 0: raise ValueError("max_step must be greater than 0") diff --git a/astrbot/core/agent/runners/dify/dify_agent_runner.py b/astrbot/core/agent/runners/dify/dify_agent_runner.py index 60408c437..679fe51bd 100644 --- a/astrbot/core/agent/runners/dify/dify_agent_runner.py +++ b/astrbot/core/agent/runners/dify/dify_agent_runner.py @@ -1,10 +1,16 @@ import base64 import os import sys -import typing as T +from collections.abc import AsyncGenerator +from typing import Any import astrbot.core.message.components as Comp from astrbot.core import logger, sp +from astrbot.core.agent.hooks import BaseAgentRunHooks +from astrbot.core.agent.response import AgentResponseData +from astrbot.core.agent.run_context import ContextWrapper, TContext +from astrbot.core.agent.runners.base import AgentResponse, AgentState, BaseAgentRunner +from astrbot.core.agent.runners.dify.dify_api_client import DifyAPIClient from astrbot.core.message.message_event_result import MessageChain from astrbot.core.provider.entities import ( LLMResponse, @@ -13,12 +19,6 @@ from astrbot.core.provider.entities import ( from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.io import download_file -from ...hooks import BaseAgentRunHooks -from ...response import AgentResponseData -from ...run_context import ContextWrapper, TContext -from ..base import AgentResponse, AgentState, BaseAgentRunner -from .dify_api_client import DifyAPIClient - if sys.version_info >= (3, 12): from typing import override else: @@ -35,7 +35,7 @@ class DifyAgentRunner(BaseAgentRunner[TContext]): run_context: ContextWrapper[TContext], agent_hooks: BaseAgentRunHooks[TContext], provider_config: dict, - **kwargs: T.Any, + **kwargs: Any, ) -> None: self.req = request self.streaming = kwargs.get("streaming", False) @@ -100,8 +100,8 @@ class DifyAgentRunner(BaseAgentRunner[TContext]): @override async def step_until_done( - self, max_step: int = 30 - ) -> T.AsyncGenerator[AgentResponse, None]: + self, max_step: int = 3 + ) -> AsyncGenerator[AgentResponse, None]: while not self.done(): async for resp in self.step(): yield resp diff --git a/astrbot/core/astr_agent_run_util.py b/astrbot/core/astr_agent_run_util.py index 856dca5fa..d269185b2 100644 --- a/astrbot/core/astr_agent_run_util.py +++ b/astrbot/core/astr_agent_run_util.py @@ -106,7 +106,7 @@ def _extract_final_streaming_chain(msg_chain: MessageChain) -> MessageChain | No async def run_agent( agent_runner: AgentRunner, - max_step: int = 30, + max_step: int = 3, show_tool_use: bool = True, show_tool_call_result: bool = False, stream_to_general: bool = False, @@ -301,7 +301,7 @@ async def _watch_agent_stop_signal(agent_runner: AgentRunner, astr_event) -> Non async def run_live_agent( agent_runner: AgentRunner, tts_provider: TTSProvider | None = None, - max_step: int = 30, + max_step: int = 3, show_tool_use: bool = True, show_tool_call_result: bool = False, show_reasoning: bool = False, diff --git a/astrbot/core/astr_agent_tool_exec.py b/astrbot/core/astr_agent_tool_exec.py index a8e1ab2d0..d42b25af0 100644 --- a/astrbot/core/astr_agent_tool_exec.py +++ b/astrbot/core/astr_agent_tool_exec.py @@ -357,7 +357,7 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]): continue prov_settings: dict = ctx.get_config(umo=umo).get("provider_settings", {}) - agent_max_step = int(prov_settings.get("max_agent_step", 30)) + agent_max_step = int(prov_settings.get("max_agent_step", 3)) stream = prov_settings.get("streaming_response", False) llm_resp = await ctx.tool_loop_agent( event=event, @@ -583,7 +583,7 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]): return runner = result.agent_runner - async for _ in runner.step_until_done(30): + async for _ in runner.step_until_done(3): # agent will send message to user via using tools pass llm_resp = runner.get_final_llm_resp() diff --git a/astrbot/core/computer/booters/shipyard_neo.py b/astrbot/core/computer/booters/shipyard_neo.py index 083c31930..b954a83b2 100644 --- a/astrbot/core/computer/booters/shipyard_neo.py +++ b/astrbot/core/computer/booters/shipyard_neo.py @@ -12,13 +12,13 @@ from astrbot.api import logger if TYPE_CHECKING: from astrbot.core.agent.tool import FunctionTool -from ..olayer import ( +from astrbot.core.computer.booters.base import ComputerBooter +from astrbot.core.computer.olayer import ( BrowserComponent, FileSystemComponent, PythonComponent, ShellComponent, ) -from .base import ComputerBooter def _maybe_model_dump(value: Any) -> dict[str, Any]: @@ -49,7 +49,7 @@ class NeoPythonComponent(PythonComponent): output_text = payload.get("output", "") or "" error_text = payload.get("error", "") or "" data = payload.get("data") if isinstance(payload.get("data"), dict) else {} - rich_output = data.get("output") if isinstance(data.get("output"), dict) else {} + rich_output = (data.get("output") or {}) if isinstance(data, dict) else {} if not isinstance(rich_output.get("images"), list): rich_output["images"] = [] if "text" not in rich_output: diff --git a/astrbot/core/computer/tools/shell.py b/astrbot/core/computer/tools/shell.py index 22f61e3ee..7b4932288 100644 --- a/astrbot/core/computer/tools/shell.py +++ b/astrbot/core/computer/tools/shell.py @@ -1,17 +1,28 @@ +from __future__ import annotations + import json from dataclasses import dataclass, field +from typing import Any + +from plumbum import local +from plumbum.commands.processes import ProcessExecutionError from astrbot.api import FunctionTool from astrbot.core.agent.run_context import ContextWrapper from astrbot.core.agent.tool import ToolExecResult from astrbot.core.astr_agent_context import AstrAgentContext -from ..computer_client import get_booter, get_local_booter from .permissions import check_admin_permission @dataclass class ExecuteShellTool(FunctionTool): + """Stateful shell execution tool based on plumbum. + + Each session maintains its own shell instance, ensuring state isolation + across different sessions (such as working directory, environment variables, etc.). + """ + name: str = "astrbot_execute_shell" description: str = "Execute a command in the shell." parameters: dict = field( @@ -39,6 +50,24 @@ class ExecuteShellTool(FunctionTool): ) is_local: bool = False + _session_shells: dict[str, Any] = field( + default_factory=dict, init=False, repr=False + ) + + def _get_session_shell(self, session_id: str) -> Any: + """Get or create a shell instance for a specific session. + + Args: + session_id: The unique session identifier. + + Returns: + plumbum shell instance for this session. + """ + import os + + if session_id not in self._session_shells: + self._session_shells[session_id] = local.cwd(os.getcwd()) + return self._session_shells[session_id] async def call( self, @@ -50,26 +79,82 @@ class ExecuteShellTool(FunctionTool): if permission_error := check_admin_permission(context, "Shell execution"): return permission_error - if self.is_local: - sb = get_local_booter() - else: - sb = await get_booter( - context.context.context, - context.context.event.unified_msg_origin, - ) try: - config = context.context.context.get_config( - umo=context.context.event.unified_msg_origin - ) + # Get session ID for per-session shell isolation + session_id = context.context.event.unified_msg_origin + shell = self._get_session_shell(session_id) + + config = context.context.context.get_config(umo=session_id) try: timeout = int( config.get("provider_settings", {}).get("tool_call_timeout", 30) ) except (ValueError, TypeError): timeout = 30 - result = await sb.shell.exec( - command, background=background, env=env, timeout=timeout - ) + + # Merge environment variables + if env: + full_env = shell.env.copy() + full_env.update(env) + else: + full_env = None + + if background: + # Background execution: use & to run in background, no waiting + cmd_line = f"{command} &" + if full_env: + _, stdout, stderr = shell.run( + ["/bin/sh", "-c", cmd_line], + env=full_env, + timeout=timeout, + ) + else: + _, stdout, stderr = shell.run( + ["/bin/sh", "-c", cmd_line], + timeout=timeout, + ) + result = { + "success": True, + "background": True, + "stdout": stdout, + "stderr": stderr, + } + else: + # Foreground execution: wait for command completion + try: + if full_env: + exit_code, stdout, stderr = shell.run( + ["/bin/sh", "-c", command], + env=full_env, + timeout=timeout, + ) + else: + exit_code, stdout, stderr = shell.run( + ["/bin/sh", "-c", command], + timeout=timeout, + ) + result = { + "success": exit_code == 0, + "exit_code": exit_code, + "stdout": stdout, + "stderr": stderr, + } + except ProcessExecutionError as e: + result = { + "success": False, + "exit_code": e.exit_code, + "stdout": e.stdout, + "stderr": e.stderr, + } + except Exception as e: + result = { + "success": False, + "exit_code": -1, + "stdout": "", + "stderr": str(e), + } + return json.dumps(result) + except Exception as e: return f"Error executing command: {e!s}" diff --git a/dashboard/.gitignore b/dashboard/.gitignore index a14565711..26c8af456 100644 --- a/dashboard/.gitignore +++ b/dashboard/.gitignore @@ -3,3 +3,4 @@ node_modules/ dist/ bun.lock pnpm-lock.yaml +worker.js diff --git a/docs/zh/dev/astrbot-config.md b/docs/zh/dev/astrbot-config.md index 10a804515..cd5f16094 100644 --- a/docs/zh/dev/astrbot-config.md +++ b/docs/zh/dev/astrbot-config.md @@ -73,7 +73,7 @@ AstrBot 默认配置如下: "streaming_response": False, "show_tool_use_status": False, "streaming_segmented": False, - "max_agent_step": 30, + "max_agent_step": 3, "tool_call_timeout": 120, }, "provider_stt_settings": { diff --git a/pyproject.toml b/pyproject.toml index c307dfba2..58f90275b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,6 +67,7 @@ dependencies = [ "quart-cors>=0.8.0", "anyio>=4.12.1", "argon2-cffi>=23.1.0", + "plumbum>=1.10.0", ] [dependency-groups]