monkeyscode 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.
- monkeyscode/__init__.py +178 -0
- monkeyscode/_http.py +83 -0
- monkeyscode/admin.py +141 -0
- monkeyscode/agent.py +772 -0
- monkeyscode/ci.py +288 -0
- monkeyscode/cli_process.py +730 -0
- monkeyscode/daemon.py +201 -0
- monkeyscode/events.py +323 -0
- monkeyscode/export.py +319 -0
- monkeyscode/hooks.py +190 -0
- monkeyscode/mcp.py +283 -0
- monkeyscode/orchestrator.py +170 -0
- monkeyscode/otel.py +181 -0
- monkeyscode/py.typed +1 -0
- monkeyscode/runs.py +138 -0
- monkeyscode/sandbox.py +125 -0
- monkeyscode/session.py +227 -0
- monkeyscode/subagent.py +195 -0
- monkeyscode/telemetry.py +204 -0
- monkeyscode/tools.py +133 -0
- monkeyscode/watcher.py +137 -0
- monkeyscode-1.0.0.dist-info/METADATA +152 -0
- monkeyscode-1.0.0.dist-info/RECORD +25 -0
- monkeyscode-1.0.0.dist-info/WHEEL +4 -0
- monkeyscode-1.0.0.dist-info/licenses/LICENSE +21 -0
monkeyscode/export.py
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MonkeysCode SDK — Conversation Export.
|
|
3
|
+
|
|
4
|
+
Export agent results as Markdown, HTML, or JSON transcript.
|
|
5
|
+
|
|
6
|
+
Usage::
|
|
7
|
+
|
|
8
|
+
from monkeyscode.export import to_markdown, to_html, to_transcript
|
|
9
|
+
|
|
10
|
+
result = await agent.run("Fix all errors")
|
|
11
|
+
|
|
12
|
+
md = to_markdown(result)
|
|
13
|
+
html = to_html(result)
|
|
14
|
+
transcript = to_transcript(result)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import html as html_mod
|
|
20
|
+
import re
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from monkeyscode.events import AgentResult
|
|
25
|
+
|
|
26
|
+
# ── Markdown Export ──────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def to_markdown(
|
|
30
|
+
result: AgentResult,
|
|
31
|
+
*,
|
|
32
|
+
title: str | None = None,
|
|
33
|
+
include_tool_calls: bool = True,
|
|
34
|
+
include_file_diffs: bool = True,
|
|
35
|
+
include_cost: bool = True,
|
|
36
|
+
) -> str:
|
|
37
|
+
"""Convert an agent result to a Markdown report."""
|
|
38
|
+
lines: list[str] = []
|
|
39
|
+
|
|
40
|
+
lines.append(f"# {title or 'MonkeysCode Agent Report'}")
|
|
41
|
+
lines.append("")
|
|
42
|
+
|
|
43
|
+
# Summary
|
|
44
|
+
lines.append("## Summary")
|
|
45
|
+
lines.append("")
|
|
46
|
+
status = "✅ Success" if result.success else "❌ Failed"
|
|
47
|
+
lines.append(f"- **Status**: {status}")
|
|
48
|
+
lines.append(f"- **Model**: {result.model or 'auto'}")
|
|
49
|
+
if result.session_id:
|
|
50
|
+
lines.append(f"- **Session**: `{result.session_id}`")
|
|
51
|
+
if result.duration_ms:
|
|
52
|
+
lines.append(f"- **Duration**: {_format_duration(result.duration_ms)}")
|
|
53
|
+
lines.append("")
|
|
54
|
+
|
|
55
|
+
# Agent output
|
|
56
|
+
if result.summary:
|
|
57
|
+
lines.append("## Agent Output")
|
|
58
|
+
lines.append("")
|
|
59
|
+
lines.append(result.summary)
|
|
60
|
+
lines.append("")
|
|
61
|
+
|
|
62
|
+
# Error
|
|
63
|
+
if result.error:
|
|
64
|
+
lines.append("## Error")
|
|
65
|
+
lines.append("")
|
|
66
|
+
lines.append(f"> {result.error}")
|
|
67
|
+
lines.append("")
|
|
68
|
+
|
|
69
|
+
# Files changed
|
|
70
|
+
if result.files_changed and include_file_diffs:
|
|
71
|
+
lines.append("## Files Changed")
|
|
72
|
+
lines.append("")
|
|
73
|
+
lines.append(f"{len(result.files_changed)} file(s) modified:")
|
|
74
|
+
lines.append("")
|
|
75
|
+
for f in result.files_changed:
|
|
76
|
+
lines.append(f"- `{f.path}` ({f.action})")
|
|
77
|
+
if f.diff:
|
|
78
|
+
lines.append("")
|
|
79
|
+
lines.append("```diff")
|
|
80
|
+
lines.append(f.diff)
|
|
81
|
+
lines.append("```")
|
|
82
|
+
lines.append("")
|
|
83
|
+
|
|
84
|
+
# Tool calls
|
|
85
|
+
if result.tool_calls and include_tool_calls:
|
|
86
|
+
lines.append("## Tool Calls")
|
|
87
|
+
lines.append("")
|
|
88
|
+
lines.append(f"{len(result.tool_calls)} tool call(s):")
|
|
89
|
+
lines.append("")
|
|
90
|
+
lines.append("| # | Tool | Duration |")
|
|
91
|
+
lines.append("|---|------|----------|")
|
|
92
|
+
for i, call in enumerate(result.tool_calls, 1):
|
|
93
|
+
if isinstance(call, dict):
|
|
94
|
+
tool_name = call.get("tool", "?")
|
|
95
|
+
duration = call.get("duration", 0)
|
|
96
|
+
else:
|
|
97
|
+
tool_name = getattr(call, "tool", "?")
|
|
98
|
+
duration = getattr(call, "duration", 0)
|
|
99
|
+
dur = f"{duration}ms" if duration else "—"
|
|
100
|
+
lines.append(f"| {i} | `{tool_name}` | {dur} |")
|
|
101
|
+
lines.append("")
|
|
102
|
+
|
|
103
|
+
# Cost
|
|
104
|
+
if include_cost and result.tokens:
|
|
105
|
+
lines.append("## Cost & Usage")
|
|
106
|
+
lines.append("")
|
|
107
|
+
lines.append("| Metric | Value |")
|
|
108
|
+
lines.append("|--------|-------|")
|
|
109
|
+
lines.append(f"| Cost | ${result.cost:.4f} |")
|
|
110
|
+
lines.append(f"| Input tokens | {result.tokens.input:,} |")
|
|
111
|
+
lines.append(f"| Output tokens | {result.tokens.output:,} |")
|
|
112
|
+
if result.tokens.thinking:
|
|
113
|
+
lines.append(f"| Thinking tokens | {result.tokens.thinking:,} |")
|
|
114
|
+
lines.append(f"| Total tokens | {result.tokens.total:,} |")
|
|
115
|
+
lines.append("")
|
|
116
|
+
|
|
117
|
+
# Footer
|
|
118
|
+
lines.append("---")
|
|
119
|
+
lines.append(f"*Generated by MonkeysCode SDK at {datetime.now(timezone.utc).isoformat()}*")
|
|
120
|
+
lines.append("")
|
|
121
|
+
|
|
122
|
+
return "\n".join(lines)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ── HTML Export ──────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def to_html(
|
|
129
|
+
result: AgentResult,
|
|
130
|
+
*,
|
|
131
|
+
title: str | None = None,
|
|
132
|
+
**kwargs: Any,
|
|
133
|
+
) -> str:
|
|
134
|
+
"""Convert an agent result to a styled HTML report."""
|
|
135
|
+
md = to_markdown(result, title=title, **kwargs)
|
|
136
|
+
escaped_title = html_mod.escape(title or "MonkeysCode Agent Report")
|
|
137
|
+
body = _markdown_to_html(md)
|
|
138
|
+
|
|
139
|
+
return f"""<!DOCTYPE html>
|
|
140
|
+
<html lang="en">
|
|
141
|
+
<head>
|
|
142
|
+
<meta charset="utf-8">
|
|
143
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
144
|
+
<title>{escaped_title}</title>
|
|
145
|
+
<style>
|
|
146
|
+
:root {{
|
|
147
|
+
--bg: #0d1117;
|
|
148
|
+
--fg: #e6edf3;
|
|
149
|
+
--accent: #58a6ff;
|
|
150
|
+
--success: #3fb950;
|
|
151
|
+
--error: #f85149;
|
|
152
|
+
--border: #30363d;
|
|
153
|
+
--code-bg: #161b22;
|
|
154
|
+
}}
|
|
155
|
+
* {{ box-sizing: border-box; }}
|
|
156
|
+
body {{
|
|
157
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
158
|
+
background: var(--bg);
|
|
159
|
+
color: var(--fg);
|
|
160
|
+
max-width: 860px;
|
|
161
|
+
margin: 0 auto;
|
|
162
|
+
padding: 2rem 1.5rem;
|
|
163
|
+
line-height: 1.6;
|
|
164
|
+
}}
|
|
165
|
+
h1 {{ border-bottom: 1px solid var(--border); padding-bottom: 0.5em; color: var(--accent); }}
|
|
166
|
+
h2 {{ margin-top: 2em; }}
|
|
167
|
+
code {{ background: var(--code-bg); padding: 0.2em 0.4em; border-radius: 4px; font-size: 0.9em; }}
|
|
168
|
+
pre {{ background: var(--code-bg); padding: 1em; border-radius: 8px; overflow-x: auto; }}
|
|
169
|
+
pre code {{ background: none; padding: 0; }}
|
|
170
|
+
table {{ width: 100%; border-collapse: collapse; margin: 1em 0; }}
|
|
171
|
+
th, td {{ padding: 0.5em 0.75em; text-align: left; border: 1px solid var(--border); }}
|
|
172
|
+
th {{ background: var(--code-bg); }}
|
|
173
|
+
blockquote {{ border-left: 3px solid var(--error); padding-left: 1em; color: var(--error); }}
|
|
174
|
+
hr {{ border: none; border-top: 1px solid var(--border); margin: 2em 0; }}
|
|
175
|
+
em {{ color: #8b949e; }}
|
|
176
|
+
</style>
|
|
177
|
+
</head>
|
|
178
|
+
<body>
|
|
179
|
+
{body}
|
|
180
|
+
</body>
|
|
181
|
+
</html>"""
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# ── Transcript Export ────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def to_transcript(
|
|
188
|
+
result: AgentResult,
|
|
189
|
+
events: list[Any] | None = None,
|
|
190
|
+
) -> dict[str, Any]:
|
|
191
|
+
"""Export the full result as a structured JSON transcript."""
|
|
192
|
+
return {
|
|
193
|
+
"version": "1.0",
|
|
194
|
+
"exported_at": datetime.now(timezone.utc).isoformat(),
|
|
195
|
+
"result": {
|
|
196
|
+
"success": result.success,
|
|
197
|
+
"status": result.status,
|
|
198
|
+
"summary": result.summary,
|
|
199
|
+
"model": result.model,
|
|
200
|
+
"cost": result.cost,
|
|
201
|
+
"duration_ms": result.duration_ms,
|
|
202
|
+
"tokens": {
|
|
203
|
+
"input": result.tokens.input if result.tokens else 0,
|
|
204
|
+
"output": result.tokens.output if result.tokens else 0,
|
|
205
|
+
"thinking": result.tokens.thinking if result.tokens else 0,
|
|
206
|
+
"total": result.tokens.total if result.tokens else 0,
|
|
207
|
+
},
|
|
208
|
+
"files_changed": [
|
|
209
|
+
{"path": f.path, "action": f.action}
|
|
210
|
+
for f in (result.files_changed or [])
|
|
211
|
+
],
|
|
212
|
+
},
|
|
213
|
+
"events": events,
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# ── Helpers ──────────────────────────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _format_duration(ms: float) -> str:
|
|
221
|
+
if ms < 1000:
|
|
222
|
+
return f"{ms:.0f}ms"
|
|
223
|
+
if ms < 60_000:
|
|
224
|
+
return f"{ms / 1000:.1f}s"
|
|
225
|
+
mins = int(ms // 60_000)
|
|
226
|
+
secs = round((ms % 60_000) / 1000)
|
|
227
|
+
return f"{mins}m {secs}s"
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _markdown_to_html(md: str) -> str:
|
|
231
|
+
"""Minimal Markdown → HTML converter (no dependencies)."""
|
|
232
|
+
lines = md.split("\n")
|
|
233
|
+
out: list[str] = []
|
|
234
|
+
in_code = False
|
|
235
|
+
in_table = False
|
|
236
|
+
in_list = False
|
|
237
|
+
|
|
238
|
+
for line in lines:
|
|
239
|
+
if line.startswith("```"):
|
|
240
|
+
if in_code:
|
|
241
|
+
out.append("</code></pre>")
|
|
242
|
+
in_code = False
|
|
243
|
+
else:
|
|
244
|
+
lang = line[3:].strip() or "text"
|
|
245
|
+
out.append(f'<pre><code class="language-{lang}">')
|
|
246
|
+
in_code = True
|
|
247
|
+
continue
|
|
248
|
+
if in_code:
|
|
249
|
+
out.append(html_mod.escape(line))
|
|
250
|
+
continue
|
|
251
|
+
|
|
252
|
+
if in_table and not line.startswith("|"):
|
|
253
|
+
out.append("</tbody></table>")
|
|
254
|
+
in_table = False
|
|
255
|
+
if in_list and not line.startswith("- "):
|
|
256
|
+
out.append("</ul>")
|
|
257
|
+
in_list = False
|
|
258
|
+
|
|
259
|
+
stripped = line.strip()
|
|
260
|
+
if not stripped:
|
|
261
|
+
out.append("")
|
|
262
|
+
continue
|
|
263
|
+
|
|
264
|
+
# Headings
|
|
265
|
+
m = re.match(r"^(#{1,6})\s+(.+)", line)
|
|
266
|
+
if m:
|
|
267
|
+
level = len(m.group(1))
|
|
268
|
+
out.append(f"<h{level}>{_inline(m.group(2))}</h{level}>")
|
|
269
|
+
continue
|
|
270
|
+
|
|
271
|
+
if re.match(r"^---+$", stripped):
|
|
272
|
+
out.append("<hr>")
|
|
273
|
+
continue
|
|
274
|
+
|
|
275
|
+
if stripped.startswith("> "):
|
|
276
|
+
out.append(f"<blockquote>{_inline(stripped[2:])}</blockquote>")
|
|
277
|
+
continue
|
|
278
|
+
|
|
279
|
+
if stripped.startswith("|"):
|
|
280
|
+
if re.match(r"^\|[\s\-|]+\|$", stripped):
|
|
281
|
+
continue
|
|
282
|
+
cells = [c.strip() for c in stripped.split("|") if c.strip()]
|
|
283
|
+
if not in_table:
|
|
284
|
+
out.append("<table><thead><tr>")
|
|
285
|
+
out.extend(f"<th>{_inline(c)}</th>" for c in cells)
|
|
286
|
+
out.append("</tr></thead><tbody>")
|
|
287
|
+
in_table = True
|
|
288
|
+
else:
|
|
289
|
+
out.append("<tr>")
|
|
290
|
+
out.extend(f"<td>{_inline(c)}</td>" for c in cells)
|
|
291
|
+
out.append("</tr>")
|
|
292
|
+
continue
|
|
293
|
+
|
|
294
|
+
if stripped.startswith("- "):
|
|
295
|
+
if not in_list:
|
|
296
|
+
out.append("<ul>")
|
|
297
|
+
in_list = True
|
|
298
|
+
out.append(f"<li>{_inline(stripped[2:])}</li>")
|
|
299
|
+
continue
|
|
300
|
+
|
|
301
|
+
out.append(f"<p>{_inline(line)}</p>")
|
|
302
|
+
|
|
303
|
+
if in_code:
|
|
304
|
+
out.append("</code></pre>")
|
|
305
|
+
if in_table:
|
|
306
|
+
out.append("</tbody></table>")
|
|
307
|
+
if in_list:
|
|
308
|
+
out.append("</ul>")
|
|
309
|
+
|
|
310
|
+
return "\n".join(out)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _inline(text: str) -> str:
|
|
314
|
+
"""Convert inline markdown."""
|
|
315
|
+
text = html_mod.escape(text)
|
|
316
|
+
text = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", text)
|
|
317
|
+
text = re.sub(r"`(.+?)`", r"<code>\1</code>", text)
|
|
318
|
+
text = re.sub(r"\*(.+?)\*", r"<em>\1</em>", text)
|
|
319
|
+
return text
|
monkeyscode/hooks.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MonkeysCode SDK — Hooks System.
|
|
3
|
+
|
|
4
|
+
Intercept, modify, or block tool calls before/after execution.
|
|
5
|
+
Matches Claude Agent SDK's PreToolUse / PostToolUse pattern.
|
|
6
|
+
|
|
7
|
+
Usage::
|
|
8
|
+
|
|
9
|
+
from monkeyscode import MonkeysCode
|
|
10
|
+
|
|
11
|
+
agent = MonkeysCode(api_key="mc_...")
|
|
12
|
+
|
|
13
|
+
@agent.on_pre_tool_use
|
|
14
|
+
async def block_destructive(ctx):
|
|
15
|
+
if ctx.tool == "shell_command" and "rm -rf" in (ctx.args.get("command") or ""):
|
|
16
|
+
return PreToolResult(action="block", reason="Destructive command blocked")
|
|
17
|
+
return PreToolResult(action="allow")
|
|
18
|
+
|
|
19
|
+
@agent.on_post_tool_use
|
|
20
|
+
async def log_tools(ctx):
|
|
21
|
+
print(f"{ctx.tool}: {'FAIL' if ctx.is_error else 'OK'} ({ctx.duration_ms}ms)")
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import logging
|
|
27
|
+
from collections.abc import Awaitable, Callable
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from typing import Any, Literal
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger("monkeyscode.hooks")
|
|
32
|
+
|
|
33
|
+
# ── Types ────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
HookAction = Literal["allow", "block", "modify"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class PreToolContext:
|
|
40
|
+
"""Context passed to pre-tool hooks."""
|
|
41
|
+
|
|
42
|
+
tool: str
|
|
43
|
+
args: dict[str, Any]
|
|
44
|
+
run_id: str
|
|
45
|
+
turn_index: int
|
|
46
|
+
session_id: str | None = None
|
|
47
|
+
timestamp: float = 0.0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class PreToolResult:
|
|
52
|
+
"""Result from a pre-tool hook."""
|
|
53
|
+
|
|
54
|
+
action: HookAction = "allow"
|
|
55
|
+
args: dict[str, Any] | None = None # for 'modify'
|
|
56
|
+
reason: str | None = None # for 'block'
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class PostToolContext:
|
|
61
|
+
"""Context passed to post-tool hooks."""
|
|
62
|
+
|
|
63
|
+
tool: str
|
|
64
|
+
args: dict[str, Any]
|
|
65
|
+
result: Any
|
|
66
|
+
is_error: bool
|
|
67
|
+
duration_ms: float
|
|
68
|
+
run_id: str
|
|
69
|
+
turn_index: int
|
|
70
|
+
session_id: str | None = None
|
|
71
|
+
timestamp: float = 0.0
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
PreToolHook = Callable[[PreToolContext], Awaitable[PreToolResult] | PreToolResult]
|
|
75
|
+
PostToolHook = Callable[[PostToolContext], Awaitable[None] | None]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ── Hook Manager ─────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class HookManager:
|
|
82
|
+
"""
|
|
83
|
+
Manages pre- and post-tool hooks for an agent instance.
|
|
84
|
+
|
|
85
|
+
Hook execution order:
|
|
86
|
+
1. All pre-hooks run in registration order
|
|
87
|
+
2. First hook to return 'block' wins (short-circuits)
|
|
88
|
+
3. Last hook to return 'modify' wins (args are cumulative)
|
|
89
|
+
4. Tool executes (or is blocked)
|
|
90
|
+
5. All post-hooks run in registration order (fire-and-forget)
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
def __init__(self) -> None:
|
|
94
|
+
self._pre_hooks: list[PreToolHook] = []
|
|
95
|
+
self._post_hooks: list[PostToolHook] = []
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def has_hooks(self) -> bool:
|
|
99
|
+
"""Whether any hooks are registered."""
|
|
100
|
+
return bool(self._pre_hooks or self._post_hooks)
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def pre_hook_count(self) -> int:
|
|
104
|
+
return len(self._pre_hooks)
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def post_hook_count(self) -> int:
|
|
108
|
+
return len(self._post_hooks)
|
|
109
|
+
|
|
110
|
+
def add_pre_hook(self, hook: PreToolHook) -> None:
|
|
111
|
+
"""Register a pre-tool hook."""
|
|
112
|
+
self._pre_hooks.append(hook)
|
|
113
|
+
|
|
114
|
+
def add_post_hook(self, hook: PostToolHook) -> None:
|
|
115
|
+
"""Register a post-tool hook."""
|
|
116
|
+
self._post_hooks.append(hook)
|
|
117
|
+
|
|
118
|
+
def remove_pre_hook(self, hook: PreToolHook) -> bool:
|
|
119
|
+
"""Remove a pre-tool hook. Returns True if found."""
|
|
120
|
+
try:
|
|
121
|
+
self._pre_hooks.remove(hook)
|
|
122
|
+
return True
|
|
123
|
+
except ValueError:
|
|
124
|
+
return False
|
|
125
|
+
|
|
126
|
+
def remove_post_hook(self, hook: PostToolHook) -> bool:
|
|
127
|
+
"""Remove a post-tool hook. Returns True if found."""
|
|
128
|
+
try:
|
|
129
|
+
self._post_hooks.remove(hook)
|
|
130
|
+
return True
|
|
131
|
+
except ValueError:
|
|
132
|
+
return False
|
|
133
|
+
|
|
134
|
+
def clear_all(self) -> None:
|
|
135
|
+
"""Remove all hooks."""
|
|
136
|
+
self._pre_hooks.clear()
|
|
137
|
+
self._post_hooks.clear()
|
|
138
|
+
|
|
139
|
+
async def run_pre_hooks(self, context: PreToolContext) -> PreToolResult:
|
|
140
|
+
"""
|
|
141
|
+
Run all pre-tool hooks in order.
|
|
142
|
+
|
|
143
|
+
- First 'block' result short-circuits and returns it.
|
|
144
|
+
- 'modify' results accumulate.
|
|
145
|
+
- If all return 'allow', returns PreToolResult(action='allow').
|
|
146
|
+
"""
|
|
147
|
+
if not self._pre_hooks:
|
|
148
|
+
return PreToolResult(action="allow")
|
|
149
|
+
|
|
150
|
+
current_args = dict(context.args)
|
|
151
|
+
last_modify: PreToolResult | None = None
|
|
152
|
+
|
|
153
|
+
for hook in self._pre_hooks:
|
|
154
|
+
try:
|
|
155
|
+
result = hook(PreToolContext(
|
|
156
|
+
tool=context.tool,
|
|
157
|
+
args=current_args,
|
|
158
|
+
run_id=context.run_id,
|
|
159
|
+
turn_index=context.turn_index,
|
|
160
|
+
session_id=context.session_id,
|
|
161
|
+
timestamp=context.timestamp,
|
|
162
|
+
))
|
|
163
|
+
# Await if coroutine
|
|
164
|
+
if hasattr(result, "__await__"):
|
|
165
|
+
result = await result # type: ignore[misc]
|
|
166
|
+
|
|
167
|
+
if result.action == "block":
|
|
168
|
+
return result
|
|
169
|
+
|
|
170
|
+
if result.action == "modify" and result.args is not None:
|
|
171
|
+
current_args.update(result.args)
|
|
172
|
+
last_modify = PreToolResult(action="modify", args=dict(current_args))
|
|
173
|
+
|
|
174
|
+
except Exception:
|
|
175
|
+
logger.exception("Pre-hook error (tool: %s)", context.tool)
|
|
176
|
+
|
|
177
|
+
if last_modify:
|
|
178
|
+
return last_modify
|
|
179
|
+
|
|
180
|
+
return PreToolResult(action="allow")
|
|
181
|
+
|
|
182
|
+
async def run_post_hooks(self, context: PostToolContext) -> None:
|
|
183
|
+
"""Run all post-tool hooks. Errors are logged, never raised."""
|
|
184
|
+
for hook in self._post_hooks:
|
|
185
|
+
try:
|
|
186
|
+
result = hook(context)
|
|
187
|
+
if hasattr(result, "__await__"):
|
|
188
|
+
await result # type: ignore[misc]
|
|
189
|
+
except Exception:
|
|
190
|
+
logger.exception("Post-hook error (tool: %s)", context.tool)
|