my-pi-agent 0.1.0

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 (141) hide show
  1. package/README.md +318 -0
  2. package/package.json +45 -0
  3. package/pyproject.toml +50 -0
  4. package/src/my_agent_core/__init__.py +123 -0
  5. package/src/my_agent_core/agent.py +441 -0
  6. package/src/my_agent_core/background.py +121 -0
  7. package/src/my_agent_core/context.py +505 -0
  8. package/src/my_agent_core/events.py +153 -0
  9. package/src/my_agent_core/extensions/__init__.py +9 -0
  10. package/src/my_agent_core/extensions/core.py +197 -0
  11. package/src/my_agent_core/hooks.py +130 -0
  12. package/src/my_agent_core/loop.py +709 -0
  13. package/src/my_agent_core/main.py +134 -0
  14. package/src/my_agent_core/memory.py +241 -0
  15. package/src/my_agent_core/message_queue.py +110 -0
  16. package/src/my_agent_core/plugins.py +212 -0
  17. package/src/my_agent_core/registry.py +186 -0
  18. package/src/my_agent_core/session/__init__.py +79 -0
  19. package/src/my_agent_core/session/entries.py +197 -0
  20. package/src/my_agent_core/session/jsonl.py +60 -0
  21. package/src/my_agent_core/session/memory.py +137 -0
  22. package/src/my_agent_core/session/session.py +400 -0
  23. package/src/my_agent_core/session/storage.py +245 -0
  24. package/src/my_agent_core/session/store.py +131 -0
  25. package/src/my_agent_core/session/tree.py +86 -0
  26. package/src/my_agent_core/skills.py +149 -0
  27. package/src/my_agent_core/subagent_tasks.py +170 -0
  28. package/src/my_agent_core/subagents.py +148 -0
  29. package/src/my_agent_core/task_store.py +248 -0
  30. package/src/my_agent_core/tool_history.py +189 -0
  31. package/src/my_agent_core/tools/__init__.py +5 -0
  32. package/src/my_agent_core/tools/builtin/__init__.py +5 -0
  33. package/src/my_agent_core/tools/builtin/task.py +30 -0
  34. package/src/my_agent_core/tools/builtin/task_tools.py +215 -0
  35. package/src/my_agent_core/tools/core.py +239 -0
  36. package/src/my_agent_llm/__init__.py +45 -0
  37. package/src/my_agent_llm/auth/__init__.py +46 -0
  38. package/src/my_agent_llm/auth/antigravity.py +209 -0
  39. package/src/my_agent_llm/auth/manager.py +259 -0
  40. package/src/my_agent_llm/auth/quota.py +56 -0
  41. package/src/my_agent_llm/auth/schema.py +94 -0
  42. package/src/my_agent_llm/client.py +116 -0
  43. package/src/my_agent_llm/config.py +17 -0
  44. package/src/my_agent_llm/events.py +84 -0
  45. package/src/my_agent_llm/models.py +195 -0
  46. package/src/my_agent_llm/providers/__init__.py +4 -0
  47. package/src/my_agent_llm/providers/_base.py +94 -0
  48. package/src/my_agent_llm/providers/anthropic.py +298 -0
  49. package/src/my_agent_llm/providers/antigravity.py +480 -0
  50. package/src/my_agent_llm/providers/deepseek.py +196 -0
  51. package/src/my_agent_llm/providers/openai.py +364 -0
  52. package/src/my_agent_llm/providers/registry.py +16 -0
  53. package/src/my_agent_llm/stream.py +218 -0
  54. package/src/my_coding_agent/__init__.py +66 -0
  55. package/src/my_coding_agent/agent.py +208 -0
  56. package/src/my_coding_agent/cli.py +78 -0
  57. package/src/my_coding_agent/file_reference.py +80 -0
  58. package/src/my_coding_agent/macro.py +408 -0
  59. package/src/my_coding_agent/mcp.py +243 -0
  60. package/src/my_coding_agent/mutation_queue.py +37 -0
  61. package/src/my_coding_agent/paths.py +119 -0
  62. package/src/my_coding_agent/permissions.py +84 -0
  63. package/src/my_coding_agent/prompt.py +54 -0
  64. package/src/my_coding_agent/rpc_server.py +2817 -0
  65. package/src/my_coding_agent/settings.py +126 -0
  66. package/src/my_coding_agent/tools/__init__.py +55 -0
  67. package/src/my_coding_agent/tools/base.py +58 -0
  68. package/src/my_coding_agent/tools/bash.py +206 -0
  69. package/src/my_coding_agent/tools/edit.py +226 -0
  70. package/src/my_coding_agent/tools/find.py +118 -0
  71. package/src/my_coding_agent/tools/grep.py +177 -0
  72. package/src/my_coding_agent/tools/ls.py +112 -0
  73. package/src/my_coding_agent/tools/read.py +113 -0
  74. package/src/my_coding_agent/tools/write.py +72 -0
  75. package/tui/README.md +27 -0
  76. package/tui/bin/my-agent.js +98 -0
  77. package/tui/dist/app.d.ts +41 -0
  78. package/tui/dist/app.js +110 -0
  79. package/tui/dist/bridge/event-translator.d.ts +92 -0
  80. package/tui/dist/bridge/event-translator.js +216 -0
  81. package/tui/dist/bridge/kernel-bridge.d.ts +48 -0
  82. package/tui/dist/bridge/kernel-bridge.js +132 -0
  83. package/tui/dist/client.d.ts +63 -0
  84. package/tui/dist/client.js +239 -0
  85. package/tui/dist/components/assistant-message.d.ts +19 -0
  86. package/tui/dist/components/assistant-message.js +90 -0
  87. package/tui/dist/components/compaction-summary-message.d.ts +19 -0
  88. package/tui/dist/components/compaction-summary-message.js +46 -0
  89. package/tui/dist/components/custom-editor.d.ts +18 -0
  90. package/tui/dist/components/custom-editor.js +56 -0
  91. package/tui/dist/components/dynamic-border.d.ts +9 -0
  92. package/tui/dist/components/dynamic-border.js +14 -0
  93. package/tui/dist/components/footer.d.ts +39 -0
  94. package/tui/dist/components/footer.js +199 -0
  95. package/tui/dist/components/header.d.ts +4 -0
  96. package/tui/dist/components/header.js +21 -0
  97. package/tui/dist/components/keys.d.ts +5 -0
  98. package/tui/dist/components/keys.js +12 -0
  99. package/tui/dist/components/login-selector.d.ts +26 -0
  100. package/tui/dist/components/login-selector.js +181 -0
  101. package/tui/dist/components/logout-selector.d.ts +19 -0
  102. package/tui/dist/components/logout-selector.js +88 -0
  103. package/tui/dist/components/model-selector.d.ts +40 -0
  104. package/tui/dist/components/model-selector.js +268 -0
  105. package/tui/dist/components/session-selector.d.ts +54 -0
  106. package/tui/dist/components/session-selector.js +393 -0
  107. package/tui/dist/components/settings-selector.d.ts +24 -0
  108. package/tui/dist/components/settings-selector.js +146 -0
  109. package/tui/dist/components/status-indicator.d.ts +25 -0
  110. package/tui/dist/components/status-indicator.js +60 -0
  111. package/tui/dist/components/theme-selector.d.ts +14 -0
  112. package/tui/dist/components/theme-selector.js +77 -0
  113. package/tui/dist/components/thinking-selector.d.ts +21 -0
  114. package/tui/dist/components/thinking-selector.js +128 -0
  115. package/tui/dist/components/tool-execution.d.ts +31 -0
  116. package/tui/dist/components/tool-execution.js +206 -0
  117. package/tui/dist/components/tree-selector.d.ts +40 -0
  118. package/tui/dist/components/tree-selector.js +173 -0
  119. package/tui/dist/components/user-message-selector.d.ts +21 -0
  120. package/tui/dist/components/user-message-selector.js +103 -0
  121. package/tui/dist/components/user-message.d.ts +5 -0
  122. package/tui/dist/components/user-message.js +15 -0
  123. package/tui/dist/index.d.ts +11 -0
  124. package/tui/dist/index.js +11 -0
  125. package/tui/dist/interactive/chat-viewport.d.ts +19 -0
  126. package/tui/dist/interactive/chat-viewport.js +41 -0
  127. package/tui/dist/interactive/components.d.ts +1 -0
  128. package/tui/dist/interactive/components.js +1 -0
  129. package/tui/dist/interactive/interactive-mode.d.ts +89 -0
  130. package/tui/dist/interactive/interactive-mode.js +1625 -0
  131. package/tui/dist/interactive/theme.d.ts +1 -0
  132. package/tui/dist/interactive/theme.js +1 -0
  133. package/tui/dist/interactive/tui-renderer.d.ts +8 -0
  134. package/tui/dist/interactive/tui-renderer.js +10 -0
  135. package/tui/dist/protocol.d.ts +78 -0
  136. package/tui/dist/protocol.js +1 -0
  137. package/tui/dist/theme/dark.json +54 -0
  138. package/tui/dist/theme/light.json +71 -0
  139. package/tui/dist/theme/theme.d.ts +20 -0
  140. package/tui/dist/theme/theme.js +86 -0
  141. package/tui/package.json +25 -0
@@ -0,0 +1,408 @@
1
+ """输入行即时宏扩展引擎 (MacroEngine)。
2
+
3
+ 负责:
4
+ 1. Shell 宏执行管道(!cmd 与 !!cmd),支持工作区相对路径、超时、退出码与输出流捕获;
5
+ 2. Skill 展开宏(/skill:<name> [args]),剥离 YAML Frontmatter 并包装为标准 XML 格式;
6
+ 3. Prompt 模板展开宏(/<template> [args]),支持 Bash 风格 shlex 引号分词与完整变量替换。
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ import shlex
13
+ import subprocess
14
+ from pathlib import Path
15
+ from typing import TYPE_CHECKING, Any
16
+
17
+ from my_coding_agent.paths import AgentPaths
18
+
19
+ if TYPE_CHECKING:
20
+ from my_agent_core.skills import SkillManager
21
+
22
+
23
+ class MacroEngine:
24
+ """管理终端即时宏解析、Shell 命令执行与模板变量展开的核心引擎。"""
25
+
26
+ def __init__(
27
+ self,
28
+ workspace: Path | str | None = None,
29
+ paths: AgentPaths | None = None,
30
+ ) -> None:
31
+ self.workspace = Path(workspace).resolve() if workspace else Path.cwd()
32
+ self.paths = paths or AgentPaths()
33
+
34
+ def execute_shell(
35
+ self,
36
+ command: str,
37
+ cwd: Path | str | None = None,
38
+ exclude_from_context: bool = False,
39
+ timeout: float | None = 60.0,
40
+ ) -> dict[str, Any]:
41
+ """同步执行外部 Shell 命令并捕获输出与退出码。
42
+
43
+ Args:
44
+ command: 待执行的命令行字符串
45
+ cwd: 执行命令的工作目录(缺省为 workspace)
46
+ exclude_from_context: 是否排除在模型上下文之外(!!cmd 静默探查标记)
47
+ timeout: 命令执行超时时间(秒,缺省 60.0)
48
+
49
+ Returns:
50
+ 字典结构:status, output, stdout, stderr, exit_code, exclude_from_context
51
+ """
52
+ cmd_str = command.strip()
53
+ if not cmd_str:
54
+ return {
55
+ "status": "error",
56
+ "output": "Empty command",
57
+ "stdout": "",
58
+ "stderr": "Empty command",
59
+ "exit_code": -1,
60
+ "exclude_from_context": exclude_from_context,
61
+ }
62
+
63
+ target_cwd = Path(cwd).resolve() if cwd else self.workspace
64
+
65
+ try:
66
+ # nosec B602
67
+ proc = subprocess.run(
68
+ cmd_str,
69
+ shell=True, # noqa: S602 # nosec B602
70
+ cwd=str(target_cwd),
71
+ capture_output=True,
72
+ text=True,
73
+ timeout=timeout,
74
+ encoding="utf-8",
75
+ errors="replace",
76
+ )
77
+ stdout = proc.stdout or ""
78
+ stderr = proc.stderr or ""
79
+
80
+ if stdout and stderr:
81
+ output = f"{stdout}\n{stderr}" if not stdout.endswith("\n") else f"{stdout}{stderr}"
82
+ elif stdout:
83
+ output = stdout
84
+ else:
85
+ output = stderr
86
+
87
+ return {
88
+ "status": "ok" if proc.returncode == 0 else "error",
89
+ "output": output,
90
+ "stdout": stdout,
91
+ "stderr": stderr,
92
+ "exit_code": proc.returncode,
93
+ "exclude_from_context": exclude_from_context,
94
+ }
95
+ except subprocess.TimeoutExpired as te:
96
+ stdout_raw = te.stdout or ""
97
+ stderr_raw = te.stderr or ""
98
+ stdout = stdout_raw if isinstance(stdout_raw, str) else stdout_raw.decode("utf-8", errors="replace")
99
+ stderr = stderr_raw if isinstance(stderr_raw, str) else stderr_raw.decode("utf-8", errors="replace")
100
+ return {
101
+ "status": "error",
102
+ "output": f"Command timed out after {timeout} seconds",
103
+ "stdout": stdout,
104
+ "stderr": stderr,
105
+ "exit_code": -1,
106
+ "exclude_from_context": exclude_from_context,
107
+ }
108
+ except Exception as exc:
109
+ return {
110
+ "status": "error",
111
+ "output": str(exc),
112
+ "stdout": "",
113
+ "stderr": str(exc),
114
+ "exit_code": -1,
115
+ "exclude_from_context": exclude_from_context,
116
+ }
117
+
118
+ def expand_skill(
119
+ self,
120
+ skill_name: str,
121
+ args: str = "",
122
+ skills_dir: Path | str | None = None,
123
+ skill_manager: SkillManager | None = None,
124
+ ) -> str | None:
125
+ """展开指定技能的 SKILL.md,脱去 YAML frontmatter 并包装为标准 XML 格式。
126
+
127
+ Args:
128
+ skill_name: 技能名称
129
+ args: 传入技能的附带参数
130
+ skills_dir: 可选显式指定的 skills 根目录
131
+ skill_manager: 可选注入的 SkillManager 实例
132
+
133
+ Returns:
134
+ 展开后的 XML + 参数字符串,若技能不存在则返回 None
135
+ """
136
+ name = skill_name.strip()
137
+ if not name:
138
+ return None
139
+
140
+ file_path: Path | None = None
141
+
142
+ if skill_manager is not None:
143
+ skill = skill_manager.get(name)
144
+ if skill is not None:
145
+ file_path = skill.file_path
146
+
147
+ if file_path is None and skills_dir is not None:
148
+ root = Path(skills_dir).resolve()
149
+ candidates = [
150
+ root / name / "SKILL.md",
151
+ root / name / "skill.md",
152
+ root / f"{name}.md",
153
+ ]
154
+ if root.name == name and (root / "SKILL.md").is_file():
155
+ candidates.insert(0, root / "SKILL.md")
156
+ for c in candidates:
157
+ if c.is_file():
158
+ file_path = c
159
+ break
160
+
161
+ if file_path is None:
162
+ search_dirs = [
163
+ self.workspace / ".agents" / "skills",
164
+ self.workspace / ".my-pi-agent" / "skills",
165
+ self.workspace / "skills",
166
+ self.paths.skills_dir,
167
+ self.paths.agents_home / "skills",
168
+ ]
169
+ for sdir in search_dirs:
170
+ if not sdir.is_dir():
171
+ continue
172
+ candidates = [
173
+ sdir / name / "SKILL.md",
174
+ sdir / name / "skill.md",
175
+ sdir / f"{name}.md",
176
+ ]
177
+ for c in candidates:
178
+ if c.is_file():
179
+ file_path = c
180
+ break
181
+ if file_path is not None:
182
+ break
183
+
184
+ if file_path is None:
185
+ return None
186
+
187
+ try:
188
+ content = file_path.read_text(encoding="utf-8")
189
+ except OSError:
190
+ return None
191
+
192
+ # 剥离 YAML Frontmatter
193
+ normalized = content.replace("\r\n", "\n")
194
+ match = re.match(r"^---\n(.*?)\n---\n?", normalized, re.DOTALL)
195
+ if match:
196
+ body = normalized[match.end() :].strip()
197
+ else:
198
+ body = normalized.strip()
199
+
200
+ location = str(file_path.resolve())
201
+ xml_block = f'<skill name="{name}" location="{location}">\n{body}\n</skill>'
202
+
203
+ clean_args = args.strip()
204
+ if clean_args:
205
+ return f"{xml_block}\n\n{clean_args}"
206
+ return xml_block
207
+
208
+ def expand_template(
209
+ self,
210
+ template_name: str,
211
+ args_string: str = "",
212
+ prompts_dir: Path | str | None = None,
213
+ ) -> str | None:
214
+ """展开指定 Prompt 模板,剥离 YAML frontmatter 并执行 Bash 风格参数变量替换。
215
+
216
+ Args:
217
+ template_name: 模板标识名(对应 prompts/<template_name>.md)
218
+ args_string: 命令行输入的参数字符串
219
+ prompts_dir: 可选显式指定的 prompts 目录
220
+
221
+ Returns:
222
+ 替换后的 Prompt 文本,若模板不存在则返回 None
223
+ """
224
+ name = template_name.strip()
225
+ if not name:
226
+ return None
227
+
228
+ file_path: Path | None = None
229
+
230
+ if prompts_dir is not None:
231
+ root = Path(prompts_dir).resolve()
232
+ candidates = [
233
+ root / f"{name}.md",
234
+ root / name,
235
+ ]
236
+ for c in candidates:
237
+ if c.is_file():
238
+ file_path = c
239
+ break
240
+
241
+ if file_path is None:
242
+ search_dirs = [
243
+ self.workspace / "prompts",
244
+ self.workspace / ".my-pi-agent" / "prompts",
245
+ self.workspace / ".agents" / "prompts",
246
+ self.paths.prompts_dir,
247
+ self.paths.agents_home / "prompts",
248
+ ]
249
+ for pdir in search_dirs:
250
+ if not pdir.is_dir():
251
+ continue
252
+ candidates = [
253
+ pdir / f"{name}.md",
254
+ pdir / name,
255
+ ]
256
+ for c in candidates:
257
+ if c.is_file():
258
+ file_path = c
259
+ break
260
+ if file_path is not None:
261
+ break
262
+
263
+ if file_path is None:
264
+ return None
265
+
266
+ try:
267
+ content = file_path.read_text(encoding="utf-8")
268
+ except OSError:
269
+ return None
270
+
271
+ # 剥离 YAML Frontmatter
272
+ normalized = content.replace("\r\n", "\n")
273
+ match = re.match(r"^---\n(.*?)\n---\n?", normalized, re.DOTALL)
274
+ if match:
275
+ body = normalized[match.end() :].strip()
276
+ else:
277
+ body = normalized.strip()
278
+
279
+ return self.substitute_template_args(body, args_string)
280
+
281
+ def substitute_template_args(self, template_body: str, args_string: str) -> str:
282
+ """以 Bash shlex 分词与变量语义替换模板正文中的参数占位符。
283
+
284
+ 支持语法全集:
285
+ - $1, $2, $N: 1-indexed 位置参数
286
+ - $@, $ARGUMENTS: 全部参数空格连接
287
+ - ${N:-default}: 带默认值的位置参数
288
+ - ${@:-default}, ${ARGUMENTS:-default}: 无参时的全局默认值
289
+ - ${@:N}: 从第 N 个参数开始截取到末尾
290
+ - ${@:N:L}: 从第 N 个参数开始截取 L 个
291
+ """
292
+ clean_args_str = args_string.strip()
293
+ if clean_args_str:
294
+ try:
295
+ args = shlex.split(clean_args_str, posix=True)
296
+ except ValueError:
297
+ args = clean_args_str.split()
298
+ else:
299
+ args = []
300
+
301
+ all_args = " ".join(args)
302
+
303
+ # 1. ${@:N:L}
304
+ def replace_slice_len(m: re.Match[str]) -> str:
305
+ start = int(m.group(1))
306
+ length = int(m.group(2))
307
+ start_idx = max(0, start - 1)
308
+ if start_idx < len(args):
309
+ return " ".join(args[start_idx : start_idx + length])
310
+ return ""
311
+
312
+ result = re.sub(r"\$\{@:(\d+):(\d+)\}", replace_slice_len, template_body)
313
+
314
+ # 2. ${@:N}
315
+ def replace_slice(m: re.Match[str]) -> str:
316
+ start = int(m.group(1))
317
+ start_idx = max(0, start - 1)
318
+ if start_idx < len(args):
319
+ return " ".join(args[start_idx:])
320
+ return ""
321
+
322
+ result = re.sub(r"\$\{@:(\d+)\}", replace_slice, result)
323
+
324
+ # 3. ${@:-default} 或 ${ARGUMENTS:-default}
325
+ def replace_all_default(m: re.Match[str]) -> str:
326
+ default_val = m.group(1)
327
+ return all_args if all_args else default_val
328
+
329
+ result = re.sub(r"\$\{(?:@|ARGUMENTS):-(.*?)\}", replace_all_default, result)
330
+
331
+ # 4. ${@} 或 ${ARGUMENTS}
332
+ result = re.sub(r"\$\{(?:@|ARGUMENTS)\}", all_args, result)
333
+
334
+ # 5. $@ 或 $ARGUMENTS
335
+ result = re.sub(r"\$(?:ARGUMENTS\b|@)", all_args, result)
336
+
337
+ # 6. ${N:-default}
338
+ def replace_pos_default(m: re.Match[str]) -> str:
339
+ n = int(m.group(1))
340
+ default_val = m.group(2)
341
+ if 1 <= n <= len(args) and args[n - 1]:
342
+ return args[n - 1]
343
+ return default_val
344
+
345
+ result = re.sub(r"\$\{(\d+):-(.*?)\}", replace_pos_default, result)
346
+
347
+ # 7. ${N}
348
+ def replace_pos_braced(m: re.Match[str]) -> str:
349
+ n = int(m.group(1))
350
+ if 1 <= n <= len(args):
351
+ return args[n - 1]
352
+ return ""
353
+
354
+ result = re.sub(r"\$\{(\d+)\}", replace_pos_braced, result)
355
+
356
+ # 8. $N
357
+ def replace_pos(m: re.Match[str]) -> str:
358
+ n = int(m.group(1))
359
+ if 1 <= n <= len(args):
360
+ return args[n - 1]
361
+ return ""
362
+
363
+ result = re.sub(r"\$(\d+)", replace_pos, result)
364
+
365
+ return result
366
+
367
+ def expand_macro(
368
+ self,
369
+ text: str,
370
+ skills_dir: Path | str | None = None,
371
+ prompts_dir: Path | str | None = None,
372
+ ) -> tuple[str, bool]:
373
+ """统一识别并展开文本中的输入宏(/skill:<name> 或 /<template>)。
374
+
375
+ Returns:
376
+ (expanded_text, is_expanded)
377
+ """
378
+ raw = text.strip()
379
+ if not raw.startswith("/"):
380
+ return text, False
381
+
382
+ # 1. /skill:<name> [args]
383
+ if raw.startswith("/skill:"):
384
+ rest = raw[len("/skill:") :].strip()
385
+ if " " in rest:
386
+ skill_name, args = rest.split(" ", 1)
387
+ else:
388
+ skill_name, args = rest, ""
389
+ expanded_skill = self.expand_skill(skill_name.strip(), args.strip(), skills_dir=skills_dir)
390
+ if expanded_skill is not None:
391
+ return expanded_skill, True
392
+ return text, False
393
+
394
+ # 2. /<template> [args]
395
+ without_slash = raw[1:].strip()
396
+ if not without_slash:
397
+ return text, False
398
+
399
+ if " " in without_slash:
400
+ template_name, args = without_slash.split(" ", 1)
401
+ else:
402
+ template_name, args = without_slash, ""
403
+
404
+ expanded_template = self.expand_template(template_name.strip(), args.strip(), prompts_dir=prompts_dir)
405
+ if expanded_template is not None:
406
+ return expanded_template, True
407
+
408
+ return text, False
@@ -0,0 +1,243 @@
1
+ """MCP 客户端(产品层)—— 原生异步 Stdio 子进程连接与 extension 入口。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import json
7
+ import os
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ import mcp.types as mcp_types # pyright: ignore[reportMissingImports]
13
+ from mcp.client.session import ClientSession # pyright: ignore[reportMissingImports]
14
+ from mcp.client.stdio import ( # pyright: ignore[reportMissingImports]
15
+ StdioServerParameters,
16
+ stdio_client,
17
+ )
18
+ from my_agent_core.tools import ( # pyright: ignore[reportMissingImports]
19
+ Tool,
20
+ ToolResult,
21
+ )
22
+
23
+ if TYPE_CHECKING:
24
+ from my_agent_core.extensions import ( # pyright: ignore[reportMissingImports]
25
+ ExtensionAPI,
26
+ )
27
+
28
+
29
+ @dataclass
30
+ class MCPServerConfig:
31
+ name: str
32
+ command: str
33
+ args: list[str]
34
+ env: dict[str, str] | None = None
35
+
36
+
37
+ class MCPConnection:
38
+ """单个 MCP Server 的原生异步连接管理器。"""
39
+
40
+ def __init__(self, config: MCPServerConfig):
41
+ self.config = config
42
+ self._session: ClientSession | None = None
43
+ self._exit_stack = contextlib.AsyncExitStack()
44
+
45
+ async def start(self) -> None:
46
+ """在当前事件循环中异步建立 Stdio 子进程长连接并完成初始化握手。"""
47
+ server_env = os.environ.copy()
48
+ if self.config.env:
49
+ server_env.update(self.config.env)
50
+
51
+ params = StdioServerParameters(
52
+ command=self.config.command,
53
+ args=self.config.args,
54
+ env=server_env,
55
+ )
56
+
57
+ read_stream, write_stream = await self._exit_stack.enter_async_context(stdio_client(params))
58
+ session = await self._exit_stack.enter_async_context(ClientSession(read_stream, write_stream))
59
+ self._session = session
60
+ await session.initialize()
61
+
62
+ async def list_tools(self) -> list[mcp_types.Tool]:
63
+ """异步拉取远程工具列表。"""
64
+ if self._session is None:
65
+ raise RuntimeError(f"MCP server '{self.config.name}' is not connected")
66
+ res = await self._session.list_tools()
67
+ return res.tools
68
+
69
+ async def call_tool(self, name: str, arguments: dict[str, Any]) -> ToolResult:
70
+ """异步调用远程工具。"""
71
+ if self._session is None:
72
+ return ToolResult(
73
+ ok=False,
74
+ error=f"MCP server '{self.config.name}' is not connected",
75
+ meta={"server": self.config.name},
76
+ )
77
+
78
+ try:
79
+ call_res = await self._session.call_tool(name=name, arguments=arguments)
80
+ except Exception as exc:
81
+ return ToolResult(
82
+ ok=False,
83
+ error=f"MCP tool '{name}' failed: {exc}",
84
+ meta={"server": self.config.name},
85
+ )
86
+
87
+ # 拼接文本输出
88
+ texts = []
89
+ for content in call_res.content:
90
+ text_val = getattr(content, "text", None)
91
+ if text_val is not None:
92
+ texts.append(str(text_val))
93
+ else:
94
+ texts.append(str(content))
95
+ out_text = "\n".join(texts) or "(no output)"
96
+
97
+ is_err = getattr(call_res, "is_error", getattr(call_res, "isError", False))
98
+ if is_err:
99
+ return ToolResult(
100
+ ok=False,
101
+ error=out_text,
102
+ meta={"server": self.config.name, "is_error": True},
103
+ )
104
+ return ToolResult(ok=True, data=out_text, meta={"server": self.config.name})
105
+
106
+ async def close(self) -> None:
107
+ """优雅关闭。"""
108
+ await self._exit_stack.aclose()
109
+ self._session = None
110
+
111
+
112
+ class MCPClientManager:
113
+ """多 MCP Server 管理器。"""
114
+
115
+ def __init__(self):
116
+ self.connections: dict[str, MCPConnection] = {}
117
+ self._tools: list[Tool] = []
118
+ self._configs: list[MCPServerConfig] = []
119
+
120
+ @classmethod
121
+ def from_config_file(cls, path: Path | str) -> MCPClientManager:
122
+ """从配置文件构造 MCPClientManager 实例。"""
123
+ mgr = cls()
124
+ mgr._configs = mgr.load_config(path)
125
+ return mgr
126
+
127
+ def load_config(self, path: Path | str) -> list[MCPServerConfig]:
128
+ """读取 .mcp.json。"""
129
+ p = Path(path)
130
+ if not p.exists():
131
+ return []
132
+ try:
133
+ data = json.loads(p.read_text(encoding="utf-8"))
134
+ except Exception as exc:
135
+ raise ValueError(f"Invalid JSON in {p}: {exc}") from exc
136
+
137
+ servers = data.get("mcpServers", {})
138
+ configs = []
139
+ for name, srv in servers.items():
140
+ cmd = srv.get("command", "")
141
+ if not cmd:
142
+ # 当前仅支持 stdio 命令行进程类型,跳过非 command 类型(如 http/sse)
143
+ continue
144
+ configs.append(
145
+ MCPServerConfig(
146
+ name=name,
147
+ command=cmd,
148
+ args=srv.get("args", []),
149
+ env=srv.get("env"),
150
+ )
151
+ )
152
+ self._configs = configs
153
+ return configs
154
+
155
+ async def connect_server(self, config: MCPServerConfig) -> list[Tool]:
156
+ """异步连接单个 Server 并返回包装后的 Tool 列表。"""
157
+ conn = MCPConnection(config)
158
+ await conn.start()
159
+ self.connections[config.name] = conn
160
+
161
+ mcp_tools = await conn.list_tools()
162
+ wrapped_tools = []
163
+ for t in mcp_tools:
164
+ tool_name = t.name
165
+ schema = getattr(t, "input_schema", getattr(t, "inputSchema", {}))
166
+
167
+ def _make_handler(target_conn: MCPConnection, target_name: str):
168
+ async def _handler(args: dict[str, Any]) -> ToolResult:
169
+ return await target_conn.call_tool(target_name, args)
170
+
171
+ return _handler
172
+
173
+ wrapped = Tool(
174
+ func=_make_handler(conn, tool_name),
175
+ name=tool_name,
176
+ description=t.description or "",
177
+ raw_schema=schema,
178
+ timeout=120.0,
179
+ is_parallel_safe=True,
180
+ )
181
+ setattr(wrapped, "is_mcp", True)
182
+ wrapped_tools.append(wrapped)
183
+ self._tools.extend(wrapped_tools)
184
+ return wrapped_tools
185
+
186
+ async def connect_all(self, configs: list[MCPServerConfig] | None = None) -> list[Tool]:
187
+ """连接所有配置的服务并返回收集的所有工具。"""
188
+ target_configs = configs if configs is not None else self._configs
189
+ tools: list[Tool] = []
190
+ for cfg in target_configs:
191
+ server_tools = await self.connect_server(cfg)
192
+ tools.extend(server_tools)
193
+ return tools
194
+
195
+ def get_all_tools(self) -> list[Tool]:
196
+ """获取当前所有已连接的工具列表。"""
197
+ return list(self._tools)
198
+
199
+ async def close_all(self) -> None:
200
+ """异步关闭所有连接。"""
201
+ for conn in self.connections.values():
202
+ with contextlib.suppress(Exception):
203
+ await conn.close()
204
+ self.connections.clear()
205
+ self._tools.clear()
206
+
207
+
208
+ # ── 标准 Extension 入口协议 ──────────────────────────────────────────
209
+
210
+
211
+ async def extension(api: ExtensionAPI) -> None:
212
+ """MCP Extension 标准入口函数。"""
213
+ config_path = Path.cwd() / ".mcp.json"
214
+ if not config_path.exists():
215
+ return
216
+
217
+ manager = MCPClientManager()
218
+ try:
219
+ server_configs = manager.load_config(config_path)
220
+ except Exception as exc:
221
+ print(f"[MCP] 解析 .mcp.json 失败: {exc}")
222
+ return
223
+
224
+ registered_tools: list[str] = []
225
+ for cfg in server_configs:
226
+ try:
227
+ tools = await manager.connect_server(cfg)
228
+ for t in tools:
229
+ api.register_tool(t)
230
+ registered_tools.append(t.name)
231
+ except Exception as exc:
232
+ print(f"[MCP] 连接服务 '{cfg.name}' 失败: {exc}")
233
+
234
+ @api.command("mcp", description="查看当前已连接的 MCP 服务状态与工具列表")
235
+ def cmd_mcp(args: str | None = None) -> str:
236
+ if not manager.connections:
237
+ return "当前未连接任何 MCP 服务。"
238
+ lines = ["=== MCP 服务状态 ==="]
239
+ for name, conn in manager.connections.items():
240
+ status = "Connected" if conn._session is not None else "Disconnected"
241
+ lines.append(f"- {name}: {status} (命令: {conn.config.command})")
242
+ lines.append(f"已加载工具: {', '.join(registered_tools) or '(none)'}")
243
+ return "\n".join(lines)
@@ -0,0 +1,37 @@
1
+ """文件变更互斥锁队列 (FileMutationQueue) —— 细粒度单文件写锁管理。
2
+
3
+ 允许不同文件的写操作完全并发,同名文件的写操作自动保序排队,兼具极致性能与并发安全。
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ from collections.abc import AsyncIterator
10
+ from contextlib import asynccontextmanager
11
+ from pathlib import Path
12
+
13
+
14
+ class FileMutationQueue:
15
+ """管理针对特定文件绝对路径的异步互斥锁。"""
16
+
17
+ def __init__(self) -> None:
18
+ self._locks: dict[Path, asyncio.Lock] = {}
19
+ self._guard = asyncio.Lock()
20
+
21
+ async def get_lock(self, path: Path | str) -> asyncio.Lock:
22
+ """获取指定文件路径对应的 asyncio.Lock(规范化绝对路径)。"""
23
+ canonical = Path(path).resolve()
24
+ async with self._guard:
25
+ if canonical not in self._locks:
26
+ self._locks[canonical] = asyncio.Lock()
27
+ return self._locks[canonical]
28
+
29
+ @asynccontextmanager
30
+ async def acquire(self, path: Path | str) -> AsyncIterator[None]:
31
+ """原生异步上下文管理器:自动获取并持有指定文件的互斥锁。"""
32
+ async with await self.get_lock(path):
33
+ yield
34
+
35
+ def clear(self) -> None:
36
+ """清空锁字典。"""
37
+ self._locks.clear()