abyss-cli 0.1.3__tar.gz → 0.1.4__tar.gz

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 (100) hide show
  1. {abyss_cli-0.1.3/src/abyss_cli.egg-info → abyss_cli-0.1.4}/PKG-INFO +2 -1
  2. {abyss_cli-0.1.3 → abyss_cli-0.1.4}/pyproject.toml +3 -2
  3. {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/__init__.py +1 -1
  4. {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/ansi_menu.py +6 -4
  5. {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/main.py +144 -0
  6. abyss_cli-0.1.4/src/abyss/mcp/manager.py +260 -0
  7. abyss_cli-0.1.4/src/abyss/runtime/__init__.py +9 -0
  8. abyss_cli-0.1.4/src/abyss/runtime/host.py +180 -0
  9. {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/session.py +5 -0
  10. {abyss_cli-0.1.3 → abyss_cli-0.1.4/src/abyss_cli.egg-info}/PKG-INFO +2 -1
  11. abyss_cli-0.1.4/src/abyss_cli.egg-info/SOURCES.txt +45 -0
  12. {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss_cli.egg-info/requires.txt +1 -0
  13. abyss_cli-0.1.3/src/abyss/mcp/manager.py +0 -189
  14. abyss_cli-0.1.3/src/abyss_cli.egg-info/SOURCES.txt +0 -94
  15. abyss_cli-0.1.3/test/test_abyss_interactive.py +0 -125
  16. abyss_cli-0.1.3/test/test_acceptance_smoke.py +0 -302
  17. abyss_cli-0.1.3/test/test_ansi.py +0 -80
  18. abyss_cli-0.1.3/test/test_ansi2.py +0 -79
  19. abyss_cli-0.1.3/test/test_ansi3.py +0 -111
  20. abyss_cli-0.1.3/test/test_ansi_menu_max_visible.py +0 -80
  21. abyss_cli-0.1.3/test/test_ansi_prompt_clear_line.py +0 -124
  22. abyss_cli-0.1.3/test/test_ansi_prompt_history.py +0 -208
  23. abyss_cli-0.1.3/test/test_ansi_prompt_line_clear.py +0 -166
  24. abyss_cli-0.1.3/test/test_ansi_prompt_menu.py +0 -188
  25. abyss_cli-0.1.3/test/test_ansi_select.py +0 -270
  26. abyss_cli-0.1.3/test/test_api.py +0 -60
  27. abyss_cli-0.1.3/test/test_batch_execution_rule.py +0 -44
  28. abyss_cli-0.1.3/test/test_bug_reproduction.py +0 -182
  29. abyss_cli-0.1.3/test/test_config_delete.py +0 -125
  30. abyss_cli-0.1.3/test/test_consume_stream.py +0 -198
  31. abyss_cli-0.1.3/test/test_debug.py +0 -103
  32. abyss_cli-0.1.3/test/test_debug_position.py +0 -118
  33. abyss_cli-0.1.3/test/test_edge_cases.py +0 -204
  34. abyss_cli-0.1.3/test/test_execute_tool_arg_validation.py +0 -91
  35. abyss_cli-0.1.3/test/test_extension_cli.py +0 -272
  36. abyss_cli-0.1.3/test/test_extension_registry.py +0 -128
  37. abyss_cli-0.1.3/test/test_help_command.py +0 -117
  38. abyss_cli-0.1.3/test/test_hook_runner.py +0 -145
  39. abyss_cli-0.1.3/test/test_input_edge_cases.py +0 -81
  40. abyss_cli-0.1.3/test/test_mcp_manager.py +0 -191
  41. abyss_cli-0.1.3/test/test_methodologies.py +0 -70
  42. abyss_cli-0.1.3/test/test_methodology_table.py +0 -240
  43. abyss_cli-0.1.3/test/test_misc.py +0 -58
  44. abyss_cli-0.1.3/test/test_multi_round_ui.py +0 -124
  45. abyss_cli-0.1.3/test/test_no_tight_polling.py +0 -26
  46. abyss_cli-0.1.3/test/test_package_installer.py +0 -435
  47. abyss_cli-0.1.3/test/test_reasoning_only_bug.py +0 -92
  48. abyss_cli-0.1.3/test/test_rich_completer.py +0 -295
  49. abyss_cli-0.1.3/test/test_session_compress.py +0 -100
  50. abyss_cli-0.1.3/test/test_shell.py +0 -30
  51. abyss_cli-0.1.3/test/test_shell_chcp_leak.py +0 -63
  52. abyss_cli-0.1.3/test/test_shell_retry_encoding.py +0 -81
  53. abyss_cli-0.1.3/test/test_shell_safety.py +0 -65
  54. abyss_cli-0.1.3/test/test_shell_timeout_fix.py +0 -155
  55. abyss_cli-0.1.3/test/test_show_reasoning_persist.py +0 -101
  56. abyss_cli-0.1.3/test/test_skill_loader.py +0 -162
  57. abyss_cli-0.1.3/test/test_slash_commands.py +0 -125
  58. abyss_cli-0.1.3/test/test_slash_methodology.py +0 -142
  59. abyss_cli-0.1.3/test/test_spawn_subagents_tool.py +0 -85
  60. abyss_cli-0.1.3/test/test_subagent.py +0 -195
  61. abyss_cli-0.1.3/test/test_subagent_methodology.py +0 -102
  62. abyss_cli-0.1.3/test/test_tool_registry.py +0 -176
  63. abyss_cli-0.1.3/test/test_unbounded_rounds.py +0 -45
  64. abyss_cli-0.1.3/test/test_url_parser.py +0 -107
  65. abyss_cli-0.1.3/test/test_web_search_rules.py +0 -54
  66. {abyss_cli-0.1.3 → abyss_cli-0.1.4}/MANIFEST.in +0 -0
  67. {abyss_cli-0.1.3 → abyss_cli-0.1.4}/setup.cfg +0 -0
  68. {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/api_client.py +0 -0
  69. {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/commands/__init__.py +0 -0
  70. {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/commands/slash.py +0 -0
  71. {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/config.py +0 -0
  72. {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/extensions/__init__.py +0 -0
  73. {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/extensions/cli.py +0 -0
  74. {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/extensions/installer.py +0 -0
  75. {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/extensions/registry.py +0 -0
  76. {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/extensions/url_parser.py +0 -0
  77. {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/hooks/__init__.py +0 -0
  78. {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/hooks/runner.py +0 -0
  79. {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/logger.py +0 -0
  80. {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/mcp/__init__.py +0 -0
  81. {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/prompts/__init__.py +0 -0
  82. {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/prompts/methodologies.py +0 -0
  83. {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/prompts/system_prompt.md +0 -0
  84. {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/skills/__init__.py +0 -0
  85. {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/skills/loader.py +0 -0
  86. {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/subagent/__init__.py +0 -0
  87. {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/subagent/runner.py +0 -0
  88. {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/tools/__init__.py +0 -0
  89. {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/tools/base.py +0 -0
  90. {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/tools/file_edit.py +0 -0
  91. {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/tools/file_read.py +0 -0
  92. {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/tools/file_write.py +0 -0
  93. {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/tools/registry.py +0 -0
  94. {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/tools/shell_exec.py +0 -0
  95. {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/tools/spawn_subagents.py +0 -0
  96. {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/tools/web_fetch.py +0 -0
  97. {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/tools/web_search.py +0 -0
  98. {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss_cli.egg-info/dependency_links.txt +0 -0
  99. {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss_cli.egg-info/entry_points.txt +0 -0
  100. {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss_cli.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: abyss-cli
3
- Version: 0.1.3
3
+ Version: 0.1.4
4
4
  Summary: 终端AI开发助手
5
5
  Author: Abyss Team
6
6
  Requires-Python: >=3.9
@@ -9,3 +9,4 @@ Requires-Dist: click>=8.0.0
9
9
  Requires-Dist: prompt-toolkit>=3.0.0
10
10
  Requires-Dist: ddgs>=9.0.0
11
11
  Requires-Dist: httpx>=0.20.0
12
+ Requires-Dist: mcp>=1.0.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "abyss-cli"
7
- version = "0.1.3"
7
+ version = "0.1.4"
8
8
  description = "终端AI开发助手"
9
9
  authors = [{name = "Abyss Team"}]
10
10
  requires-python = ">=3.9"
@@ -14,6 +14,7 @@ dependencies = [
14
14
  "prompt-toolkit>=3.0.0",
15
15
  "ddgs>=9.0.0",
16
16
  "httpx>=0.20.0",
17
+ "mcp>=1.0.0",
17
18
  ]
18
19
 
19
20
  [project.scripts]
@@ -25,7 +26,7 @@ packages = [
25
26
  "abyss", "abyss.tools", "abyss.prompts",
26
27
  "abyss.commands", "abyss.extensions",
27
28
  "abyss.hooks", "abyss.mcp", "abyss.skills",
28
- "abyss.subagent",
29
+ "abyss.subagent", "abyss.runtime",
29
30
  ]
30
31
  include-package-data = true
31
32
 
@@ -1,4 +1,4 @@
1
1
  # -*- coding: utf-8 -*-
2
2
  """Abyss - 终端AI开发助手"""
3
- __version__ = "0.1.2"
3
+ __version__ = "0.1.4"
4
4
 
@@ -34,6 +34,9 @@ COMMANDS = [
34
34
  ("/model", "查看/切换模型"),
35
35
  ("/thinking", "查看/开关思考模式"),
36
36
  ("/show-reasoning", "开关思考过程显示"),
37
+ ("/skills", "列出已加载的 Skill"),
38
+ ("/mcp", "列出 MCP server 和工具"),
39
+ ("/hooks", "列出已配置的 Hook"),
37
40
  ("/diagnose", "启动调试纪律流程"),
38
41
  ("/tdd", "启动 TDD 红-绿-重构流程"),
39
42
  ("/review", "审查当前 diff 的过度工程"),
@@ -412,8 +415,8 @@ def ansi_prompt(prompt_str: str = "> ") -> str:
412
415
  completions = []
413
416
  showing_menu = False
414
417
 
415
- # 打印初始提示符
416
- sys.stdout.write(prompt_str)
418
+ # 打印初始提示符(先清行,避免上一轮输出残留)
419
+ sys.stdout.write('\r\x1b[K' + prompt_str)
417
420
  sys.stdout.flush()
418
421
 
419
422
  while True:
@@ -447,8 +450,7 @@ def ansi_prompt(prompt_str: str = "> ") -> str:
447
450
  continue
448
451
  elif special == 'P': # 下箭头
449
452
  if showing_menu and completions:
450
- vis = min(len(completions), max_visible)
451
- selected_idx = min(vis - 1, selected_idx + 1)
453
+ selected_idx = min(len(completions) - 1, selected_idx + 1)
452
454
  show_menu()
453
455
  elif history_idx != -1:
454
456
  # 历史翻看:↓ 调到更新一条(越界则回草稿)
@@ -27,6 +27,7 @@ from .tools.registry import (
27
27
  create_default_registry, get_tc_field, merge_tool_calls, format_tool_result,
28
28
  )
29
29
  from .tools.spawn_subagents import SpawnSubagentsTool
30
+ from .runtime import LifecycleHost
30
31
  from .ansi_menu import ansi_prompt
31
32
  from . import logger
32
33
 
@@ -34,6 +35,10 @@ from . import logger
34
35
  # 用 ToolRegistry 替代原硬编码 TOOLS dict,支持动态注册。
35
36
  REGISTRY = create_default_registry()
36
37
 
38
+ # LifecycleHost 实例(run_with_pt 初始化,execute_tool 读取埋点)。
39
+ # 模块级让 execute_tool 能访问 host 而无需改签名传递。
40
+ _HOST: LifecycleHost = None
41
+
37
42
  # 工具调用往返轮数安全网(仅防 AI 死循环,正常任务不会达到)
38
43
  MAX_TOOL_ROUNDS = 100
39
44
 
@@ -54,6 +59,11 @@ _HELP_TEXT = """
54
59
  /thinking 查看或开关思考模式(on/off)
55
60
  /show-reasoning 开关思考过程在屏幕上的显示
56
61
 
62
+ 扩展系统:
63
+ /skills 列出已加载的 Skill
64
+ /mcp 列出 MCP server 和工具
65
+ /hooks 列出已配置的 Hook
66
+
57
67
  配置管理:
58
68
  /config set api-key <key> 设置 API Key
59
69
  /config set model <name> 设置模型(deepseek-v4-pro / deepseek-v4-flash)
@@ -178,6 +188,14 @@ def execute_tool(tool_call, batch_index: int = None, batch_total: int = None) ->
178
188
  if name not in REGISTRY:
179
189
  return json.dumps({"success": False, "error": f"未知工具: {name}"})
180
190
 
191
+ # PreToolUse 埋点:钩子可阻断工具调用
192
+ if _HOST is not None:
193
+ guard = _HOST.pre_tool(name, arguments)
194
+ if guard["block"]:
195
+ err = {"success": False, "error": f"被钩子阻断: {guard.get('reason', '')}"}
196
+ _show_tool_result(name, arguments, err)
197
+ return format_tool_result(name, err)
198
+
181
199
  _show_tool_action(name, arguments, batch_index, batch_total)
182
200
  logger.tool_start(name, args=args_str)
183
201
  t0 = time.time()
@@ -196,6 +214,10 @@ def execute_tool(tool_call, batch_index: int = None, batch_total: int = None) ->
196
214
  logger.tool_end(name, success=result.get("success", False), duration_ms=elapsed_ms,
197
215
  out_len=len(format_tool_result(name, result)))
198
216
 
217
+ # PostToolUse 埋点:审计/日志,结果不回 LLM
218
+ if _HOST is not None:
219
+ _HOST.post_tool(name, arguments, result)
220
+
199
221
  return format_tool_result(name, result)
200
222
 
201
223
 
@@ -381,6 +403,10 @@ def process_tool_calls(session, client, tools_schemas, tool_calls, cfg, show_rea
381
403
 
382
404
  spinner.stop()
383
405
 
406
+ # PostLLMResponse 埋点:审计每轮 LLM 响应
407
+ if _HOST is not None:
408
+ _HOST.post_llm(content_buffer, new_tool_calls)
409
+
384
410
  if not content_buffer and not new_tool_calls:
385
411
  if show_reasoning and reasoning_buffer:
386
412
  click.echo("\033[0m", nl=False)
@@ -558,6 +584,103 @@ def handle_slash_command(input_str: str, session, client, cfg, sr: list):
558
584
  click.echo(f"[OK] 思考过程显示: {'开' if sr[0] else '关'} (已保存)")
559
585
  return True
560
586
 
587
+ # /skills — Skill 列表/交互选择/直接注入
588
+ if cmd == "/skills":
589
+ if _HOST is None:
590
+ click.echo("[INFO] 扩展系统未加载")
591
+ return True
592
+ names = _HOST._skills.list_names()
593
+ if not names:
594
+ click.echo("[INFO] 未加载任何 skill(放入 ~/.abyss/skills/<name>/SKILL.md)")
595
+ return True
596
+ if len(parts) >= 2:
597
+ target = parts[1]
598
+ if target in names:
599
+ _HOST.inject_skill_by_name(session, target)
600
+ click.echo(f"[OK] 已注入 skill: {target}")
601
+ else:
602
+ click.echo(f"[ERR] 未找到 skill: {target}(可用: {', '.join(names)})")
603
+ return True
604
+ from .ansi_menu import ansi_select
605
+ default_idx = 0
606
+ selected = ansi_select("选择 Skill 注入上下文 (Esc 取消)", names, default_idx=default_idx)
607
+ if selected:
608
+ _HOST.inject_skill_by_name(session, selected)
609
+ click.echo(f"[OK] 已注入 skill: {selected}")
610
+ return True
611
+
612
+ # /mcp — MCP server/工具 列表/详情
613
+ if cmd == "/mcp":
614
+ if _HOST is None:
615
+ click.echo("[INFO] 扩展系统未加载")
616
+ return True
617
+ servers = _HOST._mcp.servers
618
+ tools = _HOST._mcp.list_tool_names()
619
+ if len(parts) >= 2:
620
+ target = parts[1]
621
+ if target in servers:
622
+ srv_tools = [t for t in tools if t.startswith(f"mcp__{target}__")]
623
+ click.echo(f"server: {target} ({servers[target].transport.value}), {len(srv_tools)} 个工具:")
624
+ for t in srv_tools:
625
+ desc = ""
626
+ for s in _HOST._mcp.get_openai_schemas():
627
+ if s.get("function", {}).get("name") == t:
628
+ desc = s["function"].get("description", "")
629
+ break
630
+ click.echo(f" • {t} {desc}")
631
+ else:
632
+ click.echo(f"[ERR] 未找到 server: {target}(可用: {', '.join(servers.keys()) or '无'})")
633
+ return True
634
+ if not servers and not tools:
635
+ click.echo("[INFO] 未配置 MCP server(编辑 ~/.abyss/mcp.json)")
636
+ return True
637
+ from .ansi_menu import ansi_select
638
+ options = [f"server: {n} ({c.transport.value})" for n, c in servers.items()]
639
+ options += [f"tool: {t}" for t in tools]
640
+ selected = ansi_select("MCP server / 工具 (Esc 取消)", options, default_idx=0)
641
+ if selected:
642
+ click.echo(f" {selected}")
643
+ if selected.startswith("server: "):
644
+ sname = selected[8:].split(" ")[0]
645
+ srv_tools = [t for t in tools if t.startswith(f"mcp__{sname}__")]
646
+ for t in srv_tools:
647
+ click.echo(f" • {t}")
648
+ return True
649
+
650
+ # /hooks — Hook 事件列表/脚本详情
651
+ if cmd == "/hooks":
652
+ if _HOST is None:
653
+ click.echo("[INFO] 扩展系统未加载")
654
+ return True
655
+ events = ["PreToolUse", "PostToolUse", "PreLLMRequest", "PostLLMResponse"]
656
+ event_counts = {ev: len(_HOST._hooks.list_hooks(ev)) for ev in events}
657
+ if len(parts) >= 2:
658
+ target = parts[1]
659
+ if target in event_counts:
660
+ scripts = _HOST._hooks.list_hooks(target)
661
+ if scripts:
662
+ click.echo(f"{target} ({len(scripts)} 个脚本):")
663
+ for s in scripts:
664
+ click.echo(f" • {s.name}")
665
+ else:
666
+ click.echo(f"[INFO] {target} 无脚本")
667
+ else:
668
+ click.echo(f"[ERR] 未知事件: {target}(可用: {', '.join(events)})")
669
+ return True
670
+ options = [f"{ev} ({event_counts[ev]})" for ev in events if event_counts[ev] > 0]
671
+ if not options:
672
+ click.echo("[INFO] 未配置 hook(放入 ~/.abyss/hooks/<事件>/<脚本>)")
673
+ return True
674
+ from .ansi_menu import ansi_select
675
+ selected = ansi_select("Hook 事件 (Esc 取消)", options, default_idx=0)
676
+ if selected:
677
+ ev_name = selected.split(" ")[0]
678
+ scripts = _HOST._hooks.list_hooks(ev_name)
679
+ click.echo(f" {ev_name}:")
680
+ for s in scripts:
681
+ click.echo(f" • {s.name}")
682
+ return True
683
+
561
684
  return False
562
685
 
563
686
 
@@ -635,6 +758,7 @@ def resolve_at_references(user_input: str) -> str:
635
758
 
636
759
  def run_with_pt(cfg, show_reasoning=False):
637
760
  """运行对话循环(使用自定义 ANSI 补全菜单)"""
761
+ global _HOST
638
762
  logger.session_start(model=cfg.get("model"), cwd=os.getcwd())
639
763
 
640
764
  if not cfg.is_ready():
@@ -648,6 +772,14 @@ def run_with_pt(cfg, show_reasoning=False):
648
772
  )
649
773
  # 注册 spawn_subagents 工具(子 Agent 编排),6 个内置工具已在模块级注册
650
774
  REGISTRY.register(SpawnSubagentsTool(cfg=cfg, working_dir=os.getcwd()))
775
+
776
+ # 初始化 LifecycleHost:启动 Skill/MCP/Hook 三模块,注册 MCP 工具到 REGISTRY
777
+ _HOST = LifecycleHost.create_default(working_dir=os.getcwd())
778
+ _HOST.start()
779
+ mcp_count = _HOST.attach_mcp_tools(REGISTRY)
780
+ if mcp_count > 0:
781
+ click.echo(f" \033[2m已加载 {mcp_count} 个 MCP 工具\033[0m")
782
+
651
783
  tools_schemas = REGISTRY.to_openai_schemas()
652
784
  sr = [show_reasoning]
653
785
 
@@ -682,6 +814,10 @@ def run_with_pt(cfg, show_reasoning=False):
682
814
  click.echo("请先配置 API Key: /config set api-key <your-key>")
683
815
  continue
684
816
 
817
+ # PreLLMRequest 埋点:用原始 user_input 匹配 skill 注入 system prompt
818
+ if _HOST is not None:
819
+ _HOST.pre_llm(session, user_input)
820
+
685
821
  user_input = resolve_at_references(user_input)
686
822
  session.add_user(user_input)
687
823
 
@@ -712,6 +848,10 @@ def run_with_pt(cfg, show_reasoning=False):
712
848
 
713
849
  spinner.stop()
714
850
 
851
+ # PostLLMResponse 埋点:审计主循环 LLM 响应
852
+ if _HOST is not None:
853
+ _HOST.post_llm(content_buffer, current_tool_calls)
854
+
715
855
  if not content_buffer and not current_tool_calls:
716
856
  # reasoning 后无 content 也无 tool_calls —— 手动关 dim
717
857
  if sr[0] and reasoning_buffer:
@@ -740,11 +880,15 @@ def run_with_pt(cfg, show_reasoning=False):
740
880
  except KeyboardInterrupt:
741
881
  logger.keyboard_interrupt()
742
882
  logger.session_end("keyboard_interrupt")
883
+ if _HOST is not None:
884
+ _HOST.shutdown()
743
885
  click.echo("\n再见!")
744
886
  break
745
887
  except EOFError:
746
888
  logger.eof()
747
889
  logger.session_end("eof")
890
+ if _HOST is not None:
891
+ _HOST.shutdown()
748
892
  click.echo("\n再见!")
749
893
  break
750
894
 
@@ -0,0 +1,260 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ MCP Client Manager 模块
4
+ 管理多个 MCP Server 连接,支持 stdio 传输(HTTP/SSE 暂未实现)。
5
+ 工具命名规则:mcp__<server_name>__<tool_name>,避免与内置工具冲突。
6
+
7
+ 配置文件格式(~/.abyss/mcp.json):
8
+ {
9
+ "mcp_servers": [
10
+ {"name": "github", "transport": "stdio", "command": "npx", "args": [...], "env": {...}}
11
+ ]
12
+ }
13
+
14
+ SDK 桥接:mcp 官方 SDK 全异步,本模块在内部维护后台事件循环线程,
15
+ 对外暴露同步接口(start_all/call_tool/stop_all),让 abyss 主同步代码无感调用。
16
+ """
17
+ import asyncio
18
+ import json
19
+ import os
20
+ import threading
21
+ from contextlib import AsyncExitStack
22
+ from dataclasses import dataclass, field
23
+ from enum import Enum
24
+ from pathlib import Path
25
+ from typing import Any, Dict, List, Optional, Tuple
26
+
27
+
28
+ class MCPServerType(Enum):
29
+ """MCP Server 传输类型。"""
30
+ STDIO = "stdio"
31
+ HTTP = "http"
32
+ SSE = "sse"
33
+
34
+
35
+ @dataclass
36
+ class ServerConfig:
37
+ """单个 MCP Server 的配置。"""
38
+ name: str
39
+ transport: MCPServerType
40
+ command: Optional[str] = None
41
+ args: List[str] = field(default_factory=list)
42
+ env: Dict[str, str] = field(default_factory=dict)
43
+ url: Optional[str] = None
44
+ headers: Dict[str, str] = field(default_factory=dict)
45
+
46
+ @classmethod
47
+ def from_dict(cls, data: Dict[str, Any]) -> Optional["ServerConfig"]:
48
+ """从配置字典构造 ServerConfig,必填字段缺失返回 None"""
49
+ name = data.get("name", "").strip()
50
+ transport_str = data.get("transport", "").strip().lower()
51
+ if not name or not transport_str:
52
+ return None
53
+ try:
54
+ transport = MCPServerType(transport_str)
55
+ except ValueError:
56
+ return None
57
+
58
+ if transport == MCPServerType.STDIO:
59
+ command = data.get("command", "").strip()
60
+ if not command:
61
+ return None
62
+ return cls(
63
+ name=name, transport=transport,
64
+ command=command,
65
+ args=data.get("args", []) or [],
66
+ env=data.get("env", {}) or {},
67
+ )
68
+ if transport in (MCPServerType.HTTP, MCPServerType.SSE):
69
+ url = data.get("url", "").strip()
70
+ if not url:
71
+ return None
72
+ return cls(
73
+ name=name, transport=transport,
74
+ url=url,
75
+ headers=data.get("headers", {}) or {},
76
+ )
77
+ return None
78
+
79
+
80
+ class MCPClientManager:
81
+ """管理所有已注册的 MCP Server 连接与工具发现。
82
+ 内部用后台事件循环线程桥接异步 mcp SDK,对外暴露同步接口。
83
+ """
84
+
85
+ def __init__(self, config_path: Optional[Path] = None):
86
+ self.servers: Dict[str, ServerConfig] = {}
87
+ # 工具全名 -> (server_name, original_tool_name, schema)
88
+ self._tools: Dict[str, Tuple[str, str, Dict[str, Any]]] = {}
89
+ # 运行中的 SDK 会话:server_name -> ClientSession
90
+ self._sessions: Dict[str, Any] = {}
91
+ # 持久化 async context(stdio_client + ClientSession 的 async with 栈)
92
+ self._cm_stacks: Dict[str, AsyncExitStack] = {}
93
+ # 后台事件循环(mcp SDK 全异步,用独立线程跑)
94
+ self._loop: Optional[asyncio.AbstractEventLoop] = None
95
+ self._loop_thread: Optional[threading.Thread] = None
96
+ self._lock = threading.Lock()
97
+
98
+ if config_path is not None:
99
+ self._load_config(config_path)
100
+
101
+ def _load_config(self, config_path: Path) -> None:
102
+ """从 JSON 配置文件加载 mcp_servers"""
103
+ if not config_path.exists():
104
+ return
105
+ try:
106
+ with open(config_path, "r", encoding="utf-8-sig") as f:
107
+ data = json.load(f)
108
+ except (json.JSONDecodeError, OSError):
109
+ return
110
+ for server_data in data.get("mcp_servers", []) or []:
111
+ cfg = ServerConfig.from_dict(server_data)
112
+ if cfg is not None:
113
+ self.servers[cfg.name] = cfg
114
+
115
+ @staticmethod
116
+ def make_tool_name(server_name: str, tool_name: str) -> str:
117
+ """生成 MCP 工具的全名:mcp__<server>__<tool>"""
118
+ return f"mcp__{server_name}__{tool_name}"
119
+
120
+ def register_tool(self, server_name: str, tool_schema: Dict[str, Any]) -> None:
121
+ """注册一个已发现的 MCP 工具"""
122
+ original_name = tool_schema.get("name", "")
123
+ if not original_name:
124
+ return
125
+ full_name = self.make_tool_name(server_name, original_name)
126
+ self._tools[full_name] = (server_name, original_name, tool_schema)
127
+
128
+ def get_server_for_tool(self, full_name: str) -> Tuple[Optional[str], Optional[str]]:
129
+ """根据工具全名返回 (server_name, original_tool_name),未注册返回 (None, None)"""
130
+ entry = self._tools.get(full_name)
131
+ if entry is None:
132
+ return (None, None)
133
+ return (entry[0], entry[1])
134
+
135
+ def get_openai_schemas(self) -> List[Dict[str, Any]]:
136
+ """生成所有 MCP 工具的 OpenAI function calling 定义"""
137
+ schemas = []
138
+ for full_name, (_, _, tool_schema) in self._tools.items():
139
+ schemas.append({
140
+ "type": "function",
141
+ "function": {
142
+ "name": full_name,
143
+ "description": tool_schema.get("description", ""),
144
+ "parameters": tool_schema.get("inputSchema", {"type": "object", "properties": {}}),
145
+ }
146
+ })
147
+ return schemas
148
+
149
+ def list_tool_names(self) -> List[str]:
150
+ """返回所有已注册 MCP 工具的全名"""
151
+ return list(self._tools.keys())
152
+
153
+ def start_all(self) -> None:
154
+ """启动后台事件循环,握手所有 stdio MCP Server 并 discover 工具。失败仅告警不抛。"""
155
+ if self._loop is not None:
156
+ return # 已启动
157
+ self._loop = asyncio.new_event_loop()
158
+ self._loop_thread = threading.Thread(target=self._loop.run_forever, daemon=True)
159
+ self._loop_thread.start()
160
+ # 在后台循环中跑握手 + discover
161
+ future = asyncio.run_coroutine_threadsafe(self._start_all_async(), self._loop)
162
+ try:
163
+ future.result(timeout=30)
164
+ except Exception as e:
165
+ # 握手失败不致命,工具调用时再报错
166
+ pass
167
+
168
+ async def _start_all_async(self) -> None:
169
+ """异步握手所有 stdio server:stdio_client + ClientSession + initialize + list_tools"""
170
+ from mcp import ClientSession
171
+ from mcp.client.stdio import stdio_client, StdioServerParameters
172
+
173
+ for name, cfg in self.servers.items():
174
+ if cfg.transport != MCPServerType.STDIO:
175
+ continue
176
+ if name in self._sessions:
177
+ continue
178
+ try:
179
+ stack = AsyncExitStack()
180
+ await stack.__aenter__()
181
+ env = {**os.environ, **cfg.env}
182
+ params = StdioServerParameters(
183
+ command=cfg.command, args=list(cfg.args), env=env
184
+ )
185
+ read, write = await stack.enter_async_context(stdio_client(params))
186
+ session = await stack.enter_async_context(ClientSession(read, write))
187
+ await session.initialize()
188
+ self._sessions[name] = session
189
+ self._cm_stacks[name] = stack
190
+ # discover 工具并注册
191
+ tools_result = await session.list_tools()
192
+ for tool in tools_result.tools:
193
+ self.register_tool(name, {
194
+ "name": tool.name,
195
+ "description": tool.description or "",
196
+ "inputSchema": tool.inputSchema or {"type": "object", "properties": {}},
197
+ })
198
+ except Exception:
199
+ # 单个 server 失败不影响其他
200
+ continue
201
+
202
+ def call_tool(self, server_name: str, tool_name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
203
+ """同步调用 MCP 工具,通过后台事件循环提交异步任务。"""
204
+ if server_name not in self._sessions:
205
+ return {"success": False, "error": f"MCP server 未启动: {server_name}"}
206
+ if self._loop is None:
207
+ return {"success": False, "error": "MCP 事件循环未启动"}
208
+ future = asyncio.run_coroutine_threadsafe(
209
+ self._call_tool_async(server_name, tool_name, arguments),
210
+ self._loop,
211
+ )
212
+ try:
213
+ return future.result(timeout=30)
214
+ except Exception as e:
215
+ return {"success": False, "error": f"MCP 调用异常: {e}"}
216
+
217
+ async def _call_tool_async(self, server_name: str, tool_name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
218
+ """异步调用单个 MCP 工具,把 SDK result 转成 dict。"""
219
+ session = self._sessions[server_name]
220
+ result = await session.call_tool(tool_name, arguments)
221
+ # SDK result.content 是 list[TextContent | ...],拼成文本
222
+ text_parts = []
223
+ for block in (result.content or []):
224
+ text = getattr(block, "text", None)
225
+ if text:
226
+ text_parts.append(text)
227
+ return {
228
+ "success": not result.isError,
229
+ "stdout": "\n".join(text_parts),
230
+ "stderr": "" if not result.isError else "\n".join(text_parts),
231
+ }
232
+
233
+ def stop_all(self) -> None:
234
+ """关闭所有 MCP 会话 + 停止后台事件循环。幂等。"""
235
+ if self._loop is not None:
236
+ future = asyncio.run_coroutine_threadsafe(self._stop_async(), self._loop)
237
+ try:
238
+ future.result(timeout=5)
239
+ except Exception:
240
+ pass
241
+ self._loop.call_soon_threadsafe(self._loop.stop)
242
+ if self._loop_thread:
243
+ self._loop_thread.join(timeout=2)
244
+ self._sessions.clear()
245
+ self._cm_stacks.clear()
246
+ self._loop = None
247
+ self._loop_thread = None
248
+
249
+ async def _stop_async(self) -> None:
250
+ """异步关闭所有 async context 栈(stdio_client + ClientSession)。"""
251
+ for stack in self._cm_stacks.values():
252
+ try:
253
+ await stack.aclose()
254
+ except Exception:
255
+ pass
256
+
257
+
258
+ def create_default_manager() -> MCPClientManager:
259
+ """创建指向 ~/.abyss/mcp.json 的默认 MCPClientManager"""
260
+ return MCPClientManager(config_path=Path.home() / ".abyss" / "mcp.json")
@@ -0,0 +1,9 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ 运行时协调层
4
+ LifecycleHost 在主 Agent 生命周期的关键节点统一调度 Skill / MCP / Hook 三模块。
5
+ 子 Agent 不接入 host(通过 create_subagent_registry() 天然隔离)。
6
+ """
7
+ from .host import LifecycleHost, MCPToolProxy
8
+
9
+ __all__ = ["LifecycleHost", "MCPToolProxy"]