Coverage for merco/tools/middleware.py: 97%
102 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-07 14:04 +0800
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-07 14:04 +0800
1"""ToolContext + ToolMiddleware + ToolMiddlewareChain"""
2from __future__ import annotations
4from abc import ABC, abstractmethod
5from dataclasses import dataclass, field
6from pathlib import Path
8from merco.sandbox.guard import GuardAction
9from merco.sandbox.confirm import confirm_edit
10import merco.sandbox.snapshot as snapshot
13@dataclass
14class ToolContext:
15 """工具执行上下文,贯穿 before/during/after"""
16 tool_name: str
17 arguments: dict
18 tool: object | None = None
19 result: dict | None = None
20 error: BaseException | None = None
21 metadata: dict = field(default_factory=dict)
24class ToolMiddleware(ABC):
25 """工具中间件基类"""
26 name: str = ""
28 @abstractmethod
29 async def before(self, ctx: ToolContext):
30 """执行前。返回 dict 短路;返回 ctx/None 继续"""
31 ...
33 @abstractmethod
34 async def after(self, ctx: ToolContext):
35 """执行后。可修改 ctx.result;返回 dict 替换 result"""
36 ...
38 @abstractmethod
39 async def on_error(self, ctx: ToolContext):
40 """异常处理。返回 dict → 错误结果;None → 抛"""
41 ...
44class ToolMiddlewareChain:
45 """洋葱模型:before 正序,after/on_error 逆序"""
47 def __init__(self):
48 self._middlewares: list[ToolMiddleware] = []
50 def use(self, middleware: ToolMiddleware) -> "ToolMiddlewareChain":
51 self._middlewares.append(middleware)
52 return self
54 async def execute(self, ctx: ToolContext, call_tool) -> dict:
55 for mw in self._middlewares:
56 r = await mw.before(ctx)
57 if isinstance(r, dict):
58 return r
59 if isinstance(r, ToolContext):
60 ctx = r
62 try:
63 result = call_tool()
64 if hasattr(result, "__await__"):
65 result = await result
66 ctx.result = result
67 except BaseException as e:
68 ctx.error = e
69 for mw in reversed(self._middlewares):
70 r = await mw.on_error(ctx)
71 if isinstance(r, dict):
72 return r
73 raise
75 for mw in reversed(self._middlewares):
76 r = await mw.after(ctx)
77 if isinstance(r, dict):
78 ctx.result = r
79 elif isinstance(r, ToolContext):
80 ctx = r
81 return ctx.result
84class GuardMiddleware(ToolMiddleware):
85 """包装 ToolGuard — DENY 返回错误,ASK 抛异常,ALLOW 继续"""
86 name = "guard"
88 def __init__(self, guard):
89 self.guard = guard
91 async def before(self, ctx: ToolContext):
92 result = await self.guard.check(ctx.tool_name, ctx.arguments)
93 if result.action == GuardAction.DENY:
94 return {"error": f"操作被安全守卫拒绝: {result.reason}", "tool": ctx.tool_name}
95 if result.action == GuardAction.ASK:
96 from merco.sandbox.guard import GuardConfirmationRequired
97 raise GuardConfirmationRequired(result)
98 return None
100 async def after(self, ctx: ToolContext):
101 return None
103 async def on_error(self, ctx: ToolContext):
104 return None
107class ErrorHandlingMiddleware(ToolMiddleware):
108 """工具异常 → 结构化 tool_error 结果"""
109 name = "error_handling"
111 async def before(self, ctx: ToolContext):
112 return None
114 async def after(self, ctx: ToolContext):
115 return None
117 async def on_error(self, ctx: ToolContext):
118 from merco.tools.errors import tool_error
119 return tool_error(
120 ctx.error,
121 ctx.tool_name,
122 getattr(ctx.tool, 'parameters', None) if ctx.tool else None,
123 )
126class EditApplyMiddleware(ToolMiddleware):
127 """应用 EditFile planned_edit:确认、快照、写入"""
128 name = "edit_apply"
130 def __init__(self, diff_view: str = "unified"):
131 self.diff_view = diff_view
133 async def before(self, ctx: ToolContext):
134 return None
136 async def after(self, ctx: ToolContext):
137 result = ctx.result or {}
138 if not isinstance(result, dict) or not result.get("planned_edit"):
139 return None
141 path = result["path"]
142 old_content = result["old_content"]
143 new_content = result["new_content"]
144 diff = result["diff"]
146 approved = await confirm_edit(diff, path, 1, old_content, new_content, self.diff_view)
147 if not approved:
148 return {
149 "success": False,
150 "path": path,
151 "message": "用户已取消修改",
152 "diff": diff,
153 }
155 snapshot.track(path, old_content)
156 Path(path).write_text(new_content, encoding="utf-8")
157 return {
158 "success": True,
159 "path": path,
160 "diff": diff,
161 "message": f"已修改 `{path}`",
162 }
164 async def on_error(self, ctx: ToolContext):
165 return None