super-code-assistant 3.3.6__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 (61) hide show
  1. commands/__init__.py +859 -0
  2. core/__init__.py +0 -0
  3. core/config.py +263 -0
  4. core/config_template.json +7 -0
  5. core/context.py +271 -0
  6. core/engine.py +635 -0
  7. core/file_state.py +279 -0
  8. core/llm.py +309 -0
  9. core/model_capabilities.py +45 -0
  10. core/permissions.py +204 -0
  11. core/sandbox/__init__.py +15 -0
  12. core/sandbox/blacklist.py +176 -0
  13. core/sandbox/config.py +38 -0
  14. core/sandbox/network.py +136 -0
  15. core/sandbox/path_protection.py +126 -0
  16. core/session.py +295 -0
  17. core/tool.py +45 -0
  18. features/__init__.py +0 -0
  19. features/compact.py +945 -0
  20. features/coordinator.py +105 -0
  21. features/cost_tracker.py +184 -0
  22. features/extract_memories.py +326 -0
  23. features/find_relevant_memories.py +376 -0
  24. features/git_ai.py +256 -0
  25. features/memory.py +531 -0
  26. features/memory_age.py +66 -0
  27. features/memory_scan.py +153 -0
  28. features/memory_types.py +34 -0
  29. features/plan.py +327 -0
  30. features/skills.py +300 -0
  31. features/worker_manager.py +232 -0
  32. mcp/__init__.py +0 -0
  33. mcp/client.py +112 -0
  34. mcp/loader.py +80 -0
  35. mcp/tool_proxy.py +59 -0
  36. super_code_assistant-3.3.6.dist-info/METADATA +45 -0
  37. super_code_assistant-3.3.6.dist-info/RECORD +61 -0
  38. super_code_assistant-3.3.6.dist-info/WHEEL +5 -0
  39. super_code_assistant-3.3.6.dist-info/entry_points.txt +2 -0
  40. super_code_assistant-3.3.6.dist-info/top_level.txt +7 -0
  41. tools/__init__.py +21 -0
  42. tools/agent.py +132 -0
  43. tools/ask_user.py +111 -0
  44. tools/bash.py +77 -0
  45. tools/file_edit.py +269 -0
  46. tools/file_read.py +206 -0
  47. tools/file_write.py +78 -0
  48. tools/glob_tool.py +81 -0
  49. tools/grep_tool.py +134 -0
  50. tools/plan_tools.py +75 -0
  51. tools/skill.py +108 -0
  52. tools/tool.py +44 -0
  53. tools/web_fetch.py +129 -0
  54. tools/web_search.py +220 -0
  55. tui/__init__.py +0 -0
  56. tui/app.py +726 -0
  57. tui/clipboard_image.py +42 -0
  58. tui/keylistener.py +140 -0
  59. tui/prompt.py +752 -0
  60. tui/query.py +200 -0
  61. tui/rendering.py +135 -0
features/skills.py ADDED
@@ -0,0 +1,300 @@
1
+ """Skill system — load, register, and execute SKILL.md-based skills."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Any, Callable
8
+
9
+
10
+ # ---------------------------------------------------------------------------
11
+ # Skill definition
12
+ # ---------------------------------------------------------------------------
13
+
14
+ @dataclass
15
+ class Skill:
16
+ name: str
17
+ description: str = ""
18
+ when_to_use: str = ""
19
+ user_invocable: bool = True
20
+ context: str = "inline" # "inline" 注入当前对话 / "fork" 独立子会话
21
+ argument_hint: str = ""
22
+ source: str = "project" # "bundled" / "project" / "user"
23
+ skill_root: str | None = None # 用于 $SKILL_DIR 变量替换的基础目录
24
+
25
+ # ── 扩展字段 ───────────────────────────────────────────────────────────
26
+ # 解析后仅存储,目标 1(字段对齐)阶段不强制行为;目标 2(自动触发)启用后才生效。
27
+ # 这样外部 SKILL.md 可以直接复制进来不报错、字段不丢失。
28
+ allowed_tools: list[str] = field(default_factory=list) # 限制 skill 可调用工具,空列表 = 不限制
29
+ model: str = "" # 指定 skill 偏好的模型,空 = 沿用主对话
30
+ disable_model_invocation: bool = False # True = 仅允许用户 /<name> 手动触发
31
+
32
+ _prompt_text: str = ""
33
+ _prompt_fn: Callable[[str], str] | None = None
34
+
35
+ def get_prompt(self, args: str = "") -> str:
36
+ """返回最终提示词,替换变量。"""
37
+ if self._prompt_fn is not None:
38
+ return self._prompt_fn(args)
39
+ text = self._prompt_text
40
+ text = text.replace("$ARGUMENTS", args)
41
+ if self.skill_root:
42
+ text = text.replace("${CLAUDE_SKILL_DIR}", self.skill_root)
43
+ if args and self.argument_hint:
44
+ text = text.replace(f"${{{self.argument_hint}}}", args)
45
+ return text
46
+
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # YAML frontmatter parser(最小实现,无 PyYAML 依赖)
50
+ # ---------------------------------------------------------------------------
51
+
52
+ _FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n?", re.DOTALL)
53
+
54
+ #标识哪些字段被认为是列表类型,可以采用逗号分隔
55
+ _LIST_FIELDS = {"allowed_tools"}
56
+
57
+
58
+ def _parse_frontmatter(text: str) -> tuple[dict[str, Any], str]:
59
+ """将 SKILL.md 文本拆分为 (frontmatter_dict, body)。
60
+
61
+ 支持 YAML 续行:以空格/制表符缩进开头的行视为前一字段的续行,
62
+ 用单个空格连接拼到前一字段值上。典型场景:
63
+ description: Use this skill whenever the user wants to do anything with
64
+ PDF files. This includes reading or extracting text/tables ...
65
+ 没有续行支持时 description 只保留第一行,模型读不到完整触发条件。
66
+ """
67
+ m = _FRONTMATTER_RE.match(text)
68
+ if not m:
69
+ return {}, text
70
+ raw = m.group(1)
71
+ body = text[m.end():]
72
+
73
+ # 第一遍:识别"key: value"行 + 续行(缩进开头),把多行 value 拼回单行
74
+ pairs: list[tuple[str, str]] = []
75
+ for line in raw.splitlines():
76
+ if not line.strip() or line.strip().startswith("#"):
77
+ continue
78
+ # 续行判定:行首是空白 + 已有累积字段 → 拼到前一字段
79
+ if line[:1] in (" ", "\t") and pairs:
80
+ cont = line.strip()
81
+ if cont:
82
+ key, prev = pairs[-1]
83
+ pairs[-1] = (key, (prev + " " + cont).strip() if prev else cont)
84
+ continue
85
+ if ":" not in line:
86
+ continue
87
+ key, _, val = line.partition(":")
88
+ pairs.append((key.strip().lower().replace("-", "_"), val.strip()))
89
+
90
+ # 第二遍:类型推断
91
+ meta: dict[str, Any] = {}
92
+ for key, val in pairs:
93
+ if val.lower() in ("true", "yes"):
94
+ meta[key] = True
95
+ elif val.lower() in ("false", "no"):
96
+ meta[key] = False
97
+ elif key in _LIST_FIELDS and "," in val:
98
+ # 仅已知列表字段才按逗号切分,避免 description/when_to_use 等被误切
99
+ meta[key] = [v.strip() for v in val.split(",") if v.strip()]
100
+ elif (val.startswith('"') and val.endswith('"')) or \
101
+ (val.startswith("'") and val.endswith("'")):
102
+ meta[key] = val[1:-1]
103
+ else:
104
+ meta[key] = val
105
+ return meta, body
106
+
107
+
108
+ def _ensure_str(val: Any, default: str = "") -> str:
109
+ """将任意类型安全转为字符串(列表则 join)。"""
110
+ if val is None:
111
+ return default
112
+ if isinstance(val, list):
113
+ return ", ".join(str(v) for v in val)
114
+ return str(val)
115
+
116
+
117
+ def _skill_from_frontmatter(meta: dict[str, Any], body: str,
118
+ name: str, source: str,
119
+ skill_root: str | None = None) -> Skill:
120
+ """根据解析的 frontmatter 和 body 构建 Skill 对象。"""
121
+ # argument-hint 是标准字段名(经 _parse_frontmatter 规范化后变成
122
+ # argument_hint);早期版本写的是 arguments,两者都兼容,前者优先。
123
+ arg_hint = meta.get("argument_hint")
124
+ if not arg_hint:
125
+ arg_hint = meta.get("arguments")
126
+
127
+ # allowed-tools:标准 frontmatter 通常是 "Read, Write, Bash" 这样的逗号串。
128
+ # _parse_frontmatter 对已知列表字段会切成 list,但用户也可能写成单值字符串,
129
+ # 这里统一规整为 list[str]。
130
+ raw_allowed = meta.get("allowed_tools")
131
+ if isinstance(raw_allowed, list):
132
+ allowed_tools = [str(t).strip() for t in raw_allowed if str(t).strip()]
133
+ elif isinstance(raw_allowed, str) and raw_allowed.strip():
134
+ allowed_tools = [t.strip() for t in raw_allowed.split(",") if t.strip()]
135
+ else:
136
+ allowed_tools = []
137
+
138
+ return Skill(
139
+ name=_ensure_str(meta.get("name"), name),
140
+ description=_ensure_str(meta.get("description")),
141
+ when_to_use=_ensure_str(meta.get("when_to_use")),
142
+ user_invocable=meta.get("user_invocable", True),
143
+ context=_ensure_str(meta.get("context"), "inline"),
144
+ argument_hint=_ensure_str(arg_hint),
145
+ source=source,
146
+ skill_root=skill_root,
147
+ allowed_tools=allowed_tools,
148
+ model=_ensure_str(meta.get("model")),
149
+ disable_model_invocation=bool(meta.get("disable_model_invocation", False)),
150
+ _prompt_text=body.strip(),
151
+ )
152
+
153
+
154
+ # ---------------------------------------------------------------------------
155
+ # Skill registry(全局注册表)
156
+ # ---------------------------------------------------------------------------
157
+
158
+ _REGISTRY: dict[str, Skill] = {}
159
+
160
+ # 本进程内"已被调用过的 skill 名称"集合。压缩流程会读取它做 Phase B 重注入,
161
+ # 让模型在压缩之后仍知道之前调用过哪些 skill 的指令内容。
162
+ # 进程级状态(不持久化、不跨会话):/resume 一个老 session 时该集合为空,
163
+ # 老对话里的 skill body 仍在 messages 中能进入 history 被总结,无需重注入。
164
+ _INVOKED_SKILLS: set[str] = set()
165
+
166
+
167
+ def mark_skill_invoked(name: str) -> None:
168
+ """记录一次 skill 调用。两条调用路径(用户 /<name> 与模型 SkillTool)都会调本函数。"""
169
+ if name:
170
+ _INVOKED_SKILLS.add(name)
171
+
172
+
173
+ def get_invoked_skills() -> list[str]:
174
+ """返回本进程内调用过的 skill 名称(按字母序)。压缩重注入用。"""
175
+ return sorted(_INVOKED_SKILLS)
176
+
177
+
178
+ def clear_invoked_skills() -> None:
179
+ """清空调用记录。测试或 /clear 命令调用。"""
180
+ _INVOKED_SKILLS.clear()
181
+
182
+
183
+ def register_skill(skill: Skill) -> None:
184
+ """将 skill 注册到全局注册表。"""
185
+ _REGISTRY[skill.name] = skill
186
+
187
+
188
+ def get_skill(name: str) -> Skill | None:
189
+ """按名称查找 skill。"""
190
+ return _REGISTRY.get(name)
191
+
192
+
193
+ def list_skills(user_invocable_only: bool = True) -> list[Skill]:
194
+ """返回所有已注册的 skill,可选只返回用户可调用的。"""
195
+ skills = list(_REGISTRY.values())
196
+ if user_invocable_only:
197
+ skills = [s for s in skills if s.user_invocable]
198
+ return sorted(skills, key=lambda s: (s.source != "bundled", s.name))
199
+
200
+
201
+ def clear_skills(source: str | None = None) -> None:
202
+ """清除注册表,可选只清除指定来源的 skill。"""
203
+ if source is None:
204
+ _REGISTRY.clear()
205
+ else:
206
+ for k in [k for k, v in _REGISTRY.items() if v.source == source]:
207
+ del _REGISTRY[k]
208
+
209
+
210
+ # ---------------------------------------------------------------------------
211
+ # Skill discovery from disk
212
+ # ---------------------------------------------------------------------------
213
+
214
+ def load_skills_from_dir(skills_dir: Path, source: str = "project") -> list[Skill]:
215
+ """扫描 skills_dir 下的 <name>/SKILL.md 并注册每个 skill。"""
216
+ loaded: list[Skill] = []
217
+ if not skills_dir.is_dir():
218
+ return loaded
219
+ for entry in sorted(skills_dir.iterdir()):
220
+ skill = None
221
+ if entry.is_dir():
222
+ skill_md = entry / "SKILL.md"
223
+ if not skill_md.exists():
224
+ md_files = list(entry.glob("*.md"))
225
+ skill_md = md_files[0] if md_files else None
226
+ if skill_md is None:
227
+ continue
228
+ try:
229
+ text = skill_md.read_text(encoding="utf-8")
230
+ except Exception:
231
+ continue
232
+ meta, body = _parse_frontmatter(text)
233
+ skill = _skill_from_frontmatter(meta, body, name=entry.name,
234
+ source=source, skill_root=str(entry))
235
+ elif entry.suffix == ".md" and entry.is_file():
236
+ try:
237
+ text = entry.read_text(encoding="utf-8")
238
+ except Exception:
239
+ continue
240
+ meta, body = _parse_frontmatter(text)
241
+ skill = _skill_from_frontmatter(meta, body, name=entry.stem,
242
+ source=source, skill_root=str(entry.parent))
243
+ if skill and skill._prompt_text:
244
+ register_skill(skill)
245
+ loaded.append(skill)
246
+ return loaded
247
+
248
+
249
+ def discover_skills(cwd: str | None = None) -> list[Skill]:
250
+ """从标准位置发现并注册 skill。
251
+
252
+ 搜索顺序(同名 skill 后扫描者覆盖前者):
253
+ 1. 用户级(HOME):~/.config/super-code/skills/
254
+ 2. 便携级(exe同级):<exe目录>/skills/
255
+ 3. 项目级(当前目录):{cwd}/.super-code/skills/
256
+ """
257
+ loaded: list[Skill] = []
258
+ home = Path.home()
259
+ loaded.extend(load_skills_from_dir(home / ".config" / "super-code" / "skills", source="user"))
260
+ from core.config import get_portable_dir
261
+ loaded.extend(load_skills_from_dir(get_portable_dir() / "skills", source="portable"))
262
+ if cwd:
263
+ loaded.extend(load_skills_from_dir(Path(cwd) / ".super-code" / "skills", source="project"))
264
+ return loaded
265
+
266
+
267
+ # ---------------------------------------------------------------------------
268
+ # System prompt section
269
+ # ---------------------------------------------------------------------------
270
+
271
+ def build_skills_prompt_section() -> str:
272
+ """生成 skill 列表文本,拼接到系统提示词中,让模型知道可用的 skill。
273
+
274
+ 输出含两段:
275
+ 1) 用法说明:告诉模型如何用 Skill 工具自主调用,以及和用户手动 /<name> 的差异
276
+ 2) skill 索引:每个 skill 的 name + description(+ when_to_use),仅作触发判据
277
+
278
+ disable_model_invocation=true 的 skill 仍出现在索引里,便于模型识别用户意图
279
+ 后建议用户手动 /<name>;SkillTool.execute 会真正拒绝模型对它们的调用。
280
+ """
281
+ skills = list_skills(user_invocable_only=False)
282
+ if not skills:
283
+ return ""
284
+ lines = [
285
+ "# Available Skills",
286
+ "",
287
+ "When the user's request matches one of these skills, prefer invoking it "
288
+ "via the Skill tool: `Skill(name=\"<skill-name>\", args=\"<user args>\")`. "
289
+ "The user can also trigger any skill manually by typing `/<skill-name> args`.",
290
+ "",
291
+ ]
292
+ for s in skills:
293
+ desc = s.description or "(no description)"
294
+ line = f"- {s.name}: {desc}"
295
+ if s.when_to_use:
296
+ line += f" — {s.when_to_use}"
297
+ if s.disable_model_invocation:
298
+ line += " [user-only: suggest /<name> instead of calling Skill tool]"
299
+ lines.append(line)
300
+ return "\n".join(lines)
@@ -0,0 +1,232 @@
1
+ from __future__ import annotations
2
+
3
+ import threading
4
+ import time
5
+ import uuid
6
+ from dataclasses import dataclass, field
7
+ from queue import Empty, Queue
8
+ from typing import Callable
9
+ from xml.sax.saxutils import escape
10
+
11
+ from core.engine import AbortedError, Engine
12
+
13
+
14
+ @dataclass
15
+ class WorkerUsage:
16
+ total_tokens: int = 0
17
+ tool_uses: int = 0
18
+ duration_ms: int = 0
19
+
20
+
21
+ @dataclass
22
+ class WorkerTask:
23
+ """表示一个后台 worker 任务。"""
24
+ task_id: str
25
+ description: str
26
+ engine: Engine
27
+ status: str = "idle"
28
+ summary: str = ""
29
+ result: str = ""
30
+ usage: WorkerUsage = field(default_factory=WorkerUsage)
31
+ thread: threading.Thread | None = None
32
+ tool_use_count: int = 0 # 已调用工具次数,用于实时状态显示
33
+ current_activity: str = "" # 当前活动描述,用于实时状态显示
34
+
35
+
36
+ class WorkerManager:
37
+ """管理后台 worker 线程的生命周期:spawn / continue / stop / 通知队列。"""
38
+
39
+ def __init__(self, build_worker_engine: Callable[[], Engine]):
40
+ self._build_worker_engine = build_worker_engine
41
+ self._tasks: dict[str, WorkerTask] = {}
42
+ self._lock = threading.Lock() # 多线程访问 _tasks 需要加锁
43
+ self._notifications: Queue[str] = Queue() # 线程安全的通知队列
44
+
45
+ def spawn(self, *, description: str, prompt: str,
46
+ subagent_type: str = "worker") -> dict[str, str]:
47
+ """启动一个新的 worker 任务,返回 task_id。"""
48
+ if subagent_type != "worker":
49
+ raise ValueError("Only subagent_type='worker' is supported.")
50
+
51
+ task = WorkerTask(
52
+ task_id=f"agent-{uuid.uuid4().hex[:8]}",
53
+ description=description.strip() or "Worker task",
54
+ engine=self._build_worker_engine(), # 每个 worker 独立的 engine 实例
55
+ )
56
+ with self._lock:
57
+ self._tasks[task.task_id] = task
58
+ self._start(task, prompt)
59
+ return {"task_id": task.task_id, "status": "started", "description": task.description}
60
+
61
+ def continue_task(self, *, task_id: str, message: str) -> dict[str, str]:
62
+ """继续一个已完成的 worker 任务(SendMessage)。"""
63
+ task = self._get_task(task_id)
64
+ if self._is_running(task):
65
+ raise ValueError("Task is still running. Wait for it to finish before continuing it.")
66
+ self._start(task, message)
67
+ return {"task_id": task.task_id, "status": "started", "description": task.description}
68
+
69
+ def stop_task(self, *, task_id: str) -> dict[str, str]:
70
+ """中止一个正在运行的 worker 任务。"""
71
+ task = self._get_task(task_id)
72
+ if not self._is_running(task):
73
+ return {"task_id": task.task_id, "status": task.status or "idle",
74
+ "description": task.description}
75
+ try:
76
+ task.engine.abort()
77
+ except Exception:
78
+ pass
79
+ return {"task_id": task.task_id, "status": "stopping", "description": task.description}
80
+
81
+ def drain_notifications(self) -> list[str]:
82
+ """取出队列中所有已完成任务的通知(非阻塞)。"""
83
+ drained: list[str] = []
84
+ while True:
85
+ try:
86
+ drained.append(self._notifications.get_nowait())
87
+ except Empty:
88
+ return drained
89
+
90
+ def has_running_tasks(self) -> bool:
91
+ """是否有正在运行的 worker。"""
92
+ with self._lock:
93
+ return any(self._is_running(t) for t in self._tasks.values())
94
+
95
+ def get_running_status(self) -> list[dict]:
96
+ """返回所有正在运行 worker 的实时状态。"""
97
+ with self._lock:
98
+ return [
99
+ {"task_id": t.task_id, "description": t.description,
100
+ "tool_uses": t.tool_use_count, "activity": t.current_activity}
101
+ for t in self._tasks.values()
102
+ if self._is_running(t)
103
+ ]
104
+
105
+ def get_panel_status(self) -> list[dict]:
106
+ """返回进度面板所需的全部 worker 状态(运行中 + 已完成,按 spawn 序)。
107
+
108
+ 与 get_running_status 的区别:不筛选运行态,附带 status 字段
109
+ (running/completed/killed/failed),供面板显示完成态(✓/✗)。
110
+ 线程安全(_lock 保护);status 的最终写发生在 worker 线程结束处。
111
+ """
112
+ with self._lock:
113
+ return [
114
+ {"task_id": t.task_id, "description": t.description,
115
+ "tool_uses": t.tool_use_count, "activity": t.current_activity,
116
+ "status": "running" if self._is_running(t) else t.status}
117
+ for t in self._tasks.values()
118
+ ]
119
+
120
+ def clear_finished(self) -> None:
121
+ """清除所有已结束任务的记录(保留仍在运行的)。
122
+
123
+ 供主循环在用户提交新一轮输入时调用:完成态面板只保留到下一轮输入,
124
+ 之后不再占用输入框上方空间;运行中的任务不受影响(继续展示进度)。
125
+ """
126
+ with self._lock:
127
+ for task_id in [tid for tid, t in self._tasks.items()
128
+ if not self._is_running(t)]:
129
+ del self._tasks[task_id]
130
+
131
+ def _get_task(self, task_id: str) -> WorkerTask:
132
+ with self._lock:
133
+ task = self._tasks.get(task_id)
134
+ if task is None:
135
+ raise ValueError(f"Unknown task id: {task_id}")
136
+ return task
137
+
138
+ @staticmethod
139
+ def _is_running(task: WorkerTask) -> bool:
140
+ return task.thread is not None and task.thread.is_alive()
141
+
142
+ def _start(self, task: WorkerTask, prompt: str) -> None:
143
+ """在后台线程中启动任务。"""
144
+ task.status = "running"
145
+ task.summary = ""
146
+ task.result = ""
147
+ task.usage = WorkerUsage()
148
+ task.thread = threading.Thread(
149
+ target=self._run_task, # 线程执行方法
150
+ name=task.task_id,
151
+ args=(task, prompt),
152
+ daemon=True,
153
+ )
154
+ task.thread.start()
155
+
156
+ def _run_task(self, task: WorkerTask, prompt: str) -> None:
157
+ """worker 线程主体:消费 engine.submit() 事件流,完成后推送通知。"""
158
+ started = time.monotonic()
159
+ parts: list[str] = []
160
+ total_tokens = 0
161
+ tool_uses = 0
162
+ task.tool_use_count = 0
163
+ task.current_activity = "Initializing…"
164
+ try:
165
+ for event in task.engine.submit(prompt):
166
+ kind = event[0]
167
+ if kind == "text":
168
+ parts.append(event[1])
169
+ task.current_activity = "Thinking…"
170
+ elif kind == "tool_call":
171
+ tool_uses += 1
172
+ task.tool_use_count = tool_uses
173
+ tool_name = event[1] if len(event) > 1 else ""
174
+ task.current_activity = f"Running {tool_name}…"
175
+ elif kind == "tool_result":
176
+ task.current_activity = "Thinking…"
177
+ elif kind == "error":
178
+ parts.append(event[1])
179
+ status = "completed"
180
+ summary = f'Agent "{task.description}" completed'
181
+ except AbortedError:
182
+ status = "killed"
183
+ summary = f'Agent "{task.description}" was stopped'
184
+ except Exception as exc:
185
+ status = "failed"
186
+ summary = f'Agent "{task.description}" failed: {exc}'
187
+ parts.append(str(exc))
188
+
189
+ task.status = status
190
+ task.summary = summary
191
+ task.current_activity = ""
192
+ task.result = "".join(parts).strip()
193
+ task.usage = WorkerUsage(
194
+ total_tokens=total_tokens,
195
+ tool_uses=tool_uses,
196
+ duration_ms=int((time.monotonic() - started) * 1000),
197
+ )
198
+ self._notifications.put(self._render_notification(task))
199
+
200
+ def _render_notification(self, task: WorkerTask) -> str:
201
+ """将任务结果序列化为 XML 风格的 <task-notification> 字符串。
202
+
203
+ 示例:
204
+ <task-notification>
205
+ <task-id>agent-a1b2c3d4</task-id>
206
+ <status>completed</status>
207
+ <summary>Agent &quot;代码重构&quot; completed</summary>
208
+ <result>已成功将 utils.py 中的函数提取到 helper.py</result>
209
+ <usage>
210
+ <total_tokens>1500</total_tokens>
211
+ <tool_uses>3</tool_uses>
212
+ <duration_ms>5200</duration_ms>
213
+ </usage>
214
+ </task-notification>
215
+ """
216
+ parts = [
217
+ "<task-notification>",
218
+ f"<task-id>{escape(task.task_id)}</task-id>",
219
+ f"<status>{escape(task.status)}</status>",
220
+ f"<summary>{escape(task.summary)}</summary>",
221
+ ]
222
+ if task.result:
223
+ parts.append(f"<result>{escape(task.result)}</result>")
224
+ parts.extend([
225
+ "<usage>",
226
+ f" <total_tokens>{task.usage.total_tokens}</total_tokens>",
227
+ f" <tool_uses>{task.usage.tool_uses}</tool_uses>",
228
+ f" <duration_ms>{task.usage.duration_ms}</duration_ms>",
229
+ "</usage>",
230
+ "</task-notification>",
231
+ ])
232
+ return "\n".join(parts)
mcp/__init__.py ADDED
File without changes
mcp/client.py ADDED
@@ -0,0 +1,112 @@
1
+ """MCP Client — 通过 stdio 与 MCP server 子进程通信。
2
+
3
+ MCP 协议简介:
4
+ - 传输层:子进程的 stdin/stdout,每条消息是一行 JSON(JSON-RPC 2.0)
5
+ - 握手流程:client 发 initialize → server 回 result → client 发 initialized 通知
6
+ - 获取工具:发 tools/list → server 返回工具列表(name, description, inputSchema)
7
+ - 调用工具:发 tools/call → server 返回 content 列表
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import subprocess
13
+ import threading
14
+ from typing import Any
15
+
16
+
17
+ class MCPError(Exception):
18
+ pass
19
+
20
+
21
+ class MCPClient:
22
+ """管理单个 MCP server 子进程的生命周期和 JSON-RPC 通信。"""
23
+
24
+ def __init__(self, name: str, command: str, args: list[str], env: dict[str, str] | None = None):
25
+ self.name = name
26
+ self._proc = subprocess.Popen(
27
+ [command, *args],
28
+ stdin=subprocess.PIPE, # 向子进程发送数据
29
+ stdout=subprocess.PIPE, # 从子进程接收数据
30
+ stderr=subprocess.DEVNULL, # 忽略 server 的调试输出
31
+ env=env, # 子进程的环境变量,默认继承父进程环境
32
+ text=True, # 用文本模式,而不是bytes
33
+ encoding="utf-8", #
34
+ )
35
+ self._lock = threading.Lock() # 保证多线程下请求串行,避免消息交错
36
+ self._next_id = 1
37
+ self._handshake()
38
+
39
+ # ------------------------------------------------------------------ #
40
+ # 公开接口
41
+ # ------------------------------------------------------------------ #
42
+
43
+ def list_tools(self) -> list[dict[str, Any]]:
44
+ """返回 server 暴露的工具列表,每项含 name / description / inputSchema。"""
45
+ result = self._call("tools/list", {})
46
+ return result.get("tools", [])
47
+
48
+ def call_tool(self, tool_name: str, arguments: dict[str, Any]) -> str:
49
+ """调用指定工具,返回纯文本结果。"""
50
+ result = self._call("tools/call", {"name": tool_name, "arguments": arguments})
51
+ # MCP 返回 content 列表,每项有 type 和 text
52
+ parts = [
53
+ item.get("text", "")
54
+ for item in result.get("content", [])
55
+ if item.get("type") == "text"
56
+ ]
57
+ return "\n".join(parts) or "(no output)"
58
+
59
+ def close(self):
60
+ try:
61
+ self._proc.terminate()
62
+ except Exception:
63
+ pass
64
+
65
+ # ------------------------------------------------------------------ #
66
+ # 内部实现
67
+ # ------------------------------------------------------------------ #
68
+
69
+ def _handshake(self):
70
+ """MCP 握手:initialize → initialized。必须在首次 tools/list 之前完成。"""
71
+ self._call("initialize", {
72
+ "protocolVersion": "2024-11-05",
73
+ "capabilities": {},
74
+ "clientInfo": {"name": "super-code", "version": "1.0"},
75
+ })
76
+ # initialized 是通知(notification),没有 id,不等待响应
77
+ self._send({"jsonrpc": "2.0", "method": "notifications/initialized"})
78
+
79
+ def _call(self, method: str, params: dict) -> dict[str, Any]:
80
+ """发送 JSON-RPC 请求并等待对应响应。"""
81
+ with self._lock:
82
+ req_id = self._next_id
83
+ self._next_id += 1
84
+ self._send({"jsonrpc": "2.0", "id": req_id, "method": method, "params": params})
85
+ return self._recv(req_id)
86
+
87
+ def _send(self, obj: dict):
88
+ line = json.dumps(obj, ensure_ascii=False) + "\n"
89
+ self._proc.stdin.write(line)
90
+ self._proc.stdin.flush()
91
+
92
+ def _recv(self, expected_id: int) -> dict[str, Any]:
93
+ """
94
+ 读取行 直到收到匹配 id 的响应(跳过 server 主动推送的通知)。
95
+ 发出去的id,要和响应的id对起来
96
+ """
97
+ while True:
98
+ line = self._proc.stdout.readline()
99
+ if not line:
100
+ raise MCPError(f"MCP server '{self.name}' closed unexpectedly")
101
+ try:
102
+ msg = json.loads(line)
103
+ except json.JSONDecodeError:
104
+ continue # 忽略非 JSON 行(server 可能输出日志)
105
+ # 通知没有 id,跳过
106
+ if "id" not in msg:
107
+ continue
108
+ if msg["id"] != expected_id: # 发出去的id,要和响应的id对起来
109
+ continue # 不属于本次请求,继续等
110
+ if "error" in msg:
111
+ raise MCPError(f"MCP error: {msg['error']}")
112
+ return msg.get("result", {})