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/agent.py
ADDED
|
@@ -0,0 +1,772 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MonkeysCode SDK — Agent Class.
|
|
3
|
+
|
|
4
|
+
The primary SDK interface for Python. Handles:
|
|
5
|
+
- Connecting to the model-proxy API (SSE streaming)
|
|
6
|
+
- Executing custom tools locally
|
|
7
|
+
- Goal mode (autonomous multi-iteration)
|
|
8
|
+
- Session management
|
|
9
|
+
|
|
10
|
+
Usage::
|
|
11
|
+
|
|
12
|
+
from monkeyscode import MonkeysCode
|
|
13
|
+
|
|
14
|
+
agent = MonkeysCode(api_key="mc_...")
|
|
15
|
+
|
|
16
|
+
# Full result
|
|
17
|
+
result = await agent.run("Fix all type errors")
|
|
18
|
+
print(result.summary)
|
|
19
|
+
|
|
20
|
+
# Streaming
|
|
21
|
+
async for event in agent.stream("Refactor auth"):
|
|
22
|
+
if event.type == "text":
|
|
23
|
+
print(event.content, end="", flush=True)
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import os
|
|
29
|
+
import time
|
|
30
|
+
from collections import defaultdict
|
|
31
|
+
from collections.abc import AsyncGenerator, Callable
|
|
32
|
+
from dataclasses import dataclass
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
from monkeyscode._http import stream_sse
|
|
36
|
+
from monkeyscode.events import (
|
|
37
|
+
AgentCompleteEvent,
|
|
38
|
+
AgentEvent,
|
|
39
|
+
AgentResult,
|
|
40
|
+
AgentSpawnedEvent,
|
|
41
|
+
CompleteEvent,
|
|
42
|
+
CostUpdateEvent,
|
|
43
|
+
ErrorEvent,
|
|
44
|
+
FanOutProgressEvent,
|
|
45
|
+
FileChange,
|
|
46
|
+
FileEditEvent,
|
|
47
|
+
FileReadEvent,
|
|
48
|
+
GoalResult,
|
|
49
|
+
PlanReadyEvent,
|
|
50
|
+
PlanStep,
|
|
51
|
+
SearchResultEvent,
|
|
52
|
+
StartEvent,
|
|
53
|
+
SuggestionEvent,
|
|
54
|
+
TerminalCommandEvent,
|
|
55
|
+
TextEvent,
|
|
56
|
+
ThinkingEvent,
|
|
57
|
+
TokenUsage,
|
|
58
|
+
ToolCallEvent,
|
|
59
|
+
ToolResultEvent,
|
|
60
|
+
VerificationEvent,
|
|
61
|
+
)
|
|
62
|
+
from monkeyscode.hooks import (
|
|
63
|
+
HookManager,
|
|
64
|
+
PostToolContext,
|
|
65
|
+
PostToolHook,
|
|
66
|
+
PreToolContext,
|
|
67
|
+
PreToolHook,
|
|
68
|
+
)
|
|
69
|
+
from monkeyscode.subagent import (
|
|
70
|
+
MAX_SUBAGENTS,
|
|
71
|
+
SubagentOptions,
|
|
72
|
+
SubagentResult,
|
|
73
|
+
spawn_subagent,
|
|
74
|
+
)
|
|
75
|
+
from monkeyscode.tools import ToolDefinition
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class MonkeysCodeOptions:
|
|
80
|
+
"""Configuration options for the MonkeysCode agent."""
|
|
81
|
+
|
|
82
|
+
model: str = "auto"
|
|
83
|
+
api_key: str | None = None
|
|
84
|
+
working_directory: str | None = None
|
|
85
|
+
proxy_url: str | None = None
|
|
86
|
+
max_turns: int = 30
|
|
87
|
+
timeout: int = 300_000
|
|
88
|
+
permissions_mode: str = "ask"
|
|
89
|
+
max_cost: float | None = None
|
|
90
|
+
max_retries: int = 3
|
|
91
|
+
debug: bool = False
|
|
92
|
+
runtime: str = "local" # 'local' | 'container'
|
|
93
|
+
sandbox: dict[str, Any] | None = None
|
|
94
|
+
telemetry: dict[str, Any] | None = None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class MonkeysCode:
|
|
98
|
+
"""
|
|
99
|
+
MonkeysCode agent — the primary SDK interface.
|
|
100
|
+
|
|
101
|
+
Connects to the model-proxy API and streams events back.
|
|
102
|
+
Supports custom tools, goal mode, and session management.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def __init__(
|
|
106
|
+
self,
|
|
107
|
+
*,
|
|
108
|
+
api_key: str | None = None,
|
|
109
|
+
model: str = "auto",
|
|
110
|
+
working_directory: str | None = None,
|
|
111
|
+
proxy_url: str | None = None,
|
|
112
|
+
max_turns: int = 30,
|
|
113
|
+
timeout: int = 300_000,
|
|
114
|
+
permissions_mode: str = "ask",
|
|
115
|
+
max_cost: float | None = None,
|
|
116
|
+
max_retries: int = 3,
|
|
117
|
+
debug: bool = False,
|
|
118
|
+
runtime: str = "local",
|
|
119
|
+
sandbox: dict[str, Any] | None = None,
|
|
120
|
+
telemetry: dict[str, Any] | None = None,
|
|
121
|
+
) -> None:
|
|
122
|
+
self._api_key = api_key or os.environ.get("MONKEYSCODE_API_KEY")
|
|
123
|
+
self._model = model
|
|
124
|
+
self._working_directory = working_directory or os.getcwd()
|
|
125
|
+
self._proxy_url = (
|
|
126
|
+
proxy_url
|
|
127
|
+
or os.environ.get("MONKEYSCODE_PROXY_URL")
|
|
128
|
+
or "https://models.monkeyscode.com"
|
|
129
|
+
)
|
|
130
|
+
self._max_turns = max_turns
|
|
131
|
+
self._timeout = timeout
|
|
132
|
+
self._permissions_mode = permissions_mode
|
|
133
|
+
self._max_cost = max_cost
|
|
134
|
+
self._max_retries = max_retries
|
|
135
|
+
self._debug = debug
|
|
136
|
+
|
|
137
|
+
self._session_id: str | None = None
|
|
138
|
+
self._total_cost: float = 0.0
|
|
139
|
+
self._total_tokens = TokenUsage()
|
|
140
|
+
self._custom_tools: dict[str, ToolDefinition] = {}
|
|
141
|
+
self._closed = False
|
|
142
|
+
self._listeners: dict[str, list[Callable]] = defaultdict(list)
|
|
143
|
+
self._hook_manager = HookManager()
|
|
144
|
+
self._subagent_count = 0
|
|
145
|
+
self._subagent_depth = 0
|
|
146
|
+
self._runtime = runtime
|
|
147
|
+
self._sandbox = sandbox
|
|
148
|
+
self._telemetry = telemetry
|
|
149
|
+
|
|
150
|
+
# ── Properties ───────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def session_id(self) -> str | None:
|
|
154
|
+
"""Current session ID."""
|
|
155
|
+
return self._session_id
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def total_cost(self) -> float:
|
|
159
|
+
"""Total accumulated cost in USD."""
|
|
160
|
+
return self._total_cost
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def total_tokens(self) -> TokenUsage:
|
|
164
|
+
"""Total accumulated token usage."""
|
|
165
|
+
return TokenUsage(
|
|
166
|
+
input=self._total_tokens.input,
|
|
167
|
+
output=self._total_tokens.output,
|
|
168
|
+
thinking=self._total_tokens.thinking,
|
|
169
|
+
total=self._total_tokens.total,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
@property
|
|
173
|
+
def model(self) -> str:
|
|
174
|
+
"""Current model."""
|
|
175
|
+
return self._model
|
|
176
|
+
|
|
177
|
+
# ── Tools ────────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
def add_tool(self, tool: ToolDefinition) -> None:
|
|
180
|
+
"""Register a custom tool."""
|
|
181
|
+
self._custom_tools[tool.name] = tool
|
|
182
|
+
|
|
183
|
+
def remove_tool(self, name: str) -> None:
|
|
184
|
+
"""Unregister a custom tool."""
|
|
185
|
+
self._custom_tools.pop(name, None)
|
|
186
|
+
|
|
187
|
+
def list_tools(self) -> list[str]:
|
|
188
|
+
"""List registered custom tools."""
|
|
189
|
+
return list(self._custom_tools.keys())
|
|
190
|
+
|
|
191
|
+
# ── Event Listeners ──────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
def on(self, event_type: str, callback: Callable) -> MonkeysCode:
|
|
194
|
+
"""Register an event listener.
|
|
195
|
+
|
|
196
|
+
Example::
|
|
197
|
+
|
|
198
|
+
agent.on("text", lambda e: print(e.content, end=""))
|
|
199
|
+
agent.on("error", lambda e: print(f"Error: {e.message}"))
|
|
200
|
+
"""
|
|
201
|
+
self._listeners[event_type].append(callback)
|
|
202
|
+
return self
|
|
203
|
+
|
|
204
|
+
def off(self, event_type: str, callback: Callable) -> MonkeysCode:
|
|
205
|
+
"""Remove an event listener."""
|
|
206
|
+
try:
|
|
207
|
+
self._listeners[event_type].remove(callback)
|
|
208
|
+
except ValueError:
|
|
209
|
+
pass
|
|
210
|
+
return self
|
|
211
|
+
|
|
212
|
+
def _emit(self, event: AgentEvent) -> None:
|
|
213
|
+
"""Notify all listeners for an event type."""
|
|
214
|
+
event_type = getattr(event, "type", None)
|
|
215
|
+
if event_type and event_type in self._listeners:
|
|
216
|
+
for cb in self._listeners[event_type]:
|
|
217
|
+
try:
|
|
218
|
+
cb(event)
|
|
219
|
+
except Exception:
|
|
220
|
+
pass # Swallow listener errors
|
|
221
|
+
|
|
222
|
+
# ── Hooks (SDK Gap #1) ────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
def on_pre_tool_use(self, hook: PreToolHook) -> MonkeysCode:
|
|
225
|
+
"""Register a pre-tool hook to intercept tool calls.
|
|
226
|
+
|
|
227
|
+
Example::
|
|
228
|
+
|
|
229
|
+
async def block_rm(ctx):
|
|
230
|
+
if ctx.tool == "shell_command" and "rm -rf" in (ctx.args.get("command") or ""):
|
|
231
|
+
return PreToolResult(action="block", reason="Blocked")
|
|
232
|
+
return PreToolResult(action="allow")
|
|
233
|
+
|
|
234
|
+
agent.on_pre_tool_use(block_rm)
|
|
235
|
+
"""
|
|
236
|
+
self._hook_manager.add_pre_hook(hook)
|
|
237
|
+
return self
|
|
238
|
+
|
|
239
|
+
def on_post_tool_use(self, hook: PostToolHook) -> MonkeysCode:
|
|
240
|
+
"""Register a post-tool hook to observe tool results."""
|
|
241
|
+
self._hook_manager.add_post_hook(hook)
|
|
242
|
+
return self
|
|
243
|
+
|
|
244
|
+
def off_pre_tool_use(self, hook: PreToolHook) -> bool:
|
|
245
|
+
"""Remove a pre-tool hook. Returns True if found."""
|
|
246
|
+
return self._hook_manager.remove_pre_hook(hook)
|
|
247
|
+
|
|
248
|
+
def off_post_tool_use(self, hook: PostToolHook) -> bool:
|
|
249
|
+
"""Remove a post-tool hook. Returns True if found."""
|
|
250
|
+
return self._hook_manager.remove_post_hook(hook)
|
|
251
|
+
|
|
252
|
+
def clear_hooks(self) -> None:
|
|
253
|
+
"""Remove all hooks."""
|
|
254
|
+
self._hook_manager.clear_all()
|
|
255
|
+
|
|
256
|
+
# ── Subagent Spawning (SDK Gap #2) ────────────────────────────────
|
|
257
|
+
|
|
258
|
+
async def spawn(self, options: SubagentOptions | None = None, **kwargs: Any) -> SubagentResult:
|
|
259
|
+
"""Spawn a focused child agent for a specific task.
|
|
260
|
+
|
|
261
|
+
Example::
|
|
262
|
+
|
|
263
|
+
sub = await agent.spawn(SubagentOptions(
|
|
264
|
+
task="Find SQL injection risks",
|
|
265
|
+
preset="reviewer",
|
|
266
|
+
))
|
|
267
|
+
print(sub.summary)
|
|
268
|
+
"""
|
|
269
|
+
if options is None:
|
|
270
|
+
options = SubagentOptions(**kwargs)
|
|
271
|
+
|
|
272
|
+
if self._subagent_count >= MAX_SUBAGENTS:
|
|
273
|
+
raise RuntimeError(
|
|
274
|
+
f"Max subagents reached ({MAX_SUBAGENTS}). Wait for one to finish.",
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
self._subagent_count += 1
|
|
278
|
+
try:
|
|
279
|
+
parent_opts = {
|
|
280
|
+
"model": self._model,
|
|
281
|
+
"api_key": self._api_key,
|
|
282
|
+
"working_directory": self._working_directory,
|
|
283
|
+
"proxy_url": self._proxy_url,
|
|
284
|
+
"debug": self._debug,
|
|
285
|
+
}
|
|
286
|
+
result = await spawn_subagent(parent_opts, options, self._subagent_depth)
|
|
287
|
+
self._total_cost += result.result.cost
|
|
288
|
+
return result
|
|
289
|
+
finally:
|
|
290
|
+
self._subagent_count -= 1
|
|
291
|
+
|
|
292
|
+
# ── Run (Full Result) ────────────────────────────────────────────
|
|
293
|
+
|
|
294
|
+
async def run(self, prompt: str) -> AgentResult:
|
|
295
|
+
"""
|
|
296
|
+
Send a prompt and get the full result after the agent finishes.
|
|
297
|
+
|
|
298
|
+
Args:
|
|
299
|
+
prompt: The instruction for the agent.
|
|
300
|
+
|
|
301
|
+
Returns:
|
|
302
|
+
AgentResult with success status, summary, files changed, etc.
|
|
303
|
+
"""
|
|
304
|
+
self._assert_not_closed()
|
|
305
|
+
|
|
306
|
+
events: list[AgentEvent] = []
|
|
307
|
+
async for event in self.stream(prompt):
|
|
308
|
+
events.append(event)
|
|
309
|
+
|
|
310
|
+
# Find the complete event
|
|
311
|
+
for event in events:
|
|
312
|
+
if isinstance(event, CompleteEvent):
|
|
313
|
+
return AgentResult(
|
|
314
|
+
success=True,
|
|
315
|
+
summary=event.summary,
|
|
316
|
+
files_changed=event.files_changed,
|
|
317
|
+
tokens=event.tokens,
|
|
318
|
+
cost=event.cost,
|
|
319
|
+
duration_ms=event.duration_ms,
|
|
320
|
+
session_id=self._session_id or "",
|
|
321
|
+
exit_code=0,
|
|
322
|
+
model=self._model,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
# Find error
|
|
326
|
+
for event in events:
|
|
327
|
+
if isinstance(event, ErrorEvent):
|
|
328
|
+
return AgentResult(
|
|
329
|
+
success=False,
|
|
330
|
+
summary=event.message,
|
|
331
|
+
exit_code=1,
|
|
332
|
+
model=self._model,
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
return AgentResult(
|
|
336
|
+
success=False,
|
|
337
|
+
summary="Agent failed with no completion event",
|
|
338
|
+
exit_code=1,
|
|
339
|
+
model=self._model,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
# ── Stream (Async Iterator) ──────────────────────────────────────
|
|
343
|
+
|
|
344
|
+
async def stream(self, prompt: str) -> AsyncGenerator[AgentEvent, None]:
|
|
345
|
+
"""
|
|
346
|
+
Send a prompt and stream events as they arrive.
|
|
347
|
+
|
|
348
|
+
Args:
|
|
349
|
+
prompt: The instruction for the agent.
|
|
350
|
+
|
|
351
|
+
Yields:
|
|
352
|
+
AgentEvent instances (TextEvent, ToolCallEvent, etc.)
|
|
353
|
+
"""
|
|
354
|
+
self._assert_not_closed()
|
|
355
|
+
|
|
356
|
+
if not self._api_key:
|
|
357
|
+
yield ErrorEvent(
|
|
358
|
+
timestamp=int(time.time() * 1000),
|
|
359
|
+
code="AUTH_MISSING",
|
|
360
|
+
message="No API key provided. Set api_key or MONKEYSCODE_API_KEY env var.",
|
|
361
|
+
)
|
|
362
|
+
return
|
|
363
|
+
|
|
364
|
+
start_time = time.monotonic()
|
|
365
|
+
|
|
366
|
+
# Build request
|
|
367
|
+
body: dict[str, Any] = {
|
|
368
|
+
"prompt": prompt,
|
|
369
|
+
"model": self._model,
|
|
370
|
+
"workspace": self._working_directory,
|
|
371
|
+
"sessionId": self._session_id,
|
|
372
|
+
"maxTurns": self._max_turns,
|
|
373
|
+
"tools": [t.to_json_schema() for t in self._custom_tools.values()],
|
|
374
|
+
"permissions": {"mode": self._permissions_mode},
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if self._max_cost is not None:
|
|
378
|
+
body["permissions"]["maxCost"] = self._max_cost
|
|
379
|
+
|
|
380
|
+
yield StartEvent(
|
|
381
|
+
timestamp=int(time.time() * 1000),
|
|
382
|
+
session_id=self._session_id or "pending",
|
|
383
|
+
model=self._model,
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
try:
|
|
387
|
+
files_changed: list[FileChange] = []
|
|
388
|
+
tokens = TokenUsage()
|
|
389
|
+
cost = 0.0
|
|
390
|
+
summary = ""
|
|
391
|
+
|
|
392
|
+
async for data in stream_sse(
|
|
393
|
+
f"{self._proxy_url}/v1/agent/run",
|
|
394
|
+
headers={"Authorization": f"Bearer {self._api_key}"},
|
|
395
|
+
json_body=body,
|
|
396
|
+
timeout=self._timeout / 1000,
|
|
397
|
+
):
|
|
398
|
+
event = self._map_sse_event(data, files_changed)
|
|
399
|
+
if event is None:
|
|
400
|
+
continue
|
|
401
|
+
|
|
402
|
+
# Track session ID
|
|
403
|
+
if "sessionId" in data:
|
|
404
|
+
self._session_id = data["sessionId"]
|
|
405
|
+
|
|
406
|
+
# Track tokens and cost
|
|
407
|
+
if isinstance(event, CompleteEvent):
|
|
408
|
+
tokens = event.tokens
|
|
409
|
+
cost = event.cost
|
|
410
|
+
summary = event.summary
|
|
411
|
+
self._total_tokens.input += tokens.input
|
|
412
|
+
self._total_tokens.output += tokens.output
|
|
413
|
+
self._total_tokens.thinking += tokens.thinking
|
|
414
|
+
self._total_tokens.total += tokens.total
|
|
415
|
+
self._total_cost += cost
|
|
416
|
+
|
|
417
|
+
# Track mid-run cost
|
|
418
|
+
if isinstance(event, CostUpdateEvent):
|
|
419
|
+
self._total_tokens.input = event.tokens_in
|
|
420
|
+
self._total_tokens.output = event.tokens_out
|
|
421
|
+
self._total_tokens.thinking = event.tokens_thinking
|
|
422
|
+
|
|
423
|
+
# ── Pre-tool hooks ──
|
|
424
|
+
if isinstance(event, ToolCallEvent) and self._hook_manager.has_hooks:
|
|
425
|
+
hook_ctx = PreToolContext(
|
|
426
|
+
tool=event.tool,
|
|
427
|
+
args=event.args,
|
|
428
|
+
run_id=f"run_{int(start_time)}",
|
|
429
|
+
turn_index=0,
|
|
430
|
+
session_id=self._session_id,
|
|
431
|
+
timestamp=event.timestamp,
|
|
432
|
+
)
|
|
433
|
+
hook_result = await self._hook_manager.run_pre_hooks(hook_ctx)
|
|
434
|
+
|
|
435
|
+
if hook_result.action == "block":
|
|
436
|
+
yield event
|
|
437
|
+
yield ToolResultEvent(
|
|
438
|
+
timestamp=int(time.time() * 1000),
|
|
439
|
+
tool=event.tool,
|
|
440
|
+
result=f"Blocked by hook: {hook_result.reason or 'no reason'}",
|
|
441
|
+
success=False,
|
|
442
|
+
duration_ms=0,
|
|
443
|
+
)
|
|
444
|
+
continue
|
|
445
|
+
|
|
446
|
+
if hook_result.action == "modify" and hook_result.args:
|
|
447
|
+
event = ToolCallEvent(
|
|
448
|
+
timestamp=event.timestamp,
|
|
449
|
+
tool=event.tool,
|
|
450
|
+
args=hook_result.args,
|
|
451
|
+
tool_call_id=getattr(event, 'tool_call_id', ''),
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
# Handle custom tool calls
|
|
455
|
+
if isinstance(event, ToolCallEvent) and event.tool in self._custom_tools:
|
|
456
|
+
yield event
|
|
457
|
+
tool = self._custom_tools[event.tool]
|
|
458
|
+
tool_start = time.monotonic()
|
|
459
|
+
try:
|
|
460
|
+
result = await tool.execute(event.args)
|
|
461
|
+
tool_result_event = ToolResultEvent(
|
|
462
|
+
timestamp=int(time.time() * 1000),
|
|
463
|
+
tool=event.tool,
|
|
464
|
+
result=str(result),
|
|
465
|
+
success=True,
|
|
466
|
+
duration_ms=int((time.monotonic() - tool_start) * 1000),
|
|
467
|
+
)
|
|
468
|
+
yield tool_result_event
|
|
469
|
+
|
|
470
|
+
# ── Post-tool hooks ──
|
|
471
|
+
if self._hook_manager.has_hooks:
|
|
472
|
+
await self._hook_manager.run_post_hooks(PostToolContext(
|
|
473
|
+
tool=event.tool,
|
|
474
|
+
args=event.args,
|
|
475
|
+
result=result,
|
|
476
|
+
is_error=False,
|
|
477
|
+
duration_ms=int((time.monotonic() - tool_start) * 1000),
|
|
478
|
+
run_id=f"run_{int(start_time)}",
|
|
479
|
+
turn_index=0,
|
|
480
|
+
session_id=self._session_id,
|
|
481
|
+
timestamp=tool_result_event.timestamp,
|
|
482
|
+
))
|
|
483
|
+
except Exception as e:
|
|
484
|
+
tool_result_event = ToolResultEvent(
|
|
485
|
+
timestamp=int(time.time() * 1000),
|
|
486
|
+
tool=event.tool,
|
|
487
|
+
result=str(e),
|
|
488
|
+
success=False,
|
|
489
|
+
duration_ms=int((time.monotonic() - tool_start) * 1000),
|
|
490
|
+
)
|
|
491
|
+
yield tool_result_event
|
|
492
|
+
|
|
493
|
+
if self._hook_manager.has_hooks:
|
|
494
|
+
await self._hook_manager.run_post_hooks(PostToolContext(
|
|
495
|
+
tool=event.tool,
|
|
496
|
+
args=event.args,
|
|
497
|
+
result=str(e),
|
|
498
|
+
is_error=True,
|
|
499
|
+
duration_ms=int((time.monotonic() - tool_start) * 1000),
|
|
500
|
+
run_id=f"run_{int(start_time)}",
|
|
501
|
+
turn_index=0,
|
|
502
|
+
session_id=self._session_id,
|
|
503
|
+
timestamp=tool_result_event.timestamp,
|
|
504
|
+
))
|
|
505
|
+
continue
|
|
506
|
+
|
|
507
|
+
yield event
|
|
508
|
+
|
|
509
|
+
# Final completion
|
|
510
|
+
elapsed_ms = int((time.monotonic() - start_time) * 1000)
|
|
511
|
+
yield CompleteEvent(
|
|
512
|
+
timestamp=int(time.time() * 1000),
|
|
513
|
+
summary=summary,
|
|
514
|
+
files_changed=files_changed,
|
|
515
|
+
tokens=tokens,
|
|
516
|
+
cost=cost,
|
|
517
|
+
duration_ms=elapsed_ms,
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
except Exception as e:
|
|
521
|
+
yield ErrorEvent(
|
|
522
|
+
timestamp=int(time.time() * 1000),
|
|
523
|
+
code="CONNECTION_ERROR",
|
|
524
|
+
message=str(e),
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
# ── Goal Mode ────────────────────────────────────────────────────
|
|
528
|
+
|
|
529
|
+
async def goal(
|
|
530
|
+
self,
|
|
531
|
+
prompt: str,
|
|
532
|
+
*,
|
|
533
|
+
verify_command: str | None = None,
|
|
534
|
+
max_iterations: int = 10,
|
|
535
|
+
timeout: int = 300_000,
|
|
536
|
+
max_cost: float | None = None,
|
|
537
|
+
) -> GoalResult:
|
|
538
|
+
"""
|
|
539
|
+
Run the agent in goal mode — iterates until the goal is met.
|
|
540
|
+
|
|
541
|
+
Args:
|
|
542
|
+
prompt: The goal to achieve.
|
|
543
|
+
verify_command: Command to verify success (e.g., "pytest").
|
|
544
|
+
max_iterations: Maximum iterations.
|
|
545
|
+
timeout: Timeout in ms.
|
|
546
|
+
max_cost: Maximum cost in USD.
|
|
547
|
+
|
|
548
|
+
Returns:
|
|
549
|
+
GoalResult with success status and iteration details.
|
|
550
|
+
"""
|
|
551
|
+
start_time = time.monotonic()
|
|
552
|
+
iteration_summaries: list[str] = []
|
|
553
|
+
last_result: AgentResult | None = None
|
|
554
|
+
goal_met = False
|
|
555
|
+
|
|
556
|
+
goal_prompt = prompt
|
|
557
|
+
if verify_command:
|
|
558
|
+
goal_prompt = (
|
|
559
|
+
f"{prompt}\n\nVerification: Run `{verify_command}` after each attempt. "
|
|
560
|
+
"Keep iterating until it passes."
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
for iteration in range(1, max_iterations + 1):
|
|
564
|
+
elapsed = (time.monotonic() - start_time) * 1000
|
|
565
|
+
if elapsed > timeout:
|
|
566
|
+
break
|
|
567
|
+
if max_cost and self._total_cost >= max_cost:
|
|
568
|
+
break
|
|
569
|
+
|
|
570
|
+
if iteration == 1:
|
|
571
|
+
iter_prompt = goal_prompt
|
|
572
|
+
else:
|
|
573
|
+
prev = last_result.summary if last_result else 'unknown'
|
|
574
|
+
iter_prompt = f"Continue working on the goal. Previous: {prev}"
|
|
575
|
+
|
|
576
|
+
last_result = await self.run(iter_prompt)
|
|
577
|
+
iteration_summaries.append(last_result.summary)
|
|
578
|
+
|
|
579
|
+
if last_result.success and last_result.exit_code == 0:
|
|
580
|
+
goal_met = True
|
|
581
|
+
break
|
|
582
|
+
|
|
583
|
+
return GoalResult(
|
|
584
|
+
success=last_result.success if last_result else False,
|
|
585
|
+
summary=last_result.summary if last_result else "No iterations completed",
|
|
586
|
+
files_changed=last_result.files_changed if last_result else [],
|
|
587
|
+
tokens=self.total_tokens,
|
|
588
|
+
cost=self._total_cost,
|
|
589
|
+
duration_ms=int((time.monotonic() - start_time) * 1000),
|
|
590
|
+
session_id=self._session_id or "",
|
|
591
|
+
exit_code=0 if goal_met else 1,
|
|
592
|
+
model=self._model,
|
|
593
|
+
goal_met=goal_met,
|
|
594
|
+
iterations=len(iteration_summaries),
|
|
595
|
+
iteration_summaries=iteration_summaries,
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
# ── Lifecycle ────────────────────────────────────────────────────
|
|
599
|
+
|
|
600
|
+
def close(self) -> None:
|
|
601
|
+
"""Close the agent. No further runs can be made."""
|
|
602
|
+
self._closed = True
|
|
603
|
+
|
|
604
|
+
async def __aenter__(self) -> MonkeysCode:
|
|
605
|
+
return self
|
|
606
|
+
|
|
607
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
608
|
+
self.close()
|
|
609
|
+
|
|
610
|
+
# ── Private ──────────────────────────────────────────────────────
|
|
611
|
+
|
|
612
|
+
def _assert_not_closed(self) -> None:
|
|
613
|
+
if self._closed:
|
|
614
|
+
raise RuntimeError("Agent is closed")
|
|
615
|
+
|
|
616
|
+
def _map_sse_event(
|
|
617
|
+
self,
|
|
618
|
+
data: dict[str, Any],
|
|
619
|
+
files_changed: list[FileChange],
|
|
620
|
+
) -> AgentEvent | None:
|
|
621
|
+
"""Map a raw SSE JSON object to a typed event."""
|
|
622
|
+
event_type = data.get("type") or data.get("kind") or ""
|
|
623
|
+
ts = data.get("timestamp") or int(time.time() * 1000)
|
|
624
|
+
|
|
625
|
+
if event_type in ("text", "run.token", "chunk", "streaming_chunk"):
|
|
626
|
+
return TextEvent(
|
|
627
|
+
timestamp=ts,
|
|
628
|
+
content=data.get("content") or data.get("data") or "",
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
if event_type == "thinking":
|
|
632
|
+
return ThinkingEvent(
|
|
633
|
+
timestamp=ts,
|
|
634
|
+
content=data.get("content") or "",
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
if event_type in ("tool_call", "run.tool_call", "tool_call_start"):
|
|
638
|
+
return ToolCallEvent(
|
|
639
|
+
timestamp=ts,
|
|
640
|
+
tool=data.get("tool") or data.get("name") or "",
|
|
641
|
+
args=data.get("args") or data.get("input") or {},
|
|
642
|
+
summary=data.get("summary") or "",
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
if event_type in ("tool_result", "run.tool_result", "tool_call_complete"):
|
|
646
|
+
return ToolResultEvent(
|
|
647
|
+
timestamp=ts,
|
|
648
|
+
tool=data.get("tool") or data.get("name") or "",
|
|
649
|
+
result=str(data.get("result") or data.get("output") or ""),
|
|
650
|
+
success=data.get("success", True),
|
|
651
|
+
duration_ms=data.get("durationMs") or data.get("duration") or 0,
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
if event_type in ("file_edit", "run.diff"):
|
|
655
|
+
fc = FileChange(
|
|
656
|
+
path=data.get("path") or "",
|
|
657
|
+
action="modified",
|
|
658
|
+
diff=data.get("diff") or "",
|
|
659
|
+
lines_added=data.get("linesAdded") or 0,
|
|
660
|
+
lines_removed=data.get("linesRemoved") or 0,
|
|
661
|
+
)
|
|
662
|
+
files_changed.append(fc)
|
|
663
|
+
return FileEditEvent(
|
|
664
|
+
timestamp=ts,
|
|
665
|
+
path=fc.path,
|
|
666
|
+
diff=fc.diff or "",
|
|
667
|
+
lines_added=fc.lines_added,
|
|
668
|
+
lines_removed=fc.lines_removed,
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
if event_type == "file_read":
|
|
672
|
+
return FileReadEvent(
|
|
673
|
+
timestamp=ts,
|
|
674
|
+
path=data.get("path") or "",
|
|
675
|
+
lines=data.get("lines") or 0,
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
if event_type == "terminal_command":
|
|
679
|
+
return TerminalCommandEvent(
|
|
680
|
+
timestamp=ts,
|
|
681
|
+
command=data.get("command") or "",
|
|
682
|
+
exit_code=data.get("exitCode") or 0,
|
|
683
|
+
output=data.get("output") or "",
|
|
684
|
+
)
|
|
685
|
+
|
|
686
|
+
if event_type == "search_result":
|
|
687
|
+
return SearchResultEvent(
|
|
688
|
+
timestamp=ts,
|
|
689
|
+
query=data.get("query") or "",
|
|
690
|
+
matches=data.get("matches") or [],
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
# Daemon events
|
|
694
|
+
if event_type in ("plan_ready", "run.plan_ready"):
|
|
695
|
+
raw_steps = data.get("steps") or []
|
|
696
|
+
return PlanReadyEvent(
|
|
697
|
+
timestamp=ts,
|
|
698
|
+
steps=[PlanStep(**s) if isinstance(s, dict) else s for s in raw_steps],
|
|
699
|
+
summary=data.get("summary") or "",
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
if event_type in ("cost_update", "run.cost"):
|
|
703
|
+
return CostUpdateEvent(
|
|
704
|
+
timestamp=ts,
|
|
705
|
+
tokens_in=data.get("tokens_in") or data.get("tokensIn") or 0,
|
|
706
|
+
tokens_out=data.get("tokens_out") or data.get("tokensOut") or 0,
|
|
707
|
+
tokens_thinking=data.get("tokens_thinking") or data.get("tokensThinking") or 0,
|
|
708
|
+
cost=data.get("usd") or data.get("cost") or 0.0,
|
|
709
|
+
)
|
|
710
|
+
|
|
711
|
+
if event_type in ("agent_spawned", "run.agent_spawned"):
|
|
712
|
+
return AgentSpawnedEvent(
|
|
713
|
+
timestamp=ts,
|
|
714
|
+
agent_id=data.get("agentId") or data.get("agent_id") or "",
|
|
715
|
+
task=data.get("task") or "",
|
|
716
|
+
)
|
|
717
|
+
|
|
718
|
+
if event_type in ("agent_complete", "run.agent_complete"):
|
|
719
|
+
return AgentCompleteEvent(
|
|
720
|
+
timestamp=ts,
|
|
721
|
+
agent_id=data.get("agentId") or data.get("agent_id") or "",
|
|
722
|
+
success=data.get("success", True),
|
|
723
|
+
summary=data.get("summary") or "",
|
|
724
|
+
)
|
|
725
|
+
|
|
726
|
+
if event_type in ("fan_out_progress", "run.fan_out_progress"):
|
|
727
|
+
return FanOutProgressEvent(
|
|
728
|
+
timestamp=ts,
|
|
729
|
+
total=data.get("total") or 0,
|
|
730
|
+
completed=data.get("completed") or 0,
|
|
731
|
+
running=data.get("running") or 0,
|
|
732
|
+
)
|
|
733
|
+
|
|
734
|
+
if event_type in ("verification", "run.verification"):
|
|
735
|
+
return VerificationEvent(
|
|
736
|
+
timestamp=ts,
|
|
737
|
+
command=data.get("command") or "",
|
|
738
|
+
passed=data.get("passed", False),
|
|
739
|
+
output=data.get("output") or "",
|
|
740
|
+
)
|
|
741
|
+
|
|
742
|
+
if event_type in ("suggestion", "run.suggestion"):
|
|
743
|
+
return SuggestionEvent(
|
|
744
|
+
timestamp=ts,
|
|
745
|
+
suggestions=data.get("suggestions") or [],
|
|
746
|
+
)
|
|
747
|
+
|
|
748
|
+
if event_type == "error":
|
|
749
|
+
return ErrorEvent(
|
|
750
|
+
timestamp=ts,
|
|
751
|
+
code=data.get("code") or "UNKNOWN",
|
|
752
|
+
message=data.get("message") or "",
|
|
753
|
+
)
|
|
754
|
+
|
|
755
|
+
if event_type in ("complete", "run.complete", "done"):
|
|
756
|
+
tokens_data = data.get("tokens") or {}
|
|
757
|
+
return CompleteEvent(
|
|
758
|
+
timestamp=ts,
|
|
759
|
+
summary=data.get("summary") or "",
|
|
760
|
+
files_changed=files_changed.copy(),
|
|
761
|
+
tokens=TokenUsage(
|
|
762
|
+
input=tokens_data.get("input") or 0,
|
|
763
|
+
output=tokens_data.get("output") or 0,
|
|
764
|
+
thinking=tokens_data.get("thinking") or 0,
|
|
765
|
+
total=tokens_data.get("total") or 0,
|
|
766
|
+
),
|
|
767
|
+
cost=data.get("cost") or 0.0,
|
|
768
|
+
duration_ms=data.get("durationMs") or data.get("duration") or 0,
|
|
769
|
+
)
|
|
770
|
+
|
|
771
|
+
return None
|
|
772
|
+
|