mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-15 17:30:13 +08:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user