Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
fcf1b08
feat: filesystem grep, read, edit file
Soulter Apr 6, 2026
8e7d995
feat: add file write tool and enhance file read functionality
Soulter Apr 7, 2026
11282c7
feat: enhance tool prompt formatting and add escaped text decoding fo…
Soulter Apr 7, 2026
a539dee
feat: remove redundant safe path tests from security restrictions
Soulter Apr 7, 2026
20fed8a
feat: implement file read tool with support for text and image files,…
Soulter Apr 7, 2026
86ac40d
feat: add file read utilities and integrate with filesystem tools
Soulter Apr 7, 2026
006aedb
Merge remote-tracking branch 'origin/master' into feat/fs-grep-read-edit
Soulter Apr 8, 2026
56a099b
refactor: move computer tools to builtin tools registry
Soulter Apr 8, 2026
efc93a3
refactor: remove unused plugin_context parameter from _apply_sandbox_…
Soulter Apr 8, 2026
adc01e0
feat: supports to display enabled builtin tools in configs
Soulter Apr 9, 2026
5ca2483
feat: add tooltip for disabled builtin tools and update localization …
Soulter Apr 9, 2026
add5db6
feat: add workspace extra prompt handling in message processing
Soulter Apr 9, 2026
5f049f2
feat: add ripgrep installation to Dockerfile
Soulter Apr 10, 2026
7bf1d19
perf: shell executed in workspace dir in local env
Soulter Apr 10, 2026
013ecac
Merge remote-tracking branch 'origin/master' into feat/fs-grep-read-edit
Soulter Apr 10, 2026
cff1488
feat: enhance file reading capabilities to support PDF and DOCX parsi…
Soulter Apr 10, 2026
3acda6f
feat: update converted text notice to suggest using grep for large files
Soulter Apr 10, 2026
1745e9c
feat: implement handling for large tool results with overflow file wr…
Soulter Apr 10, 2026
1577495
fix: test
Soulter Apr 10, 2026
31846cb
feat: enhance onboarding steps to include computer access configurati…
Soulter Apr 10, 2026
5bd9027
Merge remote-tracking branch 'origin/master' into feat/fs-grep-read-edit
Soulter Apr 10, 2026
ebc6273
feat: add support for additional temporary path in restricted environ…
Soulter Apr 11, 2026
3a79233
feat: update computer access hints and add detailed configuration ins…
Soulter Apr 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
gnupg \
git \
ripgrep \
&& curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& apt-get clean \
Expand Down
123 changes: 120 additions & 3 deletions astrbot/core/agent/runners/tool_loop_agent_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@
import time
import traceback
import typing as T
import uuid
from collections.abc import AsyncIterator
from contextlib import suppress
from dataclasses import dataclass, field
from pathlib import Path

from mcp.types import (
BlobResourceContents,
Expand All @@ -25,7 +27,7 @@

from astrbot import logger
from astrbot.core.agent.message import ImageURLPart, TextPart, ThinkPart
from astrbot.core.agent.tool import ToolSet
from astrbot.core.agent.tool import FunctionTool, ToolSet
from astrbot.core.agent.tool_image_cache import tool_image_cache
from astrbot.core.exceptions import EmptyModelOutputError
from astrbot.core.message.components import Json
Expand All @@ -45,7 +47,7 @@
from ..context.compressor import ContextCompressor
from ..context.config import ContextConfig
from ..context.manager import ContextManager
from ..context.token_counter import TokenCounter
from ..context.token_counter import EstimateTokenCounter, TokenCounter
from ..hooks import BaseAgentRunHooks
from ..message import AssistantMessageSegment, Message, ToolCallMessageSegment
from ..response import AgentResponseData, AgentStats
Expand Down Expand Up @@ -97,6 +99,8 @@ class _ToolExecutionInterrupted(Exception):


class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
TOOL_RESULT_MAX_ESTIMATED_TOKENS = 27_500
TOOL_RESULT_PREVIEW_MAX_ESTIMATED_TOKENS = 7000
EMPTY_OUTPUT_RETRY_ATTEMPTS = 3
EMPTY_OUTPUT_RETRY_WAIT_MIN_S = 1
EMPTY_OUTPUT_RETRY_WAIT_MAX_S = 4
Expand Down Expand Up @@ -151,6 +155,12 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
"Otherwise, change strategy, adjust arguments, or explain the limitation "
"to the user."
)
TOOL_RESULT_OVERFLOW_NOTICE_TEMPLATE = (
"Truncated tool output preview shown above. "
"The tool output was too large to include directly and was written to "
"`{overflow_path}`. Use {read_tool_hint} to inspect it. "
"Use a narrower window when reading large files."
)

def _get_persona_custom_error_message(self) -> str | None:
"""Read persona-level custom error message from event extras when available."""
Expand Down Expand Up @@ -206,6 +216,8 @@ async def reset(
custom_compressor: ContextCompressor | None = None,
tool_schema_mode: str | None = "full",
fallback_providers: list[Provider] | None = None,
tool_result_overflow_dir: str | None = None,
read_tool: FunctionTool | None = None,
**kwargs: T.Any,
) -> None:
self.req = request
Expand All @@ -217,6 +229,9 @@ async def reset(
self.truncate_turns = truncate_turns
self.custom_token_counter = custom_token_counter
self.custom_compressor = custom_compressor
self.tool_result_overflow_dir = tool_result_overflow_dir
self.read_tool = read_tool
self._tool_result_token_counter = EstimateTokenCounter()
# we will do compress when:
# 1. before requesting LLM
# TODO: 2. after LLM output a tool call
Expand Down Expand Up @@ -298,6 +313,103 @@ async def reset(
self.stats = AgentStats()
self.stats.start_time = time.time()

def _read_tool_hint(self) -> str:
if self.read_tool is not None:
return f"`{self.read_tool.name}`"
return "the available file-read tool"

async def _write_tool_result_overflow_file(
self,
*,
tool_call_id: str,
content: str,
) -> str:
if self.tool_result_overflow_dir is None:
raise ValueError("tool_result_overflow_dir is not configured")

overflow_dir = Path(self.tool_result_overflow_dir).resolve(strict=False)
safe_tool_call_id = (
"".join(
ch if ch.isalnum() or ch in {"-", "_", "."} else "_"
for ch in tool_call_id
).strip("._")
or "tool_call"
)
file_name = f"{safe_tool_call_id}_{uuid.uuid4().hex[:8]}.txt"
overflow_path = overflow_dir / file_name

def _run() -> str:
overflow_dir.mkdir(parents=True, exist_ok=True)
overflow_path.write_text(content, encoding="utf-8")
return str(overflow_path)

return await asyncio.to_thread(_run)

async def _materialize_large_tool_result(
self,
*,
tool_call_id: str,
content: str,
) -> str:
if self.tool_result_overflow_dir is None or self.read_tool is None:
return content

estimated_tokens = self._tool_result_token_counter.count_tokens(
[Message(role="tool", content=content, tool_call_id=tool_call_id)]
)
if estimated_tokens <= self.TOOL_RESULT_MAX_ESTIMATED_TOKENS:
return content

preview = self._truncate_tool_result_preview(content, tool_call_id=tool_call_id)
try:
overflow_path = await self._write_tool_result_overflow_file(
tool_call_id=tool_call_id,
content=content,
)
except Exception as exc:
logger.warning(
"Failed to spill oversized tool result for %s: %s",
tool_call_id,
exc,
exc_info=True,
)
error_notice = (
"Tool output exceeded the inline result limit "
f"({estimated_tokens} estimated tokens > "
f"{self.TOOL_RESULT_MAX_ESTIMATED_TOKENS}) and could not be written "
f"to `{self.tool_result_overflow_dir}`: {exc}"
)
if not preview:
return error_notice
return f"{preview}\n\n{error_notice}"

notice = self.TOOL_RESULT_OVERFLOW_NOTICE_TEMPLATE.format(
overflow_path=overflow_path,
read_tool_hint=self._read_tool_hint(),
)
if not preview:
return notice
return f"{preview}\n\n{notice}"

def _truncate_tool_result_preview(
self,
content: str,
*,
tool_call_id: str,
) -> str:
preview = content
while preview:
estimated_tokens = self._tool_result_token_counter.count_tokens(
[Message(role="tool", content=preview, tool_call_id=tool_call_id)]
)
if estimated_tokens <= self.TOOL_RESULT_PREVIEW_MAX_ESTIMATED_TOKENS:
return preview
next_len = len(preview) // 2
if next_len <= 0:
break
preview = preview[:next_len]
return preview

async def _iter_llm_responses(
self, *, include_model: bool = True
) -> T.AsyncGenerator[LLMResponse, None]:
Expand Down Expand Up @@ -933,9 +1045,14 @@ def _append_tool_call_result(tool_call_id: str, content: str) -> None:
"The tool has returned a data type that is not supported."
)
if result_parts:
inline_result = "\n\n".join(result_parts)
inline_result = await self._materialize_large_tool_result(
tool_call_id=func_tool_id,
content=inline_result,
)
_append_tool_call_result(
func_tool_id,
"\n\n".join(result_parts)
inline_result
+ self._build_repeated_tool_call_guidance(
func_tool_name, tool_call_streak
),
Expand Down
67 changes: 53 additions & 14 deletions astrbot/core/astr_agent_tool_exec.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,6 @@
from astrbot.core.astr_agent_context import AstrAgentContext
from astrbot.core.astr_main_agent_resources import (
BACKGROUND_TASK_RESULT_WOKE_SYSTEM_PROMPT,
EXECUTE_SHELL_TOOL,
FILE_DOWNLOAD_TOOL,
FILE_UPLOAD_TOOL,
LOCAL_EXECUTE_SHELL_TOOL,
LOCAL_PYTHON_TOOL,
PYTHON_TOOL,
)
from astrbot.core.cron.events import CronMessageEvent
from astrbot.core.message.components import Image
Expand All @@ -36,6 +30,17 @@
from astrbot.core.platform.message_session import MessageSession
from astrbot.core.provider.entites import ProviderRequest
from astrbot.core.provider.register import llm_tools
from astrbot.core.tools.computer_tools import (
ExecuteShellTool,
FileDownloadTool,
FileEditTool,
FileReadTool,
FileUploadTool,
FileWriteTool,
GrepTool,
LocalPythonTool,
PythonTool,
)
from astrbot.core.tools.message_tools import SendMessageToUserTool
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
from astrbot.core.utils.history_saver import persist_agent_history
Expand Down Expand Up @@ -177,18 +182,44 @@ async def _run_in_background() -> None:
return

@classmethod
def _get_runtime_computer_tools(cls, runtime: str) -> dict[str, FunctionTool]:
def _get_runtime_computer_tools(
cls,
runtime: str,
tool_mgr,
) -> dict[str, FunctionTool]:
if runtime == "sandbox":
shell_tool = tool_mgr.get_builtin_tool(ExecuteShellTool)
python_tool = tool_mgr.get_builtin_tool(PythonTool)
upload_tool = tool_mgr.get_builtin_tool(FileUploadTool)
download_tool = tool_mgr.get_builtin_tool(FileDownloadTool)
read_tool = tool_mgr.get_builtin_tool(FileReadTool)
write_tool = tool_mgr.get_builtin_tool(FileWriteTool)
edit_tool = tool_mgr.get_builtin_tool(FileEditTool)
grep_tool = tool_mgr.get_builtin_tool(GrepTool)
return {
EXECUTE_SHELL_TOOL.name: EXECUTE_SHELL_TOOL,
PYTHON_TOOL.name: PYTHON_TOOL,
FILE_UPLOAD_TOOL.name: FILE_UPLOAD_TOOL,
FILE_DOWNLOAD_TOOL.name: FILE_DOWNLOAD_TOOL,
shell_tool.name: shell_tool,
python_tool.name: python_tool,
upload_tool.name: upload_tool,
download_tool.name: download_tool,
read_tool.name: read_tool,
write_tool.name: write_tool,
edit_tool.name: edit_tool,
grep_tool.name: grep_tool,
}
if runtime == "local":
shell_tool = tool_mgr.get_builtin_tool(ExecuteShellTool)
python_tool = tool_mgr.get_builtin_tool(LocalPythonTool)
read_tool = tool_mgr.get_builtin_tool(FileReadTool)
write_tool = tool_mgr.get_builtin_tool(FileWriteTool)
edit_tool = tool_mgr.get_builtin_tool(FileEditTool)
grep_tool = tool_mgr.get_builtin_tool(GrepTool)
return {
LOCAL_EXECUTE_SHELL_TOOL.name: LOCAL_EXECUTE_SHELL_TOOL,
LOCAL_PYTHON_TOOL.name: LOCAL_PYTHON_TOOL,
shell_tool.name: shell_tool,
python_tool.name: python_tool,
read_tool.name: read_tool,
write_tool.name: write_tool,
edit_tool.name: edit_tool,
grep_tool.name: grep_tool,
}
return {}

Expand All @@ -203,7 +234,15 @@ def _build_handoff_toolset(
cfg = ctx.get_config(umo=event.unified_msg_origin)
provider_settings = cfg.get("provider_settings", {})
runtime = str(provider_settings.get("computer_use_runtime", "local"))
runtime_computer_tools = cls._get_runtime_computer_tools(runtime)
tool_mgr = (
ctx.get_llm_tool_manager()
if hasattr(ctx, "get_llm_tool_manager")
else llm_tools
)
runtime_computer_tools = cls._get_runtime_computer_tools(
runtime,
tool_mgr,
)

# Keep persona semantics aligned with the main agent: tools=None means
# "all tools", including runtime computer-use tools.
Expand Down
Loading
Loading