mycode-sdk 0.9.0__tar.gz → 0.9.1__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.0 → mycode_sdk-0.9.1}/PKG-INFO +6 -6
  2. {mycode_sdk-0.9.0 → mycode_sdk-0.9.1}/pyproject.toml +6 -6
  3. {mycode_sdk-0.9.0 → mycode_sdk-0.9.1}/src/mycode/__init__.py +1 -8
  4. {mycode_sdk-0.9.0 → mycode_sdk-0.9.1}/src/mycode/agent.py +79 -87
  5. {mycode_sdk-0.9.0 → mycode_sdk-0.9.1}/src/mycode/attachments.py +3 -2
  6. {mycode_sdk-0.9.0 → mycode_sdk-0.9.1}/src/mycode/providers/anthropic_like.py +25 -24
  7. {mycode_sdk-0.9.0 → mycode_sdk-0.9.1}/src/mycode/providers/base.py +6 -0
  8. {mycode_sdk-0.9.0 → mycode_sdk-0.9.1}/src/mycode/providers/gemini.py +2 -3
  9. {mycode_sdk-0.9.0 → mycode_sdk-0.9.1}/src/mycode/providers/openai_chat.py +6 -3
  10. {mycode_sdk-0.9.0 → mycode_sdk-0.9.1}/src/mycode/providers/openai_responses.py +4 -3
  11. {mycode_sdk-0.9.0 → mycode_sdk-0.9.1}/src/mycode/session.py +11 -10
  12. {mycode_sdk-0.9.0 → mycode_sdk-0.9.1}/src/mycode/tools.py +10 -12
  13. {mycode_sdk-0.9.0 → mycode_sdk-0.9.1}/.gitignore +0 -0
  14. {mycode_sdk-0.9.0 → mycode_sdk-0.9.1}/LICENSE +0 -0
  15. {mycode_sdk-0.9.0 → mycode_sdk-0.9.1}/README.md +0 -0
  16. {mycode_sdk-0.9.0 → mycode_sdk-0.9.1}/src/mycode/compact.py +0 -0
  17. {mycode_sdk-0.9.0 → mycode_sdk-0.9.1}/src/mycode/hooks.py +0 -0
  18. {mycode_sdk-0.9.0 → mycode_sdk-0.9.1}/src/mycode/messages.py +0 -0
  19. {mycode_sdk-0.9.0 → mycode_sdk-0.9.1}/src/mycode/models.py +0 -0
  20. {mycode_sdk-0.9.0 → mycode_sdk-0.9.1}/src/mycode/models_catalog.json +0 -0
  21. {mycode_sdk-0.9.0 → mycode_sdk-0.9.1}/src/mycode/providers/__init__.py +0 -0
  22. {mycode_sdk-0.9.0 → mycode_sdk-0.9.1}/src/mycode/py.typed +0 -0
  23. {mycode_sdk-0.9.0 → mycode_sdk-0.9.1}/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.0
3
+ Version: 0.9.1
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
@@ -18,11 +18,11 @@ Classifier: Programming Language :: Python :: 3.12
18
18
  Classifier: Programming Language :: Python :: 3.13
19
19
  Classifier: Topic :: Software Development
20
20
  Requires-Python: >=3.12
21
- Requires-Dist: anthropic>=0.102.0
22
- Requires-Dist: google-genai>=2.3.0
23
- Requires-Dist: griffelib>=2.0.2
24
- Requires-Dist: openai>=2.36.0
25
- Requires-Dist: pydantic>=2.13.4
21
+ Requires-Dist: anthropic>=0.106.0
22
+ Requires-Dist: google-genai>=2.8.0
23
+ Requires-Dist: griffelib>=2.0.0
24
+ Requires-Dist: openai>=2.41.0
25
+ Requires-Dist: pydantic>=2.13.0
26
26
  Description-Content-Type: text/markdown
27
27
 
28
28
  # mycode-sdk
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "mycode-sdk"
7
- version = "0.9.0"
7
+ version = "0.9.1"
8
8
  description = "Lightweight Python SDK for building AI agents."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -23,11 +23,11 @@ classifiers = [
23
23
  ]
24
24
  keywords = ["agent", "llm", "anthropic", "openai", "gemini", "sdk"]
25
25
  dependencies = [
26
- "anthropic>=0.102.0",
27
- "google-genai>=2.3.0",
28
- "griffelib>=2.0.2",
29
- "openai>=2.36.0",
30
- "pydantic>=2.13.4",
26
+ "anthropic>=0.106.0",
27
+ "google-genai>=2.8.0",
28
+ "griffelib>=2.0.0",
29
+ "openai>=2.41.0",
30
+ "pydantic>=2.13.0",
31
31
  ]
32
32
 
33
33
  [project.urls]
@@ -1,11 +1,4 @@
1
- """mycode — multi-turn tool-calling agent runtime.
2
-
3
- Public API for embedding the agent loop in other Python applications. The
4
- runtime ships four built-in coding tools (``read``, ``write``, ``edit``,
5
- ``bash``) exposed as :data:`read_tool`, :data:`write_tool`, :data:`edit_tool`,
6
- :data:`bash_tool` — pick the ones you want via ``tools=[...]`` rather than
7
- silently exposing file system and shell access.
8
- """
1
+ """mycode — multi-turn tool-calling agent runtime."""
9
2
 
10
3
  from importlib import metadata
11
4
 
@@ -13,6 +13,7 @@ import os
13
13
  import tempfile
14
14
  import time
15
15
  from collections.abc import AsyncIterator, Awaitable, Callable, Sequence
16
+ from contextlib import suppress
16
17
  from dataclasses import dataclass, field
17
18
  from pathlib import Path
18
19
  from typing import Any, cast
@@ -232,18 +233,12 @@ class Agent:
232
233
  yield Event("tool_start", {"tool_call": {"id": tool_id, "name": name, "input": args}})
233
234
 
234
235
  if self._cancel_event.is_set():
235
- yield self._tool_done_event(
236
- tool_id,
237
- ToolExecutionResult(output="error: cancelled", is_error=True),
238
- )
236
+ yield self._error_done(tool_id, "error: cancelled")
239
237
  return
240
238
 
241
239
  spec = self.tools.get(name)
242
240
  if spec is None:
243
- yield self._tool_done_event(
244
- tool_id,
245
- ToolExecutionResult(output=f"error: unknown tool: {name}", is_error=True),
246
- )
241
+ yield self._error_done(tool_id, f"error: unknown tool: {name}")
247
242
  return
248
243
 
249
244
  hook_ctx = ToolHookContext(
@@ -259,10 +254,7 @@ class Agent:
259
254
  try:
260
255
  result = await self.hooks.run_before_tool(hook_ctx)
261
256
  except Exception as exc:
262
- yield self._tool_done_event(
263
- tool_id,
264
- ToolExecutionResult(output=f"error: tool hook failed: {exc}", is_error=True),
265
- )
257
+ yield self._error_done(tool_id, f"error: tool hook failed: {exc}")
266
258
  return
267
259
 
268
260
  if result is not None:
@@ -270,10 +262,7 @@ class Agent:
270
262
  return
271
263
 
272
264
  if self._cancel_event.is_set():
273
- yield self._tool_done_event(
274
- tool_id,
275
- ToolExecutionResult(output="error: cancelled", is_error=True),
276
- )
265
+ yield self._error_done(tool_id, "error: cancelled")
277
266
  return
278
267
 
279
268
  if spec.streams_output:
@@ -336,13 +325,10 @@ class Agent:
336
325
  yield Event("tool_output", {"tool_use_id": tool_id, "output": output})
337
326
 
338
327
  if was_cancelled:
339
- try:
328
+ with suppress(Exception):
340
329
  await task
341
- except Exception:
342
- pass
343
330
  output = "\n".join([*output_parts, "error: cancelled"]) if output_parts else "error: cancelled"
344
- result = ToolExecutionResult(output=output, is_error=True)
345
- yield self._tool_done_event(tool_id, result)
331
+ yield self._error_done(tool_id, output)
346
332
  return
347
333
  else:
348
334
  try:
@@ -398,6 +384,11 @@ class Agent:
398
384
  data["content"] = result.content
399
385
  return Event("tool_done", data)
400
386
 
387
+ def _error_done(self, tool_id: str, message: str) -> Event:
388
+ """Build a tool_done event carrying an error result."""
389
+
390
+ return self._tool_done_event(tool_id, ToolExecutionResult(output=message, is_error=True))
391
+
401
392
  # ------------------------------------------------------------------
402
393
  # Provider streaming
403
394
  # ------------------------------------------------------------------
@@ -429,10 +420,52 @@ class Agent:
429
420
  finally:
430
421
  close = cast(Callable[[], Awaitable[None]] | None, getattr(provider_stream, "aclose", None))
431
422
  if close is not None:
432
- try:
423
+ with suppress(Exception):
433
424
  await close()
434
- except Exception:
435
- pass
425
+
426
+ def _build_request(
427
+ self,
428
+ *,
429
+ tools: list[dict[str, Any]] | None = None,
430
+ max_tokens: int | None = None,
431
+ reasoning_effort: str | None = None,
432
+ append_messages: Sequence[ConversationMessage] = (),
433
+ ) -> ProviderRequest:
434
+ """Build a ProviderRequest from the agent's current state."""
435
+
436
+ return ProviderRequest(
437
+ provider=self.provider,
438
+ model=self.model,
439
+ session_id=self.session_id,
440
+ messages=self.messages,
441
+ system=self.system,
442
+ tools=self.tools.definitions if tools is None else tools,
443
+ max_tokens=self.max_tokens if max_tokens is None else max_tokens,
444
+ temperature=self.temperature,
445
+ api_key=self.api_key,
446
+ api_base=self.api_base,
447
+ reasoning_effort=reasoning_effort,
448
+ supports_image_input=self.supports_image_input,
449
+ supports_pdf_input=self.supports_pdf_input,
450
+ transcript_path=self.transcript_path,
451
+ append_messages=list(append_messages),
452
+ )
453
+
454
+ @staticmethod
455
+ def _elapsed_ms(start: float) -> int:
456
+ return max(0, int((time.monotonic() - start) * 1000))
457
+
458
+ @staticmethod
459
+ def _stamp_thinking_duration(content: list[Any], duration_ms: int) -> None:
460
+ """Merge duration_ms onto the last thinking block in content, if any."""
461
+
462
+ for block in reversed(content):
463
+ if not isinstance(block, dict) or block.get("type") != "thinking":
464
+ continue
465
+ raw_meta = block.get("meta")
466
+ meta = raw_meta if isinstance(raw_meta, dict) else {}
467
+ block["meta"] = {**meta, "duration_ms": duration_ms}
468
+ return
436
469
 
437
470
  # ------------------------------------------------------------------
438
471
  # Public entry points
@@ -460,8 +493,9 @@ class Agent:
460
493
 
461
494
  self._cancel_event.clear()
462
495
 
496
+ user_message: ConversationMessage
463
497
  if isinstance(user_input, str):
464
- user_message: ConversationMessage = user_text_message(user_input)
498
+ user_message = user_text_message(user_input)
465
499
  else:
466
500
  if (user_input.get("role") or "user") != "user":
467
501
  yield Event("error", {"message": "user input must be a user message"})
@@ -479,16 +513,15 @@ class Agent:
479
513
  user_message["content"] = list(user_message.get("content") or []) + blocks
480
514
 
481
515
  content_blocks = user_message.get("content") or []
482
- if not self.supports_image_input and any(
483
- isinstance(block, dict) and block.get("type") == "image" for block in content_blocks
484
- ):
485
- yield Event("error", {"message": "current model does not support image input"})
486
- return
487
- if not self.supports_pdf_input and any(
488
- isinstance(block, dict) and block.get("type") == "document" for block in content_blocks
516
+ for block_type, supported, label in (
517
+ ("image", self.supports_image_input, "image input"),
518
+ ("document", self.supports_pdf_input, "PDF input"),
489
519
  ):
490
- yield Event("error", {"message": "current model does not support PDF input"})
491
- return
520
+ if not supported and any(
521
+ isinstance(block, dict) and block.get("type") == block_type for block in content_blocks
522
+ ):
523
+ yield Event("error", {"message": f"current model does not support {label}"})
524
+ return
492
525
 
493
526
  self.messages.append(user_message)
494
527
  await persist(user_message)
@@ -507,22 +540,7 @@ class Agent:
507
540
  thinking_started_at: float | None = None
508
541
  thinking_duration_ms: int | None = None
509
542
  provider_cancelled = False
510
- request = ProviderRequest(
511
- provider=self.provider,
512
- model=self.model,
513
- session_id=self.session_id,
514
- messages=self.messages,
515
- system=self.system,
516
- tools=self.tools.definitions,
517
- max_tokens=self.max_tokens,
518
- temperature=self.temperature,
519
- api_key=self.api_key,
520
- api_base=self.api_base,
521
- reasoning_effort=self.reasoning_effort,
522
- supports_image_input=self.supports_image_input,
523
- supports_pdf_input=self.supports_pdf_input,
524
- transcript_path=self.transcript_path,
525
- )
543
+ request = self._build_request(reasoning_effort=self.reasoning_effort)
526
544
 
527
545
  try:
528
546
  async for provider_event in self._stream_provider_turn(adapter, request):
@@ -546,7 +564,7 @@ class Agent:
546
564
  delta_text = str(provider_event.data.get("text") or "")
547
565
  if delta_text:
548
566
  if thinking_started_at is not None and thinking_duration_ms is None:
549
- thinking_duration_ms = max(0, int((time.monotonic() - thinking_started_at) * 1000))
567
+ thinking_duration_ms = self._elapsed_ms(thinking_started_at)
550
568
  yield Event("reasoning_done", {"duration_ms": thinking_duration_ms})
551
569
  if partial_content and partial_content[-1].get("type") == "text":
552
570
  partial_content[-1]["text"] = f"{partial_content[-1].get('text') or ''}{delta_text}"
@@ -562,7 +580,7 @@ class Agent:
562
580
  continue
563
581
 
564
582
  if thinking_started_at is not None and thinking_duration_ms is None:
565
- thinking_duration_ms = max(0, int((time.monotonic() - thinking_started_at) * 1000))
583
+ thinking_duration_ms = self._elapsed_ms(thinking_started_at)
566
584
  yield Event("reasoning_done", {"duration_ms": thinking_duration_ms})
567
585
 
568
586
  message = provider_event.data.get("message")
@@ -579,15 +597,9 @@ class Agent:
579
597
  if provider_cancelled:
580
598
  if partial_content:
581
599
  if thinking_started_at is not None and thinking_duration_ms is None:
582
- thinking_duration_ms = max(0, int((time.monotonic() - thinking_started_at) * 1000))
600
+ thinking_duration_ms = self._elapsed_ms(thinking_started_at)
583
601
  if thinking_duration_ms is not None:
584
- for block in reversed(partial_content):
585
- if block.get("type") != "thinking":
586
- continue
587
- raw_meta = block.get("meta")
588
- meta = cast(dict[str, Any], raw_meta) if isinstance(raw_meta, dict) else {}
589
- block["meta"] = {**meta, "duration_ms": thinking_duration_ms}
590
- break
602
+ self._stamp_thinking_duration(partial_content, thinking_duration_ms)
591
603
  cancelled_message = build_message(
592
604
  "assistant",
593
605
  [dict(block) for block in partial_content],
@@ -607,13 +619,7 @@ class Agent:
607
619
  return
608
620
 
609
621
  if thinking_duration_ms is not None:
610
- for block in reversed(assistant_message.get("content") or []):
611
- if not isinstance(block, dict) or block.get("type") != "thinking":
612
- continue
613
- raw_meta = block.get("meta")
614
- meta = cast(dict[str, Any], raw_meta) if isinstance(raw_meta, dict) else {}
615
- block["meta"] = {**meta, "duration_ms": thinking_duration_ms}
616
- break
622
+ self._stamp_thinking_duration(assistant_message.get("content") or [], thinking_duration_ms)
617
623
 
618
624
  # Stamp context_window onto the persisted assistant message so
619
625
  # rewinds and refreshed clients can render token-usage % without
@@ -649,16 +655,13 @@ class Agent:
649
655
  continue
650
656
 
651
657
  d = event.data
652
- output = str(d.get("output") or "")
653
- metadata = d.get("metadata") if isinstance(d.get("metadata"), dict) else None
654
- content = d.get("content")
655
658
  tool_results.append(
656
659
  tool_result_block(
657
- tool_use_id=str(d.get("tool_use_id") or ""),
658
- output=output,
659
- metadata=metadata,
660
- is_error=bool(d.get("is_error")),
661
- content=content if isinstance(content, list) else None,
660
+ tool_use_id=d["tool_use_id"],
661
+ output=d["output"],
662
+ metadata=d.get("metadata"),
663
+ is_error=d["is_error"],
664
+ content=d.get("content"),
662
665
  )
663
666
  )
664
667
 
@@ -735,20 +738,9 @@ class Agent:
735
738
  ) -> None:
736
739
  """Ask the provider for a summary, persist the compact event, append it."""
737
740
 
738
- request = ProviderRequest(
739
- provider=self.provider,
740
- model=self.model,
741
- session_id=self.session_id,
742
- messages=self.messages,
743
- system=self.system,
741
+ request = self._build_request(
744
742
  tools=[],
745
743
  max_tokens=min(self.max_tokens, 8192),
746
- temperature=self.temperature,
747
- api_key=self.api_key,
748
- api_base=self.api_base,
749
- supports_image_input=self.supports_image_input,
750
- supports_pdf_input=self.supports_pdf_input,
751
- transcript_path=self.transcript_path,
752
744
  append_messages=[user_text_message(COMPACT_SUMMARY_PROMPT)],
753
745
  )
754
746
 
@@ -6,6 +6,7 @@ same content blocks for the same input.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ import builtins
9
10
  import html
10
11
  from base64 import b64encode
11
12
  from collections.abc import Sequence
@@ -29,7 +30,7 @@ class Attachment:
29
30
  bare ``Attachment("notes.txt")`` from silently becoming text instead of a path.
30
31
  """
31
32
 
32
- source: Path | bytes | str
33
+ source: Path | builtins.bytes | str
33
34
  media_type: str | None = None
34
35
  name: str | None = None
35
36
 
@@ -40,7 +41,7 @@ class Attachment:
40
41
  return cls(source=Path(path), name=name)
41
42
 
42
43
  @classmethod
43
- def bytes(cls, data: bytes, *, media_type: str, name: str | None = None) -> Self:
44
+ def bytes(cls, data: builtins.bytes, *, media_type: str, name: str | None = None) -> Self:
44
45
  """``media_type`` must be a supported image type or ``application/pdf``."""
45
46
 
46
47
  if not media_type:
@@ -24,6 +24,7 @@ from mycode.providers.base import (
24
24
  get_native_meta,
25
25
  load_document_block_payload,
26
26
  load_image_block_payload,
27
+ native_block_meta,
27
28
  tool_result_content_blocks,
28
29
  )
29
30
 
@@ -133,25 +134,27 @@ class AnthropicLikeAdapter(ProviderAdapter):
133
134
  api_key = self.require_api_key(request.api_key)
134
135
 
135
136
  try:
136
- async with AsyncAnthropic(
137
- api_key=api_key,
138
- base_url=self.resolve_base_url(request.api_base),
139
- timeout=DEFAULT_REQUEST_TIMEOUT,
140
- ) as client:
141
- async with client.messages.stream(**self._build_request_payload(request)) as stream:
142
- async for event in stream:
143
- event_type = getattr(event, "type", None)
144
- if event_type == "thinking":
145
- thinking = cast(str | None, getattr(event, "thinking", None))
146
- if thinking:
147
- yield ProviderStreamEvent("thinking_delta", {"text": thinking})
148
- continue
149
- if event_type == "text":
150
- text = cast(str | None, getattr(event, "text", None))
151
- if text:
152
- yield ProviderStreamEvent("text_delta", {"text": text})
153
-
154
- final_message = await stream.get_final_message()
137
+ async with (
138
+ AsyncAnthropic(
139
+ api_key=api_key,
140
+ base_url=self.resolve_base_url(request.api_base),
141
+ timeout=DEFAULT_REQUEST_TIMEOUT,
142
+ ) as client,
143
+ client.messages.stream(**self._build_request_payload(request)) as stream,
144
+ ):
145
+ async for event in stream:
146
+ event_type = getattr(event, "type", None)
147
+ if event_type == "thinking":
148
+ thinking = cast(str | None, getattr(event, "thinking", None))
149
+ if thinking:
150
+ yield ProviderStreamEvent("thinking_delta", {"text": thinking})
151
+ continue
152
+ if event_type == "text":
153
+ text = cast(str | None, getattr(event, "text", None))
154
+ if text:
155
+ yield ProviderStreamEvent("text_delta", {"text": text})
156
+
157
+ final_message = await stream.get_final_message()
155
158
  except APIError as exc:
156
159
  raise ValueError(str(exc)) from exc
157
160
 
@@ -175,7 +178,7 @@ class AnthropicLikeAdapter(ProviderAdapter):
175
178
  blocks.append(
176
179
  thinking_block(
177
180
  getattr(block, "thinking", ""),
178
- meta={"native": native_meta} if native_meta else None,
181
+ meta=native_block_meta(native_meta),
179
182
  )
180
183
  )
181
184
  continue
@@ -185,9 +188,7 @@ class AnthropicLikeAdapter(ProviderAdapter):
185
188
  citations = getattr(block, "citations", None)
186
189
  if citations:
187
190
  native_meta["citations"] = dump_model(citations)
188
- blocks.append(
189
- text_block(getattr(block, "text", ""), meta={"native": native_meta} if native_meta else None)
190
- )
191
+ blocks.append(text_block(getattr(block, "text", ""), meta=native_block_meta(native_meta)))
191
192
  continue
192
193
 
193
194
  if block_type == "tool_use":
@@ -200,7 +201,7 @@ class AnthropicLikeAdapter(ProviderAdapter):
200
201
  tool_id=getattr(block, "id", ""),
201
202
  name=getattr(block, "name", ""),
202
203
  input=getattr(block, "input", None),
203
- meta={"native": native_meta} if native_meta else None,
204
+ meta=native_block_meta(native_meta),
204
205
  )
205
206
  )
206
207
  continue
@@ -68,6 +68,12 @@ def get_native_meta(block: dict[str, Any]) -> dict[str, Any]:
68
68
  return {}
69
69
 
70
70
 
71
+ def native_block_meta(native: dict[str, Any]) -> dict[str, Any] | None:
72
+ """Wrap accumulated native values as block meta, or None when empty."""
73
+
74
+ return {"native": native} if native else None
75
+
76
+
71
77
  class ProviderAdapter(ABC):
72
78
  """Base class for provider adapters.
73
79
 
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from collections.abc import AsyncIterator
6
+ from contextlib import suppress
6
7
  from typing import Any, override
7
8
  from urllib.parse import urlparse
8
9
 
@@ -88,10 +89,8 @@ class GoogleGeminiAdapter(ProviderAdapter):
88
89
  except APIError as exc:
89
90
  raise ValueError(str(exc)) from exc
90
91
  finally:
91
- try:
92
+ with suppress(Exception):
92
93
  await client.aio.aclose()
93
- except Exception:
94
- pass
95
94
 
96
95
  raw_usage = usage or {}
97
96
  total_tokens = raw_usage.get("total_token_count") or None
@@ -19,6 +19,7 @@ from mycode.providers.base import (
19
19
  get_native_meta,
20
20
  load_document_block_payload,
21
21
  load_image_block_payload,
22
+ native_block_meta,
22
23
  )
23
24
  from mycode.utils import omit_none, parse_tool_arguments
24
25
 
@@ -109,7 +110,7 @@ class OpenAIChatAdapter(ProviderAdapter):
109
110
  blocks.append(
110
111
  thinking_block(
111
112
  "".join(thinking_parts),
112
- meta={"native": thinking_native_meta} if thinking_native_meta else None,
113
+ meta=native_block_meta(thinking_native_meta),
113
114
  )
114
115
  )
115
116
  if text_parts:
@@ -248,13 +249,15 @@ class OpenAIChatAdapter(ProviderAdapter):
248
249
  if role != "assistant":
249
250
  return []
250
251
 
251
- text_parts = [str(block.get("text") or "") for block in blocks if block.get("type") == "text"]
252
+ text_parts = [
253
+ str(block.get("text") or "") for block in blocks if block.get("type") == "text" and block.get("text")
254
+ ]
252
255
  thinking_blocks = [block for block in blocks if block.get("type") == "thinking"]
253
256
  tool_use_blocks = [block for block in blocks if block.get("type") == "tool_use"]
254
257
 
255
258
  payload: dict[str, Any] = {
256
259
  "role": "assistant",
257
- "content": "\n".join(part for part in text_parts if part),
260
+ "content": "\n".join(text_parts),
258
261
  }
259
262
 
260
263
  if tool_use_blocks:
@@ -18,6 +18,7 @@ from mycode.providers.base import (
18
18
  dump_model,
19
19
  load_document_block_payload,
20
20
  load_image_block_payload,
21
+ native_block_meta,
21
22
  tool_result_content_blocks,
22
23
  )
23
24
  from mycode.utils import omit_none, parse_tool_arguments
@@ -285,7 +286,7 @@ class OpenAIResponsesAdapter(ProviderAdapter):
285
286
  blocks.append(
286
287
  thinking_block(
287
288
  "".join(text_parts),
288
- meta={"native": item_meta} if item_meta else None,
289
+ meta=native_block_meta(item_meta),
289
290
  )
290
291
  )
291
292
  continue
@@ -301,7 +302,7 @@ class OpenAIResponsesAdapter(ProviderAdapter):
301
302
  blocks.append(
302
303
  text_block(
303
304
  getattr(part, "text", ""),
304
- meta={"native": native_meta} if native_meta else None,
305
+ meta=native_block_meta(native_meta),
305
306
  )
306
307
  )
307
308
  continue
@@ -327,7 +328,7 @@ class OpenAIResponsesAdapter(ProviderAdapter):
327
328
  tool_id=getattr(item, "call_id", ""),
328
329
  name=getattr(item, "name", ""),
329
330
  input=tool_input,
330
- meta={"native": item_meta} if item_meta else None,
331
+ meta=native_block_meta(item_meta),
331
332
  )
332
333
  )
333
334
 
@@ -85,6 +85,12 @@ SessionMetaDict = dict[str, object]
85
85
  SessionIndex = dict[str, SessionMetaDict]
86
86
 
87
87
 
88
+ def _project_meta(raw: dict[str, object]) -> SessionMetaDict:
89
+ """Keep only the known session-meta keys."""
90
+
91
+ return {key: raw[key] for key in META_KEYS if key in raw}
92
+
93
+
88
94
  class SessionData(TypedDict):
89
95
  session: SessionMetaDict
90
96
  messages: list[ConversationMessage]
@@ -138,10 +144,10 @@ class SessionStore:
138
144
  return None
139
145
  if not isinstance(raw, dict):
140
146
  return None
141
- return {key: raw[key] for key in META_KEYS if key in raw}
147
+ return _project_meta(raw)
142
148
 
143
149
  def _write_meta(self, session_id: str, meta: SessionMetaDict) -> None:
144
- meta = {key: meta[key] for key in META_KEYS if key in meta}
150
+ meta = _project_meta(meta)
145
151
  self.meta_path(session_id).write_text(json.dumps(meta, indent=2, ensure_ascii=False), encoding="utf-8")
146
152
  index = self._read_index()
147
153
  index[session_id] = dict(meta)
@@ -154,11 +160,7 @@ class SessionStore:
154
160
  return self._rebuild_index()
155
161
  if not isinstance(raw, dict):
156
162
  return self._rebuild_index()
157
- return {
158
- str(session_id): {key: meta[key] for key in META_KEYS if key in meta}
159
- for session_id, meta in raw.items()
160
- if isinstance(meta, dict)
161
- }
163
+ return {str(session_id): _project_meta(meta) for session_id, meta in raw.items() if isinstance(meta, dict)}
162
164
 
163
165
  def _write_index(self, index: SessionIndex) -> None:
164
166
  self.index_path().write_text(json.dumps(index, indent=2, ensure_ascii=False), encoding="utf-8")
@@ -228,7 +230,6 @@ class SessionStore:
228
230
  if meta is None:
229
231
  return None
230
232
 
231
- # Read the raw append-only log first. Replay happens after that.
232
233
  raw_messages: list[ConversationMessage] = []
233
234
  try:
234
235
  with self.messages_path(session_id).open("r", encoding="utf-8") as handle:
@@ -238,10 +239,10 @@ class SessionStore:
238
239
  continue
239
240
  try:
240
241
  msg = json.loads(line)
241
- if isinstance(msg, dict):
242
- raw_messages.append(cast(ConversationMessage, msg))
243
242
  except ValueError:
244
243
  continue
244
+ if isinstance(msg, dict):
245
+ raw_messages.append(cast(ConversationMessage, msg))
245
246
  except FileNotFoundError:
246
247
  pass
247
248
 
@@ -26,9 +26,11 @@ import typing
26
26
  from base64 import b64encode
27
27
  from collections import deque
28
28
  from collections.abc import Callable, Mapping, Sequence
29
+ from contextlib import suppress
29
30
  from dataclasses import dataclass
30
31
  from difflib import SequenceMatcher, unified_diff
31
32
  from pathlib import Path
33
+ from types import FunctionType
32
34
  from typing import Any, TextIO, cast, overload
33
35
 
34
36
  from griffe import Docstring, DocstringSectionKind, Parser
@@ -222,10 +224,8 @@ def _kill_proc_tree(proc: subprocess.Popen[str]) -> None:
222
224
  else:
223
225
  proc.kill()
224
226
  except Exception:
225
- try:
227
+ with suppress(Exception):
226
228
  proc.kill()
227
- except Exception:
228
- pass
229
229
 
230
230
 
231
231
  # ---------------------------------------------------------------------------
@@ -235,7 +235,7 @@ def _kill_proc_tree(proc: subprocess.Popen[str]) -> None:
235
235
 
236
236
  @overload
237
237
  def tool(
238
- function: Callable[..., Any],
238
+ function: FunctionType,
239
239
  *,
240
240
  name: str | None = None,
241
241
  description: str | None = None,
@@ -252,17 +252,17 @@ def tool(
252
252
  description: str | None = None,
253
253
  parameters: Mapping[str, str] | None = None,
254
254
  streams_output: bool = False,
255
- ) -> Callable[[Callable[..., Any]], ToolSpec]: ...
255
+ ) -> Callable[[FunctionType], ToolSpec]: ...
256
256
 
257
257
 
258
258
  def tool(
259
- function: Callable[..., Any] | None = None,
259
+ function: FunctionType | None = None,
260
260
  *,
261
261
  name: str | None = None,
262
262
  description: str | None = None,
263
263
  parameters: Mapping[str, str] | None = None,
264
264
  streams_output: bool = False,
265
- ) -> ToolSpec | Callable[[Callable[..., Any]], ToolSpec]:
265
+ ) -> ToolSpec | Callable[[FunctionType], ToolSpec]:
266
266
  """Wrap a sync or async Python function as a :class:`ToolSpec`."""
267
267
 
268
268
  current_frame = inspect.currentframe()
@@ -271,7 +271,7 @@ def tool(
271
271
  del current_frame
272
272
  del caller_frame
273
273
 
274
- def wrap(fn: Callable[..., Any]) -> ToolSpec:
274
+ def wrap(fn: FunctionType) -> ToolSpec:
275
275
  tool_name = name or fn.__name__
276
276
  signature = inspect.signature(fn)
277
277
  signature_parameters = list(signature.parameters.values())
@@ -421,7 +421,7 @@ def _coerce_tool_result(value: Any) -> ToolExecutionResult:
421
421
  if isinstance(value, str):
422
422
  return ToolExecutionResult(output=value)
423
423
  try:
424
- text = json.dumps(value, ensure_ascii=False)
424
+ text = json.dumps(value, ensure_ascii=False, default=str)
425
425
  except TypeError:
426
426
  text = str(value)
427
427
  return ToolExecutionResult(output=text)
@@ -901,10 +901,8 @@ def bash_tool(ctx: ToolContext, command: str, timeout: int | None = None) -> Too
901
901
  return ToolExecutionResult(output=f"error: {exc}", is_error=True)
902
902
  finally:
903
903
  if log_file is not None:
904
- try:
904
+ with suppress(Exception):
905
905
  log_file.close()
906
- except Exception:
907
- pass
908
906
  if proc is not None:
909
907
  ctx.untrack_proc(proc)
910
908
  if proc.poll() is None:
File without changes
File without changes
File without changes