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
This commit is contained in:
Soulter
2026-04-11 17:01:54 +08:00
committed by GitHub
parent b8ccfe3f64
commit 2472a12671
59 changed files with 3995 additions and 705 deletions

View File

@@ -1,6 +1,7 @@
import asyncio
import os
import sys
from pathlib import Path
from types import SimpleNamespace
from typing import Any, cast
from unittest.mock import AsyncMock
@@ -97,6 +98,26 @@ class MockToolExecutor:
return generator()
class LargeTextToolExecutor:
"""模拟返回超长文本的工具执行器"""
def __init__(self, text: str):
self.text = text
@classmethod
def from_text(cls, text: str) -> "LargeTextToolExecutor":
return cls(text)
def execute(self, tool, run_context, **tool_args):
async def generator():
from mcp.types import CallToolResult, TextContent
result = CallToolResult(content=[TextContent(type="text", text=self.text)])
yield result
return generator()
class MockMixedContentToolExecutor:
"""模拟返回图片 + 文本的工具执行器"""
@@ -193,6 +214,32 @@ class MockToolCallProvider(MockProvider):
)
class SingleToolThenFinalProvider(MockProvider):
def __init__(self, tool_name: str, tool_args: dict[str, str] | None = None):
super().__init__()
self.tool_name = tool_name
self.tool_args = tool_args or {}
async def text_chat(self, **kwargs) -> LLMResponse:
self.call_count += 1
func_tool = kwargs.get("func_tool")
if func_tool is None or self.call_count > 1:
return LLMResponse(
role="assistant",
completion_text="最终回复",
usage=TokenUsage(input_other=10, output=5),
)
return LLMResponse(
role="assistant",
completion_text="",
tools_call_name=[self.tool_name],
tools_call_args=[self.tool_args],
tools_call_ids=["call_large_result"],
usage=TokenUsage(input_other=10, output=5),
)
class SequentialToolProvider(MockProvider):
def __init__(self, tool_sequence: list[str]):
super().__init__()
@@ -334,6 +381,10 @@ def runner():
return ToolLoopAgentRunner()
def _make_large_tool_result_text() -> str:
return "x" * 100000
@pytest.mark.asyncio
async def test_max_step_limit_functionality(
runner, mock_provider, provider_request, mock_tool_executor, mock_hooks
@@ -1124,18 +1175,116 @@ async def test_follow_up_accepted_when_active_and_not_stopping(
streaming=False,
)
# Runner is active (not done) and stop is not requested
assert not runner.done()
assert runner._is_stop_requested() is False
ticket = runner.follow_up(message_text="valid follow-up message")
assert ticket is not None, (
"Follow-up should be accepted when runner is active and not stopping"
@pytest.mark.asyncio
async def test_large_tool_result_is_spilled_to_file_and_replaced_with_read_notice(
tmp_path,
):
tool = FunctionTool(
name="test_tool",
description="测试工具",
parameters={"type": "object", "properties": {"query": {"type": "string"}}},
handler=AsyncMock(),
)
assert ticket.text == "valid follow-up message"
assert ticket.consumed is False
assert ticket in runner._pending_follow_ups
read_tool = FunctionTool(
name="astrbot_file_read_tool",
description="read file",
parameters={"type": "object", "properties": {"path": {"type": "string"}}},
handler=AsyncMock(),
)
tool_set = ToolSet(tools=[tool, read_tool])
provider = SingleToolThenFinalProvider(tool.name, {"query": "large"})
request = ProviderRequest(prompt="run tool", func_tool=tool_set, contexts=[])
runner = ToolLoopAgentRunner()
await runner.reset(
provider=provider,
request=request,
run_context=ContextWrapper(context=None),
tool_executor=cast(
Any,
LargeTextToolExecutor.from_text(_make_large_tool_result_text()),
),
agent_hooks=MockHooks(),
streaming=False,
tool_result_overflow_dir=str(tmp_path),
read_tool=read_tool,
)
responses = []
async for response in runner.step_until_done(3):
responses.append(response)
tool_messages = [m for m in runner.run_context.messages if m.role == "tool"]
assert len(tool_messages) == 1
tool_message_content = str(tool_messages[0].content)
assert "xxxxxxxxxx" in tool_message_content
assert "Truncated tool output preview shown above." in tool_message_content
assert "The tool output was too large to include directly" in tool_message_content
assert "`astrbot_file_read_tool`" in tool_message_content
assert "Use `astrbot_file_read_tool` to inspect it." in tool_message_content
overflow_files = list(Path(tmp_path).glob("call_large_result_*.txt"))
assert len(overflow_files) == 1
assert (
overflow_files[0].read_text(encoding="utf-8") == _make_large_tool_result_text()
)
assert str(overflow_files[0]) in tool_message_content
llm_results = [resp for resp in responses if resp.type == "llm_result"]
assert llm_results
@pytest.mark.asyncio
async def test_large_tool_result_keeps_preview_when_spill_fails(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
):
tool = FunctionTool(
name="test_tool",
description="测试工具",
parameters={"type": "object", "properties": {"query": {"type": "string"}}},
handler=AsyncMock(),
)
read_tool = FunctionTool(
name="astrbot_file_read_tool",
description="read file",
parameters={"type": "object", "properties": {"path": {"type": "string"}}},
handler=AsyncMock(),
)
tool_set = ToolSet(tools=[tool, read_tool])
provider = SingleToolThenFinalProvider(tool.name, {"query": "large"})
request = ProviderRequest(prompt="run tool", func_tool=tool_set, contexts=[])
runner = ToolLoopAgentRunner()
async def _raise_spill_error(*, tool_call_id: str, content: str) -> str:
raise OSError("disk full")
monkeypatch.setattr(runner, "_write_tool_result_overflow_file", _raise_spill_error)
await runner.reset(
provider=provider,
request=request,
run_context=ContextWrapper(context=None),
tool_executor=cast(
Any,
LargeTextToolExecutor.from_text(_make_large_tool_result_text()),
),
agent_hooks=MockHooks(),
streaming=False,
tool_result_overflow_dir=str(tmp_path),
read_tool=read_tool,
)
async for _ in runner.step_until_done(3):
pass
tool_messages = [m for m in runner.run_context.messages if m.role == "tool"]
assert len(tool_messages) == 1
tool_message_content = str(tool_messages[0].content)
assert "xxxxxxxxxx" in tool_message_content
assert "Tool output exceeded the inline result limit" in tool_message_content
assert "disk full" in tool_message_content
@pytest.mark.asyncio