chcode 0.1.0__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.
- chcode/__init__.py +0 -0
- chcode/__main__.py +5 -0
- chcode/agent_setup.py +395 -0
- chcode/agents/__init__.py +0 -0
- chcode/agents/definitions.py +158 -0
- chcode/agents/loader.py +104 -0
- chcode/agents/runner.py +159 -0
- chcode/chat.py +1630 -0
- chcode/cli.py +142 -0
- chcode/config.py +571 -0
- chcode/display.py +325 -0
- chcode/prompts.py +640 -0
- chcode/session.py +149 -0
- chcode/skill_manager.py +165 -0
- chcode/utils/__init__.py +3 -0
- chcode/utils/enhanced_chat_openai.py +368 -0
- chcode/utils/git_checker.py +38 -0
- chcode/utils/git_manager.py +261 -0
- chcode/utils/modelscope_ratelimit.py +65 -0
- chcode/utils/multimodal.py +268 -0
- chcode/utils/shell/__init__.py +17 -0
- chcode/utils/shell/output.py +63 -0
- chcode/utils/shell/provider.py +128 -0
- chcode/utils/shell/result.py +14 -0
- chcode/utils/shell/semantics.py +55 -0
- chcode/utils/shell/session.py +159 -0
- chcode/utils/skill_loader.py +565 -0
- chcode/utils/text_utils.py +14 -0
- chcode/utils/tool_result_pipeline.py +244 -0
- chcode/utils/tools.py +1724 -0
- chcode/vision_config.py +371 -0
- chcode-0.1.0.dist-info/METADATA +275 -0
- chcode-0.1.0.dist-info/RECORD +36 -0
- chcode-0.1.0.dist-info/WHEEL +4 -0
- chcode-0.1.0.dist-info/entry_points.txt +2 -0
- chcode-0.1.0.dist-info/licenses/LICENSE +21 -0
chcode/utils/tools.py
ADDED
|
@@ -0,0 +1,1724 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LangChain Tools 定义 (通用工具+skill工具) 后续补充web_search工具
|
|
3
|
+
|
|
4
|
+
使用 LangChain 1.0 的 @tool 装饰器和 ToolRuntime 定义工具:
|
|
5
|
+
- load_skill: 加载 Skill 详细指令(Level 2)
|
|
6
|
+
- bash: 执行命令/脚本(Level 3)
|
|
7
|
+
- read_file: 读取文件
|
|
8
|
+
|
|
9
|
+
ToolRuntime 提供访问运行时信息的统一接口:
|
|
10
|
+
- state: 可变的执行状态
|
|
11
|
+
- context: 不可变的配置(如 skill_loader)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import platform
|
|
18
|
+
import re
|
|
19
|
+
import time
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
import aiofiles
|
|
23
|
+
from typing import Annotated, Any, Literal
|
|
24
|
+
from urllib.parse import urlparse
|
|
25
|
+
|
|
26
|
+
import httpx
|
|
27
|
+
from langchain.tools import tool, ToolRuntime
|
|
28
|
+
from pydantic import BaseModel, BeforeValidator, Field
|
|
29
|
+
from rich.console import Console
|
|
30
|
+
from rich.text import Text
|
|
31
|
+
from chcode.display import render_tool_call
|
|
32
|
+
|
|
33
|
+
from chcode.utils.shell import (
|
|
34
|
+
BashProvider,
|
|
35
|
+
PowerShellProvider,
|
|
36
|
+
ShellSession,
|
|
37
|
+
interpret_command_result,
|
|
38
|
+
)
|
|
39
|
+
from chcode.utils.skill_loader import SkillAgentContext
|
|
40
|
+
from tavily import TavilyClient
|
|
41
|
+
|
|
42
|
+
console = Console()
|
|
43
|
+
|
|
44
|
+
CONFIG_DIR = Path.home() / ".chat"
|
|
45
|
+
SETTING_JSON = CONFIG_DIR / "chagent.json"
|
|
46
|
+
|
|
47
|
+
_tavily_api_key = ""
|
|
48
|
+
_tavily_key_loaded = False
|
|
49
|
+
_tavily_client: TavilyClient | None = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _ensure_tavily_key() -> None:
|
|
53
|
+
global _tavily_api_key, _tavily_key_loaded
|
|
54
|
+
if _tavily_key_loaded:
|
|
55
|
+
return
|
|
56
|
+
_tavily_key_loaded = True
|
|
57
|
+
_tavily_api_key = os.getenv("TAVILY_API_KEY", "")
|
|
58
|
+
if not _tavily_api_key and SETTING_JSON.exists():
|
|
59
|
+
try:
|
|
60
|
+
data = json.loads(SETTING_JSON.read_text(encoding="utf-8"))
|
|
61
|
+
api_key = data.get("tavily_api_key", "")
|
|
62
|
+
if api_key:
|
|
63
|
+
_tavily_api_key = api_key
|
|
64
|
+
except Exception:
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def get_tavily_client() -> TavilyClient | None:
|
|
69
|
+
"""获取 Tavily 客户端(懒加载)"""
|
|
70
|
+
global _tavily_client
|
|
71
|
+
_ensure_tavily_key()
|
|
72
|
+
if _tavily_client is not None:
|
|
73
|
+
return _tavily_client
|
|
74
|
+
if not _tavily_api_key:
|
|
75
|
+
return None
|
|
76
|
+
_tavily_client = TavilyClient(api_key=_tavily_api_key)
|
|
77
|
+
return _tavily_client
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def update_tavily_api_key(api_key: str) -> None:
|
|
81
|
+
"""运行时更新 Tavily API Key"""
|
|
82
|
+
global _tavily_api_key, _tavily_client
|
|
83
|
+
_tavily_api_key = api_key
|
|
84
|
+
if api_key:
|
|
85
|
+
_tavily_client = TavilyClient(api_key=api_key)
|
|
86
|
+
else:
|
|
87
|
+
_tavily_client = None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def resolve_path(file_path: str, working_directory: Path) -> Path: # type: ignore[assignment]
|
|
91
|
+
"""
|
|
92
|
+
解析文件路径,处理相对路径和 ~ 展开
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
file_path: 文件路径(绝对或相对,支持 ~ 表示用户主目录)
|
|
96
|
+
working_directory: 工作目录
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
解析后的绝对路径
|
|
100
|
+
"""
|
|
101
|
+
path = Path(file_path).expanduser() # 处理 ~ 展开
|
|
102
|
+
if not path.is_absolute():
|
|
103
|
+
path = working_directory / path
|
|
104
|
+
return path
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@tool
|
|
108
|
+
async def load_skill(skill_name: str, runtime: ToolRuntime[SkillAgentContext]) -> str:
|
|
109
|
+
"""
|
|
110
|
+
Load a skill's detailed instructions.
|
|
111
|
+
|
|
112
|
+
This tool reads the SKILL.md file for the specified skill and returns
|
|
113
|
+
its complete instructions. Use this when the user's request matches
|
|
114
|
+
a skill's description from the available skills list.
|
|
115
|
+
|
|
116
|
+
The skill's instructions will guide you on how to complete the task,
|
|
117
|
+
which may include running scripts via the bash tool.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
skill_name: Name of the skill to load (e.g., 'news-extractor')
|
|
121
|
+
"""
|
|
122
|
+
loader = runtime.context.skill_loader
|
|
123
|
+
render_tool_call("load_skill", skill_name)
|
|
124
|
+
|
|
125
|
+
# 尝试加载 skill
|
|
126
|
+
skill_content = loader.load_skill(skill_name)
|
|
127
|
+
|
|
128
|
+
if not skill_content:
|
|
129
|
+
# # 列出可用的 skills(从已扫描的元数据中获取)
|
|
130
|
+
skills = loader.scan_skills()
|
|
131
|
+
if skills:
|
|
132
|
+
available = [s.name for s in skills]
|
|
133
|
+
return f"Skill '{skill_name}' not found. Available skills: {', '.join(available)}"
|
|
134
|
+
return f"Skill '{skill_name}' not found. No skills are currently available."
|
|
135
|
+
|
|
136
|
+
# 获取 skill 路径信息
|
|
137
|
+
skill_path = skill_content.metadata.skill_path
|
|
138
|
+
scripts_dir = skill_path / "scripts"
|
|
139
|
+
|
|
140
|
+
scripts_info = (
|
|
141
|
+
f"""
|
|
142
|
+
- **Scripts Directory**: `{scripts_dir}`
|
|
143
|
+
|
|
144
|
+
**Important**: When running scripts, use absolute paths like:
|
|
145
|
+
```bash
|
|
146
|
+
uv run {scripts_dir}/script_name.py [args]
|
|
147
|
+
```"""
|
|
148
|
+
if scripts_dir.exists()
|
|
149
|
+
else ""
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
# 构建路径信息
|
|
153
|
+
path_info = (
|
|
154
|
+
f"""
|
|
155
|
+
## Skill Path Info
|
|
156
|
+
|
|
157
|
+
- **Skill Directory**: `{skill_path}`"""
|
|
158
|
+
+ scripts_info
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# 返回 instructions 和路径信息
|
|
162
|
+
return f"""# Skill: {skill_name}
|
|
163
|
+
|
|
164
|
+
## Instructions
|
|
165
|
+
|
|
166
|
+
{skill_content.instructions}
|
|
167
|
+
{path_info}
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _create_shell_session(workdir: str) -> ShellSession:
|
|
172
|
+
is_windows = platform.system() == "Windows"
|
|
173
|
+
if is_windows:
|
|
174
|
+
bash_provider = BashProvider()
|
|
175
|
+
if bash_provider.is_available:
|
|
176
|
+
session = ShellSession(bash_provider)
|
|
177
|
+
session.cwd = workdir
|
|
178
|
+
return session
|
|
179
|
+
ps_provider = PowerShellProvider()
|
|
180
|
+
if ps_provider.is_available:
|
|
181
|
+
session = ShellSession(ps_provider)
|
|
182
|
+
session.cwd = workdir
|
|
183
|
+
return session
|
|
184
|
+
else:
|
|
185
|
+
bash_provider = BashProvider()
|
|
186
|
+
if bash_provider.is_available:
|
|
187
|
+
session = ShellSession(bash_provider)
|
|
188
|
+
session.cwd = workdir
|
|
189
|
+
return session
|
|
190
|
+
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
_shell_sessions: dict[str, ShellSession] = {}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _get_shell_session(workdir: str) -> ShellSession:
|
|
198
|
+
key = workdir
|
|
199
|
+
session = _shell_sessions.get(key)
|
|
200
|
+
if session is not None:
|
|
201
|
+
return session
|
|
202
|
+
session = _create_shell_session(workdir)
|
|
203
|
+
if session is not None:
|
|
204
|
+
_shell_sessions[key] = session
|
|
205
|
+
return session
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@tool
|
|
210
|
+
async def bash(
|
|
211
|
+
command: str,
|
|
212
|
+
runtime: ToolRuntime[SkillAgentContext],
|
|
213
|
+
timeout: int = 300,
|
|
214
|
+
workdir: str | None = None,
|
|
215
|
+
) -> str:
|
|
216
|
+
"""
|
|
217
|
+
Execute a shell command with automatic platform detection and CWD tracking.
|
|
218
|
+
|
|
219
|
+
On Windows: uses Git Bash if available, falls back to PowerShell.
|
|
220
|
+
On Linux/Mac: uses the system shell (bash/zsh).
|
|
221
|
+
|
|
222
|
+
The working directory is tracked across commands within the same session.
|
|
223
|
+
Use 'workdir' to override the working directory for a specific command
|
|
224
|
+
without affecting the session's tracked CWD.
|
|
225
|
+
|
|
226
|
+
Output is automatically truncated if it exceeds 2000 lines or 51200 bytes.
|
|
227
|
+
Certain exit codes are interpreted semantically (e.g., grep exit 1 = no matches).
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
command: The shell command to execute
|
|
231
|
+
timeout: Timeout in seconds (default 300, max 600)
|
|
232
|
+
workdir: Working directory override (default: project root)
|
|
233
|
+
"""
|
|
234
|
+
cwd = str(runtime.context.working_directory)
|
|
235
|
+
render_tool_call("bash", command)
|
|
236
|
+
|
|
237
|
+
timeout = min(timeout, 600)
|
|
238
|
+
timeout_ms = timeout * 1000
|
|
239
|
+
|
|
240
|
+
session = _get_shell_session(cwd)
|
|
241
|
+
if session is None:
|
|
242
|
+
return "bash:\n[FAILED] No shell available on this system"
|
|
243
|
+
|
|
244
|
+
exec_workdir = workdir if workdir else None
|
|
245
|
+
result, truncated = await asyncio.to_thread(
|
|
246
|
+
session.execute, command, timeout=timeout_ms, workdir=exec_workdir
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
interpretation = interpret_command_result(command, result.exit_code)
|
|
250
|
+
|
|
251
|
+
parts = []
|
|
252
|
+
if result.exit_code == 0:
|
|
253
|
+
parts.append(f"[OK] ({session.provider_name})")
|
|
254
|
+
elif interpretation.message and not interpretation.is_error:
|
|
255
|
+
parts.append(f"[OK] ({session.provider_name}) {interpretation.message}")
|
|
256
|
+
else:
|
|
257
|
+
parts.append(
|
|
258
|
+
f"[FAILED] Exit code: {result.exit_code} ({session.provider_name})"
|
|
259
|
+
)
|
|
260
|
+
parts.append("")
|
|
261
|
+
|
|
262
|
+
output = truncated.content if truncated.truncated else result.stdout
|
|
263
|
+
if output and output.strip():
|
|
264
|
+
parts.append(output.rstrip())
|
|
265
|
+
|
|
266
|
+
if result.stderr and result.stderr.strip():
|
|
267
|
+
if output and output.strip():
|
|
268
|
+
parts.append("")
|
|
269
|
+
parts.append("--- stderr ---")
|
|
270
|
+
parts.append(result.stderr.rstrip())
|
|
271
|
+
|
|
272
|
+
if result.timed_out:
|
|
273
|
+
parts.append("")
|
|
274
|
+
parts.append(f"Command timed out after {timeout}s")
|
|
275
|
+
|
|
276
|
+
if not output or not output.strip():
|
|
277
|
+
if not result.stderr or not result.stderr.strip():
|
|
278
|
+
parts.append("(no output)")
|
|
279
|
+
|
|
280
|
+
return "bash:\n" + "\n".join(parts)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@tool
|
|
284
|
+
async def read_file(file_path: str, runtime: ToolRuntime[SkillAgentContext]) -> str:
|
|
285
|
+
"""
|
|
286
|
+
Read the contents of a file.
|
|
287
|
+
|
|
288
|
+
Use this to:
|
|
289
|
+
- Read skill documentation files
|
|
290
|
+
- View script output files
|
|
291
|
+
- Inspect any text file
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
file_path: Path to the file (absolute or relative to working directory)
|
|
295
|
+
"""
|
|
296
|
+
path = resolve_path(file_path, runtime.context.working_directory)
|
|
297
|
+
render_tool_call("read_file", file_path)
|
|
298
|
+
|
|
299
|
+
if not path.exists():
|
|
300
|
+
return f"read:\n[FAILED] File not found: {file_path}"
|
|
301
|
+
|
|
302
|
+
if not path.is_file():
|
|
303
|
+
return f"read:\n[FAILED] Not a file: {file_path}"
|
|
304
|
+
|
|
305
|
+
try:
|
|
306
|
+
async with aiofiles.open(path, 'r', encoding='utf-8') as f:
|
|
307
|
+
content = await f.read()
|
|
308
|
+
lines = content.split("\n")
|
|
309
|
+
|
|
310
|
+
numbered_lines = []
|
|
311
|
+
for i, line in enumerate(lines[:2000], 1):
|
|
312
|
+
numbered_lines.append(f"{i:4d}| {line}")
|
|
313
|
+
|
|
314
|
+
if len(lines) > 2000:
|
|
315
|
+
numbered_lines.append(f"... ({len(lines) - 2000} more lines)")
|
|
316
|
+
|
|
317
|
+
result = "\n".join(numbered_lines)
|
|
318
|
+
if len(lines) > 2000:
|
|
319
|
+
return f"read:\n[OK] ({len(lines)} lines, showing first 2000)\n\n{result}"
|
|
320
|
+
return f"read:\n[OK]\n\n{result}"
|
|
321
|
+
|
|
322
|
+
except UnicodeDecodeError:
|
|
323
|
+
return f"read:\n[FAILED] Cannot read file (binary or unknown encoding): {file_path}"
|
|
324
|
+
except Exception as e:
|
|
325
|
+
return f"read:\n[FAILED] Failed to read file: {str(e)}"
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
@tool
|
|
329
|
+
async def write_file(
|
|
330
|
+
file_path: str, content: str, runtime: ToolRuntime[SkillAgentContext]
|
|
331
|
+
) -> str:
|
|
332
|
+
"""
|
|
333
|
+
Write content to a file.
|
|
334
|
+
|
|
335
|
+
Use this to:
|
|
336
|
+
- Save generated content
|
|
337
|
+
- Create new files
|
|
338
|
+
- Modify existing files
|
|
339
|
+
|
|
340
|
+
Args:
|
|
341
|
+
file_path: Path to the file (absolute or relative to working directory)
|
|
342
|
+
content: Content to write to the file
|
|
343
|
+
"""
|
|
344
|
+
path = resolve_path(file_path, runtime.context.working_directory)
|
|
345
|
+
render_tool_call("write_file", file_path)
|
|
346
|
+
|
|
347
|
+
try:
|
|
348
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
349
|
+
|
|
350
|
+
async with aiofiles.open(path, 'w', encoding='utf-8') as f:
|
|
351
|
+
await f.write(content)
|
|
352
|
+
return f"write:\n[OK] File written: {path}"
|
|
353
|
+
|
|
354
|
+
except Exception as e:
|
|
355
|
+
return f"write:\n[FAILED] Failed to write file: {str(e)}"
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
@tool
|
|
359
|
+
async def glob(pattern: str, runtime: ToolRuntime[SkillAgentContext]) -> str:
|
|
360
|
+
"""
|
|
361
|
+
Find files matching a glob pattern.
|
|
362
|
+
|
|
363
|
+
Use this to:
|
|
364
|
+
- Find files by name pattern (e.g., "**/*.py" for all Python files)
|
|
365
|
+
- List files in a directory with wildcards
|
|
366
|
+
- Discover project structure
|
|
367
|
+
|
|
368
|
+
Args:
|
|
369
|
+
pattern: Glob pattern (e.g., "**/*.py", "src/**/*.ts", "*.md")
|
|
370
|
+
"""
|
|
371
|
+
cwd = runtime.context.working_directory
|
|
372
|
+
render_tool_call("glob", pattern)
|
|
373
|
+
|
|
374
|
+
try:
|
|
375
|
+
loop = asyncio.get_event_loop()
|
|
376
|
+
matches = await loop.run_in_executor(None, lambda: sorted(cwd.glob(pattern)))
|
|
377
|
+
|
|
378
|
+
if not matches:
|
|
379
|
+
return f"glob:\n[FAILED] No files matching pattern: {pattern}"
|
|
380
|
+
|
|
381
|
+
max_results = 100
|
|
382
|
+
result_lines = []
|
|
383
|
+
|
|
384
|
+
for path in matches[:max_results]:
|
|
385
|
+
try:
|
|
386
|
+
rel_path = path.relative_to(cwd)
|
|
387
|
+
result_lines.append(str(rel_path))
|
|
388
|
+
except ValueError:
|
|
389
|
+
result_lines.append(str(path))
|
|
390
|
+
|
|
391
|
+
result = "\n".join(result_lines)
|
|
392
|
+
|
|
393
|
+
if len(matches) > max_results:
|
|
394
|
+
result += f"\n... and {len(matches) - max_results} more files"
|
|
395
|
+
|
|
396
|
+
return f"glob:\n[OK] ({len(matches)} matches)\n\n{result}"
|
|
397
|
+
|
|
398
|
+
except Exception as e:
|
|
399
|
+
return f"glob:\n[FAILED] {str(e)}"
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
_GREP_EXCLUDED_DIRS = frozenset(
|
|
403
|
+
{
|
|
404
|
+
".git",
|
|
405
|
+
".venv",
|
|
406
|
+
"venv",
|
|
407
|
+
"node_modules",
|
|
408
|
+
"__pycache__",
|
|
409
|
+
".pytest_cache",
|
|
410
|
+
".mypy_cache",
|
|
411
|
+
".tox",
|
|
412
|
+
"dist",
|
|
413
|
+
"build",
|
|
414
|
+
".idea",
|
|
415
|
+
".vscode",
|
|
416
|
+
".cache",
|
|
417
|
+
".sass-cache",
|
|
418
|
+
"target",
|
|
419
|
+
"Pods",
|
|
420
|
+
}
|
|
421
|
+
)
|
|
422
|
+
_GREP_BINARY_EXT = frozenset(
|
|
423
|
+
{
|
|
424
|
+
".exe",
|
|
425
|
+
".dll",
|
|
426
|
+
".so",
|
|
427
|
+
".dylib",
|
|
428
|
+
".bin",
|
|
429
|
+
".obj",
|
|
430
|
+
".o",
|
|
431
|
+
".a",
|
|
432
|
+
".lib",
|
|
433
|
+
".png",
|
|
434
|
+
".jpg",
|
|
435
|
+
".jpeg",
|
|
436
|
+
".gif",
|
|
437
|
+
".bmp",
|
|
438
|
+
".ico",
|
|
439
|
+
".webp",
|
|
440
|
+
".mp3",
|
|
441
|
+
".mp4",
|
|
442
|
+
".avi",
|
|
443
|
+
".mov",
|
|
444
|
+
".mkv",
|
|
445
|
+
".wav",
|
|
446
|
+
".flac",
|
|
447
|
+
".zip",
|
|
448
|
+
".tar",
|
|
449
|
+
".gz",
|
|
450
|
+
".bz2",
|
|
451
|
+
".xz",
|
|
452
|
+
".7z",
|
|
453
|
+
".rar",
|
|
454
|
+
".pdf",
|
|
455
|
+
".doc",
|
|
456
|
+
".docx",
|
|
457
|
+
".xls",
|
|
458
|
+
".xlsx",
|
|
459
|
+
".ppt",
|
|
460
|
+
".pptx",
|
|
461
|
+
".pyc",
|
|
462
|
+
".pyo",
|
|
463
|
+
".class",
|
|
464
|
+
".jar",
|
|
465
|
+
".war",
|
|
466
|
+
".woff",
|
|
467
|
+
".woff2",
|
|
468
|
+
".ttf",
|
|
469
|
+
".eot",
|
|
470
|
+
".otf",
|
|
471
|
+
".sqlite",
|
|
472
|
+
".db",
|
|
473
|
+
}
|
|
474
|
+
)
|
|
475
|
+
_GREP_MAX_FILE_SIZE = 1 * 1024 * 1024
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
@tool
|
|
479
|
+
async def grep(pattern: str, path: str, runtime: ToolRuntime[SkillAgentContext]) -> str:
|
|
480
|
+
"""
|
|
481
|
+
Search for a pattern in files.
|
|
482
|
+
|
|
483
|
+
Use this to:
|
|
484
|
+
- Find code containing specific text or regex
|
|
485
|
+
- Search for function/class definitions
|
|
486
|
+
- Locate usages of variables or imports
|
|
487
|
+
|
|
488
|
+
Args:
|
|
489
|
+
pattern: Regular expression pattern to search for
|
|
490
|
+
path: File or directory path to search in (use "." for current directory)
|
|
491
|
+
"""
|
|
492
|
+
cwd = runtime.context.working_directory
|
|
493
|
+
render_tool_call("grep", pattern)
|
|
494
|
+
search_path = resolve_path(path, cwd)
|
|
495
|
+
|
|
496
|
+
try:
|
|
497
|
+
regex = re.compile(pattern)
|
|
498
|
+
except re.error as e:
|
|
499
|
+
return f"grep:\n[FAILED] Invalid regex pattern: {e}"
|
|
500
|
+
|
|
501
|
+
max_results = 50
|
|
502
|
+
|
|
503
|
+
def _sync_grep() -> tuple[list[str], int]:
|
|
504
|
+
results = []
|
|
505
|
+
files_searched = 0
|
|
506
|
+
|
|
507
|
+
def _search_file(file_path: Path):
|
|
508
|
+
nonlocal files_searched
|
|
509
|
+
try:
|
|
510
|
+
size = file_path.stat().st_size
|
|
511
|
+
if size > _GREP_MAX_FILE_SIZE:
|
|
512
|
+
return
|
|
513
|
+
except OSError:
|
|
514
|
+
return
|
|
515
|
+
|
|
516
|
+
try:
|
|
517
|
+
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
|
|
518
|
+
content = f.read()
|
|
519
|
+
lines = content.split("\n")
|
|
520
|
+
files_searched += 1
|
|
521
|
+
|
|
522
|
+
for line_num, line in enumerate(lines, 1):
|
|
523
|
+
if regex.search(line):
|
|
524
|
+
try:
|
|
525
|
+
rel_path = file_path.relative_to(cwd)
|
|
526
|
+
except ValueError:
|
|
527
|
+
rel_path = file_path
|
|
528
|
+
results.append(f"{rel_path}:{line_num}: {line.strip()[:100]}")
|
|
529
|
+
|
|
530
|
+
if len(results) >= max_results:
|
|
531
|
+
return
|
|
532
|
+
except (PermissionError, IsADirectoryError):
|
|
533
|
+
pass
|
|
534
|
+
|
|
535
|
+
try:
|
|
536
|
+
if search_path.is_file():
|
|
537
|
+
_search_file(search_path)
|
|
538
|
+
else:
|
|
539
|
+
for p in search_path.rglob("*"):
|
|
540
|
+
if len(results) >= max_results:
|
|
541
|
+
break
|
|
542
|
+
if not p.is_file():
|
|
543
|
+
continue
|
|
544
|
+
parts = p.parts
|
|
545
|
+
if any(
|
|
546
|
+
part.startswith(".") or part in _GREP_EXCLUDED_DIRS
|
|
547
|
+
for part in parts
|
|
548
|
+
):
|
|
549
|
+
continue
|
|
550
|
+
if p.suffix.lower() in _GREP_BINARY_EXT:
|
|
551
|
+
continue
|
|
552
|
+
_search_file(p)
|
|
553
|
+
except Exception:
|
|
554
|
+
pass
|
|
555
|
+
|
|
556
|
+
return results, files_searched
|
|
557
|
+
|
|
558
|
+
loop = asyncio.get_event_loop()
|
|
559
|
+
results, files_searched = await loop.run_in_executor(None, _sync_grep)
|
|
560
|
+
|
|
561
|
+
if not results:
|
|
562
|
+
return f"grep:\n[FAILED] No matches found for pattern: {pattern} (searched {files_searched} files)"
|
|
563
|
+
|
|
564
|
+
output = "\n".join(results)
|
|
565
|
+
if len(results) >= max_results:
|
|
566
|
+
output += f"\n... (truncated, showing first {max_results} matches)"
|
|
567
|
+
|
|
568
|
+
return f"grep:\n[OK] ({len(results)} matches in {files_searched} files)\n\n{output}"
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
@tool
|
|
572
|
+
async def edit(
|
|
573
|
+
file_path: str,
|
|
574
|
+
old_string: str,
|
|
575
|
+
new_string: str,
|
|
576
|
+
runtime: ToolRuntime[SkillAgentContext],
|
|
577
|
+
) -> str:
|
|
578
|
+
"""
|
|
579
|
+
Edit a file by replacing text.
|
|
580
|
+
|
|
581
|
+
Use this to:
|
|
582
|
+
- Modify existing code
|
|
583
|
+
- Fix bugs by replacing incorrect code
|
|
584
|
+
- Update configuration values
|
|
585
|
+
|
|
586
|
+
The old_string must match exactly (including whitespace/indentation).
|
|
587
|
+
For safety, the old_string must be unique in the file.
|
|
588
|
+
|
|
589
|
+
Args:
|
|
590
|
+
file_path: Path to the file to edit
|
|
591
|
+
old_string: The exact text to find and replace
|
|
592
|
+
new_string: The text to replace it with
|
|
593
|
+
"""
|
|
594
|
+
path = resolve_path(file_path, runtime.context.working_directory)
|
|
595
|
+
render_tool_call("edit", file_path)
|
|
596
|
+
|
|
597
|
+
if not path.exists():
|
|
598
|
+
return f"edit:\n[FAILED] File not found: {file_path}"
|
|
599
|
+
|
|
600
|
+
if not path.is_file():
|
|
601
|
+
return f"edit:\n[FAILED] Not a file: {file_path}"
|
|
602
|
+
|
|
603
|
+
try:
|
|
604
|
+
async with aiofiles.open(path, 'r', encoding='utf-8') as f:
|
|
605
|
+
content = await f.read()
|
|
606
|
+
|
|
607
|
+
count = content.count(old_string)
|
|
608
|
+
|
|
609
|
+
if count == 0:
|
|
610
|
+
return "edit:\n[FAILED] String not found in file. Make sure the text matches exactly including whitespace."
|
|
611
|
+
|
|
612
|
+
if count > 1:
|
|
613
|
+
return f"edit:\n[FAILED] String appears {count} times in file. Please provide more context to make it unique."
|
|
614
|
+
|
|
615
|
+
new_content = content.replace(old_string, new_string, 1)
|
|
616
|
+
|
|
617
|
+
async with aiofiles.open(path, 'w', encoding='utf-8') as f:
|
|
618
|
+
await f.write(new_content)
|
|
619
|
+
|
|
620
|
+
old_lines = len(old_string.split("\n"))
|
|
621
|
+
new_lines = len(new_string.split("\n"))
|
|
622
|
+
|
|
623
|
+
return f"edit:\n[OK] Edited {path.name}: replaced {old_lines} lines with {new_lines} lines"
|
|
624
|
+
|
|
625
|
+
except UnicodeDecodeError:
|
|
626
|
+
return f"edit:\n[FAILED] Cannot edit file (binary or unknown encoding): {file_path}"
|
|
627
|
+
except Exception as e:
|
|
628
|
+
return f"edit:\n[FAILED] {str(e)}"
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
@tool
|
|
632
|
+
async def list_dir(path: str, runtime: ToolRuntime[SkillAgentContext]) -> str:
|
|
633
|
+
"""
|
|
634
|
+
List contents of a directory.
|
|
635
|
+
|
|
636
|
+
Use this to:
|
|
637
|
+
- Explore directory structure
|
|
638
|
+
- See what files exist in a folder
|
|
639
|
+
- Check if files/folders exist
|
|
640
|
+
|
|
641
|
+
Args:
|
|
642
|
+
path: Directory path (use "." for current directory)
|
|
643
|
+
"""
|
|
644
|
+
dir_path = resolve_path(path, runtime.context.working_directory)
|
|
645
|
+
render_tool_call("list_dir", path)
|
|
646
|
+
|
|
647
|
+
if not dir_path.exists():
|
|
648
|
+
return f"ls:\n[FAILED] Directory not found: {path}"
|
|
649
|
+
|
|
650
|
+
if not dir_path.is_dir():
|
|
651
|
+
return f"ls:\n[FAILED] Not a directory: {path}"
|
|
652
|
+
|
|
653
|
+
def _sync_list_dir() -> list[tuple[str, bool, int]]:
|
|
654
|
+
entries = sorted(dir_path.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower()))
|
|
655
|
+
result = []
|
|
656
|
+
for entry in entries:
|
|
657
|
+
try:
|
|
658
|
+
is_dir = entry.is_dir()
|
|
659
|
+
size = entry.stat().st_size if not is_dir else 0
|
|
660
|
+
result.append((entry.name, is_dir, size))
|
|
661
|
+
except Exception:
|
|
662
|
+
result.append((entry.name, False, 0))
|
|
663
|
+
return result
|
|
664
|
+
|
|
665
|
+
loop = asyncio.get_event_loop()
|
|
666
|
+
entries = await loop.run_in_executor(None, _sync_list_dir)
|
|
667
|
+
|
|
668
|
+
result_lines = []
|
|
669
|
+
for name, is_dir, size in entries[:100]:
|
|
670
|
+
if is_dir:
|
|
671
|
+
result_lines.append(f"{name}/")
|
|
672
|
+
else:
|
|
673
|
+
if size < 1024:
|
|
674
|
+
size_str = f"{size}B"
|
|
675
|
+
elif size < 1024 * 1024:
|
|
676
|
+
size_str = f"{size // 1024}KB"
|
|
677
|
+
else:
|
|
678
|
+
size_str = f"{size // (1024 * 1024)}MB"
|
|
679
|
+
result_lines.append(f" {name} ({size_str})")
|
|
680
|
+
|
|
681
|
+
if len(entries) > 100:
|
|
682
|
+
result_lines.append(f"... and {len(entries) - 100} more entries")
|
|
683
|
+
|
|
684
|
+
return f"ls:\n[OK] ({len(entries)} entries)\n\n{chr(10).join(result_lines)}"
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
@tool
|
|
688
|
+
async def web_search(
|
|
689
|
+
query: str,
|
|
690
|
+
runtime: ToolRuntime[SkillAgentContext],
|
|
691
|
+
max_results: int = 5,
|
|
692
|
+
topic: Literal["general", "news", "finance"] = "general",
|
|
693
|
+
include_raw_content: bool = False,
|
|
694
|
+
):
|
|
695
|
+
"""Run a web search"""
|
|
696
|
+
render_tool_call("web_search", query)
|
|
697
|
+
client = get_tavily_client()
|
|
698
|
+
if client is None:
|
|
699
|
+
return "[ERROR] Tavily API Key 未配置,请使用 /search 命令配置"
|
|
700
|
+
return await asyncio.to_thread(
|
|
701
|
+
client.search,
|
|
702
|
+
query,
|
|
703
|
+
max_results=max_results,
|
|
704
|
+
include_raw_content=include_raw_content,
|
|
705
|
+
topic=topic,
|
|
706
|
+
)
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
FETCH_TIMEOUT = 60.0
|
|
710
|
+
MAX_MARKDOWN_LENGTH = 100_000
|
|
711
|
+
MAX_URL_LENGTH = 2000
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def _html_to_markdown(html: str) -> str:
|
|
715
|
+
try:
|
|
716
|
+
from markdownify import markdownify as md
|
|
717
|
+
|
|
718
|
+
return md(html)
|
|
719
|
+
except ImportError:
|
|
720
|
+
text = re.sub(
|
|
721
|
+
r"<script[^>]*>.*?</script>", "", html, flags=re.DOTALL | re.IGNORECASE
|
|
722
|
+
)
|
|
723
|
+
text = re.sub(
|
|
724
|
+
r"<style[^>]*>.*?</style>", "", text, flags=re.DOTALL | re.IGNORECASE
|
|
725
|
+
)
|
|
726
|
+
text = re.sub(r"<[^>]+>", " ", text)
|
|
727
|
+
text = re.sub(r"\s+", " ", text).strip()
|
|
728
|
+
return text
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def _is_binary_content_type(content_type: str) -> bool:
|
|
732
|
+
binary_types = [
|
|
733
|
+
"application/pdf",
|
|
734
|
+
"application/zip",
|
|
735
|
+
"application/x-tar",
|
|
736
|
+
"application/gzip",
|
|
737
|
+
"application/x-bzip2",
|
|
738
|
+
"image/",
|
|
739
|
+
"video/",
|
|
740
|
+
"audio/",
|
|
741
|
+
]
|
|
742
|
+
return any(bt in content_type.lower() for bt in binary_types)
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
@tool
|
|
746
|
+
async def web_fetch(url: str) -> dict:
|
|
747
|
+
"""Fetches content from a specified URL and converts it to text."""
|
|
748
|
+
render_tool_call("web_fetch", url)
|
|
749
|
+
start = time.time()
|
|
750
|
+
|
|
751
|
+
if len(url) > MAX_URL_LENGTH:
|
|
752
|
+
return {
|
|
753
|
+
"url": url,
|
|
754
|
+
"bytes": 0,
|
|
755
|
+
"code": 0,
|
|
756
|
+
"code_text": "Error",
|
|
757
|
+
"result": f"URL exceeds maximum length of {MAX_URL_LENGTH} characters",
|
|
758
|
+
"duration_ms": int((time.time() - start) * 1000),
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
try:
|
|
762
|
+
parsed = urlparse(url)
|
|
763
|
+
if not parsed.scheme or not parsed.netloc:
|
|
764
|
+
return {
|
|
765
|
+
"url": url,
|
|
766
|
+
"bytes": 0,
|
|
767
|
+
"code": 0,
|
|
768
|
+
"code_text": "Error",
|
|
769
|
+
"result": f"Invalid URL: {url}",
|
|
770
|
+
"duration_ms": int((time.time() - start) * 1000),
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
if parsed.scheme == "http":
|
|
774
|
+
url = url.replace("http://", "https://", 1)
|
|
775
|
+
|
|
776
|
+
async with httpx.AsyncClient(
|
|
777
|
+
follow_redirects=True,
|
|
778
|
+
timeout=FETCH_TIMEOUT,
|
|
779
|
+
max_redirects=10,
|
|
780
|
+
headers={
|
|
781
|
+
"Accept": "text/markdown, text/html, */*",
|
|
782
|
+
"User-Agent": "ClaudeToolkit/1.0",
|
|
783
|
+
},
|
|
784
|
+
) as client:
|
|
785
|
+
response = await client.get(url)
|
|
786
|
+
|
|
787
|
+
content_type = response.headers.get("content-type", "")
|
|
788
|
+
raw_bytes = len(response.content)
|
|
789
|
+
|
|
790
|
+
if _is_binary_content_type(content_type):
|
|
791
|
+
return {
|
|
792
|
+
"url": url,
|
|
793
|
+
"bytes": raw_bytes,
|
|
794
|
+
"code": response.status_code,
|
|
795
|
+
"code_text": response.reason_phrase,
|
|
796
|
+
"result": f"Binary content ({content_type}, {raw_bytes} bytes). Cannot extract text.",
|
|
797
|
+
"duration_ms": int((time.time() - start) * 1000),
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
html_content = response.text
|
|
801
|
+
|
|
802
|
+
if "text/html" in content_type:
|
|
803
|
+
markdown_content = _html_to_markdown(html_content)
|
|
804
|
+
else:
|
|
805
|
+
markdown_content = html_content
|
|
806
|
+
|
|
807
|
+
if len(markdown_content) > MAX_MARKDOWN_LENGTH:
|
|
808
|
+
markdown_content = (
|
|
809
|
+
markdown_content[:MAX_MARKDOWN_LENGTH]
|
|
810
|
+
+ "\n\n[Content truncated due to length...]"
|
|
811
|
+
)
|
|
812
|
+
|
|
813
|
+
result = f"Content from {url}:\n\n{markdown_content}\n\n---"
|
|
814
|
+
# resp=model.invoke(f"Extract effective message from {url}:\n\n{markdown_content}")
|
|
815
|
+
# result=resp.content
|
|
816
|
+
|
|
817
|
+
return {
|
|
818
|
+
"url": url,
|
|
819
|
+
"bytes": raw_bytes,
|
|
820
|
+
"code": response.status_code,
|
|
821
|
+
"code_text": response.reason_phrase,
|
|
822
|
+
"result": result,
|
|
823
|
+
"duration_ms": int((time.time() - start) * 1000),
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
except httpx.TimeoutException:
|
|
827
|
+
return {
|
|
828
|
+
"url": url,
|
|
829
|
+
"bytes": 0,
|
|
830
|
+
"code": 0,
|
|
831
|
+
"code_text": "Timeout",
|
|
832
|
+
"result": f"Request timed out after {FETCH_TIMEOUT}s",
|
|
833
|
+
"duration_ms": int((time.time() - start) * 1000),
|
|
834
|
+
}
|
|
835
|
+
except httpx.HTTPError as e:
|
|
836
|
+
return {
|
|
837
|
+
"url": url,
|
|
838
|
+
"bytes": 0,
|
|
839
|
+
"code": 0,
|
|
840
|
+
"code_text": "Error",
|
|
841
|
+
"result": f"HTTP error: {e}",
|
|
842
|
+
"duration_ms": int((time.time() - start) * 1000),
|
|
843
|
+
}
|
|
844
|
+
except Exception as e:
|
|
845
|
+
return {
|
|
846
|
+
"url": url,
|
|
847
|
+
"bytes": 0,
|
|
848
|
+
"code": 0,
|
|
849
|
+
"code_text": "Error",
|
|
850
|
+
"result": f"Error fetching URL: {e}",
|
|
851
|
+
"duration_ms": int((time.time() - start) * 1000),
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
async def _checkbox_with_other_async(
|
|
856
|
+
question: str, options: list[str]
|
|
857
|
+
) -> list[str] | None:
|
|
858
|
+
"""
|
|
859
|
+
多选 + 自定义输入框(异步版本)
|
|
860
|
+
|
|
861
|
+
空格切换选中,Tab 切换列表/输入框焦点,Enter 提交。
|
|
862
|
+
输入行始终可见,用于输入不在列表中的自定义选项。
|
|
863
|
+
"""
|
|
864
|
+
from prompt_toolkit import Application
|
|
865
|
+
from prompt_toolkit.buffer import Buffer
|
|
866
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
867
|
+
from prompt_toolkit.layout import Layout, UIContent
|
|
868
|
+
from prompt_toolkit.layout.controls import (
|
|
869
|
+
FormattedTextControl,
|
|
870
|
+
BufferControl,
|
|
871
|
+
UIControl,
|
|
872
|
+
)
|
|
873
|
+
from prompt_toolkit.layout.containers import HSplit, Window
|
|
874
|
+
|
|
875
|
+
input_buffer = Buffer()
|
|
876
|
+
input_row_idx = len(options)
|
|
877
|
+
|
|
878
|
+
class _CheckboxControl(UIControl):
|
|
879
|
+
def __init__(self, opts: list[str]):
|
|
880
|
+
self.opts = opts
|
|
881
|
+
self.selected = 0
|
|
882
|
+
self.checked: set[int] = set()
|
|
883
|
+
|
|
884
|
+
def is_focusable(self) -> bool:
|
|
885
|
+
return True
|
|
886
|
+
|
|
887
|
+
def get_invalidate_events(self):
|
|
888
|
+
yield input_buffer.on_text_changed
|
|
889
|
+
|
|
890
|
+
def preferred_height(
|
|
891
|
+
self, width, max_available_height, wrap_lines, get_line_prefix
|
|
892
|
+
):
|
|
893
|
+
return len(self.opts) + 1
|
|
894
|
+
|
|
895
|
+
def create_content(self, width: int, height: int) -> UIContent:
|
|
896
|
+
lines = []
|
|
897
|
+
for i, opt in enumerate(self.opts):
|
|
898
|
+
marker = "[√]" if i in self.checked else "[ ]"
|
|
899
|
+
prefix = " ❯ " if i == self.selected else " "
|
|
900
|
+
line = f"{prefix}{marker} {opt}"
|
|
901
|
+
style = "bold" if i == self.selected else ""
|
|
902
|
+
lines.append([(style, line)])
|
|
903
|
+
|
|
904
|
+
input_text = input_buffer.text or ""
|
|
905
|
+
input_prefix = " ❯ " if self.selected == input_row_idx else " "
|
|
906
|
+
input_line = f"{input_prefix}> {input_text}"
|
|
907
|
+
input_style = "bold" if self.selected == input_row_idx else ""
|
|
908
|
+
lines.append([(input_style, input_line)])
|
|
909
|
+
|
|
910
|
+
def get_line(i):
|
|
911
|
+
return lines[i] if i < len(lines) else [("", "")]
|
|
912
|
+
|
|
913
|
+
return UIContent(
|
|
914
|
+
get_line=get_line,
|
|
915
|
+
line_count=len(lines),
|
|
916
|
+
)
|
|
917
|
+
|
|
918
|
+
control = _CheckboxControl(options)
|
|
919
|
+
|
|
920
|
+
question_window = Window(
|
|
921
|
+
height=1,
|
|
922
|
+
content=FormattedTextControl(text=f"? {question}"),
|
|
923
|
+
dont_extend_height=True,
|
|
924
|
+
)
|
|
925
|
+
control_window = Window(content=control)
|
|
926
|
+
|
|
927
|
+
input_edit = Window(
|
|
928
|
+
content=BufferControl(buffer=input_buffer),
|
|
929
|
+
height=1,
|
|
930
|
+
dont_extend_height=True,
|
|
931
|
+
char=" ",
|
|
932
|
+
)
|
|
933
|
+
|
|
934
|
+
kb = KeyBindings()
|
|
935
|
+
_exiting = False
|
|
936
|
+
|
|
937
|
+
@kb.add("up")
|
|
938
|
+
def _up(e):
|
|
939
|
+
nonlocal _exiting
|
|
940
|
+
if _exiting:
|
|
941
|
+
return
|
|
942
|
+
if control.selected > 0:
|
|
943
|
+
control.selected -= 1
|
|
944
|
+
if control.selected < input_row_idx:
|
|
945
|
+
e.app.layout.focus(control_window)
|
|
946
|
+
else:
|
|
947
|
+
e.app.layout.focus(input_edit)
|
|
948
|
+
e.app.invalidate()
|
|
949
|
+
|
|
950
|
+
@kb.add("down")
|
|
951
|
+
def _down(e):
|
|
952
|
+
nonlocal _exiting
|
|
953
|
+
if _exiting:
|
|
954
|
+
return
|
|
955
|
+
if control.selected < input_row_idx:
|
|
956
|
+
control.selected += 1
|
|
957
|
+
if control.selected == input_row_idx:
|
|
958
|
+
e.app.layout.focus(input_edit)
|
|
959
|
+
e.app.invalidate()
|
|
960
|
+
|
|
961
|
+
@kb.add("tab")
|
|
962
|
+
def _tab(e):
|
|
963
|
+
nonlocal _exiting
|
|
964
|
+
if _exiting:
|
|
965
|
+
return
|
|
966
|
+
if control.selected == input_row_idx:
|
|
967
|
+
control.selected = 0
|
|
968
|
+
e.app.layout.focus(control_window)
|
|
969
|
+
else:
|
|
970
|
+
control.selected = input_row_idx
|
|
971
|
+
e.app.layout.focus(input_edit)
|
|
972
|
+
e.app.invalidate()
|
|
973
|
+
|
|
974
|
+
@kb.add(" ")
|
|
975
|
+
def _space(e):
|
|
976
|
+
nonlocal _exiting
|
|
977
|
+
if _exiting:
|
|
978
|
+
return
|
|
979
|
+
if control.selected < input_row_idx:
|
|
980
|
+
control.checked ^= {control.selected}
|
|
981
|
+
e.app.invalidate()
|
|
982
|
+
|
|
983
|
+
@kb.add("enter")
|
|
984
|
+
def _enter(e):
|
|
985
|
+
nonlocal _exiting
|
|
986
|
+
if _exiting:
|
|
987
|
+
return
|
|
988
|
+
_exiting = True
|
|
989
|
+
selected_names = [control.opts[i] for i in sorted(control.checked)]
|
|
990
|
+
custom = input_buffer.text.strip()
|
|
991
|
+
if custom:
|
|
992
|
+
selected_names.append(custom)
|
|
993
|
+
try:
|
|
994
|
+
e.app.exit(result=selected_names if selected_names else None)
|
|
995
|
+
except Exception:
|
|
996
|
+
pass
|
|
997
|
+
|
|
998
|
+
@kb.add("escape")
|
|
999
|
+
def _esc(e):
|
|
1000
|
+
nonlocal _exiting
|
|
1001
|
+
if _exiting:
|
|
1002
|
+
return
|
|
1003
|
+
_exiting = True
|
|
1004
|
+
try:
|
|
1005
|
+
e.app.exit(result=None)
|
|
1006
|
+
except Exception:
|
|
1007
|
+
pass
|
|
1008
|
+
|
|
1009
|
+
@kb.add("c-c")
|
|
1010
|
+
def _cancel(e):
|
|
1011
|
+
nonlocal _exiting
|
|
1012
|
+
if _exiting:
|
|
1013
|
+
return
|
|
1014
|
+
_exiting = True
|
|
1015
|
+
try:
|
|
1016
|
+
e.app.exit(result=None)
|
|
1017
|
+
except Exception:
|
|
1018
|
+
pass
|
|
1019
|
+
|
|
1020
|
+
layout = Layout(HSplit([question_window, control_window, input_edit]))
|
|
1021
|
+
app = Application(
|
|
1022
|
+
layout=layout, key_bindings=kb, full_screen=False, erase_when_done=True
|
|
1023
|
+
)
|
|
1024
|
+
result = await app.run_async()
|
|
1025
|
+
if result is not None:
|
|
1026
|
+
console.print(f"[cyan]?[/cyan] {question} [bold]{', '.join(result)}[/bold]")
|
|
1027
|
+
return result
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
async def _select_with_other_async(question: str, options: list[str]) -> str | None:
|
|
1031
|
+
"""
|
|
1032
|
+
下拉选择 + 自定义输入框(异步版本)。
|
|
1033
|
+
输入行始终可见,用上下箭头或 Tab 移动到输入行直接输入。
|
|
1034
|
+
"""
|
|
1035
|
+
from prompt_toolkit import Application
|
|
1036
|
+
from prompt_toolkit.buffer import Buffer
|
|
1037
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
1038
|
+
from prompt_toolkit.layout import Layout, UIContent
|
|
1039
|
+
from prompt_toolkit.layout.controls import (
|
|
1040
|
+
FormattedTextControl,
|
|
1041
|
+
BufferControl,
|
|
1042
|
+
UIControl,
|
|
1043
|
+
)
|
|
1044
|
+
from prompt_toolkit.layout.containers import HSplit, Window
|
|
1045
|
+
|
|
1046
|
+
input_buffer = Buffer()
|
|
1047
|
+
input_row_idx = len(options)
|
|
1048
|
+
|
|
1049
|
+
class _SelectControl(UIControl):
|
|
1050
|
+
def __init__(self, opts: list[str]):
|
|
1051
|
+
self.opts = opts
|
|
1052
|
+
self.selected = 0
|
|
1053
|
+
|
|
1054
|
+
def is_focusable(self) -> bool:
|
|
1055
|
+
return True
|
|
1056
|
+
|
|
1057
|
+
def get_invalidate_events(self):
|
|
1058
|
+
yield input_buffer.on_text_changed
|
|
1059
|
+
|
|
1060
|
+
def preferred_height(
|
|
1061
|
+
self, width, max_available_height, wrap_lines, get_line_prefix
|
|
1062
|
+
):
|
|
1063
|
+
return len(self.opts) + 1
|
|
1064
|
+
|
|
1065
|
+
def create_content(self, width: int, height: int) -> UIContent:
|
|
1066
|
+
lines = []
|
|
1067
|
+
for i, opt in enumerate(self.opts):
|
|
1068
|
+
prefix = " ❯ " if i == self.selected else " "
|
|
1069
|
+
line = f"{prefix}{opt}"
|
|
1070
|
+
style = "bold" if i == self.selected else ""
|
|
1071
|
+
lines.append([(style, line)])
|
|
1072
|
+
|
|
1073
|
+
input_text = input_buffer.text or ""
|
|
1074
|
+
input_prefix = " ❯ " if self.selected == input_row_idx else " "
|
|
1075
|
+
input_line = f"{input_prefix}> {input_text}"
|
|
1076
|
+
input_style = "bold" if self.selected == input_row_idx else ""
|
|
1077
|
+
lines.append([(input_style, input_line)])
|
|
1078
|
+
|
|
1079
|
+
def get_line(i):
|
|
1080
|
+
return lines[i] if i < len(lines) else [("", "")]
|
|
1081
|
+
|
|
1082
|
+
return UIContent(
|
|
1083
|
+
get_line=get_line,
|
|
1084
|
+
line_count=len(lines),
|
|
1085
|
+
)
|
|
1086
|
+
|
|
1087
|
+
control = _SelectControl(options)
|
|
1088
|
+
|
|
1089
|
+
question_window = Window(
|
|
1090
|
+
height=1,
|
|
1091
|
+
content=FormattedTextControl(text=f"? {question}"),
|
|
1092
|
+
dont_extend_height=True,
|
|
1093
|
+
)
|
|
1094
|
+
control_window = Window(content=control)
|
|
1095
|
+
|
|
1096
|
+
input_edit = Window(
|
|
1097
|
+
content=BufferControl(buffer=input_buffer),
|
|
1098
|
+
height=1,
|
|
1099
|
+
dont_extend_height=True,
|
|
1100
|
+
char=" ",
|
|
1101
|
+
)
|
|
1102
|
+
|
|
1103
|
+
kb = KeyBindings()
|
|
1104
|
+
_exiting = False
|
|
1105
|
+
|
|
1106
|
+
@kb.add("up")
|
|
1107
|
+
def _up(e):
|
|
1108
|
+
nonlocal _exiting
|
|
1109
|
+
if _exiting:
|
|
1110
|
+
return
|
|
1111
|
+
if control.selected > 0:
|
|
1112
|
+
control.selected -= 1
|
|
1113
|
+
if control.selected < input_row_idx:
|
|
1114
|
+
e.app.layout.focus(control_window)
|
|
1115
|
+
else:
|
|
1116
|
+
e.app.layout.focus(input_edit)
|
|
1117
|
+
e.app.invalidate()
|
|
1118
|
+
|
|
1119
|
+
@kb.add("down")
|
|
1120
|
+
def _down(e):
|
|
1121
|
+
nonlocal _exiting
|
|
1122
|
+
if _exiting:
|
|
1123
|
+
return
|
|
1124
|
+
if control.selected < input_row_idx:
|
|
1125
|
+
control.selected += 1
|
|
1126
|
+
if control.selected == input_row_idx:
|
|
1127
|
+
e.app.layout.focus(input_edit)
|
|
1128
|
+
e.app.invalidate()
|
|
1129
|
+
|
|
1130
|
+
@kb.add("tab")
|
|
1131
|
+
def _tab(e):
|
|
1132
|
+
nonlocal _exiting
|
|
1133
|
+
if _exiting:
|
|
1134
|
+
return
|
|
1135
|
+
if control.selected == input_row_idx:
|
|
1136
|
+
control.selected = 0
|
|
1137
|
+
e.app.layout.focus(control_window)
|
|
1138
|
+
else:
|
|
1139
|
+
control.selected = input_row_idx
|
|
1140
|
+
e.app.layout.focus(input_edit)
|
|
1141
|
+
e.app.invalidate()
|
|
1142
|
+
|
|
1143
|
+
@kb.add("enter")
|
|
1144
|
+
def _enter(e):
|
|
1145
|
+
nonlocal _exiting
|
|
1146
|
+
if _exiting:
|
|
1147
|
+
return
|
|
1148
|
+
_exiting = True
|
|
1149
|
+
if control.selected == input_row_idx:
|
|
1150
|
+
text = input_buffer.text.strip()
|
|
1151
|
+
if text:
|
|
1152
|
+
try:
|
|
1153
|
+
e.app.exit(result=text)
|
|
1154
|
+
except Exception:
|
|
1155
|
+
pass
|
|
1156
|
+
else:
|
|
1157
|
+
_exiting = False
|
|
1158
|
+
else:
|
|
1159
|
+
try:
|
|
1160
|
+
e.app.exit(result=control.opts[control.selected])
|
|
1161
|
+
except Exception:
|
|
1162
|
+
pass
|
|
1163
|
+
|
|
1164
|
+
@kb.add("escape")
|
|
1165
|
+
def _esc(e):
|
|
1166
|
+
nonlocal _exiting
|
|
1167
|
+
if _exiting:
|
|
1168
|
+
return
|
|
1169
|
+
_exiting = True
|
|
1170
|
+
try:
|
|
1171
|
+
e.app.exit(result=None)
|
|
1172
|
+
except Exception:
|
|
1173
|
+
pass
|
|
1174
|
+
|
|
1175
|
+
@kb.add("c-c")
|
|
1176
|
+
def _cancel(e):
|
|
1177
|
+
nonlocal _exiting
|
|
1178
|
+
if _exiting:
|
|
1179
|
+
return
|
|
1180
|
+
_exiting = True
|
|
1181
|
+
try:
|
|
1182
|
+
e.app.exit(result=None)
|
|
1183
|
+
except Exception:
|
|
1184
|
+
pass
|
|
1185
|
+
|
|
1186
|
+
layout = Layout(HSplit([question_window, control_window, input_edit]))
|
|
1187
|
+
app = Application(
|
|
1188
|
+
layout=layout, key_bindings=kb, full_screen=False, erase_when_done=True
|
|
1189
|
+
)
|
|
1190
|
+
result = await app.run_async()
|
|
1191
|
+
if result is not None:
|
|
1192
|
+
console.print(f"[cyan]?[/cyan] {question} [bold]{result}[/bold]")
|
|
1193
|
+
return result
|
|
1194
|
+
|
|
1195
|
+
|
|
1196
|
+
def _coerce_json_list(v: Any) -> Any:
|
|
1197
|
+
"""将 JSON 字符串解析为 list,兼容 LLM 把数组参数传为字符串的情况"""
|
|
1198
|
+
if isinstance(v, str):
|
|
1199
|
+
try:
|
|
1200
|
+
return json.loads(v)
|
|
1201
|
+
except (json.JSONDecodeError, TypeError):
|
|
1202
|
+
return v
|
|
1203
|
+
return v
|
|
1204
|
+
|
|
1205
|
+
|
|
1206
|
+
@tool
|
|
1207
|
+
async def ask_user(
|
|
1208
|
+
question: str = "",
|
|
1209
|
+
options: Annotated[list[str] | None, BeforeValidator(_coerce_json_list)] = None,
|
|
1210
|
+
is_multiple: bool = False,
|
|
1211
|
+
questions: Annotated[list[dict] | None, BeforeValidator(_coerce_json_list)] = None,
|
|
1212
|
+
) -> str:
|
|
1213
|
+
"""
|
|
1214
|
+
Ask the user one or more questions interactively with predefined options.
|
|
1215
|
+
|
|
1216
|
+
Use this when you need clarification, user preferences, or choices
|
|
1217
|
+
before proceeding. The user will see dropdown menus or checkboxes in the terminal.
|
|
1218
|
+
|
|
1219
|
+
Single question mode (default):
|
|
1220
|
+
ask_user(question="What's your preference?", options=["A", "B", "C"])
|
|
1221
|
+
|
|
1222
|
+
Batch questions mode (asks all questions sequentially):
|
|
1223
|
+
ask_user(questions=[
|
|
1224
|
+
{"question": "What database?", "options": ["PostgreSQL", "MySQL"]},
|
|
1225
|
+
{"question": "What framework?", "options": ["React", "Vue"], "is_multiple": true},
|
|
1226
|
+
])
|
|
1227
|
+
|
|
1228
|
+
Args:
|
|
1229
|
+
question: The question to ask (ignored if questions is provided)
|
|
1230
|
+
options: List of options for single question mode (ignored if questions is provided)
|
|
1231
|
+
is_multiple: If True in single-question mode, allow selecting multiple options
|
|
1232
|
+
questions: List of question dicts for batch mode. Each dict must have:
|
|
1233
|
+
- "question": the question text (required)
|
|
1234
|
+
- "options": list of choices (optional, falls back to text input)
|
|
1235
|
+
- "is_multiple": bool for checkbox vs single select (default: false)
|
|
1236
|
+
When provided, all questions are asked sequentially. Overrides question/options.
|
|
1237
|
+
"""
|
|
1238
|
+
import questionary
|
|
1239
|
+
|
|
1240
|
+
if questions:
|
|
1241
|
+
return await _ask_multi_questions(questions)
|
|
1242
|
+
|
|
1243
|
+
render_tool_call("ask_user", question)
|
|
1244
|
+
|
|
1245
|
+
if not options:
|
|
1246
|
+
answer = await asyncio.to_thread(lambda: questionary.text("请输入: ").ask())
|
|
1247
|
+
if answer is None:
|
|
1248
|
+
return "user_answer:\n(用户取消)"
|
|
1249
|
+
return f"user_answer:\n{answer}"
|
|
1250
|
+
|
|
1251
|
+
try:
|
|
1252
|
+
if is_multiple:
|
|
1253
|
+
selected = await _checkbox_with_other_async(question, options)
|
|
1254
|
+
if selected is None:
|
|
1255
|
+
return "user_answer:\n(用户取消)"
|
|
1256
|
+
result = ", ".join(selected)
|
|
1257
|
+
else:
|
|
1258
|
+
answer = await _select_with_other_async(question, options)
|
|
1259
|
+
if answer is None:
|
|
1260
|
+
return "user_answer:\n(用户取消)"
|
|
1261
|
+
result = answer
|
|
1262
|
+
return f"user_answer:\n{result}"
|
|
1263
|
+
except Exception as e:
|
|
1264
|
+
return f"user_answer:\n(询问失败: {e})"
|
|
1265
|
+
|
|
1266
|
+
|
|
1267
|
+
async def _ask_multi_questions(questions: list[dict]) -> str:
|
|
1268
|
+
"""
|
|
1269
|
+
多问题批量提问界面
|
|
1270
|
+
|
|
1271
|
+
Args:
|
|
1272
|
+
questions: List of {"question": "...", "options": ["..."], "is_multiple": false}
|
|
1273
|
+
Returns:
|
|
1274
|
+
Formatted string with all answers
|
|
1275
|
+
"""
|
|
1276
|
+
import questionary
|
|
1277
|
+
|
|
1278
|
+
console.print()
|
|
1279
|
+
console.print(f"[bold cyan]📋 批量提问 ({len(questions)} 个问题)[/bold cyan]")
|
|
1280
|
+
console.print()
|
|
1281
|
+
|
|
1282
|
+
answers = []
|
|
1283
|
+
for i, q in enumerate(questions, 1):
|
|
1284
|
+
q_text = q.get("question", "")
|
|
1285
|
+
q_options = q.get("options", [])
|
|
1286
|
+
q_multiple = q.get("is_multiple", False)
|
|
1287
|
+
|
|
1288
|
+
console.print(f"[dim]问题 {i}/{len(questions)}: {q_text}[/dim]")
|
|
1289
|
+
|
|
1290
|
+
if not q_options:
|
|
1291
|
+
answer = await asyncio.to_thread(lambda: questionary.text("请输入: ").ask())
|
|
1292
|
+
if answer is None:
|
|
1293
|
+
answers.append(f"Q{i}: (用户取消)")
|
|
1294
|
+
continue
|
|
1295
|
+
answers.append(f"Q{i}: {answer}")
|
|
1296
|
+
else:
|
|
1297
|
+
try:
|
|
1298
|
+
if q_multiple:
|
|
1299
|
+
selected = await _checkbox_with_other_async(
|
|
1300
|
+
"选择(空格选择,回车确认):", q_options
|
|
1301
|
+
)
|
|
1302
|
+
if selected is None:
|
|
1303
|
+
answers.append(f"Q{i}: (用户取消)")
|
|
1304
|
+
continue
|
|
1305
|
+
result = ", ".join(selected)
|
|
1306
|
+
else:
|
|
1307
|
+
selected = await _select_with_other_async(q_text, q_options)
|
|
1308
|
+
if selected is None:
|
|
1309
|
+
answers.append(f"Q{i}: (用户取消)")
|
|
1310
|
+
continue
|
|
1311
|
+
result = selected
|
|
1312
|
+
answers.append(f"Q{i}: {result}")
|
|
1313
|
+
except Exception as e:
|
|
1314
|
+
answers.append(f"Q{i}: (询问失败: {e})")
|
|
1315
|
+
|
|
1316
|
+
console.print()
|
|
1317
|
+
|
|
1318
|
+
# 汇总结果
|
|
1319
|
+
result_lines = ["=== 批量提问结果 ==="]
|
|
1320
|
+
for i, q in enumerate(questions, 1):
|
|
1321
|
+
result_lines.append(f"问题: {q.get('question', '')}\n回答: {answers[i - 1]}")
|
|
1322
|
+
return "\n\n".join(result_lines)
|
|
1323
|
+
|
|
1324
|
+
|
|
1325
|
+
@tool
|
|
1326
|
+
async def agent(
|
|
1327
|
+
prompt: str,
|
|
1328
|
+
subagent_type: str = "general-purpose",
|
|
1329
|
+
description: str = "",
|
|
1330
|
+
timeout_seconds: int = 300,
|
|
1331
|
+
runtime: ToolRuntime[SkillAgentContext] = None,
|
|
1332
|
+
) -> str:
|
|
1333
|
+
"""
|
|
1334
|
+
Launch a sub-agent to perform a task autonomously.
|
|
1335
|
+
|
|
1336
|
+
Sub-agents have their own tool set and system prompt. They run independently
|
|
1337
|
+
and return a text report when finished. Use this for complex, multi-step tasks
|
|
1338
|
+
that benefit from focused execution.
|
|
1339
|
+
|
|
1340
|
+
Available sub-agent types:
|
|
1341
|
+
- "general-purpose": Full tool access, can read/write files and run commands. Use for multi-step coding tasks, complex research, or when you need autonomous execution.
|
|
1342
|
+
- "Explore": Read-only agent specialized in codebase exploration. Use for finding files by pattern, searching code, answering questions about the codebase. Fast and efficient.
|
|
1343
|
+
- "Plan": Read-only agent for designing implementation plans. Use for architectural analysis, step-by-step implementation strategies, identifying critical files.
|
|
1344
|
+
|
|
1345
|
+
Custom agents can also be loaded from .chat/agents/*.md files.
|
|
1346
|
+
|
|
1347
|
+
Args:
|
|
1348
|
+
prompt: The task description for the sub-agent. Be specific about what to find, analyze, or do.
|
|
1349
|
+
subagent_type: Type of sub-agent to launch ("general-purpose", "Explore", "Plan", or a custom agent name).
|
|
1350
|
+
description: Short description of what this sub-agent invocation does (for display purposes).
|
|
1351
|
+
timeout_seconds: Maximum seconds the sub-agent can run before being terminated. Default 300 (5 minutes). Must be greater than 300 (5 minutes) to allow sufficient execution time.
|
|
1352
|
+
"""
|
|
1353
|
+
import chcode.display as _display
|
|
1354
|
+
from chcode.agents.loader import load_agents
|
|
1355
|
+
from chcode.agents.runner import run_subagent
|
|
1356
|
+
|
|
1357
|
+
tag = f"{subagent_type}: {(description or '')[:30]}"
|
|
1358
|
+
render_tool_call("agent", f"{subagent_type}: {description or prompt[:60]}")
|
|
1359
|
+
|
|
1360
|
+
all_agents = load_agents()
|
|
1361
|
+
agent_def = all_agents.get(subagent_type)
|
|
1362
|
+
|
|
1363
|
+
if agent_def is None:
|
|
1364
|
+
available = ", ".join(sorted(all_agents.keys()))
|
|
1365
|
+
return f"Unknown agent type '{subagent_type}'. Available types: {available}"
|
|
1366
|
+
|
|
1367
|
+
model_config = runtime.context.model_config
|
|
1368
|
+
working_directory = runtime.context.working_directory
|
|
1369
|
+
skill_loader = runtime.context.skill_loader
|
|
1370
|
+
|
|
1371
|
+
_display._current_agent_tag.set(tag)
|
|
1372
|
+
with _display._agent_progress_lock:
|
|
1373
|
+
_display._agent_progress[tag] = {
|
|
1374
|
+
"calls": 0,
|
|
1375
|
+
"start": time.time(),
|
|
1376
|
+
"timeout": timeout_seconds,
|
|
1377
|
+
"failed": False,
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
with _display._subagent_count_lock:
|
|
1381
|
+
_display._subagent_count += 1
|
|
1382
|
+
if _display._subagent_count >= 2:
|
|
1383
|
+
_display._subagent_parallel = True
|
|
1384
|
+
_display._start_progress()
|
|
1385
|
+
if _display._progress_task is None or _display._progress_task.done():
|
|
1386
|
+
_display._progress_task = asyncio.ensure_future(
|
|
1387
|
+
_display._progress_updater()
|
|
1388
|
+
)
|
|
1389
|
+
|
|
1390
|
+
try:
|
|
1391
|
+
result = await run_subagent(
|
|
1392
|
+
prompt=prompt,
|
|
1393
|
+
agent_def=agent_def,
|
|
1394
|
+
model_config=model_config,
|
|
1395
|
+
working_directory=working_directory,
|
|
1396
|
+
skill_loader=skill_loader,
|
|
1397
|
+
timeout_seconds=timeout_seconds,
|
|
1398
|
+
description=description,
|
|
1399
|
+
)
|
|
1400
|
+
|
|
1401
|
+
with _display._agent_progress_lock:
|
|
1402
|
+
if tag in _display._agent_progress:
|
|
1403
|
+
# 检查是否超时或出错
|
|
1404
|
+
if result and ("timed out" in result or "error:" in result.lower()):
|
|
1405
|
+
_display._agent_progress[tag]["failed"] = True
|
|
1406
|
+
else:
|
|
1407
|
+
_display._agent_progress[tag]["done"] = True
|
|
1408
|
+
# 触发一次立即更新
|
|
1409
|
+
_display._update_progress()
|
|
1410
|
+
|
|
1411
|
+
if not _display._subagent_parallel and result:
|
|
1412
|
+
for line in result.splitlines():
|
|
1413
|
+
_display.console.print(Text(f" {line}", style="dim"))
|
|
1414
|
+
finally:
|
|
1415
|
+
_display._current_agent_tag.set(None)
|
|
1416
|
+
with _display._subagent_count_lock:
|
|
1417
|
+
_display._subagent_count -= 1
|
|
1418
|
+
if _display._subagent_count == 0:
|
|
1419
|
+
_display._subagent_parallel = False
|
|
1420
|
+
_display._finalize_progress()
|
|
1421
|
+
|
|
1422
|
+
return result
|
|
1423
|
+
|
|
1424
|
+
|
|
1425
|
+
# ---------------------------------------------------------------------------
|
|
1426
|
+
# todo_write — 创建和管理结构化任务列表
|
|
1427
|
+
# ---------------------------------------------------------------------------
|
|
1428
|
+
|
|
1429
|
+
|
|
1430
|
+
class TodoItem(BaseModel):
|
|
1431
|
+
"""单个任务项"""
|
|
1432
|
+
|
|
1433
|
+
content: str = Field(description="Brief description of the task")
|
|
1434
|
+
status: str = Field(
|
|
1435
|
+
default="pending",
|
|
1436
|
+
description="Current status: pending, in_progress, completed, cancelled",
|
|
1437
|
+
)
|
|
1438
|
+
priority: str = Field(
|
|
1439
|
+
default="medium",
|
|
1440
|
+
description="Priority level: high, medium, low",
|
|
1441
|
+
)
|
|
1442
|
+
|
|
1443
|
+
|
|
1444
|
+
_TODO_STORAGE_DIR = os.path.join(
|
|
1445
|
+
os.environ.get(
|
|
1446
|
+
"XDG_DATA_HOME",
|
|
1447
|
+
os.path.join(os.path.expanduser("~"), ".local", "share"),
|
|
1448
|
+
),
|
|
1449
|
+
"chcode",
|
|
1450
|
+
"todo",
|
|
1451
|
+
)
|
|
1452
|
+
|
|
1453
|
+
|
|
1454
|
+
def _todo_path(session_id: str) -> str:
|
|
1455
|
+
return os.path.join(_TODO_STORAGE_DIR, f"ses_{session_id}.json")
|
|
1456
|
+
|
|
1457
|
+
|
|
1458
|
+
async def _save_todos(session_id: str, todos: list[dict]) -> None:
|
|
1459
|
+
os.makedirs(_TODO_STORAGE_DIR, exist_ok=True)
|
|
1460
|
+
now = int(time.time() * 1000)
|
|
1461
|
+
for i, todo in enumerate(todos):
|
|
1462
|
+
todo["position"] = i
|
|
1463
|
+
todo["time_updated"] = now
|
|
1464
|
+
if "time_created" not in todo:
|
|
1465
|
+
todo["time_created"] = now
|
|
1466
|
+
async with aiofiles.open(_todo_path(session_id), "w", encoding="utf-8") as f:
|
|
1467
|
+
await f.write(json.dumps(todos, ensure_ascii=False, indent=2))
|
|
1468
|
+
|
|
1469
|
+
|
|
1470
|
+
@tool
|
|
1471
|
+
async def todo_write(
|
|
1472
|
+
todos: list[TodoItem],
|
|
1473
|
+
runtime: ToolRuntime[SkillAgentContext],
|
|
1474
|
+
) -> str:
|
|
1475
|
+
"""\
|
|
1476
|
+
Use this tool to create and manage a structured task list for your current coding session. This helps you track progress, organize complex tasks, and demonstrate thoroughness to the user.
|
|
1477
|
+
|
|
1478
|
+
When to use:
|
|
1479
|
+
- Complex multistep tasks (3+ steps)
|
|
1480
|
+
- User provides multiple tasks
|
|
1481
|
+
- After receiving new instructions
|
|
1482
|
+
- After completing a task (mark complete, add follow-ups)
|
|
1483
|
+
|
|
1484
|
+
When NOT to use:
|
|
1485
|
+
- Single, straightforward tasks
|
|
1486
|
+
- Trivial tasks with no organizational benefit
|
|
1487
|
+
- Purely conversational or informational requests
|
|
1488
|
+
|
|
1489
|
+
Task states: pending, in_progress, completed, cancelled
|
|
1490
|
+
Priority levels: high, medium, low
|
|
1491
|
+
Only ONE task should be in_progress at any time. Mark tasks complete immediately after finishing.
|
|
1492
|
+
|
|
1493
|
+
Args:
|
|
1494
|
+
todos: The updated todo list. Each item has content (str), status (str), and priority (str).
|
|
1495
|
+
"""
|
|
1496
|
+
session_id = runtime.context.thread_id or "default"
|
|
1497
|
+
|
|
1498
|
+
# Convert Pydantic models to dicts
|
|
1499
|
+
todo_dicts = [t.model_dump() for t in todos]
|
|
1500
|
+
|
|
1501
|
+
# Persist (delete file if empty, matching opencode's delete-then-insert pattern)
|
|
1502
|
+
path = _todo_path(session_id)
|
|
1503
|
+
if os.path.isfile(path) and len(todo_dicts) == 0:
|
|
1504
|
+
os.remove(path)
|
|
1505
|
+
else:
|
|
1506
|
+
await _save_todos(session_id, todo_dicts)
|
|
1507
|
+
|
|
1508
|
+
active = sum(1 for t in todo_dicts if t.get("status") != "completed")
|
|
1509
|
+
|
|
1510
|
+
render_tool_call("todo_write", f"{active} active todos")
|
|
1511
|
+
|
|
1512
|
+
lines = []
|
|
1513
|
+
for t in todo_dicts:
|
|
1514
|
+
status = t.get("status", "pending")
|
|
1515
|
+
content = t.get("content", "")
|
|
1516
|
+
priority = t.get("priority", "medium")
|
|
1517
|
+
marker = {"completed": "[x]", "in_progress": "[>]", "cancelled": "[-]"}.get(
|
|
1518
|
+
status, "[ ]"
|
|
1519
|
+
)
|
|
1520
|
+
lines.append(f" {marker} {content} (priority: {priority})")
|
|
1521
|
+
|
|
1522
|
+
output = "\n".join(lines)
|
|
1523
|
+
if active > 0:
|
|
1524
|
+
output = f"{active} active todo(s):\n{output}"
|
|
1525
|
+
else:
|
|
1526
|
+
output = "All todos completed." if todo_dicts else "Todo list cleared."
|
|
1527
|
+
|
|
1528
|
+
# 直接打印到终端(chat.py 流式循环不渲染 ToolMessage 结果)
|
|
1529
|
+
# 使用 Text 对象避免 [x] 等方括号被 Rich markup 解析器吞掉
|
|
1530
|
+
if todo_dicts:
|
|
1531
|
+
console.print(Text(f"\n {active} active todo(s):", style="bold green"))
|
|
1532
|
+
for t in todo_dicts:
|
|
1533
|
+
status = t.get("status", "pending")
|
|
1534
|
+
content = t.get("content", "")
|
|
1535
|
+
priority = t.get("priority", "medium")
|
|
1536
|
+
marker_map = {
|
|
1537
|
+
"completed": "[x]",
|
|
1538
|
+
"in_progress": "[>]",
|
|
1539
|
+
"cancelled": "[-]",
|
|
1540
|
+
"pending": "[ ]",
|
|
1541
|
+
}
|
|
1542
|
+
marker = marker_map.get(status, "[ ]")
|
|
1543
|
+
ps = {"high": "red bold", "medium": "yellow", "low": "dim"}.get(
|
|
1544
|
+
priority, ""
|
|
1545
|
+
)
|
|
1546
|
+
line = Text(f" {marker} {content} ")
|
|
1547
|
+
line.append(f"({priority})", style=ps)
|
|
1548
|
+
console.print(line)
|
|
1549
|
+
else:
|
|
1550
|
+
console.print(Text(" Todo list cleared.", style="dim"))
|
|
1551
|
+
|
|
1552
|
+
return output
|
|
1553
|
+
|
|
1554
|
+
|
|
1555
|
+
# ---------------------------------------------------------------------------
|
|
1556
|
+
# vision — 视觉理解工具(通过 ModelScope API 调用视觉模型)
|
|
1557
|
+
# ---------------------------------------------------------------------------
|
|
1558
|
+
|
|
1559
|
+
_VISION_SUPPORTED_EXTS = frozenset(
|
|
1560
|
+
{
|
|
1561
|
+
".png",
|
|
1562
|
+
".jpg",
|
|
1563
|
+
".jpeg",
|
|
1564
|
+
".gif",
|
|
1565
|
+
".bmp",
|
|
1566
|
+
".webp",
|
|
1567
|
+
".tiff",
|
|
1568
|
+
".tif",
|
|
1569
|
+
".mp4",
|
|
1570
|
+
".mov",
|
|
1571
|
+
".avi",
|
|
1572
|
+
".mkv",
|
|
1573
|
+
".webm",
|
|
1574
|
+
}
|
|
1575
|
+
)
|
|
1576
|
+
@tool
|
|
1577
|
+
async def vision(
|
|
1578
|
+
image_path: str,
|
|
1579
|
+
prompt: str = "请详细描述这张图片的内容。",
|
|
1580
|
+
runtime: ToolRuntime[SkillAgentContext] = None,
|
|
1581
|
+
) -> str:
|
|
1582
|
+
"""\
|
|
1583
|
+
Analyze an image or video using a vision model.
|
|
1584
|
+
|
|
1585
|
+
Use this tool when the user provides an image/video file path
|
|
1586
|
+
and wants to understand, describe, or extract information from it.
|
|
1587
|
+
|
|
1588
|
+
The tool supports common image formats: PNG, JPG, JPEG, GIF, BMP, WebP, TIFF
|
|
1589
|
+
and video formats: MP4, MOV, AVI, MKV, WebM.
|
|
1590
|
+
|
|
1591
|
+
Args:
|
|
1592
|
+
image_path: Path to the image or video file (absolute or relative to working directory)
|
|
1593
|
+
prompt: What to ask about the media (default: describe the content)
|
|
1594
|
+
"""
|
|
1595
|
+
path = resolve_path(image_path, runtime.context.working_directory)
|
|
1596
|
+
render_tool_call("vision", image_path)
|
|
1597
|
+
|
|
1598
|
+
# 验证文件
|
|
1599
|
+
if not path.exists():
|
|
1600
|
+
return f"vision:\n[FAILED] File not found: {image_path}"
|
|
1601
|
+
|
|
1602
|
+
if not path.is_file():
|
|
1603
|
+
return f"vision:\n[FAILED] Not a file: {image_path}"
|
|
1604
|
+
|
|
1605
|
+
if path.suffix.lower() not in _VISION_SUPPORTED_EXTS:
|
|
1606
|
+
return (
|
|
1607
|
+
f"vision:\n[FAILED] Unsupported image format: {path.suffix}\n"
|
|
1608
|
+
f"Supported formats: {', '.join(sorted(_VISION_SUPPORTED_EXTS))}"
|
|
1609
|
+
)
|
|
1610
|
+
|
|
1611
|
+
# 读取并 base64 编码(使用共享工具函数)
|
|
1612
|
+
ext = path.suffix.lower().lstrip(".")
|
|
1613
|
+
is_video = ext in {"mp4", "mov", "avi", "mkv", "webm"}
|
|
1614
|
+
|
|
1615
|
+
try:
|
|
1616
|
+
from chcode.utils.multimodal import encode_media_as_base64
|
|
1617
|
+
|
|
1618
|
+
b64_image, mime_type = encode_media_as_base64(path)
|
|
1619
|
+
except Exception as e:
|
|
1620
|
+
return f"vision:\n[FAILED] Failed to read {'video' if is_video else 'image'}: {e}"
|
|
1621
|
+
|
|
1622
|
+
# 获取视觉模型配置 + 构建消息 + 调用模型
|
|
1623
|
+
from langchain_core.messages import HumanMessage
|
|
1624
|
+
|
|
1625
|
+
from chcode.utils.enhanced_chat_openai import EnhancedChatOpenAI
|
|
1626
|
+
from chcode.vision_config import (
|
|
1627
|
+
auto_configure_vision,
|
|
1628
|
+
get_vision_default_model,
|
|
1629
|
+
get_vision_fallback_models,
|
|
1630
|
+
)
|
|
1631
|
+
|
|
1632
|
+
media_url = f"data:{mime_type};base64,{b64_image}"
|
|
1633
|
+
if is_video: # pragma: no cover
|
|
1634
|
+
media_content = { # pragma: no cover
|
|
1635
|
+
"type": "video_url", # pragma: no cover
|
|
1636
|
+
"video_url": {"url": media_url}, # pragma: no cover
|
|
1637
|
+
} # pragma: no cover
|
|
1638
|
+
else:
|
|
1639
|
+
media_content = {
|
|
1640
|
+
"type": "image_url",
|
|
1641
|
+
"image_url": {"url": media_url},
|
|
1642
|
+
}
|
|
1643
|
+
messages = [
|
|
1644
|
+
HumanMessage(content=[media_content, {"type": "text", "text": prompt}])
|
|
1645
|
+
]
|
|
1646
|
+
|
|
1647
|
+
models_to_try = []
|
|
1648
|
+
default_model = get_vision_default_model()
|
|
1649
|
+
if not default_model:
|
|
1650
|
+
default_model = auto_configure_vision()
|
|
1651
|
+
if default_model:
|
|
1652
|
+
models_to_try.append(default_model)
|
|
1653
|
+
models_to_try.extend(get_vision_fallback_models())
|
|
1654
|
+
|
|
1655
|
+
seen: set[str] = set()
|
|
1656
|
+
unique_models: list[dict] = []
|
|
1657
|
+
for m in models_to_try:
|
|
1658
|
+
name = m.get("model", "")
|
|
1659
|
+
if name and name not in seen:
|
|
1660
|
+
seen.add(name)
|
|
1661
|
+
unique_models.append(m)
|
|
1662
|
+
|
|
1663
|
+
if not unique_models:
|
|
1664
|
+
return (
|
|
1665
|
+
"vision:\n[FAILED] 视觉模型未配置。\n"
|
|
1666
|
+
"请使用 /vision 命令配置 ModelScope API Key,\n"
|
|
1667
|
+
"或设置环境变量 ModelScopeToken。"
|
|
1668
|
+
)
|
|
1669
|
+
|
|
1670
|
+
last_error = None
|
|
1671
|
+
for model_config in unique_models:
|
|
1672
|
+
model_name = model_config.get("model", "unknown")
|
|
1673
|
+
api_key = model_config.get("api_key", "")
|
|
1674
|
+
if not api_key:
|
|
1675
|
+
continue
|
|
1676
|
+
|
|
1677
|
+
try:
|
|
1678
|
+
llm_kwargs: dict[str, Any] = {
|
|
1679
|
+
"model": model_name,
|
|
1680
|
+
"base_url": model_config.get(
|
|
1681
|
+
"base_url", "https://api-inference.modelscope.cn/v1"
|
|
1682
|
+
),
|
|
1683
|
+
"api_key": api_key,
|
|
1684
|
+
"max_tokens": 4096,
|
|
1685
|
+
"max_retries": 0,
|
|
1686
|
+
"timeout": 120,
|
|
1687
|
+
}
|
|
1688
|
+
if "temperature" in model_config:
|
|
1689
|
+
llm_kwargs["temperature"] = model_config["temperature"]
|
|
1690
|
+
if "top_p" in model_config:
|
|
1691
|
+
llm_kwargs["top_p"] = model_config["top_p"]
|
|
1692
|
+
|
|
1693
|
+
llm = EnhancedChatOpenAI(**llm_kwargs)
|
|
1694
|
+
result = await llm.ainvoke(messages, config={"callbacks": []})
|
|
1695
|
+
content = result.content
|
|
1696
|
+
if content:
|
|
1697
|
+
return f"vision:\n[OK] (model: {model_name})\n\n{content}"
|
|
1698
|
+
last_error = "Empty content in response"
|
|
1699
|
+
except Exception as e:
|
|
1700
|
+
last_error = str(e)
|
|
1701
|
+
console.print(
|
|
1702
|
+
f"[yellow]视觉模型 {model_name} 调用失败: {e}[/yellow]"
|
|
1703
|
+
)
|
|
1704
|
+
continue
|
|
1705
|
+
|
|
1706
|
+
return f"vision:\n[FAILED] 所有视觉模型均调用失败\n最后错误: {last_error}"
|
|
1707
|
+
|
|
1708
|
+
|
|
1709
|
+
ALL_TOOLS = [
|
|
1710
|
+
load_skill,
|
|
1711
|
+
bash,
|
|
1712
|
+
read_file,
|
|
1713
|
+
write_file,
|
|
1714
|
+
glob,
|
|
1715
|
+
grep,
|
|
1716
|
+
edit,
|
|
1717
|
+
list_dir,
|
|
1718
|
+
web_search,
|
|
1719
|
+
web_fetch,
|
|
1720
|
+
ask_user,
|
|
1721
|
+
agent,
|
|
1722
|
+
todo_write,
|
|
1723
|
+
vision,
|
|
1724
|
+
]
|