Skip to content

Commit 2472a12

Browse files
authored
feat: filesystem grep, read, write, edit file and workspace support (#7402)
* feat: filesystem grep, read, edit file * feat: add file write tool and enhance file read functionality * feat: enhance tool prompt formatting and add escaped text decoding for file editing * feat: remove redundant safe path tests from security restrictions * feat: implement file read tool with support for text and image files, including validation for large files * feat: add file read utilities and integrate with filesystem tools * refactor: move computer tools to builtin tools registry * refactor: remove unused plugin_context parameter from _apply_sandbox_tools * feat: supports to display enabled builtin tools in configs * feat: add tooltip for disabled builtin tools and update localization strings * feat: add workspace extra prompt handling in message processing * feat: add ripgrep installation to Dockerfile * perf: shell executed in workspace dir in local env * feat: enhance file reading capabilities to support PDF and DOCX parsing, including workspace storage for long documents * feat: update converted text notice to suggest using grep for large files * feat: implement handling for large tool results with overflow file writing and read tool integration * fix: test * feat: enhance onboarding steps to include computer access configuration and related help information * feat: add support for additional temporary path in restricted environment checks * feat: update computer access hints and add detailed configuration instructions
1 parent b8ccfe3 commit 2472a12

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

59 files changed

+3989
-699
lines changed

Dockerfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
1616
curl \
1717
gnupg \
1818
git \
19+
ripgrep \
1920
&& curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - \
2021
&& apt-get install -y --no-install-recommends nodejs \
2122
&& apt-get clean \

astrbot/core/agent/runners/tool_loop_agent_runner.py

Lines changed: 120 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44
import time
55
import traceback
66
import typing as T
7+
import uuid
78
from collections.abc import AsyncIterator
89
from contextlib import suppress
910
from dataclasses import dataclass, field
11+
from pathlib import Path
1012

1113
from mcp.types import (
1214
BlobResourceContents,
@@ -25,7 +27,7 @@
2527

2628
from astrbot import logger
2729
from astrbot.core.agent.message import ImageURLPart, TextPart, ThinkPart
28-
from astrbot.core.agent.tool import ToolSet
30+
from astrbot.core.agent.tool import FunctionTool, ToolSet
2931
from astrbot.core.agent.tool_image_cache import tool_image_cache
3032
from astrbot.core.exceptions import EmptyModelOutputError
3133
from astrbot.core.message.components import Json
@@ -45,7 +47,7 @@
4547
from ..context.compressor import ContextCompressor
4648
from ..context.config import ContextConfig
4749
from ..context.manager import ContextManager
48-
from ..context.token_counter import TokenCounter
50+
from ..context.token_counter import EstimateTokenCounter, TokenCounter
4951
from ..hooks import BaseAgentRunHooks
5052
from ..message import AssistantMessageSegment, Message, ToolCallMessageSegment
5153
from ..response import AgentResponseData, AgentStats
@@ -97,6 +99,8 @@ class _ToolExecutionInterrupted(Exception):
9799

98100

99101
class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
102+
TOOL_RESULT_MAX_ESTIMATED_TOKENS = 27_500
103+
TOOL_RESULT_PREVIEW_MAX_ESTIMATED_TOKENS = 7000
100104
EMPTY_OUTPUT_RETRY_ATTEMPTS = 3
101105
EMPTY_OUTPUT_RETRY_WAIT_MIN_S = 1
102106
EMPTY_OUTPUT_RETRY_WAIT_MAX_S = 4
@@ -151,6 +155,12 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
151155
"Otherwise, change strategy, adjust arguments, or explain the limitation "
152156
"to the user."
153157
)
158+
TOOL_RESULT_OVERFLOW_NOTICE_TEMPLATE = (
159+
"Truncated tool output preview shown above. "
160+
"The tool output was too large to include directly and was written to "
161+
"`{overflow_path}`. Use {read_tool_hint} to inspect it. "
162+
"Use a narrower window when reading large files."
163+
)
154164

155165
def _get_persona_custom_error_message(self) -> str | None:
156166
"""Read persona-level custom error message from event extras when available."""
@@ -206,6 +216,8 @@ async def reset(
206216
custom_compressor: ContextCompressor | None = None,
207217
tool_schema_mode: str | None = "full",
208218
fallback_providers: list[Provider] | None = None,
219+
tool_result_overflow_dir: str | None = None,
220+
read_tool: FunctionTool | None = None,
209221
**kwargs: T.Any,
210222
) -> None:
211223
self.req = request
@@ -217,6 +229,9 @@ async def reset(
217229
self.truncate_turns = truncate_turns
218230
self.custom_token_counter = custom_token_counter
219231
self.custom_compressor = custom_compressor
232+
self.tool_result_overflow_dir = tool_result_overflow_dir
233+
self.read_tool = read_tool
234+
self._tool_result_token_counter = EstimateTokenCounter()
220235
# we will do compress when:
221236
# 1. before requesting LLM
222237
# TODO: 2. after LLM output a tool call
@@ -298,6 +313,103 @@ async def reset(
298313
self.stats = AgentStats()
299314
self.stats.start_time = time.time()
300315

316+
def _read_tool_hint(self) -> str:
317+
if self.read_tool is not None:
318+
return f"`{self.read_tool.name}`"
319+
return "the available file-read tool"
320+
321+
async def _write_tool_result_overflow_file(
322+
self,
323+
*,
324+
tool_call_id: str,
325+
content: str,
326+
) -> str:
327+
if self.tool_result_overflow_dir is None:
328+
raise ValueError("tool_result_overflow_dir is not configured")
329+
330+
overflow_dir = Path(self.tool_result_overflow_dir).resolve(strict=False)
331+
safe_tool_call_id = (
332+
"".join(
333+
ch if ch.isalnum() or ch in {"-", "_", "."} else "_"
334+
for ch in tool_call_id
335+
).strip("._")
336+
or "tool_call"
337+
)
338+
file_name = f"{safe_tool_call_id}_{uuid.uuid4().hex[:8]}.txt"
339+
overflow_path = overflow_dir / file_name
340+
341+
def _run() -> str:
342+
overflow_dir.mkdir(parents=True, exist_ok=True)
343+
overflow_path.write_text(content, encoding="utf-8")
344+
return str(overflow_path)
345+
346+
return await asyncio.to_thread(_run)
347+
348+
async def _materialize_large_tool_result(
349+
self,
350+
*,
351+
tool_call_id: str,
352+
content: str,
353+
) -> str:
354+
if self.tool_result_overflow_dir is None or self.read_tool is None:
355+
return content
356+
357+
estimated_tokens = self._tool_result_token_counter.count_tokens(
358+
[Message(role="tool", content=content, tool_call_id=tool_call_id)]
359+
)
360+
if estimated_tokens <= self.TOOL_RESULT_MAX_ESTIMATED_TOKENS:
361+
return content
362+
363+
preview = self._truncate_tool_result_preview(content, tool_call_id=tool_call_id)
364+
try:
365+
overflow_path = await self._write_tool_result_overflow_file(
366+
tool_call_id=tool_call_id,
367+
content=content,
368+
)
369+
except Exception as exc:
370+
logger.warning(
371+
"Failed to spill oversized tool result for %s: %s",
372+
tool_call_id,
373+
exc,
374+
exc_info=True,
375+
)
376+
error_notice = (
377+
"Tool output exceeded the inline result limit "
378+
f"({estimated_tokens} estimated tokens > "
379+
f"{self.TOOL_RESULT_MAX_ESTIMATED_TOKENS}) and could not be written "
380+
f"to `{self.tool_result_overflow_dir}`: {exc}"
381+
)
382+
if not preview:
383+
return error_notice
384+
return f"{preview}\n\n{error_notice}"
385+
386+
notice = self.TOOL_RESULT_OVERFLOW_NOTICE_TEMPLATE.format(
387+
overflow_path=overflow_path,
388+
read_tool_hint=self._read_tool_hint(),
389+
)
390+
if not preview:
391+
return notice
392+
return f"{preview}\n\n{notice}"
393+
394+
def _truncate_tool_result_preview(
395+
self,
396+
content: str,
397+
*,
398+
tool_call_id: str,
399+
) -> str:
400+
preview = content
401+
while preview:
402+
estimated_tokens = self._tool_result_token_counter.count_tokens(
403+
[Message(role="tool", content=preview, tool_call_id=tool_call_id)]
404+
)
405+
if estimated_tokens <= self.TOOL_RESULT_PREVIEW_MAX_ESTIMATED_TOKENS:
406+
return preview
407+
next_len = len(preview) // 2
408+
if next_len <= 0:
409+
break
410+
preview = preview[:next_len]
411+
return preview
412+
301413
async def _iter_llm_responses(
302414
self, *, include_model: bool = True
303415
) -> T.AsyncGenerator[LLMResponse, None]:
@@ -933,9 +1045,14 @@ def _append_tool_call_result(tool_call_id: str, content: str) -> None:
9331045
"The tool has returned a data type that is not supported."
9341046
)
9351047
if result_parts:
1048+
inline_result = "\n\n".join(result_parts)
1049+
inline_result = await self._materialize_large_tool_result(
1050+
tool_call_id=func_tool_id,
1051+
content=inline_result,
1052+
)
9361053
_append_tool_call_result(
9371054
func_tool_id,
938-
"\n\n".join(result_parts)
1055+
inline_result
9391056
+ self._build_repeated_tool_call_guidance(
9401057
func_tool_name, tool_call_streak
9411058
),

astrbot/core/astr_agent_tool_exec.py

Lines changed: 53 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,6 @@
1919
from astrbot.core.astr_agent_context import AstrAgentContext
2020
from astrbot.core.astr_main_agent_resources import (
2121
BACKGROUND_TASK_RESULT_WOKE_SYSTEM_PROMPT,
22-
EXECUTE_SHELL_TOOL,
23-
FILE_DOWNLOAD_TOOL,
24-
FILE_UPLOAD_TOOL,
25-
LOCAL_EXECUTE_SHELL_TOOL,
26-
LOCAL_PYTHON_TOOL,
27-
PYTHON_TOOL,
2822
)
2923
from astrbot.core.cron.events import CronMessageEvent
3024
from astrbot.core.message.components import Image
@@ -36,6 +30,17 @@
3630
from astrbot.core.platform.message_session import MessageSession
3731
from astrbot.core.provider.entites import ProviderRequest
3832
from astrbot.core.provider.register import llm_tools
33+
from astrbot.core.tools.computer_tools import (
34+
ExecuteShellTool,
35+
FileDownloadTool,
36+
FileEditTool,
37+
FileReadTool,
38+
FileUploadTool,
39+
FileWriteTool,
40+
GrepTool,
41+
LocalPythonTool,
42+
PythonTool,
43+
)
3944
from astrbot.core.tools.message_tools import SendMessageToUserTool
4045
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
4146
from astrbot.core.utils.history_saver import persist_agent_history
@@ -177,18 +182,44 @@ async def _run_in_background() -> None:
177182
return
178183

179184
@classmethod
180-
def _get_runtime_computer_tools(cls, runtime: str) -> dict[str, FunctionTool]:
185+
def _get_runtime_computer_tools(
186+
cls,
187+
runtime: str,
188+
tool_mgr,
189+
) -> dict[str, FunctionTool]:
181190
if runtime == "sandbox":
191+
shell_tool = tool_mgr.get_builtin_tool(ExecuteShellTool)
192+
python_tool = tool_mgr.get_builtin_tool(PythonTool)
193+
upload_tool = tool_mgr.get_builtin_tool(FileUploadTool)
194+
download_tool = tool_mgr.get_builtin_tool(FileDownloadTool)
195+
read_tool = tool_mgr.get_builtin_tool(FileReadTool)
196+
write_tool = tool_mgr.get_builtin_tool(FileWriteTool)
197+
edit_tool = tool_mgr.get_builtin_tool(FileEditTool)
198+
grep_tool = tool_mgr.get_builtin_tool(GrepTool)
182199
return {
183-
EXECUTE_SHELL_TOOL.name: EXECUTE_SHELL_TOOL,
184-
PYTHON_TOOL.name: PYTHON_TOOL,
185-
FILE_UPLOAD_TOOL.name: FILE_UPLOAD_TOOL,
186-
FILE_DOWNLOAD_TOOL.name: FILE_DOWNLOAD_TOOL,
200+
shell_tool.name: shell_tool,
201+
python_tool.name: python_tool,
202+
upload_tool.name: upload_tool,
203+
download_tool.name: download_tool,
204+
read_tool.name: read_tool,
205+
write_tool.name: write_tool,
206+
edit_tool.name: edit_tool,
207+
grep_tool.name: grep_tool,
187208
}
188209
if runtime == "local":
210+
shell_tool = tool_mgr.get_builtin_tool(ExecuteShellTool)
211+
python_tool = tool_mgr.get_builtin_tool(LocalPythonTool)
212+
read_tool = tool_mgr.get_builtin_tool(FileReadTool)
213+
write_tool = tool_mgr.get_builtin_tool(FileWriteTool)
214+
edit_tool = tool_mgr.get_builtin_tool(FileEditTool)
215+
grep_tool = tool_mgr.get_builtin_tool(GrepTool)
189216
return {
190-
LOCAL_EXECUTE_SHELL_TOOL.name: LOCAL_EXECUTE_SHELL_TOOL,
191-
LOCAL_PYTHON_TOOL.name: LOCAL_PYTHON_TOOL,
217+
shell_tool.name: shell_tool,
218+
python_tool.name: python_tool,
219+
read_tool.name: read_tool,
220+
write_tool.name: write_tool,
221+
edit_tool.name: edit_tool,
222+
grep_tool.name: grep_tool,
192223
}
193224
return {}
194225

@@ -203,7 +234,15 @@ def _build_handoff_toolset(
203234
cfg = ctx.get_config(umo=event.unified_msg_origin)
204235
provider_settings = cfg.get("provider_settings", {})
205236
runtime = str(provider_settings.get("computer_use_runtime", "local"))
206-
runtime_computer_tools = cls._get_runtime_computer_tools(runtime)
237+
tool_mgr = (
238+
ctx.get_llm_tool_manager()
239+
if hasattr(ctx, "get_llm_tool_manager")
240+
else llm_tools
241+
)
242+
runtime_computer_tools = cls._get_runtime_computer_tools(
243+
runtime,
244+
tool_mgr,
245+
)
207246

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

0 commit comments

Comments
 (0)