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/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
+ ]