super-code-assistant 3.3.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- commands/__init__.py +859 -0
- core/__init__.py +0 -0
- core/config.py +263 -0
- core/config_template.json +7 -0
- core/context.py +271 -0
- core/engine.py +635 -0
- core/file_state.py +279 -0
- core/llm.py +309 -0
- core/model_capabilities.py +45 -0
- core/permissions.py +204 -0
- core/sandbox/__init__.py +15 -0
- core/sandbox/blacklist.py +176 -0
- core/sandbox/config.py +38 -0
- core/sandbox/network.py +136 -0
- core/sandbox/path_protection.py +126 -0
- core/session.py +295 -0
- core/tool.py +45 -0
- features/__init__.py +0 -0
- features/compact.py +945 -0
- features/coordinator.py +105 -0
- features/cost_tracker.py +184 -0
- features/extract_memories.py +326 -0
- features/find_relevant_memories.py +376 -0
- features/git_ai.py +256 -0
- features/memory.py +531 -0
- features/memory_age.py +66 -0
- features/memory_scan.py +153 -0
- features/memory_types.py +34 -0
- features/plan.py +327 -0
- features/skills.py +300 -0
- features/worker_manager.py +232 -0
- mcp/__init__.py +0 -0
- mcp/client.py +112 -0
- mcp/loader.py +80 -0
- mcp/tool_proxy.py +59 -0
- super_code_assistant-3.3.6.dist-info/METADATA +45 -0
- super_code_assistant-3.3.6.dist-info/RECORD +61 -0
- super_code_assistant-3.3.6.dist-info/WHEEL +5 -0
- super_code_assistant-3.3.6.dist-info/entry_points.txt +2 -0
- super_code_assistant-3.3.6.dist-info/top_level.txt +7 -0
- tools/__init__.py +21 -0
- tools/agent.py +132 -0
- tools/ask_user.py +111 -0
- tools/bash.py +77 -0
- tools/file_edit.py +269 -0
- tools/file_read.py +206 -0
- tools/file_write.py +78 -0
- tools/glob_tool.py +81 -0
- tools/grep_tool.py +134 -0
- tools/plan_tools.py +75 -0
- tools/skill.py +108 -0
- tools/tool.py +44 -0
- tools/web_fetch.py +129 -0
- tools/web_search.py +220 -0
- tui/__init__.py +0 -0
- tui/app.py +726 -0
- tui/clipboard_image.py +42 -0
- tui/keylistener.py +140 -0
- tui/prompt.py +752 -0
- tui/query.py +200 -0
- tui/rendering.py +135 -0
tools/grep_tool.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import glob as glob_module
|
|
7
|
+
|
|
8
|
+
from core.tool import Tool, ToolResult
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class GrepTool(Tool):
|
|
12
|
+
name = "Grep"
|
|
13
|
+
description = (
|
|
14
|
+
"Powerful search tool based on ripgrep.\n\n"
|
|
15
|
+
"- Supports full regex syntax\n"
|
|
16
|
+
"- Filter files with glob or type parameter\n"
|
|
17
|
+
"- Output modes: \"content\" (lines), \"files_with_matches\" (paths, default), \"count\"\n"
|
|
18
|
+
"- Falls back to Python regex if rg is not installed"
|
|
19
|
+
)
|
|
20
|
+
input_schema = {
|
|
21
|
+
"type": "object",
|
|
22
|
+
"properties": {
|
|
23
|
+
"pattern": {"type": "string", "description": "Regex pattern"},
|
|
24
|
+
"path": {"type": "string", "description": "Directory or file to search"},
|
|
25
|
+
"glob": {"type": "string", "description": "File glob filter e.g. '*.py'"},
|
|
26
|
+
"type": {"type": "string", "description": "File type filter (e.g. 'py', 'js')"},
|
|
27
|
+
"output_mode": {
|
|
28
|
+
"type": "string",
|
|
29
|
+
"enum": ["files_with_matches", "content", "count"],
|
|
30
|
+
"default": "files_with_matches",
|
|
31
|
+
},
|
|
32
|
+
"-i": {"type": "boolean", "description": "Case insensitive", "default": False},
|
|
33
|
+
"-n": {"type": "boolean", "description": "Show line numbers", "default": True},
|
|
34
|
+
"-A": {"type": "integer", "description": "Lines after each match"},
|
|
35
|
+
"-B": {"type": "integer", "description": "Lines before each match"},
|
|
36
|
+
"-C": {"type": "integer", "description": "Context lines around each match"},
|
|
37
|
+
"multiline": {"type": "boolean", "description": "Enable multiline mode", "default": False},
|
|
38
|
+
"head_limit": {"type": "integer", "description": "Limit output to first N lines", "default": 250},
|
|
39
|
+
"offset": {"type": "integer", "description": "Skip first N lines", "default": 0},
|
|
40
|
+
},
|
|
41
|
+
"required": ["pattern"],
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
def is_read_only(self) -> bool:
|
|
45
|
+
return True
|
|
46
|
+
|
|
47
|
+
def get_activity_description(self, **kwargs) -> str | None:
|
|
48
|
+
pattern = kwargs.get("pattern", "")
|
|
49
|
+
return f"Searching for {pattern}" if pattern else None
|
|
50
|
+
|
|
51
|
+
def execute(self, pattern: str, path: str = ".", glob: str | None = None,
|
|
52
|
+
output_mode: str = "files_with_matches", **kwargs) -> ToolResult:
|
|
53
|
+
cmd = ["rg", "--no-heading"]
|
|
54
|
+
if kwargs.get("-i"):
|
|
55
|
+
cmd.append("-i")
|
|
56
|
+
if kwargs.get("multiline"):
|
|
57
|
+
cmd.extend(["-U", "--multiline-dotall"])
|
|
58
|
+
after = kwargs.get("-A")
|
|
59
|
+
before = kwargs.get("-B")
|
|
60
|
+
context = kwargs.get("-C")
|
|
61
|
+
if after and output_mode == "content":
|
|
62
|
+
cmd.extend(["-A", str(after)])
|
|
63
|
+
if before and output_mode == "content":
|
|
64
|
+
cmd.extend(["-B", str(before)])
|
|
65
|
+
if context and output_mode == "content":
|
|
66
|
+
cmd.extend(["-C", str(context)])
|
|
67
|
+
if output_mode == "files_with_matches":
|
|
68
|
+
cmd.append("-l")
|
|
69
|
+
elif output_mode == "count":
|
|
70
|
+
cmd.append("-c")
|
|
71
|
+
else:
|
|
72
|
+
if kwargs.get("-n", True):
|
|
73
|
+
cmd.append("-n")
|
|
74
|
+
if glob:
|
|
75
|
+
cmd.extend(["-g", glob])
|
|
76
|
+
file_type = kwargs.get("type")
|
|
77
|
+
if file_type:
|
|
78
|
+
cmd.extend(["--type", file_type])
|
|
79
|
+
cmd.extend([pattern, path])
|
|
80
|
+
|
|
81
|
+
head_limit = kwargs.get("head_limit", 250) or 250
|
|
82
|
+
offset = kwargs.get("offset", 0) or 0
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
result = subprocess.run(cmd, capture_output=True, text=True,
|
|
86
|
+
encoding="utf-8", errors="replace", timeout=30)
|
|
87
|
+
output = result.stdout.strip()
|
|
88
|
+
if not output:
|
|
89
|
+
return ToolResult(content="No matches found.")
|
|
90
|
+
lines = output.split("\n")
|
|
91
|
+
if offset > 0:
|
|
92
|
+
lines = lines[offset:]
|
|
93
|
+
if head_limit > 0:
|
|
94
|
+
truncated = len(lines) > head_limit
|
|
95
|
+
lines = lines[:head_limit]
|
|
96
|
+
result_text = "\n".join(lines)
|
|
97
|
+
if truncated:
|
|
98
|
+
result_text += f"\n\n... (results truncated, showing {head_limit} entries)"
|
|
99
|
+
return ToolResult(content=result_text)
|
|
100
|
+
return ToolResult(content="\n".join(lines))
|
|
101
|
+
except FileNotFoundError:
|
|
102
|
+
return self._python_grep(pattern, path, glob, kwargs.get("-i", False), output_mode)
|
|
103
|
+
except subprocess.TimeoutExpired:
|
|
104
|
+
return ToolResult(content="Error: Search timed out.", is_error=True)
|
|
105
|
+
|
|
106
|
+
def _python_grep(self, pattern: str, path: str, glob_filter: str | None,
|
|
107
|
+
case_insensitive: bool, output_mode: str = "files_with_matches") -> ToolResult:
|
|
108
|
+
base = Path(path)
|
|
109
|
+
flags = re.IGNORECASE if case_insensitive else 0
|
|
110
|
+
regex = re.compile(pattern, flags)
|
|
111
|
+
|
|
112
|
+
if base.is_file():
|
|
113
|
+
files = [base]
|
|
114
|
+
else:
|
|
115
|
+
pat = glob_filter or "**/*"
|
|
116
|
+
files = [base / p for p in glob_module.glob(pat, root_dir=str(base), recursive=True)]
|
|
117
|
+
|
|
118
|
+
matched = []
|
|
119
|
+
for f in files:
|
|
120
|
+
if not f.is_file():
|
|
121
|
+
continue
|
|
122
|
+
try:
|
|
123
|
+
text = f.read_text(encoding="utf-8", errors="replace")
|
|
124
|
+
if output_mode == "content":
|
|
125
|
+
for lineno, line in enumerate(text.splitlines(), 1):
|
|
126
|
+
if regex.search(line):
|
|
127
|
+
matched.append(f"{f}:{lineno}:{line}")
|
|
128
|
+
else:
|
|
129
|
+
if regex.search(text):
|
|
130
|
+
matched.append(str(f))
|
|
131
|
+
except OSError:
|
|
132
|
+
pass
|
|
133
|
+
|
|
134
|
+
return ToolResult(content="\n".join(matched) if matched else "No matches found.")
|
tools/plan_tools.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""EnterPlanMode and ExitPlanMode tools."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
from core.tool import Tool, ToolResult
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from features.plan import PlanModeManager
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class EnterPlanModeTool(Tool):
|
|
12
|
+
name = "EnterPlanMode"
|
|
13
|
+
description = (
|
|
14
|
+
"Use this tool proactively when you're about to start a non-trivial implementation task. "
|
|
15
|
+
"Transitions you into plan mode where you can explore the codebase and design an "
|
|
16
|
+
"implementation approach for user approval. In plan mode you may ONLY read files and "
|
|
17
|
+
"write to the plan file — no other modifications are allowed."
|
|
18
|
+
)
|
|
19
|
+
input_schema = {"type": "object", "properties": {}}
|
|
20
|
+
|
|
21
|
+
def __init__(self, plan_manager: PlanModeManager) -> None:
|
|
22
|
+
self._plan_manager = plan_manager
|
|
23
|
+
|
|
24
|
+
def is_read_only(self) -> bool:
|
|
25
|
+
return True
|
|
26
|
+
|
|
27
|
+
def get_activity_description(self, **kwargs) -> str | None:
|
|
28
|
+
return "Entering plan mode…"
|
|
29
|
+
|
|
30
|
+
def execute(self, **kwargs) -> ToolResult:
|
|
31
|
+
return ToolResult(content=self._plan_manager.enter())
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ExitPlanModeTool(Tool):
|
|
35
|
+
name = "ExitPlanMode"
|
|
36
|
+
description = (
|
|
37
|
+
"Use this tool ONLY when you have finished writing your plan to the plan file "
|
|
38
|
+
"and want to notify the user that the plan is ready for review. "
|
|
39
|
+
"IMPORTANT: This tool does NOT exit plan mode by itself. The user must manually "
|
|
40
|
+
"press Shift+Tab after reviewing the plan to actually exit and start implementation. "
|
|
41
|
+
"After calling this tool, STOP and wait for the user — do not call any more tools. "
|
|
42
|
+
"If the user wants changes, refine the plan file and call this tool again when ready."
|
|
43
|
+
)
|
|
44
|
+
input_schema = {"type": "object", "properties": {}}
|
|
45
|
+
|
|
46
|
+
def __init__(self, plan_manager: PlanModeManager) -> None:
|
|
47
|
+
self._plan_manager = plan_manager
|
|
48
|
+
|
|
49
|
+
def get_activity_description(self, **kwargs) -> str | None:
|
|
50
|
+
return "Awaiting user approval…"
|
|
51
|
+
|
|
52
|
+
def execute(self, **kwargs) -> ToolResult:
|
|
53
|
+
# 信号语义工具:不改变任何 engine 状态(不切换工具集、不改 system_prompt、
|
|
54
|
+
# 不动 _messages),仅在终端打印通知,让用户知道 plan 已就绪。
|
|
55
|
+
#
|
|
56
|
+
# 真正的模式切换由用户按 Shift+Tab 触发(参见 tui/app.py:_toggle_plan_mode)。
|
|
57
|
+
# 这样设计的核心动机:让"模式切换"始终是用户的显式操作,杜绝 LLM 通过工具
|
|
58
|
+
# 自主修改 engine 配置而引发的一系列时序/历史一致性问题
|
|
59
|
+
# (参见 commit ee0fbc7、5a7d478、4333ff8 修复过的 bug:API 400、spinner 卡死等)。
|
|
60
|
+
from rich.console import Console
|
|
61
|
+
Console().print(
|
|
62
|
+
"\n[bold green]✓ Plan is ready for your review.[/bold green]\n"
|
|
63
|
+
"[dim]Press Shift+Tab to exit plan mode and start implementation, "
|
|
64
|
+
"or send a message to refine the plan.[/dim]\n"
|
|
65
|
+
)
|
|
66
|
+
plan_path = self._plan_manager.plan_file_path or "the plan file"
|
|
67
|
+
# 返回给 LLM 的内容必须明确:本工具调用不等于已退出,必须停下等待用户。
|
|
68
|
+
# 避免 LLM 误以为已在 normal 模式而尝试调用写工具(写工具仍被权限层拦截,
|
|
69
|
+
# 但反复尝试会污染对话历史,降低体验)。
|
|
70
|
+
return ToolResult(content=(
|
|
71
|
+
f"The user has been notified that the plan ({plan_path}) is ready for review. "
|
|
72
|
+
"You are STILL in plan mode. Stop now and wait — do NOT call any more tools. "
|
|
73
|
+
"The user will press Shift+Tab to exit plan mode if they approve, "
|
|
74
|
+
"or send a message with feedback if they want changes."
|
|
75
|
+
))
|
tools/skill.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Skill tool — let the model invoke a registered skill on its own.
|
|
2
|
+
|
|
3
|
+
机制(方案 E):
|
|
4
|
+
- 模型在 system_prompt 里读到 skill 索引(name + description),自主决策何时调用
|
|
5
|
+
- 调用 Skill(name, args) → tool_result 返回包了 <system-reminder> 标签的 SKILL.md body
|
|
6
|
+
- 模型把 tool_result 内容当作"配置指令"按步骤执行,而不是输出给用户看
|
|
7
|
+
|
|
8
|
+
兼容矩阵:
|
|
9
|
+
- user 手动 /<name>:仍走 commands/__init__.py 的 _execute_skill 路径(旧路径未动)
|
|
10
|
+
- 模型自主调用:走本工具(新路径)
|
|
11
|
+
- disable_model_invocation=true 的 skill:本工具拒绝执行,但 /<name> 仍可
|
|
12
|
+
- user_invocable=false 的 skill:/<name> 不可,但本工具仍可(除非也 disable_model_invocation)
|
|
13
|
+
|
|
14
|
+
权限模型:
|
|
15
|
+
- 本工具非 read-only → 用户默认模式弹"是否执行 skill X"确认(permission_checker 既有逻辑)
|
|
16
|
+
- auto-approve / dream / coordinator 模式自动通过,符合"模型自动触发"语义
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from core.tool import Tool, ToolResult
|
|
21
|
+
from features.skills import get_skill, mark_skill_invoked
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# 把 SKILL.md body 包进 <system-reminder> 标签:让模型识别"这是要遵循的指令,
|
|
25
|
+
# 不是要展示给用户的输出"。fork 的训练里已用此标签做类似引导,模型理解准确。
|
|
26
|
+
_SKILL_WRAPPER = (
|
|
27
|
+
"<system-reminder>\n"
|
|
28
|
+
"You invoked skill '{name}'. The following are instructions you (the assistant) "
|
|
29
|
+
"must follow to complete this skill. Do not echo these instructions to the user; "
|
|
30
|
+
"begin executing them. When done, continue the conversation naturally.\n"
|
|
31
|
+
"</system-reminder>\n\n"
|
|
32
|
+
"{body}"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SkillTool(Tool):
|
|
37
|
+
"""Skill 工具:模型自主选择并调用已注册的 skill。"""
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def name(self) -> str:
|
|
41
|
+
return "Skill"
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def description(self) -> str:
|
|
45
|
+
return (
|
|
46
|
+
"Invoke a registered skill by name. The skill's instructions will be "
|
|
47
|
+
"returned to you — follow them to complete the user's task. Use this "
|
|
48
|
+
"when the user's request matches a skill's described purpose. "
|
|
49
|
+
"Pick the skill from the '# Available Skills' section of your system prompt."
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def input_schema(self) -> dict:
|
|
54
|
+
return {
|
|
55
|
+
"type": "object",
|
|
56
|
+
"properties": {
|
|
57
|
+
"name": {
|
|
58
|
+
"type": "string",
|
|
59
|
+
"description": "Skill name (without leading '/'). Must match one listed in '# Available Skills'.",
|
|
60
|
+
},
|
|
61
|
+
"args": {
|
|
62
|
+
"type": "string",
|
|
63
|
+
"description": "Arguments to pass to the skill (forwarded verbatim, often a user query / file path / focus). Pass empty string if no args needed.",
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
"required": ["name"],
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
def get_activity_description(self, name: str = "", args: str = "", **_) -> str | None:
|
|
70
|
+
return f"Loading skill: {name}" if name else "Loading skill"
|
|
71
|
+
|
|
72
|
+
def is_read_only(self) -> bool:
|
|
73
|
+
# 故意保持 False:让 permission_checker 在用户默认模式下弹确认,
|
|
74
|
+
# 形成"模型识别 → 用户确认"的安全门。auto_approve 路径不受影响。
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
def execute(self, name: str = "", args: str = "", **_) -> ToolResult:
|
|
78
|
+
if not name or not isinstance(name, str):
|
|
79
|
+
return ToolResult("Skill tool requires a non-empty 'name'.", is_error=True)
|
|
80
|
+
|
|
81
|
+
skill = get_skill(name)
|
|
82
|
+
if skill is None:
|
|
83
|
+
return ToolResult(
|
|
84
|
+
f"Unknown skill: '{name}'. Check the '# Available Skills' section of your system prompt for valid names.",
|
|
85
|
+
is_error=True,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# disable_model_invocation=true → 该 skill 只准用户 /<name> 手动调,
|
|
89
|
+
# 不能由模型自主触发。返回 is_error=True 让模型放弃这条路径。
|
|
90
|
+
if skill.disable_model_invocation:
|
|
91
|
+
return ToolResult(
|
|
92
|
+
f"Skill '{name}' is user-only (disable_model_invocation=true). "
|
|
93
|
+
f"Ask the user to run '/{name} ...' instead.",
|
|
94
|
+
is_error=True,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
body = skill.get_prompt(args or "")
|
|
99
|
+
except Exception as e:
|
|
100
|
+
return ToolResult(f"Skill '{name}' failed to render: {e}", is_error=True)
|
|
101
|
+
|
|
102
|
+
if not body or not body.strip():
|
|
103
|
+
return ToolResult(f"Skill '{name}' produced empty content.", is_error=True)
|
|
104
|
+
|
|
105
|
+
# Phase B:记录这次调用,压缩时重注入 skill body 用。仅在成功路径调用,
|
|
106
|
+
# 失败/被拒/empty body 不记录,避免压缩时去注入实际没真正"用过"的 skill。
|
|
107
|
+
mark_skill_invoked(name)
|
|
108
|
+
return ToolResult(_SKILL_WRAPPER.format(name=name, body=body))
|
tools/tool.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class ToolResult:
|
|
7
|
+
content: str
|
|
8
|
+
is_error: bool = False
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Tool(ABC):
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
@abstractmethod
|
|
16
|
+
def name(self) -> str: ...
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
@abstractmethod
|
|
21
|
+
def description(self) -> str: ...
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
@abstractmethod
|
|
25
|
+
def input_schema(self) -> dict: ...
|
|
26
|
+
|
|
27
|
+
@abstractmethod
|
|
28
|
+
def execute(self, **kwargs) -> ToolResult: ...
|
|
29
|
+
|
|
30
|
+
def get_activity_description(self, **kwargs) -> str | None:
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
def is_read_only(self) -> bool:
|
|
34
|
+
return False
|
|
35
|
+
|
|
36
|
+
def to_api_schema(self) -> dict:
|
|
37
|
+
return {
|
|
38
|
+
"type": "function",
|
|
39
|
+
"function": {
|
|
40
|
+
"name": self.name,
|
|
41
|
+
"description": self.description,
|
|
42
|
+
"parameters": self.input_schema,
|
|
43
|
+
}
|
|
44
|
+
}
|
tools/web_fetch.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""WebFetch tool: 获取 URL 内容并转换为纯文本,供 LLM 阅读。
|
|
2
|
+
|
|
3
|
+
使用标准库实现,无需额外依赖。
|
|
4
|
+
HTML 解析采用 html.parser,提取可读正文,过滤 script/style 等噪音标签。
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import urllib.request
|
|
9
|
+
import urllib.error
|
|
10
|
+
from html.parser import HTMLParser
|
|
11
|
+
|
|
12
|
+
from core.tool import Tool, ToolResult
|
|
13
|
+
|
|
14
|
+
# 单次返回的最大字符数,避免撑爆上下文
|
|
15
|
+
_MAX_CHARS = 20_000
|
|
16
|
+
|
|
17
|
+
# 这些标签的内容对 LLM 无意义,直接跳过
|
|
18
|
+
_SKIP_TAGS = {"script", "style", "noscript", "head", "meta", "link"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _TextExtractor(HTMLParser):
|
|
22
|
+
"""最小化 HTML → 纯文本提取器。"""
|
|
23
|
+
|
|
24
|
+
def __init__(self):
|
|
25
|
+
super().__init__()
|
|
26
|
+
self._skip_depth = 0 # 当前处于需跳过的标签嵌套深度
|
|
27
|
+
self._parts: list[str] = []
|
|
28
|
+
|
|
29
|
+
def handle_starttag(self, tag, attrs):
|
|
30
|
+
if tag in _SKIP_TAGS:
|
|
31
|
+
self._skip_depth += 1
|
|
32
|
+
|
|
33
|
+
def handle_endtag(self, tag):
|
|
34
|
+
if tag in _SKIP_TAGS and self._skip_depth > 0:
|
|
35
|
+
self._skip_depth -= 1
|
|
36
|
+
# 块级标签结束时补换行,保留段落结构
|
|
37
|
+
if tag in {"p", "div", "br", "li", "h1", "h2", "h3", "h4", "h5", "h6", "tr"}:
|
|
38
|
+
self._parts.append("\n")
|
|
39
|
+
|
|
40
|
+
def handle_data(self, data):
|
|
41
|
+
if self._skip_depth == 0:
|
|
42
|
+
self._parts.append(data)
|
|
43
|
+
|
|
44
|
+
def get_text(self) -> str:
|
|
45
|
+
raw = "".join(self._parts)
|
|
46
|
+
# 合并连续空白行,保留可读段落
|
|
47
|
+
lines = [line.strip() for line in raw.splitlines()]
|
|
48
|
+
cleaned: list[str] = []
|
|
49
|
+
blank = 0
|
|
50
|
+
for line in lines:
|
|
51
|
+
if line:
|
|
52
|
+
blank = 0
|
|
53
|
+
cleaned.append(line)
|
|
54
|
+
else:
|
|
55
|
+
blank += 1
|
|
56
|
+
if blank <= 1: # 最多保留一个空行
|
|
57
|
+
cleaned.append("")
|
|
58
|
+
return "\n".join(cleaned).strip()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class WebFetchTool(Tool):
|
|
62
|
+
name = "WebFetch"
|
|
63
|
+
description = (
|
|
64
|
+
"Fetches the content of a URL and returns it as plain text. "
|
|
65
|
+
"Use this to read documentation, web pages, or any HTTP/HTTPS resource. "
|
|
66
|
+
"HTML is converted to readable text; non-HTML responses are returned as-is."
|
|
67
|
+
)
|
|
68
|
+
input_schema = {
|
|
69
|
+
"type": "object",
|
|
70
|
+
"properties": {
|
|
71
|
+
"url": {
|
|
72
|
+
"type": "string",
|
|
73
|
+
"description": "The HTTP/HTTPS URL to fetch",
|
|
74
|
+
},
|
|
75
|
+
"prompt": {
|
|
76
|
+
"type": "string",
|
|
77
|
+
"description": "Optional: what information you are looking for (for context only, does not filter output)",
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
"required": ["url"],
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
def is_read_only(self) -> bool:
|
|
84
|
+
# 只读网络请求,无需权限提示
|
|
85
|
+
return True
|
|
86
|
+
|
|
87
|
+
def get_activity_description(self, **kwargs) -> str | None:
|
|
88
|
+
url = kwargs.get("url", "")
|
|
89
|
+
return f"Fetching {url}" if url else None
|
|
90
|
+
|
|
91
|
+
def execute(self, url: str, prompt: str = "", **kwargs) -> ToolResult:
|
|
92
|
+
# 只允许 http/https,防止 file:// 等本地协议被滥用
|
|
93
|
+
if not url.startswith(("http://", "https://")):
|
|
94
|
+
return ToolResult(content="Error: Only http/https URLs are supported.", is_error=True)
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
req = urllib.request.Request(
|
|
98
|
+
url,
|
|
99
|
+
headers={"User-Agent": "Mozilla/5.0 (compatible; SuperCode/1.0)"},
|
|
100
|
+
)
|
|
101
|
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
102
|
+
content_type = resp.headers.get_content_type() or ""
|
|
103
|
+
raw_bytes = resp.read(500_000) # 最多读 500KB,避免大文件阻塞
|
|
104
|
+
except urllib.error.HTTPError as e:
|
|
105
|
+
return ToolResult(content=f"HTTP Error {e.code}: {e.reason}", is_error=True)
|
|
106
|
+
except urllib.error.URLError as e:
|
|
107
|
+
return ToolResult(content=f"URL Error: {e.reason}", is_error=True)
|
|
108
|
+
except TimeoutError:
|
|
109
|
+
return ToolResult(content="Error: Request timed out after 15s", is_error=True)
|
|
110
|
+
except Exception as e:
|
|
111
|
+
return ToolResult(content=f"Error: {e}", is_error=True)
|
|
112
|
+
|
|
113
|
+
# 解码:优先用响应头声明的编码,fallback utf-8
|
|
114
|
+
charset = resp.headers.get_content_charset() or "utf-8"
|
|
115
|
+
try:
|
|
116
|
+
text = raw_bytes.decode(charset, errors="replace")
|
|
117
|
+
except LookupError:
|
|
118
|
+
text = raw_bytes.decode("utf-8", errors="replace")
|
|
119
|
+
|
|
120
|
+
# HTML 转纯文本;其他类型(JSON、纯文本等)直接返回
|
|
121
|
+
if "html" in content_type:
|
|
122
|
+
extractor = _TextExtractor()
|
|
123
|
+
extractor.feed(text)
|
|
124
|
+
text = extractor.get_text()
|
|
125
|
+
|
|
126
|
+
if len(text) > _MAX_CHARS:
|
|
127
|
+
text = text[:_MAX_CHARS] + f"\n\n... (truncated, {len(text)} chars total)"
|
|
128
|
+
|
|
129
|
+
return ToolResult(content=text)
|