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.
- {abyss_cli-0.1.3/src/abyss_cli.egg-info → abyss_cli-0.1.4}/PKG-INFO +2 -1
- {abyss_cli-0.1.3 → abyss_cli-0.1.4}/pyproject.toml +3 -2
- {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/__init__.py +1 -1
- {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/ansi_menu.py +6 -4
- {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/main.py +144 -0
- abyss_cli-0.1.4/src/abyss/mcp/manager.py +260 -0
- abyss_cli-0.1.4/src/abyss/runtime/__init__.py +9 -0
- abyss_cli-0.1.4/src/abyss/runtime/host.py +180 -0
- {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/session.py +5 -0
- {abyss_cli-0.1.3 → abyss_cli-0.1.4/src/abyss_cli.egg-info}/PKG-INFO +2 -1
- abyss_cli-0.1.4/src/abyss_cli.egg-info/SOURCES.txt +45 -0
- {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss_cli.egg-info/requires.txt +1 -0
- abyss_cli-0.1.3/src/abyss/mcp/manager.py +0 -189
- abyss_cli-0.1.3/src/abyss_cli.egg-info/SOURCES.txt +0 -94
- abyss_cli-0.1.3/test/test_abyss_interactive.py +0 -125
- abyss_cli-0.1.3/test/test_acceptance_smoke.py +0 -302
- abyss_cli-0.1.3/test/test_ansi.py +0 -80
- abyss_cli-0.1.3/test/test_ansi2.py +0 -79
- abyss_cli-0.1.3/test/test_ansi3.py +0 -111
- abyss_cli-0.1.3/test/test_ansi_menu_max_visible.py +0 -80
- abyss_cli-0.1.3/test/test_ansi_prompt_clear_line.py +0 -124
- abyss_cli-0.1.3/test/test_ansi_prompt_history.py +0 -208
- abyss_cli-0.1.3/test/test_ansi_prompt_line_clear.py +0 -166
- abyss_cli-0.1.3/test/test_ansi_prompt_menu.py +0 -188
- abyss_cli-0.1.3/test/test_ansi_select.py +0 -270
- abyss_cli-0.1.3/test/test_api.py +0 -60
- abyss_cli-0.1.3/test/test_batch_execution_rule.py +0 -44
- abyss_cli-0.1.3/test/test_bug_reproduction.py +0 -182
- abyss_cli-0.1.3/test/test_config_delete.py +0 -125
- abyss_cli-0.1.3/test/test_consume_stream.py +0 -198
- abyss_cli-0.1.3/test/test_debug.py +0 -103
- abyss_cli-0.1.3/test/test_debug_position.py +0 -118
- abyss_cli-0.1.3/test/test_edge_cases.py +0 -204
- abyss_cli-0.1.3/test/test_execute_tool_arg_validation.py +0 -91
- abyss_cli-0.1.3/test/test_extension_cli.py +0 -272
- abyss_cli-0.1.3/test/test_extension_registry.py +0 -128
- abyss_cli-0.1.3/test/test_help_command.py +0 -117
- abyss_cli-0.1.3/test/test_hook_runner.py +0 -145
- abyss_cli-0.1.3/test/test_input_edge_cases.py +0 -81
- abyss_cli-0.1.3/test/test_mcp_manager.py +0 -191
- abyss_cli-0.1.3/test/test_methodologies.py +0 -70
- abyss_cli-0.1.3/test/test_methodology_table.py +0 -240
- abyss_cli-0.1.3/test/test_misc.py +0 -58
- abyss_cli-0.1.3/test/test_multi_round_ui.py +0 -124
- abyss_cli-0.1.3/test/test_no_tight_polling.py +0 -26
- abyss_cli-0.1.3/test/test_package_installer.py +0 -435
- abyss_cli-0.1.3/test/test_reasoning_only_bug.py +0 -92
- abyss_cli-0.1.3/test/test_rich_completer.py +0 -295
- abyss_cli-0.1.3/test/test_session_compress.py +0 -100
- abyss_cli-0.1.3/test/test_shell.py +0 -30
- abyss_cli-0.1.3/test/test_shell_chcp_leak.py +0 -63
- abyss_cli-0.1.3/test/test_shell_retry_encoding.py +0 -81
- abyss_cli-0.1.3/test/test_shell_safety.py +0 -65
- abyss_cli-0.1.3/test/test_shell_timeout_fix.py +0 -155
- abyss_cli-0.1.3/test/test_show_reasoning_persist.py +0 -101
- abyss_cli-0.1.3/test/test_skill_loader.py +0 -162
- abyss_cli-0.1.3/test/test_slash_commands.py +0 -125
- abyss_cli-0.1.3/test/test_slash_methodology.py +0 -142
- abyss_cli-0.1.3/test/test_spawn_subagents_tool.py +0 -85
- abyss_cli-0.1.3/test/test_subagent.py +0 -195
- abyss_cli-0.1.3/test/test_subagent_methodology.py +0 -102
- abyss_cli-0.1.3/test/test_tool_registry.py +0 -176
- abyss_cli-0.1.3/test/test_unbounded_rounds.py +0 -45
- abyss_cli-0.1.3/test/test_url_parser.py +0 -107
- abyss_cli-0.1.3/test/test_web_search_rules.py +0 -54
- {abyss_cli-0.1.3 → abyss_cli-0.1.4}/MANIFEST.in +0 -0
- {abyss_cli-0.1.3 → abyss_cli-0.1.4}/setup.cfg +0 -0
- {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/api_client.py +0 -0
- {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/commands/__init__.py +0 -0
- {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/commands/slash.py +0 -0
- {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/config.py +0 -0
- {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/extensions/__init__.py +0 -0
- {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/extensions/cli.py +0 -0
- {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/extensions/installer.py +0 -0
- {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/extensions/registry.py +0 -0
- {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/extensions/url_parser.py +0 -0
- {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/hooks/__init__.py +0 -0
- {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/hooks/runner.py +0 -0
- {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/logger.py +0 -0
- {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/mcp/__init__.py +0 -0
- {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/prompts/__init__.py +0 -0
- {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/prompts/methodologies.py +0 -0
- {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/prompts/system_prompt.md +0 -0
- {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/skills/__init__.py +0 -0
- {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/skills/loader.py +0 -0
- {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/subagent/__init__.py +0 -0
- {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/subagent/runner.py +0 -0
- {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/tools/__init__.py +0 -0
- {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/tools/base.py +0 -0
- {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/tools/file_edit.py +0 -0
- {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/tools/file_read.py +0 -0
- {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/tools/file_write.py +0 -0
- {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/tools/registry.py +0 -0
- {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/tools/shell_exec.py +0 -0
- {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/tools/spawn_subagents.py +0 -0
- {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/tools/web_fetch.py +0 -0
- {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss/tools/web_search.py +0 -0
- {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss_cli.egg-info/dependency_links.txt +0 -0
- {abyss_cli-0.1.3 → abyss_cli-0.1.4}/src/abyss_cli.egg-info/entry_points.txt +0 -0
- {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
|
+
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.
|
|
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
|
|
|
@@ -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
|
-
|
|
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")
|