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.
- package/README.md +318 -0
- package/package.json +45 -0
- package/pyproject.toml +50 -0
- package/src/my_agent_core/__init__.py +123 -0
- package/src/my_agent_core/agent.py +441 -0
- package/src/my_agent_core/background.py +121 -0
- package/src/my_agent_core/context.py +505 -0
- package/src/my_agent_core/events.py +153 -0
- package/src/my_agent_core/extensions/__init__.py +9 -0
- package/src/my_agent_core/extensions/core.py +197 -0
- package/src/my_agent_core/hooks.py +130 -0
- package/src/my_agent_core/loop.py +709 -0
- package/src/my_agent_core/main.py +134 -0
- package/src/my_agent_core/memory.py +241 -0
- package/src/my_agent_core/message_queue.py +110 -0
- package/src/my_agent_core/plugins.py +212 -0
- package/src/my_agent_core/registry.py +186 -0
- package/src/my_agent_core/session/__init__.py +79 -0
- package/src/my_agent_core/session/entries.py +197 -0
- package/src/my_agent_core/session/jsonl.py +60 -0
- package/src/my_agent_core/session/memory.py +137 -0
- package/src/my_agent_core/session/session.py +400 -0
- package/src/my_agent_core/session/storage.py +245 -0
- package/src/my_agent_core/session/store.py +131 -0
- package/src/my_agent_core/session/tree.py +86 -0
- package/src/my_agent_core/skills.py +149 -0
- package/src/my_agent_core/subagent_tasks.py +170 -0
- package/src/my_agent_core/subagents.py +148 -0
- package/src/my_agent_core/task_store.py +248 -0
- package/src/my_agent_core/tool_history.py +189 -0
- package/src/my_agent_core/tools/__init__.py +5 -0
- package/src/my_agent_core/tools/builtin/__init__.py +5 -0
- package/src/my_agent_core/tools/builtin/task.py +30 -0
- package/src/my_agent_core/tools/builtin/task_tools.py +215 -0
- package/src/my_agent_core/tools/core.py +239 -0
- package/src/my_agent_llm/__init__.py +45 -0
- package/src/my_agent_llm/auth/__init__.py +46 -0
- package/src/my_agent_llm/auth/antigravity.py +209 -0
- package/src/my_agent_llm/auth/manager.py +259 -0
- package/src/my_agent_llm/auth/quota.py +56 -0
- package/src/my_agent_llm/auth/schema.py +94 -0
- package/src/my_agent_llm/client.py +116 -0
- package/src/my_agent_llm/config.py +17 -0
- package/src/my_agent_llm/events.py +84 -0
- package/src/my_agent_llm/models.py +195 -0
- package/src/my_agent_llm/providers/__init__.py +4 -0
- package/src/my_agent_llm/providers/_base.py +94 -0
- package/src/my_agent_llm/providers/anthropic.py +298 -0
- package/src/my_agent_llm/providers/antigravity.py +480 -0
- package/src/my_agent_llm/providers/deepseek.py +196 -0
- package/src/my_agent_llm/providers/openai.py +364 -0
- package/src/my_agent_llm/providers/registry.py +16 -0
- package/src/my_agent_llm/stream.py +218 -0
- package/src/my_coding_agent/__init__.py +66 -0
- package/src/my_coding_agent/agent.py +208 -0
- package/src/my_coding_agent/cli.py +78 -0
- package/src/my_coding_agent/file_reference.py +80 -0
- package/src/my_coding_agent/macro.py +408 -0
- package/src/my_coding_agent/mcp.py +243 -0
- package/src/my_coding_agent/mutation_queue.py +37 -0
- package/src/my_coding_agent/paths.py +119 -0
- package/src/my_coding_agent/permissions.py +84 -0
- package/src/my_coding_agent/prompt.py +54 -0
- package/src/my_coding_agent/rpc_server.py +2817 -0
- package/src/my_coding_agent/settings.py +126 -0
- package/src/my_coding_agent/tools/__init__.py +55 -0
- package/src/my_coding_agent/tools/base.py +58 -0
- package/src/my_coding_agent/tools/bash.py +206 -0
- package/src/my_coding_agent/tools/edit.py +226 -0
- package/src/my_coding_agent/tools/find.py +118 -0
- package/src/my_coding_agent/tools/grep.py +177 -0
- package/src/my_coding_agent/tools/ls.py +112 -0
- package/src/my_coding_agent/tools/read.py +113 -0
- package/src/my_coding_agent/tools/write.py +72 -0
- package/tui/README.md +27 -0
- package/tui/bin/my-agent.js +98 -0
- package/tui/dist/app.d.ts +41 -0
- package/tui/dist/app.js +110 -0
- package/tui/dist/bridge/event-translator.d.ts +92 -0
- package/tui/dist/bridge/event-translator.js +216 -0
- package/tui/dist/bridge/kernel-bridge.d.ts +48 -0
- package/tui/dist/bridge/kernel-bridge.js +132 -0
- package/tui/dist/client.d.ts +63 -0
- package/tui/dist/client.js +239 -0
- package/tui/dist/components/assistant-message.d.ts +19 -0
- package/tui/dist/components/assistant-message.js +90 -0
- package/tui/dist/components/compaction-summary-message.d.ts +19 -0
- package/tui/dist/components/compaction-summary-message.js +46 -0
- package/tui/dist/components/custom-editor.d.ts +18 -0
- package/tui/dist/components/custom-editor.js +56 -0
- package/tui/dist/components/dynamic-border.d.ts +9 -0
- package/tui/dist/components/dynamic-border.js +14 -0
- package/tui/dist/components/footer.d.ts +39 -0
- package/tui/dist/components/footer.js +199 -0
- package/tui/dist/components/header.d.ts +4 -0
- package/tui/dist/components/header.js +21 -0
- package/tui/dist/components/keys.d.ts +5 -0
- package/tui/dist/components/keys.js +12 -0
- package/tui/dist/components/login-selector.d.ts +26 -0
- package/tui/dist/components/login-selector.js +181 -0
- package/tui/dist/components/logout-selector.d.ts +19 -0
- package/tui/dist/components/logout-selector.js +88 -0
- package/tui/dist/components/model-selector.d.ts +40 -0
- package/tui/dist/components/model-selector.js +268 -0
- package/tui/dist/components/session-selector.d.ts +54 -0
- package/tui/dist/components/session-selector.js +393 -0
- package/tui/dist/components/settings-selector.d.ts +24 -0
- package/tui/dist/components/settings-selector.js +146 -0
- package/tui/dist/components/status-indicator.d.ts +25 -0
- package/tui/dist/components/status-indicator.js +60 -0
- package/tui/dist/components/theme-selector.d.ts +14 -0
- package/tui/dist/components/theme-selector.js +77 -0
- package/tui/dist/components/thinking-selector.d.ts +21 -0
- package/tui/dist/components/thinking-selector.js +128 -0
- package/tui/dist/components/tool-execution.d.ts +31 -0
- package/tui/dist/components/tool-execution.js +206 -0
- package/tui/dist/components/tree-selector.d.ts +40 -0
- package/tui/dist/components/tree-selector.js +173 -0
- package/tui/dist/components/user-message-selector.d.ts +21 -0
- package/tui/dist/components/user-message-selector.js +103 -0
- package/tui/dist/components/user-message.d.ts +5 -0
- package/tui/dist/components/user-message.js +15 -0
- package/tui/dist/index.d.ts +11 -0
- package/tui/dist/index.js +11 -0
- package/tui/dist/interactive/chat-viewport.d.ts +19 -0
- package/tui/dist/interactive/chat-viewport.js +41 -0
- package/tui/dist/interactive/components.d.ts +1 -0
- package/tui/dist/interactive/components.js +1 -0
- package/tui/dist/interactive/interactive-mode.d.ts +89 -0
- package/tui/dist/interactive/interactive-mode.js +1625 -0
- package/tui/dist/interactive/theme.d.ts +1 -0
- package/tui/dist/interactive/theme.js +1 -0
- package/tui/dist/interactive/tui-renderer.d.ts +8 -0
- package/tui/dist/interactive/tui-renderer.js +10 -0
- package/tui/dist/protocol.d.ts +78 -0
- package/tui/dist/protocol.js +1 -0
- package/tui/dist/theme/dark.json +54 -0
- package/tui/dist/theme/light.json +71 -0
- package/tui/dist/theme/theme.d.ts +20 -0
- package/tui/dist/theme/theme.js +86 -0
- 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()
|