Improve agent file reading and structured LLM summaries

This commit is contained in:
jxxghp
2026-08-07 13:31:39 +08:00
parent 7985268f10
commit c6bd396794
8 changed files with 79 additions and 18 deletions
+1 -1
View File
@@ -447,7 +447,7 @@ async def _summarize_with_llm(conversation_text: str) -> Optional[str]:
llm = await LLMHelper.get_llm(streaming=False) llm = await LLMHelper.get_llm(streaming=False)
prompt = SUMMARY_PROMPT.format(conversation=conversation_text) prompt = SUMMARY_PROMPT.format(conversation=conversation_text)
response = await llm.ainvoke(prompt) response = await llm.ainvoke(prompt)
summary = response.content.strip() summary = LLMHelper.extract_text_content(response.content).strip()
# 清理模型可能输出的前缀(如 "摘要:" "总结:" # 清理模型可能输出的前缀(如 "摘要:" "总结:"
summary = re.sub(r"^(摘要|总结|活动记录)[:]\s*", "", summary) summary = re.sub(r"^(摘要|总结|活动记录)[:]\s*", "", summary)
if summary.strip().upper() == SUMMARY_SKIP_MARKER: if summary.strip().upper() == SUMMARY_SKIP_MARKER:
+1 -1
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 `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. - 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. - 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; 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. - 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. If `read_file` reports truncation, continue with smaller `start_line` and `end_line` ranges instead of assuming the file ended.
- 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. - 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. - 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. - 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.
+22 -15
View File
@@ -14,6 +14,10 @@ from app.log import logger
# 最大读取大小 50KB # 最大读取大小 50KB
MAX_READ_SIZE = 50 * 1024 MAX_READ_SIZE = 50 * 1024
READ_FILE_TRUNCATION_MESSAGE = (
"文件内容超过50KB,本次结果已截断。"
"请使用 start_line 和 end_line 参数指定行号范围分段读取。"
)
class ReadFileInput(BaseModel): class ReadFileInput(BaseModel):
@@ -39,7 +43,11 @@ class ReadFileTool(MoviePilotTool):
ToolTag.Read, ToolTag.Read,
ToolTag.File, ToolTag.File,
] ]
description: str = "Read the content of a text file. Supports reading by line range. Each read is limited to 50KB; content exceeding this limit will be truncated." description: str = (
"Read the content of a text file. Supports reading by line range. Each "
"read is limited to 50KB; when content is truncated, continue with "
"smaller start_line and end_line ranges."
)
args_schema: Type[BaseModel] = ReadFileInput args_schema: Type[BaseModel] = ReadFileInput
def get_tool_message(self, **kwargs) -> Optional[str]: def get_tool_message(self, **kwargs) -> Optional[str]:
@@ -99,22 +107,21 @@ class ReadFileTool(MoviePilotTool):
truncated = True truncated = True
if include_metadata: if include_metadata:
return json.dumps( payload = {
{ "file_path": str(resolved_path),
"file_path": str(resolved_path), "sha256": hashlib.sha256(raw_content).hexdigest(),
"sha256": hashlib.sha256(raw_content).hexdigest(), "size_bytes": len(raw_content),
"size_bytes": len(raw_content), "start_line": start_line,
"start_line": start_line, "end_line": end_line,
"end_line": end_line, "truncated": truncated,
"truncated": truncated, }
"content": content, if truncated:
}, payload["truncation_message"] = READ_FILE_TRUNCATION_MESSAGE
ensure_ascii=False, payload["content"] = content
indent=2, return json.dumps(payload, ensure_ascii=False, indent=2)
)
if truncated: if truncated:
return f"{content}\n\n[警告:文件内容已超过50KB限制,以上内容已被截断。请使用 start_line/end_line 参数分段读取。]" return f"{content}\n\n[警告:{READ_FILE_TRUNCATION_MESSAGE}]"
return content return content
+2
View File
@@ -490,6 +490,8 @@ moviepilot tool run search_torrents media_type=movie tmdb_id=12345
- `read_file``write_file``edit_file``execute_command` - `read_file``write_file``edit_file``execute_command`
属于内置 Agent 的本地敏感能力,不通过 MCP/`moviepilot tool` 暴露;插件开发时 属于内置 Agent 的本地敏感能力,不通过 MCP/`moviepilot tool` 暴露;插件开发时
由 Agent 按当前用户权限直接调用这些工具。 由 Agent 按当前用户权限直接调用这些工具。
- `read_file` 单次最多返回 50KB 文件内容;超出时会截断并提示 Agent 使用
`start_line``end_line` 指定更小的行号范围继续读取。
## Scheduler 命令 ## Scheduler 命令
+2
View File
@@ -260,6 +260,8 @@ TMDB 缓存查询响应的 `data` 包含 `count`、`recognized`、`unrecognized`
内置 Agent 的本地文件与命令工具 `read_file``write_file``edit_file` 内置 Agent 的本地文件与命令工具 `read_file``write_file``edit_file`
`execute_command` 不通过 MCP 暴露。这些工具在 Agent 运行时执行独立的 `execute_command` 不通过 MCP 暴露。这些工具在 Agent 运行时执行独立的
用户权限与路径边界检查;MCP 隐藏列表只负责收敛接口暴露面,不替代权限控制。 用户权限与路径边界检查;MCP 隐藏列表只负责收敛接口暴露面,不替代权限控制。
其中 `read_file` 单次最多返回 50KB 文件内容;超出时会截断并提示 Agent 使用
`start_line``end_line` 指定更小的行号范围继续读取。
媒体相关 MCP 工具(如 `query_media_detail``search_torrents``query_library_exists``add_subscribe``transfer_file`)接受 `tmdb_id`/`tmdbid``douban_id`/`doubanid``bangumi_id`/`bangumiid``anilist_id`/`anilistid`,也接受 `media_source` + `media_id`。工具返回的媒体、订阅、下载和整理记录会同步带回可用的四种专用 ID 及通用主身份。 媒体相关 MCP 工具(如 `query_media_detail``search_torrents``query_library_exists``add_subscribe``transfer_file`)接受 `tmdb_id`/`tmdbid``douban_id`/`doubanid``bangumi_id`/`bangumiid``anilist_id`/`anilistid`,也接受 `media_source` + `media_id`。工具返回的媒体、订阅、下载和整理记录会同步带回可用的四种专用 ID 及通用主身份。
+2
View File
@@ -41,6 +41,8 @@ a local plugin source and installed into the running MoviePilot instance.
`list_directory` only when inspecting one known folder or a configured remote `list_directory` only when inspecting one known folder or a configured remote
storage backend. storage backend.
- Read the relevant implementation and adjacent example before editing. - Read the relevant implementation and adjacent example before editing.
- If `read_file` reports truncation, continue with smaller `start_line` and
`end_line` ranges until all relevant sections have been inspected.
- Before using a Python or Node.js dependency API, determine the exact installed - Before using a Python or Node.js dependency API, determine the exact installed
or locked version from requirements, package manifests, lockfiles, local or locked version from requirements, package manifests, lockfiles, local
package source, and `.pyi`/`.d.ts` declarations. If those are insufficient, package source, and `.pyi`/`.d.ts` declarations. If those are insufficient,
+23
View File
@@ -151,6 +151,29 @@ def test_summarize_with_llm_ignores_skip_marker():
llm.ainvoke.assert_awaited_once() llm.ainvoke.assert_awaited_once()
def test_summarize_with_llm_extracts_text_blocks():
"""活动摘要应兼容 LLM 返回的结构化文本块。"""
llm = SimpleNamespace(
ainvoke=AsyncMock(
return_value=SimpleNamespace(
content=[
{"type": "reasoning", "text": "内部推理"},
{"type": "text", "text": "摘要:用户完成了文件工具排查。"},
]
)
)
)
with patch(
"app.agent.llm.LLMHelper.get_llm",
new=AsyncMock(return_value=llm),
):
summary = asyncio.run(_summarize_with_llm("用户: 排查文件工具"))
assert summary == "用户完成了文件工具排查。"
llm.ainvoke.assert_awaited_once()
def test_activity_log_records_detailed_summary(tmp_path): def test_activity_log_records_detailed_summary(tmp_path):
"""有实际工具动作的交互应写入较完整的活动摘要。""" """有实际工具动作的交互应写入较完整的活动摘要。"""
summary = ( summary = (
+26 -1
View File
@@ -8,7 +8,7 @@ from unittest.mock import patch
from app.agent.tools.impl.edit_file import EditFileTool from app.agent.tools.impl.edit_file import EditFileTool
from app.agent.tools.impl.list_directory import ListDirectoryTool from app.agent.tools.impl.list_directory import ListDirectoryTool
from app.agent.tools.impl.read_file import ReadFileTool from app.agent.tools.impl.read_file import MAX_READ_SIZE, ReadFileTool
from app.agent.tools.impl.write_file import WriteFileTool from app.agent.tools.impl.write_file import WriteFileTool
from app.chain.storage import StorageChain from app.chain.storage import StorageChain
@@ -135,6 +135,31 @@ def test_read_file_can_return_sha256_metadata(tmp_path):
assert payload["truncated"] is False assert payload["truncated"] is False
def test_read_file_returns_line_range_hint_when_truncated(tmp_path):
"""超过50KB时应保留前段内容并提示按行号范围继续读取。"""
file_path = tmp_path / "large.py"
exact_content = "a" * MAX_READ_SIZE
file_path.write_text(exact_content, encoding="utf-8")
tool = _make_admin_tool(ReadFileTool)
exact_result = asyncio.run(tool.ainvoke({"file_path": str(file_path)}))
file_path.write_text(f"{exact_content}b", encoding="utf-8")
truncated_result = asyncio.run(tool.ainvoke({"file_path": str(file_path)}))
metadata_result = asyncio.run(
tool.run(str(file_path), include_metadata=True)
)
metadata = json.loads(metadata_result)
assert exact_result == exact_content
assert truncated_result.startswith(exact_content)
assert "50KB" in truncated_result
assert "start_line" in truncated_result
assert "end_line" in truncated_result
assert "tool_result_truncated" not in truncated_result
assert metadata["truncated"] is True
assert "行号范围" in metadata["truncation_message"]
def test_list_directory_returns_paged_items_with_next_offset(tmp_path): def test_list_directory_returns_paged_items_with_next_offset(tmp_path):
"""目录工具应返回可继续查询的分页元数据。""" """目录工具应返回可继续查询的分页元数据。"""
items = [ items = [