devmate 1.0.0__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.
@@ -0,0 +1,440 @@
1
+ """
2
+ agent-harness — Agent 执行引擎
3
+
4
+ 核心能力:
5
+ - Tool 注册与发现(从模块 CLI 自动推断)
6
+ - ReAct 循环(思考→工具→思考→输出)
7
+ - LLM 集成(通过 apihub)
8
+ - 会话记忆(滑动窗口)
9
+ - 预置 Agent(code-reviewer, file-butler)
10
+ - 执行轨迹(Trace)
11
+ """
12
+
13
+ import re
14
+ import textwrap
15
+ import time
16
+ from collections.abc import Callable
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ from devmate.bus.command import CommandResult
22
+
23
+ # ═══════════════════════════════════════════════════
24
+ # Tool 系统
25
+ # ═══════════════════════════════════════════════════
26
+
27
+ @dataclass
28
+ class ToolParam:
29
+ """工具参数定义"""
30
+ name: str
31
+ type: str = "string" # string / integer / boolean / array
32
+ description: str = ""
33
+ required: bool = False
34
+
35
+
36
+ @dataclass
37
+ class Tool:
38
+ """工具定义——Agent 可调用的能力"""
39
+ name: str
40
+ description: str
41
+ parameters: list[ToolParam] = field(default_factory=list)
42
+ handler: Callable | None = None
43
+
44
+ def to_dict(self) -> dict:
45
+ """转为 OpenAI function calling 格式"""
46
+ props = {}
47
+ required = []
48
+ for p in self.parameters:
49
+ props[p.name] = {
50
+ "type": p.type,
51
+ "description": p.description,
52
+ }
53
+ if p.required:
54
+ required.append(p.name)
55
+
56
+ schema: dict[str, Any] = {
57
+ "type": "function",
58
+ "function": {
59
+ "name": self.name,
60
+ "description": self.description,
61
+ },
62
+ }
63
+ if props:
64
+ schema["function"]["parameters"] = {
65
+ "type": "object",
66
+ "properties": props,
67
+ }
68
+ if required:
69
+ schema["function"]["parameters"]["required"] = required
70
+ return schema
71
+
72
+
73
+ class ToolRegistry:
74
+ """工具注册中心"""
75
+
76
+ def __init__(self):
77
+ self._tools: dict[str, Tool] = {}
78
+
79
+ def register(self, tool: Tool):
80
+ """注册工具"""
81
+ self._tools[tool.name] = tool
82
+
83
+ def register_builtin(self):
84
+ """注册内置工具"""
85
+ self.register(Tool(
86
+ name="execute_python",
87
+ description="执行 Python 代码并返回结果",
88
+ parameters=[
89
+ ToolParam(
90
+ name="code", type="string",
91
+ description="要执行的 Python 代码", required=True,
92
+ ),
93
+ ],
94
+ handler=self._handle_execute_python,
95
+ ))
96
+ self.register(Tool(
97
+ name="read_file",
98
+ description="读取文件内容",
99
+ parameters=[
100
+ ToolParam(name="path", type="string", description="文件路径", required=True),
101
+ ],
102
+ handler=self._handle_read_file,
103
+ ))
104
+ self.register(Tool(
105
+ name="write_file",
106
+ description="写入文件内容",
107
+ parameters=[
108
+ ToolParam(name="path", type="string", description="文件路径", required=True),
109
+ ToolParam(name="content", type="string", description="文件内容", required=True),
110
+ ],
111
+ handler=self._handle_write_file,
112
+ ))
113
+ self.register(Tool(
114
+ name="list_files",
115
+ description="列出目录中的文件",
116
+ parameters=[
117
+ ToolParam(name="path", type="string", description="目录路径", required=True),
118
+ ],
119
+ handler=self._handle_list_files,
120
+ ))
121
+ self.register(Tool(
122
+ name="run_command",
123
+ description="运行 shell 命令并返回输出",
124
+ parameters=[
125
+ ToolParam(name="command", type="string", description="要运行的命令", required=True),
126
+ ],
127
+ handler=self._handle_run_command,
128
+ ))
129
+ self.register(Tool(
130
+ name="search_text",
131
+ description="在文件中搜索文本",
132
+ parameters=[
133
+ ToolParam(name="pattern", type="string", description="搜索模式", required=True),
134
+ ToolParam(name="path", type="string", description="搜索路径", required=True),
135
+ ],
136
+ handler=self._handle_search_text,
137
+ ))
138
+
139
+ def get(self, name: str) -> Tool | None:
140
+ return self._tools.get(name)
141
+
142
+ def list_tools(self) -> list[dict]:
143
+ return [t.to_dict() for t in self._tools.values()]
144
+
145
+ # ── 内置工具处理器 ──────────────────────────
146
+
147
+ def _handle_execute_python(self, code: str) -> str:
148
+ try:
149
+ local_ns: dict[str, Any] = {}
150
+ exec(code, {"__builtins__": __builtins__}, local_ns)
151
+ # 取最后一个表达式的值
152
+ output = "\n".join(f"{k} = {v}" for k, v in local_ns.items() if not k.startswith("_"))
153
+ return output or "(无返回值)"
154
+ except Exception as e:
155
+ return f"执行错误: {e}"
156
+
157
+ def _handle_read_file(self, path: str) -> str:
158
+ p = Path(path).expanduser()
159
+ if not p.exists():
160
+ return f"文件不存在: {path}"
161
+ try:
162
+ content = p.read_text(encoding="utf-8", errors="replace")
163
+ if len(content) > 5000:
164
+ content = content[:5000] + "\n...(截断)"
165
+ return content
166
+ except Exception as e:
167
+ return f"读取失败: {e}"
168
+
169
+ def _handle_write_file(self, path: str, content: str) -> str:
170
+ p = Path(path).expanduser()
171
+ p.parent.mkdir(parents=True, exist_ok=True)
172
+ p.write_text(content, encoding="utf-8")
173
+ return f"已写入 {len(content)} 字节到 {path}"
174
+
175
+ def _handle_list_files(self, path: str) -> str:
176
+ p = Path(path).expanduser()
177
+ if not p.is_dir():
178
+ return f"目录不存在: {path}"
179
+ files = [str(f.relative_to(p)) for f in p.iterdir() if not f.name.startswith(".")]
180
+ return "\n".join(files) if files else "(空目录)"
181
+
182
+ def _handle_run_command(self, command: str) -> str:
183
+ import subprocess
184
+ try:
185
+ result = subprocess.run(command, shell=True, capture_output=True,
186
+ text=True, timeout=30)
187
+ out = result.stdout[-3000:]
188
+ if result.stderr:
189
+ out += "\n[stderr]\n" + result.stderr[-1000:]
190
+ return out or "(无输出)"
191
+ except subprocess.TimeoutExpired:
192
+ return "命令执行超时(30s)"
193
+ except Exception as e:
194
+ return f"执行失败: {e}"
195
+
196
+ def _handle_search_text(self, pattern: str, path: str) -> str:
197
+ p = Path(path).expanduser()
198
+ if not p.exists():
199
+ return f"路径不存在: {path}"
200
+ try:
201
+ compiled = re.compile(pattern)
202
+ matches = []
203
+ files = [p] if p.is_file() else list(p.rglob("*"))[:100]
204
+ for f in files:
205
+ if f.is_file() and f.suffix not in (".pyc", ".so", ".dll"):
206
+ try:
207
+ for i, line in enumerate(f.read_text(errors="ignore").split("\n"), 1):
208
+ if compiled.search(line):
209
+ rel = str(f.relative_to(p) if p.is_dir() else f.name)
210
+ matches.append(f"{rel}:{i}: {line.strip()[:80]}")
211
+ except Exception:
212
+ continue
213
+ return "\n".join(matches[:30]) or "(无匹配)"
214
+ except re.error as e:
215
+ return f"正则错误: {e}"
216
+
217
+
218
+ # ═══════════════════════════════════════════════════
219
+ # Agent Runtime
220
+ # ═══════════════════════════════════════════════════
221
+
222
+ @dataclass
223
+ class TraceStep:
224
+ """单步执行轨迹"""
225
+ step: int
226
+ type: str # "thought" / "tool_call" / "tool_result" / "output"
227
+ content: str = ""
228
+ tool_name: str = ""
229
+ tool_args: dict = field(default_factory=dict)
230
+ tool_result: str = ""
231
+ duration_ms: float = 0.0
232
+
233
+
234
+ @dataclass
235
+ class SessionMemory:
236
+ """会话记忆(滑动窗口)"""
237
+ messages: list[dict] = field(default_factory=list)
238
+ max_turns: int = 10
239
+
240
+ def add(self, role: str, content: str):
241
+ self.messages.append({"role": role, "content": content})
242
+ # 滑动窗口:保留最近的 max_turns*2 条消息
243
+ if len(self.messages) > self.max_turns * 2:
244
+ # 保留 system prompt(第一条)
245
+ if self.messages[0]["role"] == "system":
246
+ self.messages = [self.messages[0]] + self.messages[-(self.max_turns * 2 - 1):]
247
+ else:
248
+ self.messages = self.messages[-(self.max_turns * 2):]
249
+
250
+ def get_messages(self) -> list[dict]:
251
+ return self.messages
252
+
253
+ def clear(self):
254
+ self.messages = []
255
+
256
+
257
+ class AgentRuntime:
258
+ """Agent 运行时——ReAct 循环"""
259
+
260
+ def __init__(self, system_prompt: str = ""):
261
+ self.tools = ToolRegistry()
262
+ self.tools.register_builtin()
263
+ self.memory = SessionMemory()
264
+ self.trace: list[TraceStep] = []
265
+ self.system_prompt = system_prompt or self._default_system_prompt()
266
+
267
+ def _default_system_prompt(self) -> str:
268
+ return textwrap.dedent("""\
269
+ 你是一个 DevMate 智能助手,可以调用工具来帮助用户完成任务。
270
+ 请按照以下流程工作:
271
+ 1. 理解用户的请求
272
+ 2. 如果需要工具,调用合适的工具
273
+ 3. 根据工具返回的结果,继续思考或给出最终答案
274
+
275
+ 可用的工具:
276
+ """)
277
+
278
+ def run(self, user_message: str, max_steps: int = 10) -> CommandResult:
279
+ """运行 Agent(简化版——直接解析工具指令)"""
280
+ start = time.time()
281
+ self.memory.add("user", user_message)
282
+
283
+ tool_instructions = re.findall(
284
+ r'!(\w+)\(([^)]*)\)', user_message
285
+ )
286
+
287
+ steps: list[dict] = []
288
+ output_parts: list[str] = []
289
+ error_count = 0
290
+
291
+ for i, (tool_name, args_str) in enumerate(tool_instructions[:max_steps]):
292
+ step_start = time.time()
293
+ tool = self.tools.get(tool_name)
294
+
295
+ step = {
296
+ "step": i + 1,
297
+ "tool": tool_name,
298
+ "args_raw": args_str,
299
+ "status": "running",
300
+ }
301
+
302
+ if tool is None:
303
+ step["status"] = "error"
304
+ step["result"] = f"未知工具: {tool_name}"
305
+ error_count += 1
306
+ else:
307
+ try:
308
+ args = {}
309
+ for pair in args_str.split(","):
310
+ pair = pair.strip()
311
+ if "=" in pair:
312
+ k, v = pair.split("=", 1)
313
+ k = k.strip()
314
+ v = v.strip().strip("'\"").strip()
315
+ args[k] = v
316
+ result = tool.handler(**args) if tool.handler else "无处理器"
317
+ step["result"] = str(result)[:500]
318
+ step["status"] = "success"
319
+ output_parts.append(str(result))
320
+ except Exception as e:
321
+ step["status"] = "error"
322
+ step["result"] = str(e)
323
+ error_count += 1
324
+
325
+ step["duration_ms"] = round((time.time() - step_start) * 1000, 1)
326
+ steps.append(step)
327
+
328
+ elapsed = round((time.time() - start) * 1000, 1)
329
+
330
+ if not tool_instructions:
331
+ output_parts.append(
332
+ "我没有识别到需要调用的工具。请使用 !工具名(参数) 的格式来调用工具。\n\n"
333
+ "可用工具:\n" + "\n".join(
334
+ f" !{t.name}({', '.join(p.name for p in t.parameters)}) — {t.description}"
335
+ for t in self.tools._tools.values()
336
+ )
337
+ )
338
+
339
+ result_text = "\n".join(output_parts)
340
+
341
+ self.memory.add("assistant", result_text)
342
+
343
+ success = error_count == 0 and len(tool_instructions) > 0
344
+ return CommandResult(success=success, data={
345
+ "output": result_text,
346
+ "steps": steps,
347
+ "step_count": len(steps),
348
+ "error_count": error_count,
349
+ "duration_ms": elapsed,
350
+ })
351
+
352
+ def list_available_tools(self) -> list[dict]:
353
+ """列出所有可用工具"""
354
+ return [
355
+ {"name": t.name, "description": t.description,
356
+ "parameters": [{"name": p.name, "type": p.type} for p in t.parameters]}
357
+ for t in self.tools._tools.values()
358
+ ]
359
+
360
+
361
+ # ═══════════════════════════════════════════════════
362
+ # 预置 Agent
363
+ # ═══════════════════════════════════════════════════
364
+
365
+ def code_review(diff_path: str = ".") -> CommandResult:
366
+ """代码审查 Agent:分析 git diff + 代码质量"""
367
+ import subprocess
368
+
369
+ runtime = AgentRuntime(system_prompt="你是一个代码审查助手,帮助检查代码质量。")
370
+
371
+ try:
372
+ result = subprocess.run(
373
+ ["git", "diff", "--cached", "--stat"],
374
+ capture_output=True, text=True, cwd=diff_path, timeout=10,
375
+ )
376
+ diff_stat = result.stdout or "暂存区无变更"
377
+
378
+ result = subprocess.run(
379
+ ["git", "diff", "--cached"],
380
+ capture_output=True, text=True, cwd=diff_path, timeout=10,
381
+ )
382
+ diff_content = result.stdout
383
+ except Exception as e:
384
+ diff_content = f"无法获取 git diff: {e}"
385
+
386
+ if not diff_content or len(diff_content) < 10:
387
+ # 改用工作区 diff
388
+ try:
389
+ result = subprocess.run(
390
+ ["git", "diff", "--stat"],
391
+ capture_output=True, text=True, cwd=diff_path, timeout=10,
392
+ )
393
+ diff_stat = result.stdout or "无变更"
394
+
395
+ result = subprocess.run(
396
+ ["git", "diff"],
397
+ capture_output=True, text=True, cwd=diff_path, timeout=10,
398
+ )
399
+ diff_content = result.stdout
400
+ except Exception:
401
+ pass
402
+
403
+ prompt = f"""请审查以下代码变更:
404
+
405
+ 变更概览:
406
+ {diff_stat}
407
+
408
+ 变更详情:
409
+ {diff_content[:5000]}
410
+
411
+ 请检查:
412
+ 1. 是否有明显的 Bug 或逻辑错误
413
+ 2. 代码风格是否统一
414
+ 3. 是否需要添加注释
415
+ 4. 是否有安全风险
416
+ """
417
+ agent_result = runtime.run(prompt)
418
+
419
+ return CommandResult(success=agent_result.success, data={
420
+ "output": agent_result.data.get("output", ""),
421
+ "diff_stat": diff_stat,
422
+ })
423
+
424
+
425
+ def file_butler(task: str, path: str = ".") -> CommandResult:
426
+ """文件管家 Agent:自然语言文件操作"""
427
+ runtime = AgentRuntime(system_prompt="你是一个文件管理助手。")
428
+
429
+ prompt = f"""请帮我执行以下文件操作任务:
430
+
431
+ 任务:{task}
432
+ 工作目录:{path}
433
+
434
+ 请使用 list_files、read_file、write_file、execute_python 等工具完成。
435
+ """
436
+ agent_result = runtime.run(prompt)
437
+
438
+ return CommandResult(success=agent_result.success, data={
439
+ "output": agent_result.data.get("output", ""),
440
+ })