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/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
+