From e85eef05b8d3b74a9afd1a29e6b6728fee6b9d2d Mon Sep 17 00:00:00 2001 From: zenfun Date: Thu, 12 Mar 2026 02:42:14 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20stabilize=20tool=20injection=20for=20LLM?= =?UTF-8?q?=20prefix=20cache=20hits=20Two=20changes=20to=20make=20the=20to?= =?UTF-8?q?ol=20schema=20sent=20to=20the=20LLM=20deterministic:=201.=20Too?= =?UTF-8?q?lSet.normalize()=20=E2=80=94=20sort=20tools=20by=20name=20befor?= =?UTF-8?q?e=20serialization.=20=20=20=20Called=20at=20the=20end=20of=20bu?= =?UTF-8?q?ild=5Fmain=5Fagent()=20after=20all=20injection=20passes.=20=20?= =?UTF-8?q?=20=20Eliminates=20ordering=20drift=20from=20plugin=20load=20or?= =?UTF-8?q?der,=20MCP=20reconnection,=20=20=20=20and=20persona=20tool=20li?= =?UTF-8?q?st=20differences.=202.=20Always=20inject=20full=20sandbox=20too?= =?UTF-8?q?l=20set=20=E2=80=94=20ComputerToolProvider=20now=20=20=20=20ret?= =?UTF-8?q?urns=20get=5Fdefault=5Fsandbox=5Ftools()=20unconditionally,=20r?= =?UTF-8?q?egardless=20of=20=20=20=20sandbox=20boot=20state.=20Browser=20t?= =?UTF-8?q?ools=20are=20always=20in=20the=20schema=20even=20if=20=20=20=20?= =?UTF-8?q?the=20sandbox=20profile=20lacks=20browser=20capability.=20The?= =?UTF-8?q?=20executor=20rejects=20=20=20=20calls=20to=20unavailable=20bro?= =?UTF-8?q?wser=20tools=20with=20a=20descriptive=20error=20instead=20=20?= =?UTF-8?q?=20=20of=20silently=20omitting=20them=20from=20the=20schema.=20?= =?UTF-8?q?=20=20=20This=20eliminates=20the=20pre-boot/post-boot=20tool=20?= =?UTF-8?q?set=20jump=20that=20caused=20=20=20=20prefix=20cache=20misses?= =?UTF-8?q?=20on=20the=20second=20request=20of=20a=20conversation.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/agent/tool.py | 9 ++++ astrbot/core/astr_agent_tool_exec.py | 51 +++++++++++++++++++ astrbot/core/astr_main_agent.py | 4 ++ .../core/computer/computer_tool_provider.py | 25 ++++----- 4 files changed, 75 insertions(+), 14 deletions(-) diff --git a/astrbot/core/agent/tool.py b/astrbot/core/agent/tool.py index aba421f3d..42b7e8eb4 100644 --- a/astrbot/core/agent/tool.py +++ b/astrbot/core/agent/tool.py @@ -106,6 +106,15 @@ class ToolSet: """Remove a tool by its name.""" self.tools = [tool for tool in self.tools if tool.name != name] + def normalize(self) -> None: + """Sort tools by name for deterministic serialization. + + This ensures the serialized tool schema sent to the LLM is + identical across requests regardless of registration/injection + order, enabling LLM provider prefix cache hits. + """ + self.tools.sort(key=lambda t: t.name) + def get_tool(self, name: str) -> FunctionTool | None: """Get a tool by its name.""" for tool in self.tools: diff --git a/astrbot/core/astr_agent_tool_exec.py b/astrbot/core/astr_agent_tool_exec.py index 27d6aeae3..8b9bcf0c2 100644 --- a/astrbot/core/astr_agent_tool_exec.py +++ b/astrbot/core/astr_agent_tool_exec.py @@ -169,10 +169,61 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]): return else: + # Guard: reject sandbox tools whose capability is unavailable. + # Tools are always injected (for schema stability / prefix caching), + # but execution is blocked when the sandbox lacks the capability. + rejection = cls._check_sandbox_capability(tool, run_context) + if rejection is not None: + yield rejection + return + async for r in cls._execute_local(tool, run_context, **tool_args): yield r return + # Browser tool names that require the "browser" sandbox capability. + _BROWSER_TOOL_NAMES: frozenset[str] = frozenset({ + "astrbot_execute_browser", + "astrbot_execute_browser_batch", + "astrbot_run_browser_skill", + }) + + @classmethod + def _check_sandbox_capability( + cls, + tool: FunctionTool, + run_context: ContextWrapper[AstrAgentContext], + ) -> mcp.types.CallToolResult | None: + """Return a rejection result if the tool requires a sandbox capability + that is not available, or None if the tool may proceed.""" + if tool.name not in cls._BROWSER_TOOL_NAMES: + return None + + from astrbot.core.computer.computer_client import get_sandbox_capabilities + + session_id = run_context.context.event.unified_msg_origin + caps = get_sandbox_capabilities(session_id) + + # Sandbox not yet booted — allow through (boot will happen on first + # shell/python call; browser tools will fail naturally if truly unavailable). + if caps is None: + return None + + if "browser" not in caps: + msg = ( + f"Tool '{tool.name}' requires browser capability, but the current " + f"sandbox profile does not include it (capabilities: {list(caps)}). " + "Please ask the administrator to switch to a sandbox profile with " + "browser support, or use shell/python tools instead." + ) + logger.warning("[ToolExec] capability_rejected tool=%s caps=%s", tool.name, list(caps)) + return mcp.types.CallToolResult( + content=[mcp.types.TextContent(type="text", text=msg)], + isError=True, + ) + + return None + @classmethod def _get_runtime_computer_tools( cls, diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py index f65fb9919..dedd58574 100644 --- a/astrbot/core/astr_main_agent.py +++ b/astrbot/core/astr_main_agent.py @@ -1074,6 +1074,10 @@ async def build_main_agent( asyncio.create_task(_handle_webchat(event, req, provider)) if req.func_tool and req.func_tool.tools: + # Sort tools by name for deterministic serialization so that + # LLM provider prefix caching can match across requests. + req.func_tool.normalize() + tool_prompt = ( TOOL_CALL_PROMPT if config.tool_schema_mode == "full" diff --git a/astrbot/core/computer/computer_tool_provider.py b/astrbot/core/computer/computer_tool_provider.py index 998cb11f8..7a2952dc5 100644 --- a/astrbot/core/computer/computer_tool_provider.py +++ b/astrbot/core/computer/computer_tool_provider.py @@ -181,15 +181,17 @@ class ComputerToolProvider: def _sandbox_tools(self, ctx: ToolProviderContext) -> list[FunctionTool]: """Collect tools for sandbox mode. - Prefers the precise post-boot tool list from the running session's - booter instance (``get_sandbox_tools``). When the sandbox has not - yet been booted, falls back to the conservative pre-boot default - declared by the booter class (``get_default_sandbox_tools``). + Always returns the full (pre-boot default) tool set declared by the + booter class, regardless of whether the sandbox is already booted. + + This ensures the tool schema sent to the LLM is stable across the + entire conversation lifecycle (pre-boot and post-boot produce the + same set), enabling LLM prefix cache hits. Tools whose underlying + capability is unavailable at runtime are rejected by the executor + with a descriptive error message instead of being omitted from the + schema. """ - from astrbot.core.computer.computer_client import ( - get_default_sandbox_tools, - get_sandbox_tools, - ) + from astrbot.core.computer.computer_client import get_default_sandbox_tools booter_type = ctx.sandbox_cfg.get("booter", "shipyard_neo") @@ -203,12 +205,7 @@ class ComputerToolProvider: os.environ["SHIPYARD_ENDPOINT"] = ep os.environ["SHIPYARD_ACCESS_TOKEN"] = at - # Prefer precise post-boot tools from the running session - booted_tools = get_sandbox_tools(ctx.session_id) - if booted_tools: - return booted_tools - - # Pre-boot: conservative default from booter class + # Always return the full tool set for schema stability return get_default_sandbox_tools(ctx.sandbox_cfg) def _sandbox_prompt_addon(self, ctx: ToolProviderContext) -> str: