mycode-sdk 0.10.0__tar.gz → 0.11.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.
Files changed (23) hide show
  1. {mycode_sdk-0.10.0 → mycode_sdk-0.11.0}/PKG-INFO +5 -3
  2. {mycode_sdk-0.10.0 → mycode_sdk-0.11.0}/README.md +4 -2
  3. {mycode_sdk-0.10.0 → mycode_sdk-0.11.0}/pyproject.toml +1 -1
  4. {mycode_sdk-0.10.0 → mycode_sdk-0.11.0}/src/mycode/agent.py +53 -8
  5. {mycode_sdk-0.10.0 → mycode_sdk-0.11.0}/src/mycode/tools.py +85 -11
  6. {mycode_sdk-0.10.0 → mycode_sdk-0.11.0}/.gitignore +0 -0
  7. {mycode_sdk-0.10.0 → mycode_sdk-0.11.0}/LICENSE +0 -0
  8. {mycode_sdk-0.10.0 → mycode_sdk-0.11.0}/src/mycode/__init__.py +0 -0
  9. {mycode_sdk-0.10.0 → mycode_sdk-0.11.0}/src/mycode/attachments.py +0 -0
  10. {mycode_sdk-0.10.0 → mycode_sdk-0.11.0}/src/mycode/compact.py +0 -0
  11. {mycode_sdk-0.10.0 → mycode_sdk-0.11.0}/src/mycode/hooks.py +0 -0
  12. {mycode_sdk-0.10.0 → mycode_sdk-0.11.0}/src/mycode/messages.py +0 -0
  13. {mycode_sdk-0.10.0 → mycode_sdk-0.11.0}/src/mycode/models.py +0 -0
  14. {mycode_sdk-0.10.0 → mycode_sdk-0.11.0}/src/mycode/models_catalog.json +0 -0
  15. {mycode_sdk-0.10.0 → mycode_sdk-0.11.0}/src/mycode/providers/__init__.py +0 -0
  16. {mycode_sdk-0.10.0 → mycode_sdk-0.11.0}/src/mycode/providers/anthropic_like.py +0 -0
  17. {mycode_sdk-0.10.0 → mycode_sdk-0.11.0}/src/mycode/providers/base.py +0 -0
  18. {mycode_sdk-0.10.0 → mycode_sdk-0.11.0}/src/mycode/providers/gemini.py +0 -0
  19. {mycode_sdk-0.10.0 → mycode_sdk-0.11.0}/src/mycode/providers/openai_chat.py +0 -0
  20. {mycode_sdk-0.10.0 → mycode_sdk-0.11.0}/src/mycode/providers/openai_responses.py +0 -0
  21. {mycode_sdk-0.10.0 → mycode_sdk-0.11.0}/src/mycode/py.typed +0 -0
  22. {mycode_sdk-0.10.0 → mycode_sdk-0.11.0}/src/mycode/session.py +0 -0
  23. {mycode_sdk-0.10.0 → mycode_sdk-0.11.0}/src/mycode/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mycode-sdk
3
- Version: 0.10.0
3
+ Version: 0.11.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
@@ -71,7 +71,7 @@ print(result.text)
71
71
 
72
72
  ## Multi-turn conversations
73
73
 
74
- Call `achat()` or `run()` again on the same `Agent` — history accumulates automatically:
74
+ Call `achat()` or `run()` again on the same `Agent` to continue the conversation:
75
75
 
76
76
  ```python
77
77
  agent = Agent(model="claude-sonnet-4-6", api_key="...")
@@ -117,7 +117,7 @@ agent = Agent(
117
117
  )
118
118
  ```
119
119
 
120
- Construct another `Agent` with the same `(session_dir, session_id)` later to resume the conversation — the history is loaded automatically.
120
+ Construct another `Agent` with the same `(session_dir, session_id)` to load the conversation history.
121
121
 
122
122
  ## Built-in tools
123
123
 
@@ -167,6 +167,8 @@ def summarize_file(ctx: ToolContext, path: str) -> str:
167
167
  return result.output.splitlines()[0] if result.output else ""
168
168
  ```
169
169
 
170
+ Async tools use `await ctx.aread()`, `await ctx.awrite()`, `await ctx.aedit()`, and `await ctx.abash()`. Use `await ctx.acall(name, args)` to call another registered tool by name.
171
+
170
172
  ## Tool hooks
171
173
 
172
174
  Inspect or replace tool calls before they run. Return `None` from `before_tool` to let the tool execute, or a `ToolExecutionResult` to skip it:
@@ -44,7 +44,7 @@ print(result.text)
44
44
 
45
45
  ## Multi-turn conversations
46
46
 
47
- Call `achat()` or `run()` again on the same `Agent` — history accumulates automatically:
47
+ Call `achat()` or `run()` again on the same `Agent` to continue the conversation:
48
48
 
49
49
  ```python
50
50
  agent = Agent(model="claude-sonnet-4-6", api_key="...")
@@ -90,7 +90,7 @@ agent = Agent(
90
90
  )
91
91
  ```
92
92
 
93
- Construct another `Agent` with the same `(session_dir, session_id)` later to resume the conversation — the history is loaded automatically.
93
+ Construct another `Agent` with the same `(session_dir, session_id)` to load the conversation history.
94
94
 
95
95
  ## Built-in tools
96
96
 
@@ -140,6 +140,8 @@ def summarize_file(ctx: ToolContext, path: str) -> str:
140
140
  return result.output.splitlines()[0] if result.output else ""
141
141
  ```
142
142
 
143
+ Async tools use `await ctx.aread()`, `await ctx.awrite()`, `await ctx.aedit()`, and `await ctx.abash()`. Use `await ctx.acall(name, args)` to call another registered tool by name.
144
+
143
145
  ## Tool hooks
144
146
 
145
147
  Inspect or replace tool calls before they run. Return `None` from `before_tool` to let the tool execute, or a `ToolExecutionResult` to skip it:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "mycode-sdk"
7
- version = "0.10.0"
7
+ version = "0.11.0"
8
8
  description = "Lightweight Python SDK for building AI agents."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -131,7 +131,9 @@ class Agent:
131
131
  self.system = system
132
132
  self.hooks = hooks or Hooks()
133
133
  self._cancel_event = asyncio.Event()
134
+ self._event_loop: asyncio.AbstractEventLoop | None = None
134
135
  self._provider_event_task: asyncio.Future[ProviderStreamEvent] | None = None
136
+ self._active_tool_task: asyncio.Task[ToolExecutionResult] | None = None
135
137
 
136
138
  # History resolution:
137
139
  # - messages is None → auto-resume from disk if the session exists
@@ -201,10 +203,24 @@ class Agent:
201
203
  def cancel(self) -> None:
202
204
  """Request cancellation of the in-flight turn."""
203
205
 
206
+ try:
207
+ running_loop = asyncio.get_running_loop()
208
+ except RuntimeError:
209
+ running_loop = None
210
+
211
+ loop = self._event_loop
212
+ if loop is not None and loop.is_running() and running_loop is not loop:
213
+ loop.call_soon_threadsafe(self._cancel_in_loop)
214
+ return
215
+ self._cancel_in_loop()
216
+
217
+ def _cancel_in_loop(self) -> None:
204
218
  self._cancel_event.set()
205
219
  self.tools.cancel_active()
206
220
  if self._provider_event_task and not self._provider_event_task.done():
207
221
  self._provider_event_task.cancel()
222
+ if self._active_tool_task and not self._active_tool_task.done():
223
+ self._active_tool_task.cancel()
208
224
 
209
225
  def clear(self) -> None:
210
226
  """Drop the in-memory conversation history."""
@@ -215,6 +231,23 @@ class Agent:
215
231
  # Tool execution
216
232
  # ------------------------------------------------------------------
217
233
 
234
+ async def _execute_tool(
235
+ self,
236
+ spec: ToolSpec,
237
+ args: dict[str, Any],
238
+ ctx: ToolContext,
239
+ ) -> ToolExecutionResult:
240
+ if not spec.is_async:
241
+ return await self.tools.aexecute(spec.name, args, ctx)
242
+
243
+ task = asyncio.create_task(self.tools.aexecute(spec.name, args, ctx))
244
+ self._active_tool_task = task
245
+ try:
246
+ return await task
247
+ finally:
248
+ if self._active_tool_task is task:
249
+ self._active_tool_task = None
250
+
218
251
  async def _run_tool_call(self, tool_use: dict[str, Any]) -> AsyncIterator[Event]:
219
252
  """Run one tool call and emit the standard tool events."""
220
253
 
@@ -261,13 +294,23 @@ class Agent:
261
294
  return
262
295
 
263
296
  if spec.streams_output:
264
- async for event in self._run_streaming_tool(tool_id=tool_id, name=name, args=args, hook_ctx=hook_ctx):
297
+ async for event in self._run_streaming_tool(
298
+ tool_id=tool_id,
299
+ spec=spec,
300
+ args=args,
301
+ hook_ctx=hook_ctx,
302
+ ):
265
303
  yield event
266
304
  return
267
305
 
268
306
  try:
269
307
  ctx = self._ctx_for_call(tool_id)
270
- result = await asyncio.to_thread(self.tools.execute, name, args, ctx)
308
+ result = await self._execute_tool(spec, args, ctx)
309
+ except asyncio.CancelledError:
310
+ if self._cancel_event.is_set():
311
+ yield self._error_done(tool_id, "error: cancelled")
312
+ return
313
+ raise
271
314
  except Exception as exc: # pragma: no cover - defensive
272
315
  result = ToolExecutionResult(output=f"error: {exc}", is_error=True)
273
316
 
@@ -277,7 +320,7 @@ class Agent:
277
320
  self,
278
321
  *,
279
322
  tool_id: str,
280
- name: str,
323
+ spec: ToolSpec,
281
324
  args: dict[str, Any],
282
325
  hook_ctx: ToolHookContext,
283
326
  ) -> AsyncIterator[Event]:
@@ -291,13 +334,13 @@ class Agent:
291
334
 
292
335
  ctx = self._ctx_for_call(tool_id, emit=on_output)
293
336
 
294
- async def run_in_thread() -> ToolExecutionResult:
337
+ async def run_tool() -> ToolExecutionResult:
295
338
  try:
296
- return await asyncio.to_thread(self.tools.execute, name, args, ctx)
339
+ return await self._execute_tool(spec, args, ctx)
297
340
  finally:
298
341
  loop.call_soon_threadsafe(output_queue.put_nowait, None)
299
342
 
300
- task = asyncio.create_task(run_in_thread())
343
+ task = asyncio.create_task(run_tool())
301
344
  was_cancelled = False
302
345
  output_parts: list[str] = []
303
346
 
@@ -319,8 +362,8 @@ class Agent:
319
362
  output_parts.append(output)
320
363
  yield Event("tool_output", {"tool_use_id": tool_id, "output": output})
321
364
 
322
- if was_cancelled:
323
- with suppress(Exception):
365
+ if was_cancelled or (self._cancel_event.is_set() and task.cancelled()):
366
+ with suppress(asyncio.CancelledError, Exception):
324
367
  await task
325
368
  output = "\n".join([*output_parts, "error: cancelled"]) if output_parts else "error: cancelled"
326
369
  yield self._error_done(tool_id, output)
@@ -490,6 +533,7 @@ class Agent:
490
533
  async def persist(message: ConversationMessage) -> None:
491
534
  await self._persist_message(message, on_persist)
492
535
 
536
+ self._event_loop = asyncio.get_running_loop()
493
537
  self._cancel_event.clear()
494
538
 
495
539
  user_message: ConversationMessage
@@ -742,6 +786,7 @@ class Agent:
742
786
  :meth:`cancel` stops the summary request.
743
787
  """
744
788
 
789
+ self._event_loop = asyncio.get_running_loop()
745
790
  self._cancel_event.clear()
746
791
  adapter = get_provider_adapter(self.provider)
747
792
  return await self._compact(adapter, on_persist)
@@ -13,6 +13,7 @@ facades on :class:`ToolContext`::
13
13
  from __future__ import annotations
14
14
 
15
15
  import asyncio
16
+ import functools
16
17
  import inspect
17
18
  import json
18
19
  import os
@@ -25,7 +26,7 @@ import time
25
26
  import typing
26
27
  from base64 import b64encode
27
28
  from collections import deque
28
- from collections.abc import Callable, Mapping, Sequence
29
+ from collections.abc import Callable, Coroutine, Mapping, Sequence
29
30
  from contextlib import suppress
30
31
  from dataclasses import dataclass
31
32
  from difflib import SequenceMatcher, unified_diff
@@ -71,7 +72,19 @@ class ToolExecutionResult:
71
72
  is_error: bool = False
72
73
 
73
74
 
74
- ToolRunner = Callable[["ToolContext", dict[str, Any]], ToolExecutionResult]
75
+ SyncToolRunner = Callable[["ToolContext", dict[str, Any]], ToolExecutionResult]
76
+ AsyncToolRunner = Callable[["ToolContext", dict[str, Any]], Coroutine[Any, Any, ToolExecutionResult]]
77
+ ToolRunner = SyncToolRunner | AsyncToolRunner
78
+
79
+
80
+ def _is_async_callable(runner: ToolRunner) -> bool:
81
+ current: Any = runner
82
+ while True:
83
+ current = inspect.unwrap(current)
84
+ if isinstance(current, functools.partial):
85
+ current = current.func
86
+ continue
87
+ return inspect.iscoroutinefunction(current) or inspect.iscoroutinefunction(type(current).__call__)
75
88
 
76
89
 
77
90
  @dataclass(frozen=True)
@@ -85,6 +98,10 @@ class ToolSpec:
85
98
  # Streaming tools push incremental output through ToolContext.emit.
86
99
  streams_output: bool = False
87
100
 
101
+ @property
102
+ def is_async(self) -> bool:
103
+ return _is_async_callable(self.runner)
104
+
88
105
 
89
106
  # ---------------------------------------------------------------------------
90
107
  # ToolContext
@@ -115,20 +132,48 @@ class ToolContext:
115
132
  ) -> ToolExecutionResult:
116
133
  return self.call("read", {"path": path, "offset": offset, "limit": limit})
117
134
 
135
+ async def aread(
136
+ self,
137
+ path: str,
138
+ *,
139
+ offset: int | None = None,
140
+ limit: int | None = None,
141
+ ) -> ToolExecutionResult:
142
+ return await self.acall("read", {"path": path, "offset": offset, "limit": limit})
143
+
118
144
  def write(self, path: str, content: str) -> ToolExecutionResult:
119
145
  return self.call("write", {"path": path, "content": content})
120
146
 
147
+ async def awrite(self, path: str, content: str) -> ToolExecutionResult:
148
+ return await self.acall("write", {"path": path, "content": content})
149
+
121
150
  def edit(self, path: str, edits: list[dict[str, str]]) -> ToolExecutionResult:
122
151
  return self.call("edit", {"path": path, "edits": edits})
123
152
 
153
+ async def aedit(self, path: str, edits: list[dict[str, str]]) -> ToolExecutionResult:
154
+ return await self.acall("edit", {"path": path, "edits": edits})
155
+
124
156
  def bash(self, command: str, *, timeout: int | None = None) -> ToolExecutionResult:
125
157
  return self.call("bash", {"command": command, "timeout": timeout})
126
158
 
159
+ async def abash(
160
+ self,
161
+ command: str,
162
+ *,
163
+ timeout: int | None = None, # noqa: ASYNC109
164
+ ) -> ToolExecutionResult:
165
+ return await self.acall("bash", {"command": command, "timeout": timeout})
166
+
127
167
  def call(self, name: str, args: dict[str, Any]) -> ToolExecutionResult:
128
168
  """Dispatch through the registry, including from custom tool wrappers."""
129
169
 
130
170
  return self.executor.execute(name, args, self)
131
171
 
172
+ async def acall(self, name: str, args: dict[str, Any]) -> ToolExecutionResult:
173
+ """Asynchronously dispatch through the registry."""
174
+
175
+ return await self.executor.aexecute(name, args, self)
176
+
132
177
 
133
178
  # ---------------------------------------------------------------------------
134
179
  # ToolExecutor
@@ -160,7 +205,21 @@ class ToolExecutor:
160
205
  spec = self._tools.get(name)
161
206
  if spec is None:
162
207
  return ToolExecutionResult(output=f"error: unknown tool: {name}", is_error=True)
163
- return spec.runner(ctx, args)
208
+ if spec.is_async:
209
+ runner = cast(AsyncToolRunner, spec.runner)
210
+ return asyncio.run(runner(ctx, args))
211
+ runner = cast(SyncToolRunner, spec.runner)
212
+ return runner(ctx, args)
213
+
214
+ async def aexecute(self, name: str, args: dict[str, Any], ctx: ToolContext) -> ToolExecutionResult:
215
+ spec = self._tools.get(name)
216
+ if spec is None:
217
+ return ToolExecutionResult(output=f"error: unknown tool: {name}", is_error=True)
218
+ if spec.is_async:
219
+ runner = cast(AsyncToolRunner, spec.runner)
220
+ return await runner(ctx, args)
221
+ runner = cast(SyncToolRunner, spec.runner)
222
+ return await asyncio.to_thread(runner, ctx, args)
164
223
 
165
224
  # Subprocess tracking: register with both this executor (per-session
166
225
  # cancel) and the process-global set (shutdown cleanup).
@@ -319,7 +378,7 @@ def tool(
319
378
 
320
379
  is_async = inspect.iscoroutinefunction(fn)
321
380
 
322
- def runner(context: ToolContext, args: dict[str, Any]) -> ToolExecutionResult:
381
+ def prepare_args(args: dict[str, Any]) -> dict[str, Any] | ToolExecutionResult:
323
382
  # Strict providers send explicit null for an omitted optional field;
324
383
  # drop it so the parameter default applies instead of failing validation.
325
384
  validation_args = {
@@ -332,14 +391,29 @@ def tool(
332
391
  except ValidationError as exc:
333
392
  return ToolExecutionResult(output=f"error: invalid tool input: {exc}", is_error=True)
334
393
 
335
- call_args = {name: getattr(parsed_args, name) for name in tool_param_names}
336
- if is_async:
337
- # The executor runs on a worker thread, so spinning a fresh
338
- # event loop here is safe.
339
- value = asyncio.run(fn(context, **call_args) if wants_context else fn(**call_args))
340
- else:
394
+ return {name: getattr(parsed_args, name) for name in tool_param_names}
395
+
396
+ if is_async:
397
+
398
+ async def async_runner(context: ToolContext, args: dict[str, Any]) -> ToolExecutionResult:
399
+ call_args = prepare_args(args)
400
+ if isinstance(call_args, ToolExecutionResult):
401
+ return call_args
402
+ value = await (fn(context, **call_args) if wants_context else fn(**call_args))
403
+ return _coerce_tool_result(value)
404
+
405
+ runner: ToolRunner = async_runner
406
+
407
+ else:
408
+
409
+ def sync_runner(context: ToolContext, args: dict[str, Any]) -> ToolExecutionResult:
410
+ call_args = prepare_args(args)
411
+ if isinstance(call_args, ToolExecutionResult):
412
+ return call_args
341
413
  value = fn(context, **call_args) if wants_context else fn(**call_args)
342
- return _coerce_tool_result(value)
414
+ return _coerce_tool_result(value)
415
+
416
+ runner = sync_runner
343
417
 
344
418
  return ToolSpec(
345
419
  name=tool_name,
File without changes
File without changes