mycode-sdk 0.5.8__tar.gz → 0.7.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mycode-sdk
3
- Version: 0.5.8
3
+ Version: 0.7.0
4
4
  Summary: Lightweight Python SDK for building AI agents.
5
5
  Project-URL: Homepage, https://github.com/legibet/mycode
6
6
  Project-URL: Repository, https://github.com/legibet/mycode
@@ -139,4 +139,32 @@ def summarize_file(ctx: ToolContext, path: str) -> str:
139
139
  return result.output.splitlines()[0] if result.output else ""
140
140
  ```
141
141
 
142
+ ## Tool hooks
143
+
144
+ Inspect or replace tool calls before they run. Return `None` from `before_tool` to let the tool execute, or a `ToolExecutionResult` to skip it:
145
+
146
+ ```python
147
+ from mycode import Agent, Hooks, ToolExecutionResult, bash_tool
148
+
149
+ hooks = Hooks()
150
+
151
+
152
+ @hooks.before_tool
153
+ async def block_rm(ctx):
154
+ cmd = str(ctx.tool_input.get("command") or "")
155
+ if ctx.tool_name == "bash" and "rm -rf" in cmd:
156
+ return ToolExecutionResult(output="error: blocked", is_error=True)
157
+ return None
158
+
159
+
160
+ agent = Agent(
161
+ model="claude-sonnet-4-6",
162
+ api_key="...",
163
+ tools=[bash_tool],
164
+ hooks=hooks,
165
+ )
166
+ ```
167
+
168
+ `@hooks.after_tool` runs after the tool and can replace the result (audit, redact, etc.).
169
+
142
170
  See [docs/sdk.md](../docs/sdk.md) for the event stream, cancellation, sessions, and the full `Agent` / `@tool` reference.
@@ -114,4 +114,32 @@ def summarize_file(ctx: ToolContext, path: str) -> str:
114
114
  return result.output.splitlines()[0] if result.output else ""
115
115
  ```
116
116
 
117
+ ## Tool hooks
118
+
119
+ Inspect or replace tool calls before they run. Return `None` from `before_tool` to let the tool execute, or a `ToolExecutionResult` to skip it:
120
+
121
+ ```python
122
+ from mycode import Agent, Hooks, ToolExecutionResult, bash_tool
123
+
124
+ hooks = Hooks()
125
+
126
+
127
+ @hooks.before_tool
128
+ async def block_rm(ctx):
129
+ cmd = str(ctx.tool_input.get("command") or "")
130
+ if ctx.tool_name == "bash" and "rm -rf" in cmd:
131
+ return ToolExecutionResult(output="error: blocked", is_error=True)
132
+ return None
133
+
134
+
135
+ agent = Agent(
136
+ model="claude-sonnet-4-6",
137
+ api_key="...",
138
+ tools=[bash_tool],
139
+ hooks=hooks,
140
+ )
141
+ ```
142
+
143
+ `@hooks.after_tool` runs after the tool and can replace the result (audit, redact, etc.).
144
+
117
145
  See [docs/sdk.md](../docs/sdk.md) for the event stream, cancellation, sessions, and the full `Agent` / `@tool` reference.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "mycode-sdk"
7
- version = "0.5.8"
7
+ version = "0.7.0"
8
8
  description = "Lightweight Python SDK for building AI agents."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -10,6 +10,7 @@ silently exposing file system and shell access.
10
10
  from importlib import metadata
11
11
 
12
12
  from mycode.agent import Agent, Event, PersistCallback, RunResult
13
+ from mycode.hooks import AfterToolHook, BeforeToolHook, HookResult, Hooks, ToolHookContext
13
14
  from mycode.messages import (
14
15
  ContentBlock,
15
16
  ConversationMessage,
@@ -49,12 +50,17 @@ __all__ = [
49
50
  "ConversationMessage",
50
51
  "DEFAULT_TOOL_SPECS",
51
52
  "Event",
53
+ "AfterToolHook",
54
+ "BeforeToolHook",
55
+ "HookResult",
56
+ "Hooks",
52
57
  "PersistCallback",
53
58
  "RunResult",
54
59
  "SessionStore",
55
60
  "ToolContext",
56
61
  "ToolExecutionResult",
57
62
  "ToolExecutor",
63
+ "ToolHookContext",
58
64
  "ToolSpec",
59
65
  "__version__",
60
66
  "assistant_message",
@@ -17,6 +17,7 @@ from pathlib import Path
17
17
  from typing import Any, cast
18
18
  from uuid import uuid4
19
19
 
20
+ from mycode.hooks import Hooks, ToolHookContext
20
21
  from mycode.messages import (
21
22
  ConversationMessage,
22
23
  build_message,
@@ -83,6 +84,7 @@ class Agent:
83
84
  supports_pdf_input: bool | None = None,
84
85
  system: str = "",
85
86
  tools: Sequence[ToolSpec] = (),
87
+ hooks: Hooks | None = None,
86
88
  ):
87
89
  self.model = model
88
90
  if provider is None:
@@ -109,6 +111,7 @@ class Agent:
109
111
  self.reasoning_effort = reasoning_effort
110
112
 
111
113
  self.system = system
114
+ self.hooks = hooks or Hooks()
112
115
  self._cancel_event = asyncio.Event()
113
116
  self._provider_event_task: asyncio.Future[ProviderStreamEvent] | None = None
114
117
 
@@ -210,6 +213,8 @@ class Agent:
210
213
  raw_args = tool_use.get("input")
211
214
  args = raw_args if isinstance(raw_args, dict) else {}
212
215
 
216
+ # Surface the tool to the UI before running hooks so the call is
217
+ # always visible — even when a hook is awaiting a permission decision.
213
218
  yield Event("tool_start", {"tool_call": {"id": tool_id, "name": name, "input": args}})
214
219
 
215
220
  if self._cancel_event.is_set():
@@ -227,8 +232,38 @@ class Agent:
227
232
  )
228
233
  return
229
234
 
235
+ hook_ctx = ToolHookContext(
236
+ session_id=self.session_id,
237
+ cwd=self.cwd,
238
+ provider=self.provider,
239
+ model=self.model,
240
+ tool_call_id=tool_id,
241
+ tool_name=name,
242
+ tool_input=args,
243
+ tool=spec,
244
+ )
245
+ try:
246
+ result = await self.hooks.run_before_tool(hook_ctx)
247
+ except Exception as exc:
248
+ yield self._tool_done_event(
249
+ tool_id,
250
+ ToolExecutionResult(output=f"error: tool hook failed: {exc}", is_error=True),
251
+ )
252
+ return
253
+
254
+ if result is not None:
255
+ yield await self._finish_tool_call(tool_id, hook_ctx, result)
256
+ return
257
+
258
+ if self._cancel_event.is_set():
259
+ yield self._tool_done_event(
260
+ tool_id,
261
+ ToolExecutionResult(output="error: cancelled", is_error=True),
262
+ )
263
+ return
264
+
230
265
  if spec.streams_output:
231
- async for event in self._run_streaming_tool(tool_id=tool_id, name=name, args=args):
266
+ async for event in self._run_streaming_tool(tool_id=tool_id, name=name, args=args, hook_ctx=hook_ctx):
232
267
  yield event
233
268
  return
234
269
 
@@ -238,7 +273,7 @@ class Agent:
238
273
  except Exception as exc: # pragma: no cover - defensive
239
274
  result = ToolExecutionResult(output=f"error: {exc}", is_error=True)
240
275
 
241
- yield self._tool_done_event(tool_id, result)
276
+ yield await self._finish_tool_call(tool_id, hook_ctx, result)
242
277
 
243
278
  async def _run_streaming_tool(
244
279
  self,
@@ -246,6 +281,7 @@ class Agent:
246
281
  tool_id: str,
247
282
  name: str,
248
283
  args: dict[str, Any],
284
+ hook_ctx: ToolHookContext,
249
285
  ) -> AsyncIterator[Event]:
250
286
  """Run one streaming tool, forwarding ``tool_output`` events live."""
251
287
 
@@ -289,13 +325,31 @@ class Agent:
289
325
  except Exception:
290
326
  pass
291
327
  result = ToolExecutionResult(output="error: cancelled", is_error=True)
328
+ yield self._tool_done_event(tool_id, result)
329
+ return
292
330
  else:
293
331
  try:
294
332
  result = await task
295
333
  except Exception as exc: # pragma: no cover - defensive
296
334
  result = ToolExecutionResult(output=f"error: {exc}", is_error=True)
297
335
 
298
- yield self._tool_done_event(tool_id, result)
336
+ yield await self._finish_tool_call(tool_id, hook_ctx, result)
337
+
338
+ async def _finish_tool_call(
339
+ self,
340
+ tool_id: str,
341
+ hook_ctx: ToolHookContext,
342
+ result: ToolExecutionResult,
343
+ ) -> Event:
344
+ try:
345
+ result = await self.hooks.run_after_tool(hook_ctx, result)
346
+ except Exception:
347
+ logger.exception(
348
+ "after_tool hook failed for %s (call %s)",
349
+ hook_ctx.tool_name,
350
+ hook_ctx.tool_call_id,
351
+ )
352
+ return self._tool_done_event(tool_id, result)
299
353
 
300
354
  def _ctx_for_call(
301
355
  self,
@@ -530,7 +584,7 @@ class Agent:
530
584
  )
531
585
  )
532
586
 
533
- if output == "error: cancelled" and self._cancel_event.is_set():
587
+ if self._cancel_event.is_set():
534
588
  tool_result_message = build_message("user", tool_results)
535
589
  self.messages.append(tool_result_message)
536
590
  await persist(tool_result_message)
@@ -0,0 +1,97 @@
1
+ """Tool execution hooks for the agent runtime."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ from collections.abc import Awaitable, Callable, Mapping, Sequence
7
+ from dataclasses import dataclass
8
+ from types import MappingProxyType
9
+ from typing import Any
10
+
11
+ from mycode.tools import ToolExecutionResult, ToolSpec
12
+
13
+ type HookResult = ToolExecutionResult | None
14
+ type BeforeToolHook = Callable[["ToolHookContext"], HookResult | Awaitable[HookResult]]
15
+ type AfterToolHook = Callable[["ToolHookContext", ToolExecutionResult], HookResult | Awaitable[HookResult]]
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class ToolHookContext:
20
+ """Read-only context passed to tool execution hooks."""
21
+
22
+ session_id: str
23
+ cwd: str
24
+ provider: str
25
+ model: str
26
+ tool_call_id: str
27
+ tool_name: str
28
+ tool_input: Mapping[str, Any]
29
+ tool: ToolSpec
30
+
31
+ def __post_init__(self) -> None:
32
+ object.__setattr__(self, "tool_input", _freeze(self.tool_input))
33
+
34
+
35
+ class Hooks:
36
+ """Ordered callbacks around model-requested tool execution."""
37
+
38
+ def __init__(
39
+ self,
40
+ *,
41
+ before_tool: Sequence[BeforeToolHook] = (),
42
+ after_tool: Sequence[AfterToolHook] = (),
43
+ ) -> None:
44
+ self._before_tool = list(before_tool)
45
+ self._after_tool = list(after_tool)
46
+
47
+ def before_tool(self, hook: BeforeToolHook) -> BeforeToolHook:
48
+ """Register a hook that can return a tool result before execution."""
49
+
50
+ self._before_tool.append(hook)
51
+ return hook
52
+
53
+ def after_tool(self, hook: AfterToolHook) -> AfterToolHook:
54
+ """Register a hook that can observe or replace a tool result."""
55
+
56
+ self._after_tool.append(hook)
57
+ return hook
58
+
59
+ async def run_before_tool(self, ctx: ToolHookContext) -> ToolExecutionResult | None:
60
+ for hook in self._before_tool:
61
+ result = await _resolve(hook(ctx))
62
+ if result is not None:
63
+ return result
64
+ return None
65
+
66
+ async def run_after_tool(self, ctx: ToolHookContext, result: ToolExecutionResult) -> ToolExecutionResult:
67
+ for hook in self._after_tool:
68
+ replacement = await _resolve(hook(ctx, result))
69
+ if replacement is not None:
70
+ result = replacement
71
+ return result
72
+
73
+
74
+ async def _resolve(value: object) -> HookResult:
75
+ if inspect.isawaitable(value):
76
+ value = await value
77
+ if value is None or isinstance(value, ToolExecutionResult):
78
+ return value
79
+ raise TypeError(f"tool hook returned unsupported value: {type(value).__name__}")
80
+
81
+
82
+ def _freeze(value: Any) -> Any:
83
+ """Recursively wrap mappings in MappingProxyType and lists/tuples in tuples."""
84
+ if isinstance(value, Mapping):
85
+ return MappingProxyType({str(k): _freeze(v) for k, v in value.items()})
86
+ if isinstance(value, (list, tuple)):
87
+ return tuple(_freeze(item) for item in value)
88
+ return value
89
+
90
+
91
+ __all__ = [
92
+ "AfterToolHook",
93
+ "BeforeToolHook",
94
+ "HookResult",
95
+ "Hooks",
96
+ "ToolHookContext",
97
+ ]
@@ -177,6 +177,13 @@
177
177
  "supports_pdf_input": false,
178
178
  "supports_reasoning": true
179
179
  },
180
+ "deepseek-v4-flash": {
181
+ "context_window": 1000000,
182
+ "max_output_tokens": 384000,
183
+ "supports_image_input": false,
184
+ "supports_pdf_input": false,
185
+ "supports_reasoning": true
186
+ },
180
187
  "deepseek-v4-pro": {
181
188
  "context_window": 1000000,
182
189
  "max_output_tokens": 384000,
@@ -18,7 +18,7 @@ from datetime import UTC, datetime
18
18
  from pathlib import Path
19
19
  from typing import Any, TypedDict, cast
20
20
 
21
- from mycode.messages import ConversationMessage, build_message, flatten_message_text, text_block, tool_result_block
21
+ from mycode.messages import ConversationMessage, build_message, flatten_message_text, text_block
22
22
 
23
23
  # ---------------------------------------------------------------------
24
24
  # Session format and compacting defaults
@@ -315,10 +315,10 @@ class SessionStore:
315
315
  # Replay order defines the visible conversation state.
316
316
  # 1) compact rewrites older history into one summary view
317
317
  # 2) rewind truncates that visible list by message index
318
- # 3) interrupted tool repair patches the final visible state
318
+ # Orphan tool_use blocks (e.g. left open by a server crash) are
319
+ # closed by the provider adapter at replay time, not here.
319
320
  visible_messages = apply_compact(raw_messages)
320
321
  visible_messages = apply_rewind(visible_messages)
321
- self._repair_interrupted_tool_loop(session_id, meta, visible_messages)
322
322
 
323
323
  return {"session": self._summary(session_id, meta), "messages": visible_messages}
324
324
 
@@ -387,94 +387,3 @@ class SessionStore:
387
387
  """Return meta augmented with the session id for API responses."""
388
388
 
389
389
  return {"id": session_id, **meta}
390
-
391
- # ---------------------------------------------------------------------
392
- # Interrupted tool repair
393
- # ---------------------------------------------------------------------
394
-
395
- def _repair_interrupted_tool_loop(
396
- self,
397
- session_id: str,
398
- meta: SessionMetaDict,
399
- messages: list[ConversationMessage],
400
- ) -> None:
401
- """Append a synthetic tool result when the latest tool loop was interrupted.
402
-
403
- The runtime persists sessions as append-only JSONL. If a previous run was
404
- interrupted after an assistant emitted `tool_use` blocks but before a
405
- matching `tool_result` user message was written, repair the session by
406
- appending one synthetic error result message. ``meta`` is mutated in
407
- place so the caller's view stays consistent with the updated file.
408
- """
409
-
410
- pending_tool_use_ids: list[str] = []
411
- pending_tool_call_index: int | None = None
412
-
413
- # Find the latest assistant message that started a tool loop.
414
- for index in range(len(messages) - 1, -1, -1):
415
- message = messages[index]
416
- if message.get("role") != "assistant":
417
- continue
418
-
419
- blocks = message.get("content")
420
- if not isinstance(blocks, list):
421
- continue
422
-
423
- tool_use_ids = [
424
- str(block.get("id") or "")
425
- for block in blocks
426
- if isinstance(block, dict) and block.get("type") == "tool_use" and block.get("id")
427
- ]
428
- if not tool_use_ids:
429
- continue
430
-
431
- pending_tool_use_ids = tool_use_ids
432
- pending_tool_call_index = index
433
- break
434
-
435
- if pending_tool_call_index is None:
436
- return
437
-
438
- # Collect tool results that were recorded after the assistant message.
439
- completed_tool_use_ids: set[str] = set()
440
- for message in messages[pending_tool_call_index + 1 :]:
441
- if message.get("role") != "user":
442
- continue
443
-
444
- blocks = message.get("content")
445
- if not isinstance(blocks, list):
446
- continue
447
-
448
- for block in blocks:
449
- if not isinstance(block, dict) or block.get("type") != "tool_result":
450
- continue
451
- tool_use_id = str(block.get("tool_use_id") or "")
452
- if tool_use_id:
453
- completed_tool_use_ids.add(tool_use_id)
454
-
455
- missing_tool_use_ids = [
456
- tool_use_id for tool_use_id in pending_tool_use_ids if tool_use_id not in completed_tool_use_ids
457
- ]
458
- if not missing_tool_use_ids:
459
- return
460
-
461
- repair_message = build_message(
462
- "user",
463
- [
464
- tool_result_block(
465
- tool_use_id=tool_use_id,
466
- output="error: tool call was interrupted",
467
- is_error=True,
468
- )
469
- for tool_use_id in missing_tool_use_ids
470
- ],
471
- )
472
-
473
- with self.messages_path(session_id).open("a", encoding="utf-8") as handle:
474
- handle.write(json.dumps(repair_message, ensure_ascii=False))
475
- handle.write("\n")
476
-
477
- meta["updated_at"] = _now()
478
- self._write_meta(session_id, meta)
479
-
480
- messages.append(repair_message)
File without changes
File without changes