feat(agent): expand tool output and search pagination

This commit is contained in:
jxxghp
2026-08-06 22:36:05 +08:00
parent cf6c73d85b
commit 83409c1439
11 changed files with 330 additions and 63 deletions

View File

@@ -27,8 +27,10 @@ from app.agent.middleware.utils import append_to_system_message
from app.agent.tools.tags import ToolTag
from app.log import logger
# 安全提示: SKILL.md 文件最大限制为 10MB防止 DoS 攻击
MAX_SKILL_FILE_SIZE = 10 * 1024 * 1024
# 磁盘读取上限与模型返回上限分离,避免异常大的 Skill 文件撑爆内存或上下文。
MAX_SKILL_FILE_SIZE = 1 * 1024 * 1024
MAX_SKILL_RESULT_CHARS = 64 * 1024
SKILL_CONTENT_TRUNCATION_SUFFIX = "\n...(Skill 内容已截断)"
# Agent Skills 规范约束 (https://agentskills.io/specification)
MAX_SKILL_NAME_LENGTH = 64
@@ -248,7 +250,17 @@ async def _alist_skills(source_path: AsyncPath) -> list[SkillMetadata]:
for skill_path in skill_dirs:
skill_md_path = skill_path / "SKILL.md"
skill_content = await skill_md_path.read_text(encoding="utf-8", errors="replace")
stat = await skill_md_path.stat()
if stat.st_size > MAX_SKILL_FILE_SIZE:
logger.warning(
"Skipping %s: file too large (%d bytes)",
skill_md_path,
stat.st_size,
)
continue
skill_content = (await skill_md_path.read_bytes()).decode(
"utf-8", errors="replace"
)
# 解析元数据
skill_metadata = _parse_skill_metadata(
@@ -280,7 +292,16 @@ def _list_skills(source_path: Path) -> list[SkillMetadata]:
skills: list[SkillMetadata] = []
for skill_path in skill_dirs:
skill_md_path = skill_path / "SKILL.md"
skill_content = skill_md_path.read_text(encoding="utf-8", errors="replace")
if skill_md_path.stat().st_size > MAX_SKILL_FILE_SIZE:
logger.warning(
"Skipping %s: file too large (%d bytes)",
skill_md_path,
skill_md_path.stat().st_size,
)
continue
skill_content = skill_md_path.read_bytes().decode(
"utf-8", errors="replace"
)
skill_metadata = _parse_skill_metadata(
content=skill_content,
skill_path=str(skill_md_path),
@@ -456,6 +477,46 @@ class _SkillToolProvider:
raw_content = await handle.read(MAX_SKILL_FILE_SIZE)
return raw_content.decode("utf-8", errors="replace"), truncated
@staticmethod
def _serialize_skill_payload(payload: dict[str, Any]) -> str:
"""序列化 Skill 返回值,并严格限制最终进入模型的字符数。"""
serialized = json.dumps(payload, ensure_ascii=False, indent=2)
if len(serialized) <= MAX_SKILL_RESULT_CHARS:
return serialized
original_content = str(payload.get("content") or "")
truncated_payload = dict(payload)
truncated_payload["truncated"] = True
low = 0
high = len(original_content)
best_result = json.dumps(
{
**truncated_payload,
"content": SKILL_CONTENT_TRUNCATION_SUFFIX.strip(),
},
ensure_ascii=False,
indent=2,
)
while low <= high:
middle = (low + high) // 2
candidate = json.dumps(
{
**truncated_payload,
"content": (
original_content[:middle]
+ SKILL_CONTENT_TRUNCATION_SUFFIX
),
},
ensure_ascii=False,
indent=2,
)
if len(candidate) <= MAX_SKILL_RESULT_CHARS:
best_result = candidate
low = middle + 1
else:
high = middle - 1
return best_result
async def load_skill(self, name: str) -> str:
"""加载指定 Skill 的完整说明并返回 JSON 字符串。"""
logger.info(f"加载 Skill: name={name}")
@@ -471,7 +532,7 @@ class _SkillToolProvider:
)
content, truncated = await self._read_skill_content(skill["path"])
return json.dumps(
return self._serialize_skill_payload(
{
"success": True,
"skill": {
@@ -483,9 +544,7 @@ class _SkillToolProvider:
},
"content": content,
"truncated": truncated,
},
ensure_ascii=False,
indent=2,
}
)
except Exception as err:
logger.error(f"加载 Skill 失败: {err}", exc_info=True)

View File

@@ -65,7 +65,7 @@ You act as a proactive agent. Your goal is to fully resolve the user's media-rel
- If `search_media` fails, fall back to `search_web` or `recognize_media`. Only ask the user when automated paths are exhausted.
- If torrent search yields no useful result, check site scope, site health, and recognition quality before concluding that the resource is unavailable.
- Reuse the latest torrent search cache for `get_search_results` and `add_download_tasks` instead of re-running the same search unnecessarily.
- For administrator code discovery across local files, use `execute_command(action="run")` with `rg` and narrow globs or paths. Use `list_directory` to inspect one known directory or a supported remote storage backend, and use `read_file` when the exact local file is known.
- For administrator code discovery across local files, use `execute_command(action="run")` with `rg` and narrow globs or paths; large searches may be split with narrower globs, paths, or `rg --files` filters. Use `list_directory` to inspect one known directory or a supported remote storage backend; request its `limit`/`offset` page fields when more than the first page is needed, and use `read_file` when the exact local file is known.
- Read the relevant file before changing it. Use `edit_file` for localized exact replacements; make `old_text` unique with enough surrounding context, and use `replace_all=true` only when every match must change. Use `write_file` for new files; set `overwrite=true` only for an intentional full rewrite, and use `read_file(include_metadata=true)` plus `expected_sha256` when preserving the previously read version matters.
- When implementation depends on a Python or Node.js API, first identify the installed or locked dependency version from environment metadata, requirements, package manifests, lockfiles, local source, and type declarations. Use `rg` against the relevant package directory, `.venv`, or `node_modules` instead of scanning the entire project without bounds. If local evidence is insufficient, use `search_web` and then `browse_webpage` to read the matching version of the official documentation. Do not guess signatures from memory, mix examples from incompatible versions, or install a package only to inspect its API.
- Use structured file tools for source edits because they enforce file access boundaries and conflict checks. Never use shell redirection, inline scripts, or another tool to bypass a file-tool permission denial.

View File

@@ -28,7 +28,6 @@ class ToolChain(ChainBase):
# 单个工具结果的兜底上限。各工具仍应优先在自身逻辑中分页或摘要化;
# 这里用于拦截遗漏路径,避免超大结果直接进入模型上下文。
DEFAULT_TOOL_RESULT_MAX_CHARS = 64 * 1024
MIN_TOOL_RESULT_PREVIEW_CHARS = 512
def serialize_tool_result_for_agent(result: Any) -> str:
@@ -59,20 +58,35 @@ def format_tool_result_for_agent(
if not max_chars or max_chars <= 0 or len(formatted_result) <= max_chars:
return formatted_result
preview_limit = max(MIN_TOOL_RESULT_PREVIEW_CHARS, max_chars)
preview = formatted_result[:preview_limit]
payload = {
"tool_result_truncated": True,
"tool_name": tool_name,
"total_chars": len(formatted_result),
"returned_chars": len(preview),
"content_preview": preview,
"message": (
f"工具返回内容超过 {max_chars} 字符,已截断为预览;"
"请使用更精确的筛选条件、分页参数或专用查询参数继续获取。"
),
}
return json.dumps(payload, ensure_ascii=False, indent=2)
def _dump_preview(preview: str) -> str:
"""序列化截断结果,并让 returned_chars 与实际预览保持一致。"""
payload = {
"tool_result_truncated": True,
"tool_name": tool_name,
"total_chars": len(formatted_result),
"returned_chars": len(preview),
"content_preview": preview,
"message": (
f"工具返回内容超过 {max_chars} 字符,已截断为预览;"
"请使用更精确的筛选条件、分页参数或专用查询参数继续获取。"
),
}
return json.dumps(payload, ensure_ascii=False, indent=2)
# JSON 会转义换行、引号和反斜杠,预览本身等于上限时,最终返回值仍可能
# 明显超限。通过二分查找预留包装开销,确保进入模型的最终字符串是硬上限。
low = 0
high = min(len(formatted_result), max_chars)
best_result = _dump_preview("")
while low <= high:
middle = (low + high) // 2
candidate = _dump_preview(formatted_result[:middle])
if len(candidate) <= max_chars:
best_result = candidate
low = middle + 1
else:
high = middle - 1
return best_result
# 将常见的阻塞调用按能力域拆分到独立线程池,避免外部慢 IO 抢占同一批 worker。

View File

@@ -12,8 +12,8 @@ from app.agent.tools.tags import ToolTag
from app.helper.browser import BrowserSessionHelper
from app.log import logger
# 页面内容最大长度
MAX_CONTENT_LENGTH = 8000
# 页面内容最大长度;保留在全局工具结果兜底上限以内。
MAX_CONTENT_LENGTH = 12_000
# 默认超时时间(秒)
DEFAULT_TIMEOUT = 30
# 截图最大宽度

View File

@@ -7,6 +7,7 @@ import json
import os
import signal
import subprocess
from collections import deque
from dataclasses import dataclass, field
from tempfile import NamedTemporaryFile
from typing import Any, Literal, Optional, TextIO, Type
@@ -27,7 +28,9 @@ from app.log import logger
DEFAULT_TIMEOUT_SECONDS = 60
MAX_TIMEOUT_SECONDS = 300
MAX_OUTPUT_PREVIEW_BYTES = 10 * 1024
MAX_OUTPUT_PREVIEW_BYTES = 32 * 1024
MAX_OUTPUT_HEAD_BYTES = 16 * 1024
MAX_OUTPUT_TAIL_BYTES = 16 * 1024
READ_CHUNK_SIZE = 4096
KILL_GRACE_SECONDS = 3
COMMAND_CONCURRENCY_LIMIT = 2
@@ -36,11 +39,13 @@ _command_semaphore = asyncio.Semaphore(COMMAND_CONCURRENCY_LIMIT)
@dataclass
class _CommandOutput:
"""保存前 10KB 预览,并在超限时将完整输出写入临时文件。"""
"""保存命令头尾预览,并在超限时将完整输出写入临时文件。"""
preview_limit_bytes: int
preview_entries: list[tuple[str, str]] = field(default_factory=list)
tail_entries: deque[tuple[str, str]] = field(default_factory=deque)
captured_bytes: int = 0
tail_bytes: int = 0
preview_truncated: bool = False
temp_file_path: Optional[str] = None
temp_file_handle: Optional[TextIO] = None
@@ -93,10 +98,12 @@ class _CommandOutput:
self.temp_file_handle = None
def append(self, stream_name: str, text: str) -> None:
"""追加一段输出,超出预览上限后保留完整日志文件。"""
"""追加一段输出,超出预览上限后保留头尾预览和完整日志文件。"""
if not text:
return
self._append_tail(stream_name, text)
if self.temp_file_handle:
self._write_chunk(stream_name, text)
return
@@ -117,6 +124,60 @@ class _CommandOutput:
self.preview_entries.append((stream_name, preview))
self.captured_bytes += len(preview.encode("utf-8"))
def _append_tail(self, stream_name: str, text: str) -> None:
"""维护固定字节大小的尾部输出,方便定位测试和构建失败信息。"""
self.tail_entries.append((stream_name, text))
self.tail_bytes += len(text.encode("utf-8"))
while self.tail_bytes > MAX_OUTPUT_TAIL_BYTES and self.tail_entries:
old_stream, old_text = self.tail_entries.popleft()
old_bytes = len(old_text.encode("utf-8"))
overflow = self.tail_bytes - MAX_OUTPUT_TAIL_BYTES
if old_bytes <= overflow:
self.tail_bytes -= old_bytes
continue
kept_text = old_text.encode("utf-8")[overflow:].decode(
"utf-8", errors="ignore"
)
kept_bytes = len(kept_text.encode("utf-8"))
self.tail_bytes -= old_bytes
if kept_text:
self.tail_entries.appendleft((old_stream, kept_text))
self.tail_bytes += kept_bytes
@staticmethod
def _format_entries(entries: list[tuple[str, str]]) -> str:
"""按 stdout/stderr 切换插入可读的输出分段标题。"""
parts: list[str] = []
last_stream: Optional[str] = None
for stream_name, text in entries:
if stream_name != last_stream:
title = "标准输出" if stream_name == "stdout" else "错误输出"
parts.append(f"\n[{title}]\n")
last_stream = stream_name
parts.append(text)
return "".join(parts).strip()
@property
def combined_preview(self) -> str:
"""返回完整输出或头尾组合预览。"""
if not self.preview_truncated:
return self._format_entries(self.preview_entries)
head_entries: list[tuple[str, str]] = []
remaining = MAX_OUTPUT_HEAD_BYTES
for stream_name, text in self.preview_entries:
if remaining <= 0:
break
clipped = self._clip_text_to_bytes(text, remaining)
if clipped:
head_entries.append((stream_name, clipped))
remaining -= len(clipped.encode("utf-8"))
head = self._format_entries(head_entries)
tail = self._format_entries(list(self.tail_entries))
return (
f"{head}\n\n...(中间输出已省略,完整内容在临时文件中)...\n\n{tail}"
).strip()
@property
def stdout(self) -> str:
"""返回当前保留的 stdout 预览。"""
@@ -295,7 +356,7 @@ class ExecuteCommandTool(MoviePilotTool):
stream_name: str,
output: _CommandOutput,
) -> None:
"""按块读取一次性命令输出,只把前 10KB 保留在返回结果中"""
"""按块读取一次性命令输出,保留 32KB 头尾预览"""
while True:
chunk = await stream.read(READ_CHUNK_SIZE)
if not chunk:
@@ -379,17 +440,16 @@ class ExecuteCommandTool(MoviePilotTool):
file_note = "截至命令终止前的完整输出" if timed_out else "完整输出"
result += (
"\n\n提示:\n"
f"命令输出超过 10KB仅返回前 {MAX_OUTPUT_PREVIEW_BYTES} 字节内容。\n"
f"命令输出超过 {MAX_OUTPUT_PREVIEW_BYTES // 1024}KB"
f"仅返回前后各 {MAX_OUTPUT_HEAD_BYTES // 1024}KB 预览。\n"
f"{file_note}已写入临时文件: {output.temp_file_path}\n"
"如需完整内容,请继续读取该文件。"
)
if output.stdout:
result += f"\n\n标准输出:\n{output.stdout}"
if output.stderr:
result += f"\n\n错误输出:\n{output.stderr}"
if output.combined_preview:
result += f"\n\n命令输出预览:\n{output.combined_preview}"
if output.preview_truncated:
result += "\n\n...(仅展示前 10KB 内容)"
if not output.stdout and not output.stderr:
result += "\n\n...(仅展示前后各 16KB 内容)"
if not output.combined_preview:
result += "\n\n(无输出内容)"
return result

View File

@@ -15,21 +15,47 @@ from app.schemas.file import FileItem
from app.utils.string import StringUtils
DEFAULT_DIRECTORY_PAGE_SIZE = 50
MAX_DIRECTORY_PAGE_SIZE = 200
class ListDirectoryInput(BaseModel):
"""查询文件系统目录内容工具的输入参数模型"""
path: str = Field(..., description="Directory path to list contents (e.g., '/home/user/downloads' or 'C:/Downloads')")
storage: Optional[str] = Field("local", description="Storage type (default: 'local' for local file system, can be 'smb', 'alist', etc.)")
sort_by: Optional[str] = Field("name", description="Sort order: 'name' for alphabetical sorting, 'time' for modification time sorting (default: 'name')")
limit: Optional[int] = Field(
DEFAULT_DIRECTORY_PAGE_SIZE,
ge=1,
le=MAX_DIRECTORY_PAGE_SIZE,
description=(
f"Maximum items to return in this page (default: {DEFAULT_DIRECTORY_PAGE_SIZE}, "
f"maximum: {MAX_DIRECTORY_PAGE_SIZE})"
),
)
offset: Optional[int] = Field(
0,
ge=0,
description="Number of sorted directory items to skip before this page",
)
class ListDirectoryTool(MoviePilotTool):
"""分页查询本地或远程存储目录中的文件和子目录。"""
name: str = "list_directory"
tags: list[str] = [
ToolTag.Read,
ToolTag.Directory,
ToolTag.File,
]
description: str = "List actual files and folders in a file system directory (NOT configuration). Shows files and subdirectories with their names, types, sizes, and modification times. Returns up to 20 items and the total count if there are more items. Use 'query_directory_settings' to query directory configuration settings."
description: str = (
"List actual files and folders in a file system directory (NOT configuration). "
"Shows files and subdirectories with their names, types, sizes, and modification "
f"times. Returns a page of up to {DEFAULT_DIRECTORY_PAGE_SIZE} items with total "
f"count and next offset; limit is capped at {MAX_DIRECTORY_PAGE_SIZE}. "
"Use 'query_directory_settings' to query directory configuration settings."
)
args_schema: Type[BaseModel] = ListDirectoryInput
def get_tool_message(self, **kwargs) -> Optional[str]:
@@ -45,10 +71,14 @@ class ListDirectoryTool(MoviePilotTool):
@staticmethod
def _list_directory_sync(
path: str, storage: Optional[str] = "local", sort_by: Optional[str] = "name"
path: str,
storage: Optional[str] = "local",
sort_by: Optional[str] = "name",
limit: Optional[int] = DEFAULT_DIRECTORY_PAGE_SIZE,
offset: Optional[int] = 0,
) -> str:
"""
目录遍历可能触发本地磁盘或远程存储请求,统一放到线程池中执行。
目录遍历可能触发本地磁盘或远程存储请求,统一放到线程池中执行并分页返回
"""
if not path:
return "错误:路径不能为空"
@@ -64,9 +94,6 @@ class ListDirectoryTool(MoviePilotTool):
if file_list is None:
return f"无法访问目录:{path},请检查路径是否正确或存储是否可用"
if not file_list:
return f"目录 {path} 为空"
if sort_by == "time":
file_list.sort(key=lambda x: x.modify_time or 0, reverse=True)
else:
@@ -78,7 +105,14 @@ class ListDirectoryTool(MoviePilotTool):
)
total_count = len(file_list)
limited_list = file_list[:20]
normalized_limit = max(
1,
min(int(limit or DEFAULT_DIRECTORY_PAGE_SIZE), MAX_DIRECTORY_PAGE_SIZE),
)
normalized_offset = max(0, int(offset or 0))
limited_list = file_list[
normalized_offset : normalized_offset + normalized_limit
]
simplified_items = []
for item in limited_list:
size_str = StringUtils.str_filesize(item.size) if item.size else None
@@ -102,16 +136,39 @@ class ListDirectoryTool(MoviePilotTool):
simplified["extension"] = item.extension
simplified_items.append(simplified)
result_json = json.dumps(simplified_items, ensure_ascii=False, indent=2)
if total_count > 20:
return (
f"注意:目录中共有 {total_count} 个项目,为节省上下文空间,仅显示前 20 个项目。\n\n"
f"{result_json}"
)
return result_json
returned_count = len(simplified_items)
has_more = normalized_offset + returned_count < total_count
return json.dumps(
{
"items": simplified_items,
"total_count": total_count,
"returned_count": returned_count,
"limit": normalized_limit,
"offset": normalized_offset,
"has_more": has_more,
"next_offset": (
normalized_offset + returned_count if has_more else None
),
},
ensure_ascii=False,
indent=2,
)
async def run(self, path: str, storage: Optional[str] = "local",
sort_by: Optional[str] = "name", **kwargs) -> str:
sort_by: Optional[str] = "name",
limit: Optional[int] = DEFAULT_DIRECTORY_PAGE_SIZE,
offset: Optional[int] = 0,
**kwargs) -> str:
"""
分页查询指定目录的文件和子目录。
:param path: 要查询的目录路径
:param storage: 存储类型,默认为本地存储
:param sort_by: 排序方式,支持名称或修改时间
:param limit: 当前页最大条数,最高不超过工具上限
:param offset: 当前页起始偏移量
:return: 包含项目列表和分页元数据的 JSON 字符串
"""
logger.info(f"执行工具: {self.name}, 参数: path={path}, storage={storage}, sort_by={sort_by}")
try:
@@ -123,7 +180,13 @@ class ListDirectoryTool(MoviePilotTool):
if resolved_path:
path = str(resolved_path)
return await self.run_blocking(
"storage", self._list_directory_sync, path, storage, sort_by
"storage",
self._list_directory_sync,
path,
storage,
sort_by,
limit,
offset,
)
except Exception as e:
logger.error(f"查询目录内容失败: {e}", exc_info=True)

View File

@@ -568,8 +568,8 @@ class ConfigModel(BaseModel):
LLM_USE_PROXY: bool = True
# LLM Base URL 预设标识,用于区分同一 Base URL 下的不同模型目录
LLM_BASE_URL_PRESET: Optional[str] = None
# LLM最大上下文Token数量K
LLM_MAX_CONTEXT_TOKENS: int = 128
# LLM最大上下文Token数量K,仅在模型目录未提供规格时作为回退值
LLM_MAX_CONTEXT_TOKENS: int = 256
# LLM OpenAI兼容接口请求User-Agent
LLM_USER_AGENT: Optional[str] = None
# LLM温度参数

View File

@@ -3,10 +3,14 @@
import asyncio
import hashlib
import json
from types import SimpleNamespace
from unittest.mock import patch
from app.agent.tools.impl.edit_file import EditFileTool
from app.agent.tools.impl.list_directory import ListDirectoryTool
from app.agent.tools.impl.read_file import ReadFileTool
from app.agent.tools.impl.write_file import WriteFileTool
from app.chain.storage import StorageChain
def _make_admin_tool(tool_class):
@@ -129,3 +133,33 @@ def test_read_file_can_return_sha256_metadata(tmp_path):
"插件内容".encode("utf-8")
).hexdigest()
assert payload["truncated"] is False
def test_list_directory_returns_paged_items_with_next_offset(tmp_path):
"""目录工具应返回可继续查询的分页元数据。"""
items = [
SimpleNamespace(
name=f"file-{index:03d}.txt",
type="file",
path=str(tmp_path / f"file-{index:03d}.txt"),
size=100,
modify_time=None,
extension=".txt",
)
for index in range(120)
]
tool = _make_admin_tool(ListDirectoryTool)
with patch.object(StorageChain, "list_files", return_value=items):
result = asyncio.run(
tool.run(str(tmp_path), limit=50, offset=50)
)
payload = json.loads(result)
assert payload["total_count"] == 120
assert payload["returned_count"] == 50
assert payload["offset"] == 50
assert payload["limit"] == 50
assert payload["has_more"] is True
assert payload["next_offset"] == 100
assert payload["items"][0]["name"] == "file-050.txt"

View File

@@ -7,6 +7,7 @@ from langchain.agents.middleware.types import ModelRequest
from langchain_core.messages import SystemMessage
from app.agent.middleware.skills import (
MAX_SKILL_RESULT_CHARS,
SKILL_TOOL_NAME,
SkillsMiddleware,
_alist_skills,
@@ -80,6 +81,24 @@ async def test_skill_tool_loads_skill_by_id_and_name(tmp_path):
assert by_name["skill"]["name"] == "MoviePilot CLI"
@pytest.mark.anyio
async def test_skill_tool_caps_large_result_before_model_context(tmp_path):
"""超大 Skill 内容应在工具返回前限制到模型上下文上限。"""
_write_skill(tmp_path, "large-skill")
skill_path = tmp_path / "large-skill" / "SKILL.md"
with skill_path.open("a", encoding="utf-8") as file_handle:
file_handle.write("\n" + ("large-line\n" * 30000))
middleware = SkillsMiddleware(sources=[str(tmp_path)])
result = await middleware.tools[0].ainvoke({"name": "large-skill"})
payload = json.loads(result)
assert len(result) <= MAX_SKILL_RESULT_CHARS
assert payload["success"] is True
assert payload["truncated"] is True
assert "Skill 内容已截断" in payload["content"]
@pytest.mark.anyio
async def test_skill_tool_returns_not_found_for_unknown_skill(tmp_path):
"""skill 工具找不到技能时应返回结构化失败信息。"""

View File

@@ -26,9 +26,23 @@ class TestAgentToolResultLimits(unittest.TestCase):
self.assertTrue(payload["tool_result_truncated"])
self.assertEqual(payload["tool_name"], "oversized_result_tool")
self.assertEqual(payload["returned_chars"], DEFAULT_TOOL_RESULT_MAX_CHARS)
self.assertLessEqual(len(result), DEFAULT_TOOL_RESULT_MAX_CHARS)
self.assertEqual(payload["returned_chars"], len(payload["content_preview"]))
self.assertLess(payload["returned_chars"], DEFAULT_TOOL_RESULT_MAX_CHARS)
self.assertGreater(payload["total_chars"], payload["returned_chars"])
def test_formatter_keeps_escaped_preview_within_hard_limit(self):
"""大量换行转义后,最终工具结果仍不得超过配置的字符上限。"""
result = format_tool_result_for_agent(
"line\n" * DEFAULT_TOOL_RESULT_MAX_CHARS,
tool_name="escaped_result_tool",
)
payload = json.loads(result)
self.assertLessEqual(len(result), DEFAULT_TOOL_RESULT_MAX_CHARS)
self.assertTrue(payload["tool_result_truncated"])
self.assertEqual(payload["returned_chars"], len(payload["content_preview"]))
def test_formatter_preserves_sensitive_json_fields_for_agent_use(self):
result = format_tool_result_for_agent(
{

View File

@@ -36,24 +36,28 @@ class TestExecuteCommandTool(unittest.TestCase):
return asyncio.run(tool.run(action="run", command=command, timeout=timeout))
def test_large_output_is_truncated_before_returning_to_agent(self):
"""大输出一次性命令只把预览返回给 Agent,并把完整内容写到临时文件。"""
"""大输出一次性命令返回头尾预览,并把完整内容写到临时文件。"""
command = _python_command(
"import sys; sys.stdout.write('x' * 200000); sys.stdout.flush()"
"import sys; sys.stdout.write('HEAD-' + 'x' * 200000 + '-TAIL'); sys.stdout.flush()"
)
result = self._run_command(command)
temp_file_path = self._temp_file_path_from_result(result)
self.addCleanup(lambda: os.path.exists(temp_file_path) and os.unlink(temp_file_path))
self.assertIn("命令输出超过 10KB", result)
self.assertIn("仅展示前 10KB 内容", result)
self.assertIn("命令输出超过 32KB", result)
self.assertIn("仅展示前后各 16KB 内容", result)
self.assertIn("如需完整内容,请继续读取该文件", result)
self.assertLess(len(result), MAX_OUTPUT_PREVIEW_BYTES + 600)
self.assertIn("HEAD-", result)
self.assertIn("-TAIL", result)
self.assertLess(len(result), MAX_OUTPUT_PREVIEW_BYTES + 1200)
with open(temp_file_path, encoding="utf-8") as file_handle:
file_content = file_handle.read()
self.assertIn("[标准输出]", file_content)
self.assertIn("HEAD-", file_content)
self.assertIn("-TAIL", file_content)
self.assertGreater(len(file_content), 100000)
def test_timeout_returns_partial_output_promptly(self):
@@ -106,7 +110,7 @@ class TestExecuteCommandTool(unittest.TestCase):
def test_timeout_with_large_output_writes_partial_full_log_to_temp_file(self):
"""超时且输出较大时,终止前完整输出应写入临时文件。"""
command = _python_command(
"import sys, time; sys.stdout.write('x' * 20000); sys.stdout.flush(); time.sleep(5)"
"import sys, time; sys.stdout.write('x' * 60000); sys.stdout.flush(); time.sleep(5)"
)
result = self._run_command(command, timeout=1)
@@ -120,7 +124,7 @@ class TestExecuteCommandTool(unittest.TestCase):
file_content = file_handle.read()
self.assertIn("[标准输出]", file_content)
self.assertGreaterEqual(file_content.count("x"), 20000)
self.assertGreaterEqual(file_content.count("x"), 60000)
def test_timeout_is_capped(self):
"""一次性执行的 timeout 参数超过上限时应自动限幅。"""