mycode-sdk 0.11.2__tar.gz → 0.12.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 (24) hide show
  1. {mycode_sdk-0.11.2 → mycode_sdk-0.12.0}/PKG-INFO +32 -7
  2. {mycode_sdk-0.11.2 → mycode_sdk-0.12.0}/README.md +30 -6
  3. {mycode_sdk-0.11.2 → mycode_sdk-0.12.0}/pyproject.toml +2 -1
  4. {mycode_sdk-0.11.2 → mycode_sdk-0.12.0}/src/mycode/__init__.py +9 -0
  5. {mycode_sdk-0.11.2 → mycode_sdk-0.12.0}/src/mycode/agent.py +200 -30
  6. {mycode_sdk-0.11.2 → mycode_sdk-0.12.0}/src/mycode/attachments.py +1 -1
  7. {mycode_sdk-0.11.2 → mycode_sdk-0.12.0}/src/mycode/compact.py +3 -3
  8. {mycode_sdk-0.11.2 → mycode_sdk-0.12.0}/src/mycode/messages.py +50 -5
  9. mycode_sdk-0.12.0/src/mycode/models.py +282 -0
  10. {mycode_sdk-0.11.2 → mycode_sdk-0.12.0}/src/mycode/models_catalog.json +4825 -119
  11. {mycode_sdk-0.11.2 → mycode_sdk-0.12.0}/src/mycode/providers/__init__.py +3 -0
  12. {mycode_sdk-0.11.2 → mycode_sdk-0.12.0}/src/mycode/providers/anthropic_like.py +62 -59
  13. {mycode_sdk-0.11.2 → mycode_sdk-0.12.0}/src/mycode/providers/base.py +132 -0
  14. {mycode_sdk-0.11.2 → mycode_sdk-0.12.0}/src/mycode/providers/gemini.py +48 -32
  15. {mycode_sdk-0.11.2 → mycode_sdk-0.12.0}/src/mycode/providers/openai_chat.py +136 -56
  16. {mycode_sdk-0.11.2 → mycode_sdk-0.12.0}/src/mycode/providers/openai_responses.py +50 -16
  17. {mycode_sdk-0.11.2 → mycode_sdk-0.12.0}/src/mycode/session.py +17 -6
  18. {mycode_sdk-0.11.2 → mycode_sdk-0.12.0}/src/mycode/tools.py +20 -17
  19. {mycode_sdk-0.11.2 → mycode_sdk-0.12.0}/src/mycode/utils.py +2 -12
  20. mycode_sdk-0.11.2/src/mycode/models.py +0 -167
  21. {mycode_sdk-0.11.2 → mycode_sdk-0.12.0}/.gitignore +0 -0
  22. {mycode_sdk-0.11.2 → mycode_sdk-0.12.0}/LICENSE +0 -0
  23. {mycode_sdk-0.11.2 → mycode_sdk-0.12.0}/src/mycode/hooks.py +0 -0
  24. {mycode_sdk-0.11.2 → mycode_sdk-0.12.0}/src/mycode/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mycode-sdk
3
- Version: 0.11.2
3
+ Version: 0.12.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
@@ -21,13 +21,14 @@ Requires-Python: >=3.12
21
21
  Requires-Dist: anthropic>=0.116.0
22
22
  Requires-Dist: google-genai>=2.11.0
23
23
  Requires-Dist: griffelib>=2.1.0
24
+ Requires-Dist: httpx>=0.28.0
24
25
  Requires-Dist: openai>=2.45.0
25
26
  Requires-Dist: pydantic>=2.13.4
26
27
  Description-Content-Type: text/markdown
27
28
 
28
29
  # mycode-sdk
29
30
 
30
- Lightweight Python SDK for building AI agents.
31
+ Lightweight Python SDK for building AI agents. Multi-turn conversations, tool calling, session persistence, and streaming events. Provider adapters for Anthropic, OpenAI, Google, and more.
31
32
 
32
33
  ## Install
33
34
 
@@ -62,16 +63,36 @@ asyncio.run(main())
62
63
 
63
64
  `Agent(...)` infers the provider from the model id. No tools are registered unless you pass `tools=[...]`.
64
65
 
65
- For a simple synchronous call, use `run()`:
66
+ For a synchronous call, use `run()`:
66
67
 
67
68
  ```python
68
69
  result = agent.run("Read pyproject.toml and tell me the project name.")
69
70
  print(result.text)
70
71
  ```
71
72
 
73
+ ## Providers
74
+
75
+ The SDK infers the provider from the model string (`claude-*` to Anthropic, `gpt-*` to OpenAI, etc.). API keys are auto-discovered from environment variables:
76
+
77
+ | Provider | id | Env var |
78
+ | --- | --- | --- |
79
+ | Anthropic | `anthropic` | `ANTHROPIC_API_KEY` |
80
+ | OpenAI | `openai` | `OPENAI_API_KEY` |
81
+ | Google Gemini | `google` | `GEMINI_API_KEY` |
82
+ | Moonshot | `moonshotai` | `MOONSHOT_API_KEY` |
83
+ | MiniMax | `minimax` | `MINIMAX_API_KEY` |
84
+ | DeepSeek | `deepseek` | `DEEPSEEK_API_KEY` |
85
+ | Z.AI | `zai` | `ZAI_API_KEY` |
86
+ | OpenRouter | `openrouter` | `OPENROUTER_API_KEY` |
87
+ | Alibaba Cloud | `alibaba` | `DASHSCOPE_API_KEY` |
88
+ | xAI | `xai` | `XAI_API_KEY` |
89
+ | OpenAI-compatible | `openai_chat` | - |
90
+
91
+ Pass `api_key=` to override the env var, `api_base=` for a custom endpoint. Model metadata is bundled from [models.dev](https://models.dev); pass `context_window`, `supports_reasoning`, `supports_image_input`, or `supports_pdf_input` to override.
92
+
72
93
  ## Multi-turn conversations
73
94
 
74
- Call `achat()` or `run()` again on the same `Agent` to continue the conversation:
95
+ Call `achat()` or `run()` again on the same `Agent` to continue:
75
96
 
76
97
  ```python
77
98
  agent = Agent(model="claude-sonnet-4-6", api_key="...")
@@ -80,6 +101,8 @@ agent.run("What is 2 + 2?")
80
101
  agent.run("Now multiply that by 10.") # remembers the earlier answer
81
102
  ```
82
103
 
104
+ `agent.clear()` drops in-memory history. `agent.messages` accumulates across calls.
105
+
83
106
  ## Attachments
84
107
 
85
108
  Pass `attachments` to `achat()` or `run()` to add files alongside the prompt:
@@ -89,7 +112,7 @@ from mycode import Attachment
89
112
 
90
113
  agent.run("Describe these.", attachments=["diagram.png", "report.pdf", "notes.txt"])
91
114
 
92
- # Or build them explicitly — raw bytes and inline text never touch the disk:
115
+ # Or build them explicitly:
93
116
  agent.run(
94
117
  "Review.",
95
118
  attachments=[
@@ -100,7 +123,7 @@ agent.run(
100
123
  )
101
124
  ```
102
125
 
103
- Images support `image/png`, `image/jpeg`, `image/gif`, `image/webp`; documents support `application/pdf`. Sending an image or PDF to a model that lacks that capability yields an `error` event; a bad path or unsupported type raises `ValueError` before the model is called.
126
+ Images support `image/png`, `image/jpeg`, `image/gif`, `image/webp`; documents support `application/pdf`. Sending an image or PDF to a model without that capability yields an `error` event. A bad path or unsupported type raises `ValueError` before the provider is called.
104
127
 
105
128
  ## Saving sessions
106
129
 
@@ -197,4 +220,6 @@ agent = Agent(
197
220
 
198
221
  `@hooks.after_tool` runs after the tool and can replace the result (audit, redact, etc.).
199
222
 
200
- See [docs/sdk.md](../docs/sdk.md) for the event stream, cancellation, sessions, and the full `Agent` / `@tool` reference.
223
+ ## Further reading
224
+
225
+ See [docs/sdk.md](https://github.com/legibet/mycode/blob/main/docs/sdk.md) for the streaming event API, cancellation, retries, compaction, session internals, and the full `Agent` / `@tool` reference.
@@ -1,6 +1,6 @@
1
1
  # mycode-sdk
2
2
 
3
- Lightweight Python SDK for building AI agents.
3
+ Lightweight Python SDK for building AI agents. Multi-turn conversations, tool calling, session persistence, and streaming events. Provider adapters for Anthropic, OpenAI, Google, and more.
4
4
 
5
5
  ## Install
6
6
 
@@ -35,16 +35,36 @@ asyncio.run(main())
35
35
 
36
36
  `Agent(...)` infers the provider from the model id. No tools are registered unless you pass `tools=[...]`.
37
37
 
38
- For a simple synchronous call, use `run()`:
38
+ For a synchronous call, use `run()`:
39
39
 
40
40
  ```python
41
41
  result = agent.run("Read pyproject.toml and tell me the project name.")
42
42
  print(result.text)
43
43
  ```
44
44
 
45
+ ## Providers
46
+
47
+ The SDK infers the provider from the model string (`claude-*` to Anthropic, `gpt-*` to OpenAI, etc.). API keys are auto-discovered from environment variables:
48
+
49
+ | Provider | id | Env var |
50
+ | --- | --- | --- |
51
+ | Anthropic | `anthropic` | `ANTHROPIC_API_KEY` |
52
+ | OpenAI | `openai` | `OPENAI_API_KEY` |
53
+ | Google Gemini | `google` | `GEMINI_API_KEY` |
54
+ | Moonshot | `moonshotai` | `MOONSHOT_API_KEY` |
55
+ | MiniMax | `minimax` | `MINIMAX_API_KEY` |
56
+ | DeepSeek | `deepseek` | `DEEPSEEK_API_KEY` |
57
+ | Z.AI | `zai` | `ZAI_API_KEY` |
58
+ | OpenRouter | `openrouter` | `OPENROUTER_API_KEY` |
59
+ | Alibaba Cloud | `alibaba` | `DASHSCOPE_API_KEY` |
60
+ | xAI | `xai` | `XAI_API_KEY` |
61
+ | OpenAI-compatible | `openai_chat` | - |
62
+
63
+ Pass `api_key=` to override the env var, `api_base=` for a custom endpoint. Model metadata is bundled from [models.dev](https://models.dev); pass `context_window`, `supports_reasoning`, `supports_image_input`, or `supports_pdf_input` to override.
64
+
45
65
  ## Multi-turn conversations
46
66
 
47
- Call `achat()` or `run()` again on the same `Agent` to continue the conversation:
67
+ Call `achat()` or `run()` again on the same `Agent` to continue:
48
68
 
49
69
  ```python
50
70
  agent = Agent(model="claude-sonnet-4-6", api_key="...")
@@ -53,6 +73,8 @@ agent.run("What is 2 + 2?")
53
73
  agent.run("Now multiply that by 10.") # remembers the earlier answer
54
74
  ```
55
75
 
76
+ `agent.clear()` drops in-memory history. `agent.messages` accumulates across calls.
77
+
56
78
  ## Attachments
57
79
 
58
80
  Pass `attachments` to `achat()` or `run()` to add files alongside the prompt:
@@ -62,7 +84,7 @@ from mycode import Attachment
62
84
 
63
85
  agent.run("Describe these.", attachments=["diagram.png", "report.pdf", "notes.txt"])
64
86
 
65
- # Or build them explicitly — raw bytes and inline text never touch the disk:
87
+ # Or build them explicitly:
66
88
  agent.run(
67
89
  "Review.",
68
90
  attachments=[
@@ -73,7 +95,7 @@ agent.run(
73
95
  )
74
96
  ```
75
97
 
76
- Images support `image/png`, `image/jpeg`, `image/gif`, `image/webp`; documents support `application/pdf`. Sending an image or PDF to a model that lacks that capability yields an `error` event; a bad path or unsupported type raises `ValueError` before the model is called.
98
+ Images support `image/png`, `image/jpeg`, `image/gif`, `image/webp`; documents support `application/pdf`. Sending an image or PDF to a model without that capability yields an `error` event. A bad path or unsupported type raises `ValueError` before the provider is called.
77
99
 
78
100
  ## Saving sessions
79
101
 
@@ -170,4 +192,6 @@ agent = Agent(
170
192
 
171
193
  `@hooks.after_tool` runs after the tool and can replace the result (audit, redact, etc.).
172
194
 
173
- See [docs/sdk.md](../docs/sdk.md) for the event stream, cancellation, sessions, and the full `Agent` / `@tool` reference.
195
+ ## Further reading
196
+
197
+ See [docs/sdk.md](https://github.com/legibet/mycode/blob/main/docs/sdk.md) for the streaming event API, cancellation, retries, compaction, session internals, 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.11.2"
7
+ version = "0.12.0"
8
8
  description = "Lightweight Python SDK for building AI agents."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -26,6 +26,7 @@ dependencies = [
26
26
  "anthropic>=0.116.0",
27
27
  "google-genai>=2.11.0",
28
28
  "griffelib>=2.1.0",
29
+ "httpx>=0.28.0",
29
30
  "openai>=2.45.0",
30
31
  "pydantic>=2.13.4",
31
32
  ]
@@ -11,6 +11,7 @@ from mycode.messages import (
11
11
  ConversationMessage,
12
12
  assistant_message,
13
13
  build_message,
14
+ build_usage,
14
15
  document_block,
15
16
  flatten_message_text,
16
17
  image_block,
@@ -20,6 +21,8 @@ from mycode.messages import (
20
21
  tool_use_block,
21
22
  user_text_message,
22
23
  )
24
+ from mycode.models import ModelMetadata, estimate_cost, resolve_model_metadata
25
+ from mycode.providers.base import ProviderError, StreamStartTimeoutError
23
26
  from mycode.session import SessionStore
24
27
  from mycode.tools import (
25
28
  ToolContext,
@@ -46,10 +49,13 @@ __all__ = [
46
49
  "BeforeToolHook",
47
50
  "HookResult",
48
51
  "Hooks",
52
+ "ModelMetadata",
49
53
  "NothingToCompactError",
50
54
  "PersistCallback",
55
+ "ProviderError",
51
56
  "RunResult",
52
57
  "SessionStore",
58
+ "StreamStartTimeoutError",
53
59
  "ToolContext",
54
60
  "ToolExecutionResult",
55
61
  "ToolExecutor",
@@ -59,12 +65,15 @@ __all__ = [
59
65
  "assistant_message",
60
66
  "bash_tool",
61
67
  "build_message",
68
+ "build_usage",
62
69
  "cancel_all_tools",
63
70
  "document_block",
64
71
  "edit_tool",
72
+ "estimate_cost",
65
73
  "flatten_message_text",
66
74
  "image_block",
67
75
  "read_tool",
76
+ "resolve_model_metadata",
68
77
  "text_block",
69
78
  "thinking_block",
70
79
  "tool",
@@ -10,6 +10,7 @@ from __future__ import annotations
10
10
  import asyncio
11
11
  import logging
12
12
  import os
13
+ import random
13
14
  import tempfile
14
15
  import time
15
16
  from collections.abc import AsyncIterator, Awaitable, Callable, Sequence
@@ -30,6 +31,7 @@ from mycode.compact import (
30
31
  )
31
32
  from mycode.hooks import Hooks, ToolHookContext
32
33
  from mycode.messages import (
34
+ USAGE_TOKEN_KEYS,
33
35
  ConversationMessage,
34
36
  build_message,
35
37
  flatten_message_text,
@@ -38,9 +40,16 @@ from mycode.messages import (
38
40
  tool_result_block,
39
41
  user_text_message,
40
42
  )
41
- from mycode.models import infer_provider_from_model, resolve_model_metadata
43
+ from mycode.models import estimate_cost, infer_provider_from_model, resolve_model_metadata
42
44
  from mycode.providers import get_provider_adapter
43
- from mycode.providers.base import ProviderAdapter, ProviderRequest, ProviderStreamEvent
45
+ from mycode.providers.base import (
46
+ DEFAULT_REQUEST_TIMEOUT,
47
+ ProviderAdapter,
48
+ ProviderError,
49
+ ProviderRequest,
50
+ ProviderStreamEvent,
51
+ StreamStartTimeoutError,
52
+ )
44
53
  from mycode.session import SessionStore
45
54
  from mycode.tools import ToolContext, ToolExecutionResult, ToolExecutor, ToolSpec
46
55
 
@@ -64,6 +73,29 @@ class RunResult:
64
73
  text: str = ""
65
74
  events: list[Event] = field(default_factory=list)
66
75
  error: str | None = None
76
+ usage: dict[str, Any] | None = None
77
+
78
+
79
+ def _accumulate_usage(
80
+ turn_usage: dict[str, Any],
81
+ turn_cost: float | None,
82
+ usage: dict[str, Any],
83
+ cost: dict[str, Any] | None,
84
+ ) -> float | None:
85
+ """Fold one provider request's usage into the turn accumulator.
86
+
87
+ Mutates ``turn_usage`` and returns the updated best-effort turn cost.
88
+ """
89
+
90
+ for key in USAGE_TOKEN_KEYS:
91
+ value = usage.get(key)
92
+ if value is None:
93
+ continue
94
+ turn_usage[key] = turn_usage.get(key, 0) + value
95
+ request_cost = estimate_cost(usage, cost)
96
+ if request_cost is None:
97
+ return turn_cost
98
+ return request_cost if turn_cost is None else turn_cost + request_cost
67
99
 
68
100
 
69
101
  class Agent:
@@ -83,6 +115,9 @@ class Agent:
83
115
  max_turns: int | None = None,
84
116
  max_tokens: int | None = None,
85
117
  temperature: float = 1.0,
118
+ request_timeout: float = DEFAULT_REQUEST_TIMEOUT,
119
+ stream_start_timeout: float = 60.0,
120
+ max_retries: int = 2,
86
121
  context_window: int | None = None,
87
122
  compact_threshold: float | None = None,
88
123
  reasoning_effort: str | None = None,
@@ -116,6 +151,15 @@ class Agent:
116
151
  self.api_key = api_key
117
152
  self.api_base = api_base
118
153
  self.max_turns = max_turns
154
+ if request_timeout <= 0:
155
+ raise ValueError("request_timeout must be positive")
156
+ if stream_start_timeout <= 0:
157
+ raise ValueError("stream_start_timeout must be positive")
158
+ if max_retries < 0:
159
+ raise ValueError("max_retries must be >= 0")
160
+ self.request_timeout = float(request_timeout)
161
+ self.stream_start_timeout = float(stream_start_timeout)
162
+ self.max_retries = int(max_retries)
119
163
  if not 0 <= temperature <= 1:
120
164
  raise ValueError("temperature must be between 0 and 1")
121
165
  if (
@@ -200,6 +244,7 @@ class Agent:
200
244
  )
201
245
  self.max_tokens: int = meta.max_output_tokens or 16_384
202
246
  self.context_window: int = meta.context_window or 128_000
247
+ self.model_cost: dict[str, Any] | None = meta.cost
203
248
  self.supports_reasoning: bool | None = meta.supports_reasoning
204
249
  self.supports_image_input: bool = bool(meta.supports_image_input)
205
250
  self.supports_pdf_input: bool = bool(meta.supports_pdf_input)
@@ -438,9 +483,55 @@ class Agent:
438
483
  adapter: ProviderAdapter,
439
484
  request: ProviderRequest,
440
485
  ) -> AsyncIterator[ProviderStreamEvent]:
441
- """Iterate one provider turn with best-effort cancellation support."""
486
+ """Stream one provider turn, retrying failed attempts before any output.
487
+
488
+ Yields an internal ``retry`` event before each new attempt. Once a
489
+ canonical event (thinking/text delta or message_done) has been yielded,
490
+ a partially consumed stream cannot be replayed safely, so failures
491
+ propagate instead of retrying. Adapter ``stream_started`` markers are
492
+ consumed here and never reach the caller.
493
+ """
494
+
495
+ max_attempts = self.max_retries + 1
496
+ for attempt in range(1, max_attempts + 1):
497
+ output_emitted = False
498
+ try:
499
+ async for event in self._stream_provider_attempt(adapter, request):
500
+ if event.type == "stream_started":
501
+ continue
502
+ output_emitted = True
503
+ yield event
504
+ return
505
+ except ProviderError as exc:
506
+ if output_emitted or not exc.retryable or attempt >= max_attempts:
507
+ raise
508
+ delay = self._retry_delay(attempt, exc)
509
+ retry_data: dict[str, Any] = {
510
+ "attempt": attempt + 1,
511
+ "max_attempts": max_attempts,
512
+ "delay_seconds": round(delay, 3),
513
+ "reason": exc.reason,
514
+ "message": str(exc),
515
+ }
516
+ if exc.status_code is not None:
517
+ retry_data["status_code"] = exc.status_code
518
+ yield ProviderStreamEvent("retry", retry_data)
519
+ await self._backoff(delay)
520
+
521
+ async def _stream_provider_attempt(
522
+ self,
523
+ adapter: ProviderAdapter,
524
+ request: ProviderRequest,
525
+ ) -> AsyncIterator[ProviderStreamEvent]:
526
+ """Iterate one provider attempt with cancellation and the start deadline.
527
+
528
+ The deadline covers everything up to the first upstream event: DNS,
529
+ connect, request upload, response headers, and the wait for the first
530
+ SSE event or chunk.
531
+ """
442
532
 
443
533
  provider_stream: AsyncIterator[ProviderStreamEvent] = adapter.stream_turn(request)
534
+ started = False
444
535
 
445
536
  try:
446
537
  while True:
@@ -449,17 +540,49 @@ class Agent:
449
540
 
450
541
  self._provider_event_task = asyncio.ensure_future(anext(provider_stream))
451
542
  try:
452
- yield await self._provider_event_task
543
+ if started:
544
+ event = await self._provider_event_task
545
+ else:
546
+ try:
547
+ # wait_for cancels the pending anext and awaits its
548
+ # cancellation before raising.
549
+ event = await asyncio.wait_for(self._provider_event_task, self.stream_start_timeout)
550
+ except TimeoutError:
551
+ raise StreamStartTimeoutError(
552
+ f"no provider stream event received within {self.stream_start_timeout:g}s"
553
+ ) from None
453
554
  except StopAsyncIteration:
454
555
  return
455
556
  finally:
456
557
  self._provider_event_task = None
558
+
559
+ started = True
560
+ yield event
457
561
  finally:
562
+ # Runs before the retry loop backs off, so a failed attempt's
563
+ # stream is closed before the next attempt starts.
458
564
  close = cast(Callable[[], Awaitable[None]] | None, getattr(provider_stream, "aclose", None))
459
565
  if close is not None:
460
566
  with suppress(Exception):
461
567
  await close()
462
568
 
569
+ def _retry_delay(self, failed_attempts: int, error: ProviderError) -> float:
570
+ """Backoff before the next attempt: Retry-After when sane, else exponential."""
571
+
572
+ if error.retry_after is not None and 0 < error.retry_after <= 60:
573
+ return error.retry_after
574
+ base = min(8.0, 0.5 * 2 ** (failed_attempts - 1))
575
+ return base * (1 - 0.25 * random.random())
576
+
577
+ async def _backoff(self, delay: float) -> None:
578
+ """Wait between attempts; Agent.cancel() interrupts immediately."""
579
+
580
+ try:
581
+ await asyncio.wait_for(self._cancel_event.wait(), timeout=delay)
582
+ except TimeoutError:
583
+ return
584
+ raise asyncio.CancelledError
585
+
463
586
  def _build_request(
464
587
  self,
465
588
  *,
@@ -486,6 +609,7 @@ class Agent:
486
609
  supports_pdf_input=self.supports_pdf_input,
487
610
  transcript_path=self.transcript_path,
488
611
  append_messages=list(append_messages),
612
+ request_timeout=self.request_timeout,
489
613
  )
490
614
 
491
615
  @staticmethod
@@ -504,6 +628,43 @@ class Agent:
504
628
  block["meta"] = {**meta, "duration_ms": duration_ms}
505
629
  return
506
630
 
631
+ def _partial_assistant_message(
632
+ self,
633
+ partial_content: list[dict[str, Any]],
634
+ duration_ms: int | None,
635
+ *,
636
+ stop_reason: str | None = None,
637
+ ) -> ConversationMessage:
638
+ """Build the assistant message persisted for an interrupted stream."""
639
+
640
+ if duration_ms is not None:
641
+ self._stamp_thinking_duration(partial_content, duration_ms)
642
+ meta: dict[str, Any] = {
643
+ "provider": self.provider,
644
+ "model": self.model,
645
+ "context_window": self.context_window,
646
+ }
647
+ if stop_reason:
648
+ meta["stop_reason"] = stop_reason
649
+ return build_message("assistant", [dict(block) for block in partial_content], meta=meta)
650
+
651
+ def _usage_event(
652
+ self,
653
+ context_tokens: int | None,
654
+ turn_usage: dict[str, Any],
655
+ turn_cost: float | None,
656
+ ) -> Event:
657
+ """Build a usage event: turn-cumulative billing facts + context metric."""
658
+
659
+ return Event(
660
+ "usage",
661
+ {
662
+ "context_tokens": context_tokens,
663
+ "turn_usage": dict(turn_usage),
664
+ "turn_cost_usd": turn_cost,
665
+ },
666
+ )
667
+
507
668
  async def _persist_message(
508
669
  self,
509
670
  message: ConversationMessage,
@@ -575,6 +736,9 @@ class Agent:
575
736
 
576
737
  adapter = get_provider_adapter(self.provider)
577
738
 
739
+ turn_usage: dict[str, Any] = {}
740
+ turn_cost: float | None = None
741
+ context_tokens: int | None = None
578
742
  turn_number = 0
579
743
  while True:
580
744
  if self.max_turns is not None and turn_number >= self.max_turns:
@@ -598,6 +762,10 @@ class Agent:
598
762
  provider_cancelled = True
599
763
  break
600
764
 
765
+ if provider_event.type == "retry":
766
+ yield Event("retry", dict(provider_event.data))
767
+ continue
768
+
601
769
  if provider_event.type == "thinking_delta":
602
770
  delta_text = str(provider_event.data.get("text") or "")
603
771
  if delta_text:
@@ -641,6 +809,17 @@ class Agent:
641
809
  provider_cancelled = True
642
810
  except Exception as exc:
643
811
  logger.exception("Provider request failed")
812
+ if partial_content:
813
+ # Output already reached the caller, so the attempt was not
814
+ # retried; keep the JSONL consistent with what was shown.
815
+ # stop_reason="error" excludes the partial from replay.
816
+ if thinking_started_at is not None and thinking_duration_ms is None:
817
+ thinking_duration_ms = self._elapsed_ms(thinking_started_at)
818
+ failed_message = self._partial_assistant_message(
819
+ partial_content, thinking_duration_ms, stop_reason="error"
820
+ )
821
+ self.messages.append(failed_message)
822
+ await persist(failed_message)
644
823
  yield Event("error", {"message": str(exc)})
645
824
  return
646
825
 
@@ -648,17 +827,7 @@ class Agent:
648
827
  if partial_content:
649
828
  if thinking_started_at is not None and thinking_duration_ms is None:
650
829
  thinking_duration_ms = self._elapsed_ms(thinking_started_at)
651
- if thinking_duration_ms is not None:
652
- self._stamp_thinking_duration(partial_content, thinking_duration_ms)
653
- cancelled_message = build_message(
654
- "assistant",
655
- [dict(block) for block in partial_content],
656
- meta={
657
- "provider": self.provider,
658
- "model": self.model,
659
- "context_window": self.context_window,
660
- },
661
- )
830
+ cancelled_message = self._partial_assistant_message(partial_content, thinking_duration_ms)
662
831
  self.messages.append(cancelled_message)
663
832
  await persist(cancelled_message)
664
833
  yield Event("error", {"message": "cancelled"})
@@ -680,17 +849,10 @@ class Agent:
680
849
  self.messages.append(assistant_message)
681
850
  await persist(assistant_message)
682
851
 
683
- total_tokens = meta.get("total_tokens")
684
- if total_tokens:
685
- yield Event(
686
- "usage",
687
- {
688
- "total_tokens": total_tokens,
689
- "model": meta.get("model") or self.model,
690
- "provider": meta.get("provider") or self.provider,
691
- "context_window": meta["context_window"],
692
- },
693
- )
852
+ request_usage = cast(dict[str, Any], meta.get("usage") or {})
853
+ context_tokens = request_usage.get("total_tokens")
854
+ turn_cost = _accumulate_usage(turn_usage, turn_cost, request_usage, self.model_cost)
855
+ yield self._usage_event(context_tokens, turn_usage, turn_cost)
694
856
 
695
857
  tool_calls = [
696
858
  block
@@ -728,10 +890,16 @@ class Agent:
728
890
 
729
891
  if self._cancel_event.is_set():
730
892
  return
731
- if should_compact(total_tokens, self.context_window, self.compact_threshold):
893
+ if should_compact(context_tokens, self.context_window, self.compact_threshold):
732
894
  try:
733
- await self._compact(adapter, on_persist)
895
+ compact_marker = await self._compact(adapter, on_persist)
734
896
  yield Event("compact", {})
897
+ # The summary call is a billed provider request; its total
898
+ # is not the post-compact context size, so context_tokens
899
+ # keeps the last normal request's value.
900
+ compact_usage = cast(dict[str, Any], (compact_marker.get("meta") or {}).get("usage") or {})
901
+ turn_cost = _accumulate_usage(turn_usage, turn_cost, compact_usage, self.model_cost)
902
+ yield self._usage_event(context_tokens, turn_usage, turn_cost)
735
903
  except asyncio.CancelledError:
736
904
  yield Event("error", {"message": "cancelled"})
737
905
  return
@@ -768,6 +936,8 @@ class Agent:
768
936
  result.events.append(event)
769
937
  if event.type == "text":
770
938
  result.text += str(event.data.get("delta") or "")
939
+ elif event.type == "usage":
940
+ result.usage = event.data
771
941
  elif event.type == "error" and result.error is None:
772
942
  result.error = str(event.data.get("message") or "")
773
943
  return result
@@ -844,12 +1014,12 @@ class Agent:
844
1014
  if self._cancel_event.is_set():
845
1015
  raise asyncio.CancelledError
846
1016
 
847
- summary_total_tokens = (summary_message.get("meta") or {}).get("total_tokens")
1017
+ summary_meta = cast(dict[str, Any], summary_message.get("meta") or {})
848
1018
  compact_event = build_compact_event(
849
1019
  summary_text,
850
1020
  provider=self.provider,
851
1021
  model=self.model,
852
- total_tokens=summary_total_tokens,
1022
+ usage=summary_meta.get("usage"),
853
1023
  )
854
1024
 
855
1025
  await self._persist_message(compact_event, on_persist)
@@ -86,7 +86,7 @@ def build_attachment_blocks(
86
86
  supported = sorted(SUPPORTED_IMAGE_MIME_TYPES | SUPPORTED_DOCUMENT_MIME_TYPES)
87
87
  raise ValueError(f"unsupported media_type {media_type!r}; want one of {supported}")
88
88
  case Path() as raw:
89
- path = Path(resolve_path(str(raw), cwd=cwd))
89
+ path = resolve_path(raw, cwd=cwd)
90
90
  if not path.exists():
91
91
  raise ValueError(f"attachment not found: {raw}")
92
92
  if path.is_dir():
@@ -89,11 +89,11 @@ def build_compact_event(
89
89
  *,
90
90
  provider: str,
91
91
  model: str,
92
- total_tokens: int | None = None,
92
+ usage: dict[str, Any] | None = None,
93
93
  ) -> ConversationMessage:
94
94
  meta: dict[str, Any] = {"provider": provider, "model": model}
95
- if total_tokens is not None:
96
- meta["total_tokens"] = total_tokens
95
+ if usage:
96
+ meta["usage"] = dict(usage)
97
97
  return build_message("compact", [text_block(summary_text)], meta=meta)
98
98
 
99
99