mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-08 14:43:34 +08:00
feat(agent): expand tool output and search pagination
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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
|
||||
# 截图最大宽度
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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温度参数
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 工具找不到技能时应返回结构化失败信息。"""
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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 参数超过上限时应自动限幅。"""
|
||||
|
||||
Reference in New Issue
Block a user