langchain-agentx-python 1.3.2__py3-none-any.whl → 1.3.4__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.
Files changed (37) hide show
  1. langchain_agentx/__init__.py +1 -1
  2. langchain_agentx/command/dispatcher.py +69 -0
  3. langchain_agentx/loop/graph/factory.py +39 -2
  4. langchain_agentx/session/agent_session.py +15 -0
  5. langchain_agentx/session/conversation_session.py +27 -3
  6. langchain_agentx/task_runtime/skill_prefetch/provider.py +48 -19
  7. langchain_agentx/tool_runtime/read_permission.py +7 -0
  8. langchain_agentx/tool_runtime/session_store.py +4 -0
  9. langchain_agentx/tools/ask_user_question/constants.py +10 -0
  10. langchain_agentx/tools/ask_user_question/tool.py +14 -3
  11. langchain_agentx/tools/skill/__init__.py +2 -1
  12. langchain_agentx/tools/skill/bundled/__init__.py +157 -0
  13. langchain_agentx/tools/skill/bundled/prompts.py +143 -0
  14. langchain_agentx/tools/skill/bundled/registry.py +162 -0
  15. langchain_agentx/tools/skill/catalog.py +463 -0
  16. langchain_agentx/tools/skill/dynamic_catalog.py +62 -4
  17. langchain_agentx/tools/skill/file_discovery.py +24 -1
  18. langchain_agentx/tools/skill/hook_registration.py +110 -0
  19. langchain_agentx/tools/skill/loader.py +104 -36
  20. langchain_agentx/tools/skill/models.py +3 -3
  21. langchain_agentx/tools/skill/multi_source_repository.py +93 -0
  22. langchain_agentx/tools/skill/paths_matcher.py +119 -0
  23. langchain_agentx/tools/skill/plugin_loader.py +43 -0
  24. langchain_agentx/tools/skill/policy.py +2 -37
  25. langchain_agentx/tools/skill/repository_factory.py +39 -0
  26. langchain_agentx/tools/skill/source_paths.py +88 -0
  27. langchain_agentx/tools/skill/tool.py +57 -8
  28. langchain_agentx/utils/claude_temp_paths.py +34 -1
  29. langchain_agentx/utils/permissions/filesystem.py +6 -1
  30. langchain_agentx/utils/settings/__init__.py +5 -0
  31. langchain_agentx/utils/settings/managed_path.py +38 -0
  32. langchain_agentx/workspace/config.py +6 -0
  33. {langchain_agentx_python-1.3.2.dist-info → langchain_agentx_python-1.3.4.dist-info}/METADATA +1 -1
  34. {langchain_agentx_python-1.3.2.dist-info → langchain_agentx_python-1.3.4.dist-info}/RECORD +37 -25
  35. {langchain_agentx_python-1.3.2.dist-info → langchain_agentx_python-1.3.4.dist-info}/LICENSE +0 -0
  36. {langchain_agentx_python-1.3.2.dist-info → langchain_agentx_python-1.3.4.dist-info}/WHEEL +0 -0
  37. {langchain_agentx_python-1.3.2.dist-info → langchain_agentx_python-1.3.4.dist-info}/top_level.txt +0 -0
@@ -11,7 +11,7 @@ from langchain_agentx import create_loop_agent
11
11
  ```
12
12
  """
13
13
 
14
- __version__ = "1.3.2"
14
+ __version__ = "1.3.4"
15
15
 
16
16
  from .loop import ( # noqa: F401
17
17
  create_loop_agent,
@@ -22,6 +22,12 @@ from .registry import CommandRegistry
22
22
  from .result import CommandResult
23
23
  from .types import CommandDefinition
24
24
  from ..plugin.registries import CommandEntry, CommandRegistry as PluginCommandRegistry
25
+ from ..tools.skill.catalog import (
26
+ SkillCatalogService,
27
+ build_skill_catalog_for_context,
28
+ resolve_dynamic_catalog,
29
+ _session_store_from_context,
30
+ )
25
31
 
26
32
  logger = logging.getLogger(__name__)
27
33
 
@@ -31,9 +37,11 @@ class CommandDispatcher:
31
37
  self,
32
38
  registry: CommandRegistry,
33
39
  plugin_command_registry: PluginCommandRegistry | None = None,
40
+ skill_catalog: SkillCatalogService | None = None,
34
41
  ) -> None:
35
42
  self._registry = registry
36
43
  self._plugin_registry = plugin_command_registry
44
+ self._skill_catalog = skill_catalog
37
45
  self._plugin_markdown_cache: dict[Path, tuple[tuple[int, int, int], tuple[str, str]]] = {}
38
46
 
39
47
  def is_slash_command(self, user_input: str) -> bool:
@@ -53,6 +61,9 @@ class CommandDispatcher:
53
61
  if lookup_error is not None:
54
62
  return CommandResult(error=self._format_error("lookup", lookup_error))
55
63
  if command is None:
64
+ skill_result = await self._dispatch_disk_skill(name, args, context)
65
+ if skill_result is not None:
66
+ return skill_result
56
67
  return CommandResult(error=self._format_error("lookup", f"unknown command: /{name}"))
57
68
  if not command.user_invocable:
58
69
  return CommandResult(
@@ -186,6 +197,64 @@ class CommandDispatcher:
186
197
  return None, f"unknown command: /{name} (ambiguous plugin command: {candidates})"
187
198
  return None, None
188
199
 
200
+ async def _dispatch_disk_skill(
201
+ self,
202
+ name: str,
203
+ args: str,
204
+ context: CommandContext,
205
+ ) -> CommandResult | None:
206
+ catalog = self._skill_catalog or build_skill_catalog_for_context(context)
207
+ segment = context.workspace_cfg.agent_home_segment
208
+ session_store = _session_store_from_context(context)
209
+ dynamic = resolve_dynamic_catalog(session_store, agent_home_segment=segment)
210
+ cmd = catalog.find_by_name(
211
+ name,
212
+ dynamic_catalog=dynamic,
213
+ session_store=session_store,
214
+ agent_home_segment=segment,
215
+ )
216
+ if cmd is None:
217
+ return None
218
+ if not catalog.is_user_invocable(cmd):
219
+ return CommandResult(
220
+ error=self._format_error(
221
+ "permission",
222
+ (
223
+ f'/{name} can only be invoked by the model via the Skill tool, '
224
+ f'not directly by users. Ask the agent to use the "{name}" skill.'
225
+ ),
226
+ )
227
+ )
228
+ expanded = catalog.expand_for_slash(
229
+ name,
230
+ args,
231
+ dynamic_catalog=dynamic,
232
+ session_store=session_store,
233
+ agent_home_segment=segment,
234
+ )
235
+ if expanded is None:
236
+ return None
237
+ from langchain_agentx.tools.skill.hook_registration import (
238
+ register_skill_hooks,
239
+ resolve_hooks_snapshot,
240
+ )
241
+
242
+ register_skill_hooks(
243
+ resolve_hooks_snapshot(
244
+ session_store=session_store,
245
+ hook_engine=context.hook_engine,
246
+ ),
247
+ skill_name=expanded.command.name,
248
+ skill_root=expanded.command.path.parent,
249
+ frontmatter=expanded.command.frontmatter,
250
+ )
251
+ return CommandResult(
252
+ should_query=True,
253
+ injected_prompt=expanded.content,
254
+ allowed_tools=list(expanded.allowed_tools) if expanded.allowed_tools else None,
255
+ execution_context=expanded.execution_context,
256
+ )
257
+
189
258
  @staticmethod
190
259
  def _format_error(error_type: str, detail: str) -> str:
191
260
  return f"[command/{error_type}] {detail}"
@@ -573,6 +573,11 @@ class LoopGraphBuilder:
573
573
  if self._services.hooks is not None
574
574
  else HooksConfigSnapshot()
575
575
  )
576
+ if session_store is not None:
577
+ session_store.hooks_snapshot = self._hook_snapshot
578
+ from langchain_agentx.tools.skill.repository_factory import build_skill_repository
579
+
580
+ session_store.skill_repository = build_skill_repository(self._workspace_cfg)
576
581
  self._trace_callback_handler: TraceCallbackHandler | None = None
577
582
  self._trace_tool_callback_handler: TraceCallbackHandler | None = None
578
583
  hook_event_emitter = None
@@ -1539,11 +1544,32 @@ class LoopGraphBuilder:
1539
1544
  # Let providers optionally ingest current loop messages before reserve.
1540
1545
  # This enables auto-bridge scenarios like tool_use_summary without external pump.
1541
1546
  state_messages = list(state.get("messages") or [])
1547
+ session_store = None
1548
+ try:
1549
+ from langgraph.config import get_config
1550
+
1551
+ configurable = (get_config() or {}).get("configurable") or {}
1552
+ session_store = configurable.get("session_store")
1553
+ except Exception:
1554
+ pass
1542
1555
  for provider in provider_chain:
1543
1556
  ingest = getattr(provider, "ingest_from_messages", None)
1544
1557
  if callable(ingest):
1545
1558
  try:
1546
- ingest(scope=scope, messages=state_messages)
1559
+ ingest(
1560
+ scope=scope,
1561
+ messages=state_messages,
1562
+ session_store=session_store,
1563
+ )
1564
+ except TypeError:
1565
+ try:
1566
+ ingest(scope=scope, messages=state_messages)
1567
+ except Exception:
1568
+ logger.debug(
1569
+ "provider ingest_from_messages failed; skip provider=%s",
1570
+ provider.__class__.__name__,
1571
+ exc_info=True,
1572
+ )
1547
1573
  except Exception:
1548
1574
  logger.debug(
1549
1575
  "provider ingest_from_messages failed; skip provider=%s",
@@ -1766,7 +1792,18 @@ class LoopGraphBuilder:
1766
1792
  content = getattr(msg, "content", "")
1767
1793
  if isinstance(content, str) and "[skill_listing]" in content:
1768
1794
  return {}
1769
- hint = skill_prefetch_provider._build_listing_hint(scope=skill_init_scope)
1795
+ session_store = None
1796
+ try:
1797
+ from langgraph.config import get_config
1798
+
1799
+ configurable = (get_config() or {}).get("configurable") or {}
1800
+ session_store = configurable.get("session_store")
1801
+ except Exception:
1802
+ pass
1803
+ hint = skill_prefetch_provider._build_listing_hint(
1804
+ scope=skill_init_scope,
1805
+ session_store=session_store,
1806
+ )
1770
1807
  if hint is None:
1771
1808
  return {}
1772
1809
  # skill_listing 以 HumanMessage 注入(非 SystemMessage),与 CC role=user 对齐
@@ -427,6 +427,21 @@ class AgentSession:
427
427
  def list_commands(self) -> list[dict[str, object]]:
428
428
  return self._runtime_command_registry.list_for_help()
429
429
 
430
+ def list_skills(self) -> dict[str, object]:
431
+ """CLI ``/skills`` 菜单:按 source 分组 + token 估算。"""
432
+ from ..tools.skill.catalog import build_skill_catalog, resolve_dynamic_catalog
433
+
434
+ catalog = build_skill_catalog(
435
+ workspace_root=self._workspace_cfg.workspace_root,
436
+ agent_home_segment=self._workspace_cfg.agent_home_segment,
437
+ plugin_registry=self._skill_registry,
438
+ )
439
+ dynamic = resolve_dynamic_catalog(
440
+ self._resolve_session_store(),
441
+ agent_home_segment=self._workspace_cfg.agent_home_segment,
442
+ )
443
+ return catalog.list_skills_menu(dynamic_catalog=dynamic)
444
+
430
445
  async def _execute_hook(self, event: HookEvent) -> None:
431
446
  ctx = HookContext(event=event, state={}, session_id=self._session_id)
432
447
  # HookEngine 当前主接口是 execute;若未来扩展 execute_async,优先兼容新接口。
@@ -34,6 +34,7 @@ from ..command.builtin import (
34
34
  make_memory_command,
35
35
  make_reload_plugins_command,
36
36
  )
37
+ from ..loop.config import merge_run_config
37
38
  from ..loop.config.loop_runtime_overlay import LoopRuntimeOverlay
38
39
  from ..loop.context.compaction_service import default_loop_context_compaction
39
40
  from ..loop.context.message_utils import total_estimated_tokens_for_messages
@@ -119,12 +120,14 @@ class ConversationSession:
119
120
  capabilities=self._capabilities,
120
121
  ),
121
122
  )
123
+ run_config = merge_run_config(graph, None)
122
124
  try:
123
125
  result = await graph.ainvoke(
124
126
  {
125
127
  "messages": list(self._messages),
126
128
  "_session_id": self._conversation_id,
127
- }
129
+ },
130
+ config=run_config,
128
131
  )
129
132
  except Exception:
130
133
  # 回滚本轮 append,避免异常路径消息重复累积。
@@ -174,13 +177,15 @@ class ConversationSession:
174
177
  prompt = result.injected_prompt or user_input
175
178
  appended_message = HumanMessage(content=prompt)
176
179
  self._messages.append(appended_message)
180
+ run_config = merge_run_config(graph, None)
177
181
  try:
178
182
  apply_command_tool_scope(store, result.allowed_tools)
179
183
  loop_output = await graph.ainvoke(
180
184
  {
181
185
  "messages": list(self._messages),
182
186
  "_session_id": self._conversation_id,
183
- }
187
+ },
188
+ config=run_config,
184
189
  )
185
190
  except Exception:
186
191
  if self._messages and self._messages[-1] is appended_message:
@@ -220,6 +225,7 @@ class ConversationSession:
220
225
  )
221
226
  output_messages: list[Any] | None = None
222
227
  graph_output: dict[str, Any] | None = None
228
+ run_config = merge_run_config(graph, config)
223
229
  driver = LoopStreamDriver()
224
230
  try:
225
231
  async for event in driver.run(
@@ -228,7 +234,7 @@ class ConversationSession:
228
234
  "messages": list(self._messages),
229
235
  "_session_id": self._conversation_id,
230
236
  },
231
- config=config,
237
+ config=run_config,
232
238
  version="v2",
233
239
  ):
234
240
  if event.get("event") == "on_chain_end" and event.get("name") == "LangGraph":
@@ -313,6 +319,24 @@ class ConversationSession:
313
319
  def list_commands(self) -> list[dict[str, object]]:
314
320
  return self._runtime_command_registry.list_for_help()
315
321
 
322
+ def list_skills(self) -> dict[str, object]:
323
+ """CLI ``/skills`` 菜单:按 source 分组 + token 估算。"""
324
+ from ..tools.skill.catalog import build_skill_catalog, resolve_dynamic_catalog
325
+
326
+ catalog = build_skill_catalog(
327
+ workspace_root=self._workspace_cfg.workspace_root,
328
+ agent_home_segment=self._workspace_cfg.agent_home_segment,
329
+ plugin_registry=self._skill_registry,
330
+ )
331
+ dynamic = resolve_dynamic_catalog(
332
+ self._resolve_session_store(),
333
+ agent_home_segment=self._workspace_cfg.agent_home_segment,
334
+ )
335
+ return catalog.list_skills_menu(dynamic_catalog=dynamic)
336
+
337
+ def _resolve_session_store(self) -> Any | None:
338
+ return None
339
+
316
340
  async def _execute_hook(self, event: HookEvent) -> None:
317
341
  ctx = HookContext(event=event, state={}, session_id=self._conversation_id)
318
342
  execute_async = getattr(self._hook_engine, "execute_async", None)
@@ -30,6 +30,8 @@ from .attachments import SkillAttachmentBuilder
30
30
  from .models import SkillHint
31
31
 
32
32
  if TYPE_CHECKING:
33
+ from langchain_agentx.tools.skill.catalog import SkillCatalogService
34
+ from langchain_agentx.tools.skill.dynamic_catalog import DynamicSkillSessionCatalog
33
35
  from langchain_agentx.tools.skill.loader import SkillCommandRepository
34
36
 
35
37
  _PRIORITY_ORDER = {
@@ -56,6 +58,7 @@ class SkillPrefetchProvider:
56
58
  agent_home_segment: str = ".langchain_agentx",
57
59
  skills_root: str | Path | None = None,
58
60
  repository: "SkillCommandRepository | None" = None,
61
+ catalog: "SkillCatalogService | None" = None,
59
62
  attachment_builder: SkillAttachmentBuilder | None = None,
60
63
  max_queue_size: int | None = None,
61
64
  ttl_sec: float | None = None,
@@ -70,12 +73,22 @@ class SkillPrefetchProvider:
70
73
  if skills_root
71
74
  else workspace_cfg.skills_dir
72
75
  )
73
- if repository is None:
74
- # 延迟导入,避免 task_runtime <-> tools 初始化循环依赖。
75
- from langchain_agentx.tools.skill.loader import SkillCommandRepository
76
-
77
- repository = SkillCommandRepository(skills_dir)
78
- self._repository = repository
76
+ if catalog is not None:
77
+ self._catalog = catalog
78
+ elif repository is not None:
79
+ from langchain_agentx.tools.skill.catalog import SkillCatalogService
80
+
81
+ self._catalog = SkillCatalogService(repository=repository)
82
+ else:
83
+ from langchain_agentx.tools.skill.catalog import build_skill_catalog
84
+
85
+ self._catalog = build_skill_catalog(
86
+ workspace_root=workspace_root,
87
+ agent_home_segment=agent_home_segment,
88
+ skills_root=skills_root,
89
+ )
90
+ self._repository = self._catalog.repository
91
+ self._agent_home_segment = agent_home_segment
79
92
  self._attachment_builder = attachment_builder or SkillAttachmentBuilder()
80
93
  self._lock = RLock()
81
94
  self._queue: list[SkillHint] = []
@@ -204,25 +217,29 @@ class SkillPrefetchProvider:
204
217
  for item in self._queue
205
218
  )
206
219
 
207
- def ingest_from_messages(self, *, scope: TaskScope, messages: list) -> int:
208
- # v1:仅触发 listing 刷新(幂等),不从 messages 抽取动态候选。
209
- # v2 特性:基于 messages 做 dynamic_skill 候选提取,待专项设计后启用。
220
+ def ingest_from_messages(
221
+ self,
222
+ *,
223
+ scope: TaskScope,
224
+ messages: list,
225
+ session_store: object | None = None,
226
+ ) -> int:
227
+ # v1:listing 刷新(幂等);dynamic 来自 session_store 上的 walk-up catalog。
210
228
  del messages
211
- hint = self._build_listing_hint(scope=scope)
229
+ hint = self._build_listing_hint(scope=scope, session_store=session_store)
212
230
  if hint is None:
213
231
  return 0
214
232
  self.enqueue_hint(hint)
215
233
  return 1
216
234
 
217
- def _build_listing_hint(self, *, scope: TaskScope) -> SkillHint | None:
218
- # 1) 从仓库读取所有命令并做可见性过滤(description/when_to_use/disable-model-invocation)。
219
- # 2) 通过签名判断是否变化,未变化则不重复注入(减少上下文噪声)。
220
- # 3) 生成“仅 metadata”的 listing 文本,正文由 skill 工具按需加载。
221
- commands = self._repository.list_commands()
222
- if not commands:
223
- return None
224
- ordered = sorted((c for c in commands if c.name), key=lambda c: c.name)
225
- visible = [c for c in ordered if c.visible_in_slash_command_tool_listing()]
235
+ def _build_listing_hint(
236
+ self,
237
+ *,
238
+ scope: TaskScope,
239
+ session_store: object | None = None,
240
+ ) -> SkillHint | None:
241
+ dynamic = self._resolve_dynamic_catalog(session_store)
242
+ visible = self._catalog.list_for_listing(dynamic_catalog=dynamic)
226
243
  if not visible:
227
244
  return None
228
245
  signature = tuple(
@@ -251,6 +268,18 @@ class SkillPrefetchProvider:
251
268
  dedup_key=f"skill_listing:{scope_id}",
252
269
  )
253
270
 
271
+ def _resolve_dynamic_catalog(
272
+ self, session_store: object | None
273
+ ) -> "DynamicSkillSessionCatalog | None":
274
+ if session_store is None:
275
+ return None
276
+ from langchain_agentx.tools.skill.catalog import resolve_dynamic_catalog
277
+
278
+ return resolve_dynamic_catalog(
279
+ session_store,
280
+ agent_home_segment=self._agent_home_segment,
281
+ )
282
+
254
283
  @staticmethod
255
284
  def _scope_id(scope: TaskScope) -> str:
256
285
  return scope.agent_id or "main"
@@ -196,6 +196,13 @@ class ReadPathAuthorizer:
196
196
  policy_id="readable_internal_project_temp",
197
197
  message="Read allowed for project temp directory path.",
198
198
  )
199
+
200
+ if self._internal.is_bundled_skills_path(real_path):
201
+ return AuthorizationDecision(
202
+ behavior="allow",
203
+ policy_id="readable_internal_bundled_skills",
204
+ message="Read allowed for bundled skill reference files.",
205
+ )
199
206
  return None
200
207
 
201
208
  def _evaluate_unc(self, real_path: str) -> AuthorizationDecision | None:
@@ -15,6 +15,8 @@ runtime/session_store.py — 会话级状态存储
15
15
  granted_roots — 动态扩展的 read/write 边界(Phase 2 grant API)
16
16
  denial_tracking — L3 连续拒绝计数(GAP-7;子代理 fork_local 隔离)
17
17
  dynamic_skill_catalog — 嵌套 skills walk-up 缓存(D6c;类型为 tools.skill 侧 catalog)
18
+ hooks_snapshot — HookEngine 可变快照引用(skill frontmatter hooks 注册用)
19
+ skill_repository — 会话级 skill 仓库(Read/Write/Edit 条件 skill seed 用)
18
20
 
19
21
  链路位置:
20
22
  GraphFactory 注入 configurable["session_store"] → LangChainAdapter.build_tool_execution_context
@@ -123,6 +125,8 @@ class AgentSessionStore:
123
125
  self._root_grant_manager: Any | None = None
124
126
  self._denial_tracker: Any | None = None
125
127
  self.dynamic_skill_catalog: Any = None
128
+ self.hooks_snapshot: Any = None
129
+ self.skill_repository: Any = None
126
130
 
127
131
  def record_file_read(
128
132
  self,
@@ -24,3 +24,13 @@ MAX_PREVIEW_SNAPSHOT_CHARS: int = 8192
24
24
 
25
25
  # present():每段 preview / snapshot 写入模型可见文本时的默认截断
26
26
  PRESENT_PREVIEW_SNIPPET_MAX: int = 500
27
+
28
+ # 全角问号(中文输入法常见);校验要求 ASCII '?'
29
+ FULLWIDTH_QUESTION_MARK: str = "\uff1f"
30
+
31
+
32
+ def normalize_question_text(text: str) -> str:
33
+ """将题干末尾全角 ``?`` 规范为 ASCII ``?``(CC / QuestionValidator 一致)。"""
34
+ if text.endswith(FULLWIDTH_QUESTION_MARK):
35
+ return text[:-1] + "?"
36
+ return text
@@ -29,7 +29,7 @@ from langchain_agentx.tool_runtime.models import (
29
29
  ValidationResult,
30
30
  )
31
31
 
32
- from .constants import PRESENT_PREVIEW_SNIPPET_MAX
32
+ from .constants import PRESENT_PREVIEW_SNIPPET_MAX, normalize_question_text
33
33
  from .html_preview import HtmlPreviewValidator
34
34
  from .models import AskUserQuestionInput, AskUserQuestionOutput, QuestionAnnotation
35
35
  from .prompt import (
@@ -116,8 +116,19 @@ class AskUserQuestionTool(RuntimeTool):
116
116
  questions = data.get("questions")
117
117
  if isinstance(questions, list):
118
118
  for q in questions:
119
- if isinstance(q, dict) and "multiSelect" not in q:
120
- q["multiSelect"] = False
119
+ if isinstance(q, dict):
120
+ if "multiSelect" not in q:
121
+ q["multiSelect"] = False
122
+ qt = q.get("question")
123
+ if isinstance(qt, str):
124
+ q["question"] = normalize_question_text(qt)
125
+ for key in ("answers", "annotations"):
126
+ mapping = data.get(key)
127
+ if isinstance(mapping, dict) and mapping:
128
+ data[key] = {
129
+ normalize_question_text(k) if isinstance(k, str) else k: v
130
+ for k, v in mapping.items()
131
+ }
121
132
  if "answers" not in data or data["answers"] is None:
122
133
  data["answers"] = {}
123
134
  if "annotations" not in data or data["annotations"] is None:
@@ -1,4 +1,5 @@
1
1
  from .tool import SkillRuntimeTool
2
+ from .catalog import SkillCatalogService
2
3
 
3
- __all__ = ["SkillRuntimeTool"]
4
+ __all__ = ["SkillRuntimeTool", "SkillCatalogService"]
4
5
 
@@ -0,0 +1,157 @@
1
+ """
2
+ tools/skill/bundled/__init__.py — Product bundled skill 初始化
3
+
4
+ 职责:
5
+ 进程级注册 debug / simplify / batch / update-config bundled skill。
6
+
7
+ 链路位置:
8
+ ``SkillCatalogService`` / ``SkillRuntimeTool`` / ``build_skill_catalog`` 启动时调用。
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from langchain_agentx.utils.agent_settings import normalize_agent_home_segment
14
+
15
+ from ..models import SkillCommand
16
+ from .prompts import (
17
+ BATCH_MISSING_INSTRUCTION,
18
+ BATCH_NOT_GIT_REPO,
19
+ SIMPLIFY_PROMPT,
20
+ build_batch_prompt,
21
+ build_debug_prompt,
22
+ build_update_config_prompt,
23
+ )
24
+ from .registry import (
25
+ BundledSkillDefinition,
26
+ BundledSkillRegistry,
27
+ bundled_skills_disabled,
28
+ )
29
+
30
+ _registry: BundledSkillRegistry | None = None
31
+
32
+
33
+ def _settings_hint(agent_home_segment: str = ".langchain_agentx") -> str:
34
+ seg = normalize_agent_home_segment(agent_home_segment)
35
+ return (
36
+ f"- Project settings: `{seg}/settings.json`\n"
37
+ f"- Local overrides: `{seg}/settings.local.json` (typically gitignored)"
38
+ )
39
+
40
+
41
+ def _register_debug(registry: BundledSkillRegistry) -> None:
42
+ hint = _settings_hint()
43
+
44
+ def _content(args: str) -> str:
45
+ return build_debug_prompt(args=args, settings_hint=hint)
46
+
47
+ registry.register(
48
+ BundledSkillDefinition(
49
+ name="debug",
50
+ description="Enable debug logging for this session and help diagnose issues",
51
+ content=_content,
52
+ allowed_tools=["Read", "Grep", "Glob"],
53
+ disable_model_invocation=True,
54
+ user_invocable=True,
55
+ )
56
+ )
57
+
58
+
59
+ def _register_simplify(registry: BundledSkillRegistry) -> None:
60
+ def _content(args: str) -> str:
61
+ prompt = SIMPLIFY_PROMPT
62
+ if args.strip():
63
+ prompt += f"\n\n## Additional Focus\n\n{args.strip()}\n"
64
+ return prompt
65
+
66
+ registry.register(
67
+ BundledSkillDefinition(
68
+ name="simplify",
69
+ description=(
70
+ "Review changed code for reuse, quality, and efficiency, then fix any issues found."
71
+ ),
72
+ content=_content,
73
+ user_invocable=True,
74
+ )
75
+ )
76
+
77
+
78
+ def _register_batch(registry: BundledSkillRegistry) -> None:
79
+ def _content(args: str) -> str:
80
+ instruction = args.strip()
81
+ if not instruction:
82
+ return BATCH_MISSING_INSTRUCTION
83
+ try:
84
+ from pathlib import Path
85
+
86
+ if not (Path.cwd() / ".git").exists():
87
+ return BATCH_NOT_GIT_REPO
88
+ except OSError:
89
+ pass
90
+ return build_batch_prompt(instruction)
91
+
92
+ registry.register(
93
+ BundledSkillDefinition(
94
+ name="batch",
95
+ description=(
96
+ "Research and plan a large-scale change, then execute it in parallel across "
97
+ "isolated worktree agents that each open a PR."
98
+ ),
99
+ when_to_use=(
100
+ "Use when the user wants a sweeping mechanical change across many files "
101
+ "that can be decomposed into independent parallel units."
102
+ ),
103
+ content=_content,
104
+ user_invocable=True,
105
+ disable_model_invocation=True,
106
+ )
107
+ )
108
+
109
+
110
+ def _register_update_config(registry: BundledSkillRegistry) -> None:
111
+ hint = _settings_hint()
112
+
113
+ def _content(args: str) -> str:
114
+ return build_update_config_prompt(args=args, settings_hint=hint)
115
+
116
+ registry.register(
117
+ BundledSkillDefinition(
118
+ name="update-config",
119
+ description="Update agent settings.json / settings.local.json safely",
120
+ content=_content,
121
+ allowed_tools=["Read"],
122
+ user_invocable=True,
123
+ )
124
+ )
125
+
126
+
127
+ def init_bundled_skills() -> BundledSkillRegistry:
128
+ global _registry
129
+ if _registry is not None:
130
+ return _registry
131
+ registry = BundledSkillRegistry()
132
+ if not bundled_skills_disabled():
133
+ _register_debug(registry)
134
+ _register_simplify(registry)
135
+ _register_batch(registry)
136
+ _register_update_config(registry)
137
+ _registry = registry
138
+ return registry
139
+
140
+
141
+ def get_bundled_skill_commands() -> list[SkillCommand]:
142
+ return init_bundled_skills().list_commands()
143
+
144
+
145
+ def reset_bundled_skills_for_tests() -> None:
146
+ """测试专用:清空进程级 registry。"""
147
+ global _registry
148
+ _registry = None
149
+
150
+
151
+ __all__ = [
152
+ "BundledSkillDefinition",
153
+ "BundledSkillRegistry",
154
+ "get_bundled_skill_commands",
155
+ "init_bundled_skills",
156
+ "reset_bundled_skills_for_tests",
157
+ ]