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 ADDED
File without changes
chcode/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """python -m chcode 入口"""
2
+ from chcode.cli import app
3
+
4
+ if __name__ == "__main__":
5
+ app()
chcode/agent_setup.py ADDED
@@ -0,0 +1,395 @@
1
+ """
2
+ Agent 构建 — 中间件注册、checkpointer 初始化
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Callable
11
+
12
+ from langchain.agents import create_agent
13
+ from langchain.agents.middleware import (
14
+ dynamic_prompt,
15
+ wrap_tool_call,
16
+ wrap_model_call,
17
+ ModelRequest,
18
+ ModelResponse,
19
+ HumanInTheLoopMiddleware,
20
+ )
21
+ from langchain.agents.middleware.context_editing import (
22
+ ContextEditingMiddleware,
23
+ ClearToolUsesEdit,
24
+ )
25
+ from langchain.agents.middleware.summarization import SummarizationMiddleware
26
+ from langchain_core.messages import ToolMessage
27
+ from langchain.tools.tool_node import ToolCallRequest
28
+ from langgraph.types import Command
29
+ from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
30
+
31
+ from chcode.utils.enhanced_chat_openai import EnhancedChatOpenAI
32
+ from chcode.utils.modelscope_ratelimit import is_modelscope_model, get_modelscope_clients
33
+ from chcode.utils.multimodal import is_multimodal_model
34
+ from chcode.utils.skill_loader import SkillAgentContext
35
+ from chcode.display import console
36
+ from chcode.utils.tool_result_pipeline import (
37
+ clean_tool_output,
38
+ truncate_large_result,
39
+ enforce_per_turn_budget,
40
+ reset_budget_state, # noqa: F401 # 重新导出供其他模块使用
41
+ )
42
+
43
+ import aiosqlite
44
+
45
+ # ─── 内置默认模型配置 ──────────────────────────────────
46
+
47
+ import os
48
+
49
+ INNER_MODEL_CONFIG = {
50
+ "model": "Qwen/Qwen3-235B-A22B-Thinking-2507",
51
+ "base_url": "https://api-inference.modelscope.cn/v1",
52
+ "api_key": os.getenv("ModelScopeToken"),
53
+ "temperature": 1,
54
+ "top_p": 1,
55
+ "stream_usage": True,
56
+ "extra_body": {"stream": True},
57
+ }
58
+
59
+
60
+ # ─── 重试配置 ──────────────────────────────────────────
61
+
62
+ RETRY_DELAYS = [3, 10, 30, 60]
63
+ _fallback_models: list[dict] = []
64
+ _fallback_index: int = 0
65
+
66
+
67
+ def set_fallback_models(models: list[dict]) -> None:
68
+ global _fallback_models, _fallback_index
69
+ _fallback_models = models
70
+ _fallback_index = 0
71
+
72
+
73
+ def get_fallback_model() -> dict | None:
74
+ if _fallback_index < len(_fallback_models):
75
+ return _fallback_models[_fallback_index]
76
+ return None
77
+
78
+
79
+ def advance_fallback() -> None:
80
+ global _fallback_index
81
+ _fallback_index += 1
82
+
83
+
84
+ def _load_fallback_config() -> dict | None:
85
+ """获取当前备用模型"""
86
+ global _fallback_models
87
+ if not _fallback_models:
88
+ from chcode.config import load_model_json
89
+
90
+ data = load_model_json()
91
+ fallback = data.get("fallback", {})
92
+ if not fallback:
93
+ return None
94
+ _fallback_models = list(fallback.values())
95
+
96
+ return get_fallback_model()
97
+
98
+
99
+ # ─── 中间件 ──────────────────────────────────────────
100
+
101
+
102
+ @wrap_tool_call
103
+ async def handle_tool_errors(
104
+ request: ToolCallRequest, handler: Callable[[ToolCallRequest], Command]
105
+ ) -> Command | ToolMessage:
106
+ try:
107
+ return await handler(request)
108
+ except Exception as e:
109
+ return ToolMessage(
110
+ f"Tool error: Please check your input and try again ({e})",
111
+ tool_call_id=request.tool_call["id"],
112
+ status="error",
113
+ )
114
+
115
+
116
+ class ModelSwitchError(Exception):
117
+ """标记需要切换模型的异常"""
118
+ pass
119
+
120
+
121
+ @wrap_tool_call
122
+ async def filter_vision_tool(
123
+ request: ToolCallRequest,
124
+ handler: Callable[[ToolCallRequest], Command],
125
+ ) -> Command | ToolMessage:
126
+ """多模态模型时屏蔽 vision 工具 — 模型自带视觉能力"""
127
+ tool_name = request.tool_call.get("name", "")
128
+ if tool_name == "vision":
129
+ model_config = request.runtime.context.model_config
130
+ model_name = model_config.get("model", "")
131
+
132
+ if is_multimodal_model(model_name):
133
+ return ToolMessage(
134
+ content="当前模型支持原生视觉,图片/视频已直接嵌入消息,无需调用 vision 工具。请直接分析消息中的图片/视频内容。",
135
+ tool_call_id=request.tool_call["id"],
136
+ status="error",
137
+ )
138
+ return await handler(request)
139
+
140
+
141
+ @wrap_model_call
142
+ async def model_retry_with_backoff(
143
+ request: ModelRequest, handler: Callable[[ModelRequest], ModelResponse]
144
+ ) -> ModelResponse:
145
+ """指数级退避重试中间件 — 每次调用独立计数"""
146
+ max_retries = 4
147
+
148
+ retry_count = 0
149
+
150
+ while True:
151
+ try:
152
+ return await handler(request)
153
+ except Exception as e:
154
+ retry_count += 1
155
+
156
+ if retry_count >= max_retries:
157
+ fallback = _load_fallback_config()
158
+ if fallback:
159
+ console.print(f"[yellow]主模型重试{retry_count}次失败,切换到备用模型...[/yellow]")
160
+ raise ModelSwitchError("切换到备用模型")
161
+ console.print(f"[red]请求失败,无备用模型可用,放弃请求\n {e}[/red]")
162
+ raise
163
+
164
+ delay_idx = min(retry_count - 1, len(RETRY_DELAYS) - 1)
165
+ delay = RETRY_DELAYS[delay_idx]
166
+
167
+ console.print(f"[yellow]请求失败 ({retry_count}/{max_retries}), {delay}秒后重试...\n {e}[/yellow]")
168
+
169
+ await asyncio.sleep(delay)
170
+
171
+
172
+ @dynamic_prompt
173
+ async def load_skills(request: ModelRequest) -> str:
174
+ """构建 system prompt — Level 1: 注入所有 Skills 元数据"""
175
+ skill_loader = request.runtime.context.skill_loader
176
+ os_name = sys.platform
177
+ model_config = request.runtime.context.model_config
178
+ model_name = model_config.get("model", "")
179
+
180
+ native_vision = is_multimodal_model(model_name)
181
+
182
+ if native_vision:
183
+ base_prompt = f"""You are a coding assistant. OS: {os_name}. CWD: {request.runtime.context.working_directory}.
184
+
185
+ Tools:
186
+ - bash: execute shell commands and scripts. Stop immediately if the user refuses.
187
+ - read_file: view file content; write_file: create or save files; edit: modify existing files. Always read before write, prefer edit over write_file.
188
+ - glob: find files by name pattern; grep: search file contents with regex; list_dir: browse directory structure.
189
+ - web_search: search the Internet; web_fetch: fetch and read a URL's content.
190
+ - ask_user: present choices to the user and collect their input or confirmation.
191
+ - todo_write: create and manage a task list for complex multi-step work.
192
+ - load_skill: when a request matches a skill's description, load it first to get detailed instructions.
193
+
194
+ Guidelines:
195
+ - Never create .md/README files unless explicitly asked.
196
+ - You have native vision capability. When the user sends an image or video file path, the image/video is already embedded in the message — analyze it directly. Do NOT call the vision tool."""
197
+ else:
198
+ base_prompt = f"""You are a coding assistant. OS: {os_name}. CWD: {request.runtime.context.working_directory}.
199
+
200
+ Tools:
201
+ - bash: execute shell commands and scripts. Stop immediately if the user refuses.
202
+ - read_file: view file content; write_file: create or save files; edit: modify existing files. Always read before write, prefer edit over write_file.
203
+ - glob: find files by name pattern; grep: search file contents with regex; list_dir: browse directory structure.
204
+ - web_search: search the Internet; web_fetch: fetch and read a URL's content.
205
+ - ask_user: present choices to the user and collect their input or confirmation.
206
+ - todo_write: create and manage a task list for complex multi-step work.
207
+ - load_skill: when a request matches a skill's description, load it first to get detailed instructions.
208
+ - vision: analyze an image or video file using a vision model. Use when the user provides an image/video path or asks about visual content. Supports PNG, JPG, GIF, BMP, WebP, TIFF, MP4, MOV, AVI, MKV, WebM. The user can paste file paths directly in chat.
209
+
210
+ Guidelines:
211
+ - Never create .md/README files unless explicitly asked.
212
+ - When the user sends an image or video file path, use vision to understand it before responding."""
213
+
214
+ return await asyncio.to_thread(skill_loader.build_system_prompt, base_prompt)
215
+
216
+
217
+ @wrap_model_call
218
+ async def load_model(
219
+ request: ModelRequest, handler: Callable[[ModelRequest], ModelResponse]
220
+ ) -> ModelResponse:
221
+ """动态加载模型"""
222
+ model_config = request.runtime.context.model_config
223
+ kwargs = dict(model_config)
224
+ if is_modelscope_model(model_config):
225
+ sync_client, async_client = get_modelscope_clients()
226
+ kwargs["http_client"] = sync_client
227
+ kwargs["http_async_client"] = async_client
228
+ return await handler(request.override(model=EnhancedChatOpenAI(**kwargs)))
229
+
230
+
231
+ @wrap_model_call
232
+ async def fix_messages(
233
+ request: ModelRequest, handler: Callable[[ModelRequest], ModelResponse]
234
+ ) -> ModelResponse:
235
+ """过滤隐藏消息"""
236
+ messages = request.messages
237
+ real_messages = [m for m in messages if not m.additional_kwargs.get("composed", "")]
238
+ if len(real_messages) == len(messages):
239
+ return await handler(request)
240
+ return await handler(request.override(messages=real_messages))
241
+
242
+
243
+ @wrap_model_call
244
+ async def tool_result_budget(
245
+ request: ModelRequest, handler: Callable[[ModelRequest], ModelResponse]
246
+ ) -> ModelResponse:
247
+ """工具结果截断和 token 预算控制"""
248
+ workplace = request.runtime.context.working_directory
249
+ messages = list(request.messages)
250
+ changed = False
251
+ for i, msg in enumerate(messages):
252
+ if isinstance(msg, ToolMessage) and msg.content:
253
+ if msg.additional_kwargs.get("_budget_ok"):
254
+ continue
255
+ cleaned = clean_tool_output(msg.content)
256
+ truncated = truncate_large_result(
257
+ cleaned,
258
+ msg.name or "",
259
+ msg.tool_call_id,
260
+ workplace=workplace,
261
+ )
262
+ new_kwargs = {**msg.additional_kwargs, "_budget_ok": True}
263
+ messages[i] = msg.model_copy(update={"content": truncated, "additional_kwargs": new_kwargs})
264
+ changed = True
265
+ if changed:
266
+ messages = enforce_per_turn_budget(messages, budget=200_000, workplace=workplace)
267
+ return await handler(request.override(messages=messages))
268
+ return await handler(request)
269
+
270
+
271
+ # ─── Agent 构建 ──────────────────────────────────────────
272
+
273
+
274
+ class AsyncHITL(HumanInTheLoopMiddleware):
275
+ """异步 HITL 中间件 — 审批在 chat loop 中处理"""
276
+
277
+ async def awrap_model_call(self, request, handler):
278
+ return await handler(request)
279
+
280
+
281
+ _hitl_middleware: AsyncHITL | None = None
282
+ _summarization_model: EnhancedChatOpenAI | None = None
283
+
284
+
285
+ def _build_interrupt_on(yolo: bool) -> dict:
286
+ return (
287
+ {}
288
+ if yolo
289
+ else {
290
+ "bash": {"allowed_decisions": ["approve", "reject"]},
291
+ "edit": {"allowed_decisions": ["approve", "reject"]},
292
+ "write_file": {"allowed_decisions": ["approve", "reject"]},
293
+ }
294
+ )
295
+
296
+
297
+ def _dummy_model():
298
+ from langchain_openai import ChatOpenAI
299
+
300
+ return ChatOpenAI(model="placeholder", api_key="sk-placeholder", max_retries=0)
301
+
302
+
303
+ def build_agent(
304
+ model_config: dict | None = None,
305
+ checkpointer: AsyncSqliteSaver | None = None,
306
+ mcp_tools: list | None = None,
307
+ yolo: bool = False,
308
+ ) -> object:
309
+ """构建 agent 实例"""
310
+ global _hitl_middleware, _summarization_model
311
+
312
+ cfg = model_config or INNER_MODEL_CONFIG
313
+ model = _dummy_model()
314
+
315
+ _hitl_middleware = AsyncHITL(interrupt_on=_build_interrupt_on(yolo))
316
+ _summarization_model = EnhancedChatOpenAI(**cfg)
317
+
318
+ # 加载 fallback 模型配置
319
+ from chcode.config import load_model_json, get_context_window_size
320
+
321
+ data = load_model_json()
322
+ fallback = data.get("fallback", {})
323
+ if fallback:
324
+ current_model = cfg.get("model", "")
325
+ filtered = [v for k, v in fallback.items() if v.get("model") != current_model]
326
+ set_fallback_models(filtered)
327
+
328
+ # 摘要触发阈值 = 上下文窗口的 90%
329
+ model_name = cfg.get("model", "")
330
+ ctx_window = get_context_window_size(model_name)
331
+ summary_trigger = int(ctx_window * 0.9)
332
+
333
+ agent = create_agent(
334
+ model,
335
+ _get_all_tools() + (mcp_tools or []),
336
+ middleware=[
337
+ handle_tool_errors,
338
+ filter_vision_tool,
339
+ tool_result_budget,
340
+ load_skills,
341
+ load_model,
342
+ model_retry_with_backoff,
343
+ fix_messages,
344
+ ContextEditingMiddleware(
345
+ edits=[
346
+ ClearToolUsesEdit(
347
+ trigger=100_000,
348
+ keep=3,
349
+ exclude_tools=["read_file"],
350
+ placeholder="[Old tool result content cleared]",
351
+ )
352
+ ]
353
+ ),
354
+ SummarizationMiddleware(
355
+ model=_summarization_model,
356
+ trigger=("tokens", summary_trigger),
357
+ keep=("messages", 20),
358
+ ),
359
+ _hitl_middleware,
360
+ ],
361
+ context_schema=SkillAgentContext,
362
+ checkpointer=checkpointer,
363
+ )
364
+ return agent
365
+
366
+
367
+ def update_hitl_config(yolo: bool) -> None:
368
+ """运行时更新 HITL interrupt_on 配置,无需重建 agent"""
369
+ if _hitl_middleware is not None:
370
+ _hitl_middleware.interrupt_on = _build_interrupt_on(yolo)
371
+
372
+
373
+ def update_summarization_model(model_config: dict) -> None:
374
+ """运行时更新 SummarizationMiddleware 的模型"""
375
+ if _summarization_model is not None:
376
+ new_model = EnhancedChatOpenAI(**model_config)
377
+ for key in new_model.model_fields_set:
378
+ try:
379
+ if key in new_model.__dict__:
380
+ setattr(_summarization_model, key, new_model.__dict__[key])
381
+ except (AttributeError, TypeError):
382
+ pass
383
+
384
+
385
+ async def create_checkpointer(db_path: Path) -> AsyncSqliteSaver:
386
+ """创建异步 SQLite checkpointer"""
387
+ conn = await aiosqlite.connect(str(db_path))
388
+ return AsyncSqliteSaver(conn)
389
+
390
+
391
+ def _get_all_tools() -> list:
392
+ """获取所有工具(延迟导入避免循环依赖)"""
393
+ from chcode.utils.tools import ALL_TOOLS
394
+
395
+ return ALL_TOOLS
File without changes
@@ -0,0 +1,158 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+
6
+ @dataclass
7
+ class AgentDefinition:
8
+ agent_type: str
9
+ when_to_use: str
10
+ system_prompt: str
11
+ tools: list[str] | None = None
12
+ disallowed_tools: list[str] = field(default_factory=list)
13
+ model: str | None = None
14
+ read_only: bool = False
15
+ source: str = "built-in"
16
+
17
+
18
+ _GENERAL_PURPOSE_SYSTEM_PROMPT = """You are a sub-agent for ChCode, a terminal-based AI coding assistant. Given the task description, use the tools available to complete it fully.
19
+
20
+ Your strengths:
21
+ - Searching for code, configurations, and patterns across large codebases
22
+ - Analyzing multiple files to understand system architecture
23
+ - Investigating complex questions that require exploring many files
24
+ - Performing multi-step research tasks
25
+
26
+ Guidelines:
27
+ - For file searches: search broadly when you don't know where something lives. Use read_file when you know the specific file path.
28
+ - For analysis: Start broad and narrow down. Use multiple search strategies if the first doesn't yield results.
29
+ - Be thorough: Check multiple locations, consider different naming conventions, look for related files.
30
+ - NEVER create files unless they're absolutely necessary for achieving your goal. ALWAYS prefer editing an existing file to creating a new one.
31
+ - NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested.
32
+
33
+ When you complete the task, respond with a concise report covering what was done and any key findings."""
34
+
35
+
36
+ _EXPLORE_SYSTEM_PROMPT = """You are a file search specialist for ChCode. You excel at thoroughly navigating and exploring codebases.
37
+
38
+ === CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS ===
39
+ This is a READ-ONLY exploration task. You are STRICTLY PROHIBITED from:
40
+ - Creating new files (no write_file, touch, or file creation of any kind)
41
+ - Modifying existing files (no edit operations)
42
+ - Deleting files (no rm or deletion)
43
+ - Moving or copying files (no mv or cp)
44
+ - Running ANY commands that change system state
45
+
46
+ Your role is EXCLUSIVELY to search and analyze existing code.
47
+
48
+ Your strengths:
49
+ - Rapidly finding files using glob patterns
50
+ - Searching code and text with powerful regex patterns via grep
51
+ - Reading and analyzing file contents
52
+
53
+ Guidelines:
54
+ - Use glob for broad file pattern matching
55
+ - Use grep for searching file contents with regex
56
+ - Use read_file when you know the specific file path you need to read
57
+ - Use bash ONLY for read-only operations (ls, git status, git log, git diff, find, cat, head, tail)
58
+ - NEVER use bash for: mkdir, touch, rm, cp, mv, git add, git commit, npm install, pip install, or any file creation/modification
59
+ - Adapt your search approach based on the thoroughness level specified by the caller
60
+ - Communicate your final report directly as a regular message - do NOT attempt to create files
61
+
62
+ NOTE: You are meant to be a fast agent that returns output as quickly as possible. In order to achieve this you must:
63
+ - Make efficient use of the tools that you have at your disposal: be smart about how you search for files and implementations
64
+ - Wherever possible you should try to spawn multiple parallel tool calls for grepping and reading files
65
+
66
+ Complete the user's search request efficiently and report your findings clearly."""
67
+
68
+
69
+ _PLAN_SYSTEM_PROMPT = """You are a software architect and planning specialist for ChCode. Your role is to explore the codebase and design implementation plans.
70
+
71
+ === CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS ===
72
+ This is a READ-ONLY planning task. You are STRICTLY PROHIBITED from:
73
+ - Creating new files (no write_file, touch, or file creation of any kind)
74
+ - Modifying existing files (no edit operations)
75
+ - Deleting files (no rm or deletion)
76
+ - Running ANY commands that change system state
77
+
78
+ Your role is EXCLUSIVELY to explore the codebase and design implementation plans.
79
+
80
+ You will be provided with a set of requirements and optionally a perspective on how to approach the design process.
81
+
82
+ ## Your Process
83
+
84
+ 1. **Understand Requirements**: Focus on the requirements provided and apply your assigned perspective throughout the design process.
85
+
86
+ 2. **Explore Thoroughly**:
87
+ - Read any files provided to you in the initial prompt
88
+ - Find existing patterns and conventions using glob, grep, and read_file
89
+ - Understand the current architecture
90
+ - Identify similar features as reference
91
+ - Trace through relevant code paths
92
+ - Use bash ONLY for read-only operations (ls, git status, git log, git diff, find, cat, head, tail)
93
+ - NEVER use bash for: mkdir, touch, rm, cp, mv, git add, git commit, npm install, pip install, or any file creation/modification
94
+
95
+ 3. **Design Solution**:
96
+ - Create implementation approach based on your assigned perspective
97
+ - Consider trade-offs and architectural decisions
98
+ - Follow existing patterns where appropriate
99
+
100
+ 4. **Detail the Plan**:
101
+ - Provide step-by-step implementation strategy
102
+ - Identify dependencies and sequencing
103
+ - Anticipate potential challenges
104
+
105
+ ## Required Output
106
+
107
+ End your response with:
108
+
109
+ ### Critical Files for Implementation
110
+ List 3-5 files most critical for implementing this plan:
111
+ - path/to/file1
112
+ - path/to/file2
113
+ - path/to/file3
114
+
115
+ REMEMBER: You can ONLY explore and plan. You CANNOT and MUST NOT write, edit, or modify any files."""
116
+
117
+
118
+ BUILT_IN_AGENTS: dict[str, AgentDefinition] = {
119
+ "general-purpose": AgentDefinition(
120
+ agent_type="general-purpose",
121
+ when_to_use=(
122
+ "General-purpose agent for researching complex questions, searching for code, "
123
+ "and executing multi-step tasks. When you are searching for a keyword or file "
124
+ "and are not confident that you will find the right match in the first few tries "
125
+ "use this agent to perform the search for you."
126
+ ),
127
+ system_prompt=_GENERAL_PURPOSE_SYSTEM_PROMPT,
128
+ tools=None,
129
+ disallowed_tools=[],
130
+ read_only=False,
131
+ ),
132
+ "Explore": AgentDefinition(
133
+ agent_type="Explore",
134
+ when_to_use=(
135
+ "Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns "
136
+ '(eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions '
137
+ 'about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired '
138
+ 'thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or '
139
+ '"very thorough" for comprehensive analysis across multiple locations and naming conventions.'
140
+ ),
141
+ system_prompt=_EXPLORE_SYSTEM_PROMPT,
142
+ tools=None,
143
+ disallowed_tools=["write_file", "edit"],
144
+ read_only=True,
145
+ ),
146
+ "Plan": AgentDefinition(
147
+ agent_type="Plan",
148
+ when_to_use=(
149
+ "Software architect agent for designing implementation plans. Use this when you need to "
150
+ "plan the implementation strategy for a task. Returns step-by-step plans, identifies "
151
+ "critical files, and considers architectural trade-offs."
152
+ ),
153
+ system_prompt=_PLAN_SYSTEM_PROMPT,
154
+ tools=None,
155
+ disallowed_tools=["write_file", "edit"],
156
+ read_only=True,
157
+ ),
158
+ }
@@ -0,0 +1,104 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from pathlib import Path
5
+
6
+ import yaml
7
+
8
+ from chcode.agents.definitions import AgentDefinition
9
+
10
+ DEFAULT_AGENT_PATHS = [
11
+ Path.cwd() / ".chat" / "agents",
12
+ Path.home() / ".chat" / "agents",
13
+ ]
14
+
15
+
16
+ def _parse_agent_md(md_path: Path) -> AgentDefinition | None:
17
+ try:
18
+ content = md_path.read_text(encoding="utf-8")
19
+ except Exception:
20
+ return None
21
+
22
+ fm_match = re.match(r"^---\s*\n(.*?)\n---\s*\n", content, re.DOTALL)
23
+ if not fm_match:
24
+ return None
25
+
26
+ try:
27
+ frontmatter = yaml.safe_load(fm_match.group(1))
28
+ except yaml.YAMLError:
29
+ return None
30
+
31
+ if not isinstance(frontmatter, dict):
32
+ return None
33
+
34
+ agent_type = frontmatter.get("name", "")
35
+ description = frontmatter.get("description", "")
36
+
37
+ if not agent_type or not description:
38
+ return None
39
+
40
+ body = content[fm_match.end() :]
41
+ system_prompt = body.strip()
42
+ if not system_prompt:
43
+ return None
44
+
45
+ tools_raw = frontmatter.get("tools")
46
+ tools = (
47
+ [t.strip() for t in tools_raw.split(",") if t.strip()]
48
+ if isinstance(tools_raw, str)
49
+ else None
50
+ )
51
+
52
+ disallowed_raw = frontmatter.get("disallowed_tools")
53
+ disallowed_tools = (
54
+ [t.strip() for t in disallowed_raw.split(",") if t.strip()]
55
+ if isinstance(disallowed_raw, str)
56
+ else []
57
+ )
58
+
59
+ model = frontmatter.get("model") or None
60
+ read_only = bool(frontmatter.get("read_only", False))
61
+
62
+ return AgentDefinition(
63
+ agent_type=agent_type,
64
+ when_to_use=description.replace("\\n", "\n"),
65
+ system_prompt=system_prompt,
66
+ tools=tools,
67
+ disallowed_tools=disallowed_tools,
68
+ model=model,
69
+ read_only=read_only,
70
+ source="custom",
71
+ )
72
+
73
+
74
+ _agents_cache: dict[str, AgentDefinition] | None = None
75
+
76
+
77
+ def load_agents(extra_paths: list[Path] | None = None) -> dict[str, AgentDefinition]:
78
+ global _agents_cache
79
+
80
+ if _agents_cache is not None and not extra_paths:
81
+ return dict(_agents_cache)
82
+
83
+ from chcode.agents.definitions import BUILT_IN_AGENTS
84
+
85
+ result: dict[str, AgentDefinition] = dict(BUILT_IN_AGENTS)
86
+
87
+ paths = list(DEFAULT_AGENT_PATHS)
88
+ if extra_paths:
89
+ paths = extra_paths + paths
90
+
91
+ for base_path in paths:
92
+ if not base_path.exists():
93
+ continue
94
+ for item in base_path.iterdir():
95
+ if not item.is_file() or not item.suffix == ".md":
96
+ continue
97
+ agent = _parse_agent_md(item)
98
+ if agent and agent.agent_type not in result:
99
+ result[agent.agent_type] = agent
100
+
101
+ if not extra_paths:
102
+ _agents_cache = result
103
+
104
+ return result