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.
- {mycode_sdk-0.11.2 → mycode_sdk-0.12.0}/PKG-INFO +32 -7
- {mycode_sdk-0.11.2 → mycode_sdk-0.12.0}/README.md +30 -6
- {mycode_sdk-0.11.2 → mycode_sdk-0.12.0}/pyproject.toml +2 -1
- {mycode_sdk-0.11.2 → mycode_sdk-0.12.0}/src/mycode/__init__.py +9 -0
- {mycode_sdk-0.11.2 → mycode_sdk-0.12.0}/src/mycode/agent.py +200 -30
- {mycode_sdk-0.11.2 → mycode_sdk-0.12.0}/src/mycode/attachments.py +1 -1
- {mycode_sdk-0.11.2 → mycode_sdk-0.12.0}/src/mycode/compact.py +3 -3
- {mycode_sdk-0.11.2 → mycode_sdk-0.12.0}/src/mycode/messages.py +50 -5
- mycode_sdk-0.12.0/src/mycode/models.py +282 -0
- {mycode_sdk-0.11.2 → mycode_sdk-0.12.0}/src/mycode/models_catalog.json +4825 -119
- {mycode_sdk-0.11.2 → mycode_sdk-0.12.0}/src/mycode/providers/__init__.py +3 -0
- {mycode_sdk-0.11.2 → mycode_sdk-0.12.0}/src/mycode/providers/anthropic_like.py +62 -59
- {mycode_sdk-0.11.2 → mycode_sdk-0.12.0}/src/mycode/providers/base.py +132 -0
- {mycode_sdk-0.11.2 → mycode_sdk-0.12.0}/src/mycode/providers/gemini.py +48 -32
- {mycode_sdk-0.11.2 → mycode_sdk-0.12.0}/src/mycode/providers/openai_chat.py +136 -56
- {mycode_sdk-0.11.2 → mycode_sdk-0.12.0}/src/mycode/providers/openai_responses.py +50 -16
- {mycode_sdk-0.11.2 → mycode_sdk-0.12.0}/src/mycode/session.py +17 -6
- {mycode_sdk-0.11.2 → mycode_sdk-0.12.0}/src/mycode/tools.py +20 -17
- {mycode_sdk-0.11.2 → mycode_sdk-0.12.0}/src/mycode/utils.py +2 -12
- mycode_sdk-0.11.2/src/mycode/models.py +0 -167
- {mycode_sdk-0.11.2 → mycode_sdk-0.12.0}/.gitignore +0 -0
- {mycode_sdk-0.11.2 → mycode_sdk-0.12.0}/LICENSE +0 -0
- {mycode_sdk-0.11.2 → mycode_sdk-0.12.0}/src/mycode/hooks.py +0 -0
- {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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
92
|
+
usage: dict[str, Any] | None = None,
|
|
93
93
|
) -> ConversationMessage:
|
|
94
94
|
meta: dict[str, Any] = {"provider": provider, "model": model}
|
|
95
|
-
if
|
|
96
|
-
meta["
|
|
95
|
+
if usage:
|
|
96
|
+
meta["usage"] = dict(usage)
|
|
97
97
|
return build_message("compact", [text_block(summary_text)], meta=meta)
|
|
98
98
|
|
|
99
99
|
|