mycode-sdk 0.9.5__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.9.5 → mycode_sdk-0.11.0}/PKG-INFO +5 -3
  2. {mycode_sdk-0.9.5 → mycode_sdk-0.11.0}/README.md +4 -2
  3. {mycode_sdk-0.9.5 → mycode_sdk-0.11.0}/pyproject.toml +1 -1
  4. {mycode_sdk-0.9.5 → mycode_sdk-0.11.0}/src/mycode/__init__.py +2 -0
  5. {mycode_sdk-0.9.5 → mycode_sdk-0.11.0}/src/mycode/agent.py +117 -22
  6. {mycode_sdk-0.9.5 → mycode_sdk-0.11.0}/src/mycode/compact.py +19 -0
  7. {mycode_sdk-0.9.5 → mycode_sdk-0.11.0}/src/mycode/messages.py +2 -2
  8. {mycode_sdk-0.9.5 → mycode_sdk-0.11.0}/src/mycode/models.py +4 -4
  9. {mycode_sdk-0.9.5 → mycode_sdk-0.11.0}/src/mycode/providers/anthropic_like.py +3 -2
  10. {mycode_sdk-0.9.5 → mycode_sdk-0.11.0}/src/mycode/providers/base.py +1 -1
  11. {mycode_sdk-0.9.5 → mycode_sdk-0.11.0}/src/mycode/providers/gemini.py +8 -8
  12. {mycode_sdk-0.9.5 → mycode_sdk-0.11.0}/src/mycode/providers/openai_chat.py +2 -2
  13. {mycode_sdk-0.9.5 → mycode_sdk-0.11.0}/src/mycode/providers/openai_responses.py +4 -5
  14. {mycode_sdk-0.9.5 → mycode_sdk-0.11.0}/src/mycode/session.py +4 -7
  15. {mycode_sdk-0.9.5 → mycode_sdk-0.11.0}/src/mycode/tools.py +91 -24
  16. {mycode_sdk-0.9.5 → mycode_sdk-0.11.0}/.gitignore +0 -0
  17. {mycode_sdk-0.9.5 → mycode_sdk-0.11.0}/LICENSE +0 -0
  18. {mycode_sdk-0.9.5 → mycode_sdk-0.11.0}/src/mycode/attachments.py +0 -0
  19. {mycode_sdk-0.9.5 → mycode_sdk-0.11.0}/src/mycode/hooks.py +0 -0
  20. {mycode_sdk-0.9.5 → mycode_sdk-0.11.0}/src/mycode/models_catalog.json +0 -0
  21. {mycode_sdk-0.9.5 → mycode_sdk-0.11.0}/src/mycode/providers/__init__.py +0 -0
  22. {mycode_sdk-0.9.5 → mycode_sdk-0.11.0}/src/mycode/py.typed +0 -0
  23. {mycode_sdk-0.9.5 → 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.9.5
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.9.5"
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"
@@ -4,6 +4,7 @@ from importlib import metadata
4
4
 
5
5
  from mycode.agent import Agent, Event, PersistCallback, RunResult
6
6
  from mycode.attachments import Attachment
7
+ from mycode.compact import NothingToCompactError
7
8
  from mycode.hooks import AfterToolHook, BeforeToolHook, HookResult, Hooks, ToolHookContext
8
9
  from mycode.messages import (
9
10
  ContentBlock,
@@ -45,6 +46,7 @@ __all__ = [
45
46
  "BeforeToolHook",
46
47
  "HookResult",
47
48
  "Hooks",
49
+ "NothingToCompactError",
48
50
  "PersistCallback",
49
51
  "RunResult",
50
52
  "SessionStore",
@@ -23,7 +23,9 @@ from mycode.attachments import AttachmentLike, build_attachment_blocks
23
23
  from mycode.compact import (
24
24
  COMPACT_SUMMARY_PROMPT,
25
25
  DEFAULT_COMPACT_THRESHOLD,
26
+ NothingToCompactError,
26
27
  build_compact_event,
28
+ has_compactable_history,
27
29
  should_compact,
28
30
  )
29
31
  from mycode.hooks import Hooks, ToolHookContext
@@ -129,7 +131,9 @@ class Agent:
129
131
  self.system = system
130
132
  self.hooks = hooks or Hooks()
131
133
  self._cancel_event = asyncio.Event()
134
+ self._event_loop: asyncio.AbstractEventLoop | None = None
132
135
  self._provider_event_task: asyncio.Future[ProviderStreamEvent] | None = None
136
+ self._active_tool_task: asyncio.Task[ToolExecutionResult] | None = None
133
137
 
134
138
  # History resolution:
135
139
  # - messages is None → auto-resume from disk if the session exists
@@ -199,10 +203,24 @@ class Agent:
199
203
  def cancel(self) -> None:
200
204
  """Request cancellation of the in-flight turn."""
201
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:
202
218
  self._cancel_event.set()
203
219
  self.tools.cancel_active()
204
220
  if self._provider_event_task and not self._provider_event_task.done():
205
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()
206
224
 
207
225
  def clear(self) -> None:
208
226
  """Drop the in-memory conversation history."""
@@ -213,6 +231,23 @@ class Agent:
213
231
  # Tool execution
214
232
  # ------------------------------------------------------------------
215
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
+
216
251
  async def _run_tool_call(self, tool_use: dict[str, Any]) -> AsyncIterator[Event]:
217
252
  """Run one tool call and emit the standard tool events."""
218
253
 
@@ -259,13 +294,23 @@ class Agent:
259
294
  return
260
295
 
261
296
  if spec.streams_output:
262
- 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
+ ):
263
303
  yield event
264
304
  return
265
305
 
266
306
  try:
267
307
  ctx = self._ctx_for_call(tool_id)
268
- 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
269
314
  except Exception as exc: # pragma: no cover - defensive
270
315
  result = ToolExecutionResult(output=f"error: {exc}", is_error=True)
271
316
 
@@ -275,7 +320,7 @@ class Agent:
275
320
  self,
276
321
  *,
277
322
  tool_id: str,
278
- name: str,
323
+ spec: ToolSpec,
279
324
  args: dict[str, Any],
280
325
  hook_ctx: ToolHookContext,
281
326
  ) -> AsyncIterator[Event]:
@@ -289,13 +334,13 @@ class Agent:
289
334
 
290
335
  ctx = self._ctx_for_call(tool_id, emit=on_output)
291
336
 
292
- async def run_in_thread() -> ToolExecutionResult:
337
+ async def run_tool() -> ToolExecutionResult:
293
338
  try:
294
- return await asyncio.to_thread(self.tools.execute, name, args, ctx)
339
+ return await self._execute_tool(spec, args, ctx)
295
340
  finally:
296
341
  loop.call_soon_threadsafe(output_queue.put_nowait, None)
297
342
 
298
- task = asyncio.create_task(run_in_thread())
343
+ task = asyncio.create_task(run_tool())
299
344
  was_cancelled = False
300
345
  output_parts: list[str] = []
301
346
 
@@ -317,8 +362,8 @@ class Agent:
317
362
  output_parts.append(output)
318
363
  yield Event("tool_output", {"tool_use_id": tool_id, "output": output})
319
364
 
320
- if was_cancelled:
321
- with suppress(Exception):
365
+ if was_cancelled or (self._cancel_event.is_set() and task.cancelled()):
366
+ with suppress(asyncio.CancelledError, Exception):
322
367
  await task
323
368
  output = "\n".join([*output_parts, "error: cancelled"]) if output_parts else "error: cancelled"
324
369
  yield self._error_done(tool_id, output)
@@ -455,6 +500,23 @@ class Agent:
455
500
  block["meta"] = {**meta, "duration_ms": duration_ms}
456
501
  return
457
502
 
503
+ async def _persist_message(
504
+ self,
505
+ message: ConversationMessage,
506
+ on_persist: PersistCallback | None,
507
+ ) -> None:
508
+ """Persist one message: caller callback first, then the SDK session store."""
509
+
510
+ if on_persist is not None:
511
+ # Callers may need to write related records before the SDK
512
+ # appends this message to its own session log.
513
+ await on_persist(message)
514
+ if self._store is None:
515
+ return
516
+ if not self._store.session_exists(self.session_id):
517
+ await self._store.create_session(self.session_id, cwd=self.cwd)
518
+ await self._store.append_message(self.session_id, message)
519
+
458
520
  # ------------------------------------------------------------------
459
521
  # Public entry points
460
522
  # ------------------------------------------------------------------
@@ -469,16 +531,9 @@ class Agent:
469
531
  """Run the full agent loop for one user message."""
470
532
 
471
533
  async def persist(message: ConversationMessage) -> None:
472
- if on_persist is not None:
473
- # Callers may need to write related records before the SDK
474
- # appends this message to its own session log.
475
- await on_persist(message)
476
- if self._store is None:
477
- return
478
- if not self._store.session_exists(self.session_id):
479
- await self._store.create_session(self.session_id, cwd=self.cwd)
480
- await self._store.append_message(self.session_id, message)
534
+ await self._persist_message(message, on_persist)
481
535
 
536
+ self._event_loop = asyncio.get_running_loop()
482
537
  self._cancel_event.clear()
483
538
 
484
539
  user_message: ConversationMessage
@@ -671,7 +726,7 @@ class Agent:
671
726
  return
672
727
  if should_compact(total_tokens, self.context_window, self.compact_threshold):
673
728
  try:
674
- await self._compact(adapter, persist)
729
+ await self._compact(adapter, on_persist)
675
730
  yield Event("compact", {})
676
731
  except asyncio.CancelledError:
677
732
  yield Event("error", {"message": "cancelled"})
@@ -719,12 +774,48 @@ class Agent:
719
774
  # Context compaction
720
775
  # ------------------------------------------------------------------
721
776
 
777
+ async def acompact(
778
+ self,
779
+ *,
780
+ on_persist: PersistCallback | None = None,
781
+ ) -> ConversationMessage:
782
+ """Compact the conversation now and return the persisted compact marker.
783
+
784
+ Raises :class:`NothingToCompactError` when no new context follows the
785
+ latest compact marker, and :class:`asyncio.CancelledError` when
786
+ :meth:`cancel` stops the summary request.
787
+ """
788
+
789
+ self._event_loop = asyncio.get_running_loop()
790
+ self._cancel_event.clear()
791
+ adapter = get_provider_adapter(self.provider)
792
+ return await self._compact(adapter, on_persist)
793
+
794
+ def compact(
795
+ self,
796
+ *,
797
+ on_persist: PersistCallback | None = None,
798
+ ) -> ConversationMessage:
799
+ """Compact the conversation synchronously; see :meth:`acompact`."""
800
+
801
+ try:
802
+ asyncio.get_running_loop()
803
+ except RuntimeError:
804
+ pass
805
+ else:
806
+ raise RuntimeError("Agent.compact() cannot run inside an active event loop; use Agent.acompact() instead")
807
+
808
+ return asyncio.run(self.acompact(on_persist=on_persist))
809
+
722
810
  async def _compact(
723
811
  self,
724
812
  adapter: ProviderAdapter,
725
- persist: PersistCallback,
726
- ) -> None:
727
- """Ask the provider for a summary, persist the compact event, append it."""
813
+ on_persist: PersistCallback | None,
814
+ ) -> ConversationMessage:
815
+ """Ask the provider for a summary, persist and append the compact marker."""
816
+
817
+ if not has_compactable_history(self.messages):
818
+ raise NothingToCompactError("nothing to compact")
728
819
 
729
820
  request = self._build_request(
730
821
  tools=[],
@@ -746,6 +837,9 @@ class Agent:
746
837
  if not summary_text:
747
838
  raise ValueError("compaction produced empty summary")
748
839
 
840
+ if self._cancel_event.is_set():
841
+ raise asyncio.CancelledError
842
+
749
843
  summary_total_tokens = (summary_message.get("meta") or {}).get("total_tokens")
750
844
  compact_event = build_compact_event(
751
845
  summary_text,
@@ -754,5 +848,6 @@ class Agent:
754
848
  total_tokens=summary_total_tokens,
755
849
  )
756
850
 
757
- await persist(compact_event)
851
+ await self._persist_message(compact_event, on_persist)
758
852
  self.messages.append(compact_event)
853
+ return compact_event
@@ -8,6 +8,11 @@ from mycode.messages import ConversationMessage, build_message, text_block
8
8
 
9
9
  DEFAULT_COMPACT_THRESHOLD = 0.8
10
10
 
11
+
12
+ class NothingToCompactError(ValueError):
13
+ """Raised when there is no new context to compact past the latest marker."""
14
+
15
+
11
16
  COMPACT_SUMMARY_PROMPT = """\
12
17
  Summarize this conversation to create a continuation document. \
13
18
  This summary will replace the full conversation history, so it must \
@@ -65,6 +70,20 @@ def should_compact(
65
70
  return last_total_tokens >= context_window * threshold
66
71
 
67
72
 
73
+ def has_compactable_history(messages: list[ConversationMessage]) -> bool:
74
+ """True when at least one non-empty user/assistant message follows the latest compact marker."""
75
+
76
+ last_compact = -1
77
+ for i, message in enumerate(messages):
78
+ if message.get("role") == "compact":
79
+ last_compact = i
80
+
81
+ return any(
82
+ message.get("role") in ("user", "assistant") and message.get("content")
83
+ for message in messages[last_compact + 1 :]
84
+ )
85
+
86
+
68
87
  def build_compact_event(
69
88
  summary_text: str,
70
89
  *,
@@ -165,8 +165,8 @@ def flatten_message_text(message: ConversationMessage, *, include_thinking: bool
165
165
  continue
166
166
  raw_meta = block.get("meta")
167
167
  meta = raw_meta if isinstance(raw_meta, dict) else {}
168
- # Attached file snapshots should not become session titles or history labels.
169
- if meta.get("attachment"):
168
+ # Local payload blocks should not become session titles or history labels.
169
+ if meta.get("attachment") or meta.get("skill_snapshot"):
170
170
  continue
171
171
  btype = block.get("type")
172
172
  if btype == "text" or (include_thinking and btype == "thinking"):
@@ -2,9 +2,9 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import functools
6
5
  import json
7
6
  from dataclasses import dataclass, replace
7
+ from functools import cache
8
8
  from pathlib import Path
9
9
  from typing import Any
10
10
 
@@ -30,14 +30,14 @@ class ModelMetadata:
30
30
  supports_pdf_input: bool | None = None
31
31
 
32
32
 
33
- @functools.cache
33
+ @cache
34
34
  def load_models_catalog() -> dict[str, Any] | None:
35
35
  """Load the bundled model catalog from disk once per process."""
36
36
 
37
37
  try:
38
38
  data = json.loads(_MODELS_CATALOG_PATH.read_text(encoding="utf-8"))
39
- except Exception:
40
- data = None
39
+ except (OSError, UnicodeError, json.JSONDecodeError):
40
+ return None
41
41
  return data if isinstance(data, dict) else None
42
42
 
43
43
 
@@ -111,8 +111,9 @@ class AnthropicLikeAdapter(ProviderAdapter):
111
111
  if not isinstance(content, list):
112
112
  return
113
113
 
114
- blocks: list[dict[str, Any]] = [cast(dict[str, Any], block) for block in content if isinstance(block, dict)]
115
- for block in reversed(blocks):
114
+ for block in reversed(content):
115
+ if not isinstance(block, dict):
116
+ continue
116
117
  block_type = str(block.get("type") or "")
117
118
  if block_type not in {"text", "image", "document", "tool_result"}:
118
119
  continue
@@ -88,7 +88,7 @@ def parse_tool_call_input(raw_arguments: str) -> tuple[dict[str, Any], dict[str,
88
88
  try:
89
89
  parsed = json.loads(raw_arguments)
90
90
  except json.JSONDecodeError:
91
- parsed = None
91
+ return {}, {"raw_arguments": raw_arguments}
92
92
  if isinstance(parsed, dict):
93
93
  return parsed, {}
94
94
  return {}, {"raw_arguments": raw_arguments}
@@ -84,7 +84,7 @@ class GoogleGeminiAdapter(ProviderAdapter):
84
84
  finish_message = getattr(candidate, "finish_message", None) or finish_message
85
85
 
86
86
  for part in getattr(getattr(candidate, "content", None), "parts", None) or []:
87
- for event in self._consume_part(blocks, part):
87
+ if event := self._consume_part(blocks, part):
88
88
  yield event
89
89
  except APIError as exc:
90
90
  raise ValueError(str(exc)) from exc
@@ -223,7 +223,7 @@ class GoogleGeminiAdapter(ProviderAdapter):
223
223
  return contents
224
224
 
225
225
  def _build_config(self, request: ProviderRequest) -> types.GenerateContentConfig:
226
- tools: list[types.Tool | Any] | None = None
226
+ tools: list[types.Tool] | None = None
227
227
  if request.tools:
228
228
  tools = [
229
229
  types.Tool(
@@ -262,7 +262,7 @@ class GoogleGeminiAdapter(ProviderAdapter):
262
262
  thinking_config=thinking_config,
263
263
  )
264
264
 
265
- def _consume_part(self, blocks: list[dict[str, Any]], part: Any) -> list[ProviderStreamEvent]:
265
+ def _consume_part(self, blocks: list[dict[str, Any]], part: Any) -> ProviderStreamEvent | None:
266
266
  native_part = _to_json(part) or {}
267
267
  if native_part.get("thought") is False:
268
268
  native_part.pop("thought", None)
@@ -278,12 +278,12 @@ class GoogleGeminiAdapter(ProviderAdapter):
278
278
  meta={"native": {"part": native_part}},
279
279
  )
280
280
  )
281
- return []
281
+ return None
282
282
 
283
283
  text = getattr(part, "text", None)
284
284
  if text is None or text == "":
285
285
  if not native_part.get("thought_signature"):
286
- return []
286
+ return None
287
287
 
288
288
  # Gemini may put the final thought signature into an empty-text part.
289
289
  # Keep it as a separate empty block so replay preserves the original
@@ -291,7 +291,7 @@ class GoogleGeminiAdapter(ProviderAdapter):
291
291
  part_meta = {"native": {"part": native_part}}
292
292
  is_thought = getattr(part, "thought", False)
293
293
  blocks.append(thinking_block("", meta=part_meta) if is_thought else text_block("", meta=part_meta))
294
- return []
294
+ return None
295
295
 
296
296
  is_thought = bool(getattr(part, "thought", False))
297
297
  event = ProviderStreamEvent("thinking_delta" if is_thought else "text_delta", {"text": str(text)})
@@ -310,10 +310,10 @@ class GoogleGeminiAdapter(ProviderAdapter):
310
310
  last_part["text"] = f"{last_part.get('text') or ''}{text}"
311
311
  if current_signature and not last_signature:
312
312
  last_part["thought_signature"] = current_signature
313
- return [event]
313
+ return event
314
314
 
315
315
  part_meta = {"native": {"part": native_part}}
316
316
  blocks.append(
317
317
  thinking_block(str(text), meta=part_meta) if is_thought else text_block(str(text), meta=part_meta)
318
318
  )
319
- return [event]
319
+ return event
@@ -9,7 +9,7 @@ from typing import Any, override
9
9
 
10
10
  from openai import APIError, AsyncOpenAI
11
11
 
12
- from mycode.messages import assistant_message, text_block, thinking_block, tool_use_block
12
+ from mycode.messages import ConversationMessage, assistant_message, text_block, thinking_block, tool_use_block
13
13
  from mycode.providers.base import (
14
14
  DEFAULT_REQUEST_TIMEOUT,
15
15
  ProviderAdapter,
@@ -174,7 +174,7 @@ class OpenAIChatAdapter(ProviderAdapter):
174
174
  },
175
175
  }
176
176
 
177
- def _serialize_message(self, message: dict[str, Any]) -> list[dict[str, Any]]:
177
+ def _serialize_message(self, message: ConversationMessage) -> list[dict[str, Any]]:
178
178
  """Convert one canonical message into Chat Completions wire messages."""
179
179
 
180
180
  role = str(message.get("role") or "user")
@@ -102,9 +102,8 @@ class OpenAIResponsesAdapter(ProviderAdapter):
102
102
  raise ValueError(str(exc)) from exc
103
103
 
104
104
  def _build_request_payload(self, request: ProviderRequest) -> dict[str, Any]:
105
- prepared_messages = self.prepare_messages(request)
106
105
  input_items: list[dict[str, Any]] = []
107
- for message in prepared_messages:
106
+ for message in self.prepare_messages(request):
108
107
  role = message.get("role")
109
108
  if role == "user":
110
109
  input_items.extend(self._serialize_user_message(message))
@@ -262,7 +261,7 @@ class OpenAIResponsesAdapter(ProviderAdapter):
262
261
  response: Any,
263
262
  *,
264
263
  output_items: list[Any] | None = None,
265
- ) -> dict[str, Any]:
264
+ ) -> ConversationMessage:
266
265
  raw_output = output_items if output_items is not None else (getattr(response, "output", None) or [])
267
266
  dumped_output_items = dump_model(raw_output)
268
267
  blocks: list[dict[str, Any]] = []
@@ -357,8 +356,8 @@ def _normalize_strict_schema(schema: Any) -> None:
357
356
 
358
357
  properties = schema.get("properties")
359
358
  if isinstance(properties, dict):
360
- required_names = {str(name) for name in schema.get("required", []) if isinstance(name, str)}
361
- for name, property_schema in list(properties.items()):
359
+ required_names = {name for name in schema.get("required", []) if isinstance(name, str)}
360
+ for name, property_schema in properties.items():
362
361
  _normalize_strict_schema(property_schema)
363
362
  if name not in required_names:
364
363
  properties[name] = _nullable_schema(property_schema)
@@ -139,7 +139,7 @@ class SessionStore:
139
139
  meta = _project_meta(meta)
140
140
  self.meta_path(session_id).write_text(json.dumps(meta, indent=2, ensure_ascii=False), encoding="utf-8")
141
141
  index = self._read_index()
142
- index[session_id] = dict(meta)
142
+ index[session_id] = meta
143
143
  self._write_index(index)
144
144
 
145
145
  def _read_index(self) -> SessionIndex:
@@ -161,7 +161,7 @@ class SessionStore:
161
161
  continue
162
162
  meta = self._read_meta(entry.name)
163
163
  if meta is not None:
164
- index[entry.name] = dict(meta)
164
+ index[entry.name] = meta
165
165
  self._write_index(index)
166
166
  return index
167
167
 
@@ -246,9 +246,7 @@ class SessionStore:
246
246
 
247
247
  async def delete_session(self, session_id: str) -> None:
248
248
  def delete() -> None:
249
- sdir = self.session_dir(session_id)
250
- if sdir.exists():
251
- shutil.rmtree(sdir, ignore_errors=True)
249
+ shutil.rmtree(self.session_dir(session_id), ignore_errors=True)
252
250
  index = self._read_index()
253
251
  index.pop(session_id, None)
254
252
  self._write_index(index)
@@ -285,8 +283,7 @@ class SessionStore:
285
283
 
286
284
  def append() -> None:
287
285
  with self.messages_path(session_id).open("a", encoding="utf-8") as handle:
288
- handle.write(json.dumps(message, ensure_ascii=False))
289
- handle.write("\n")
286
+ handle.write(json.dumps(message, ensure_ascii=False) + "\n")
290
287
 
291
288
  meta = self._read_meta(session_id)
292
289
  if meta is None:
@@ -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,25 +132,47 @@ 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
 
132
- def track_proc(self, proc: subprocess.Popen[str]) -> None:
133
- self.executor.track_proc(proc)
172
+ async def acall(self, name: str, args: dict[str, Any]) -> ToolExecutionResult:
173
+ """Asynchronously dispatch through the registry."""
134
174
 
135
- def untrack_proc(self, proc: subprocess.Popen[str]) -> None:
136
- self.executor.untrack_proc(proc)
175
+ return await self.executor.aexecute(name, args, self)
137
176
 
138
177
 
139
178
  # ---------------------------------------------------------------------------
@@ -166,7 +205,21 @@ class ToolExecutor:
166
205
  spec = self._tools.get(name)
167
206
  if spec is None:
168
207
  return ToolExecutionResult(output=f"error: unknown tool: {name}", is_error=True)
169
- 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)
170
223
 
171
224
  # Subprocess tracking: register with both this executor (per-session
172
225
  # cancel) and the process-global set (shutdown cleanup).
@@ -202,6 +255,9 @@ class ToolExecutor:
202
255
  # agents may run concurrently in the same process); this module-level set is
203
256
  # a shutdown-time safety net exposed as ``cancel_all_tools``.
204
257
 
258
+ _ACTIVE_PROCS: set[subprocess.Popen[str]] = set()
259
+ _ACTIVE_PROCS_LOCK = threading.Lock()
260
+
205
261
 
206
262
  def cancel_all_tools() -> None:
207
263
  """Terminate every running bash subprocess in the current process."""
@@ -213,10 +269,6 @@ def cancel_all_tools() -> None:
213
269
  _kill_proc_tree(proc)
214
270
 
215
271
 
216
- _ACTIVE_PROCS: set[subprocess.Popen[str]] = set()
217
- _ACTIVE_PROCS_LOCK = threading.Lock()
218
-
219
-
220
272
  def _kill_proc_tree(proc: subprocess.Popen[str]) -> None:
221
273
  try:
222
274
  if os.name == "posix":
@@ -326,7 +378,7 @@ def tool(
326
378
 
327
379
  is_async = inspect.iscoroutinefunction(fn)
328
380
 
329
- def runner(context: ToolContext, args: dict[str, Any]) -> ToolExecutionResult:
381
+ def prepare_args(args: dict[str, Any]) -> dict[str, Any] | ToolExecutionResult:
330
382
  # Strict providers send explicit null for an omitted optional field;
331
383
  # drop it so the parameter default applies instead of failing validation.
332
384
  validation_args = {
@@ -339,14 +391,29 @@ def tool(
339
391
  except ValidationError as exc:
340
392
  return ToolExecutionResult(output=f"error: invalid tool input: {exc}", is_error=True)
341
393
 
342
- call_args = {name: getattr(parsed_args, name) for name in tool_param_names}
343
- if is_async:
344
- # The executor runs on a worker thread, so spinning a fresh
345
- # event loop here is safe.
346
- value = asyncio.run(fn(context, **call_args) if wants_context else fn(**call_args))
347
- 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
348
413
  value = fn(context, **call_args) if wants_context else fn(**call_args)
349
- return _coerce_tool_result(value)
414
+ return _coerce_tool_result(value)
415
+
416
+ runner = sync_runner
350
417
 
351
418
  return ToolSpec(
352
419
  name=tool_name,
@@ -479,8 +546,8 @@ def read_tool(
479
546
  ],
480
547
  )
481
548
 
482
- start_line = offset if isinstance(offset, int) and offset > 0 else 1
483
- line_limit = limit if isinstance(limit, int) and limit > 0 else DEFAULT_MAX_LINES
549
+ start_line = offset if offset is not None and offset > 0 else 1
550
+ line_limit = limit if limit is not None and limit > 0 else DEFAULT_MAX_LINES
484
551
  lines: list[str] = []
485
552
  total_lines = 0
486
553
  next_offset: int | None = None
@@ -764,7 +831,7 @@ def bash_tool(ctx: ToolContext, command: str, timeout: int | None = None) -> Too
764
831
  again by :func:`truncate_text` to the display limits.
765
832
  """
766
833
 
767
- timeout_seconds = int(timeout) if isinstance(timeout, int) and timeout > 0 else BASH_TIMEOUT_SECONDS
834
+ timeout_seconds = timeout if timeout is not None and timeout > 0 else BASH_TIMEOUT_SECONDS
768
835
 
769
836
  proc: subprocess.Popen[str] | None = None
770
837
  log_path = ctx.tool_output_dir / f"bash-{ctx.tool_call_id or 'call'}.log"
@@ -787,7 +854,7 @@ def bash_tool(ctx: ToolContext, command: str, timeout: int | None = None) -> Too
787
854
  bufsize=1,
788
855
  start_new_session=os.name == "posix",
789
856
  )
790
- ctx.track_proc(proc)
857
+ ctx.executor.track_proc(proc)
791
858
 
792
859
  stdout = cast(TextIO, proc.stdout)
793
860
  output_queue: queue.Queue[str | None] = queue.Queue()
@@ -903,7 +970,7 @@ def bash_tool(ctx: ToolContext, command: str, timeout: int | None = None) -> Too
903
970
  with suppress(Exception):
904
971
  log_file.close()
905
972
  if proc is not None:
906
- ctx.untrack_proc(proc)
973
+ ctx.executor.untrack_proc(proc)
907
974
  if proc.poll() is None:
908
975
  _kill_proc_tree(proc)
909
976
 
File without changes
File without changes