axio 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.
- {axio-0.11.2 → axio-0.12.0}/.gitignore +1 -0
- {axio-0.11.2 → axio-0.12.0}/PKG-INFO +24 -10
- {axio-0.11.2 → axio-0.12.0}/README.md +23 -9
- {axio-0.11.2 → axio-0.12.0}/pyproject.toml +1 -1
- {axio-0.11.2 → axio-0.12.0}/src/axio/agent.py +173 -7
- axio-0.12.0/src/axio/credentials/__init__.py +15 -0
- axio-0.12.0/src/axio/credentials/codec.py +148 -0
- axio-0.12.0/src/axio/credentials/files.py +148 -0
- axio-0.12.0/src/axio/credentials/stores.py +326 -0
- axio-0.12.0/src/axio/credentials/types.py +64 -0
- axio-0.12.0/src/axio/instructions.py +151 -0
- axio-0.12.0/src/axio/permission_policy.py +319 -0
- axio-0.12.0/src/axio/plugins.py +233 -0
- {axio-0.11.2 → axio-0.12.0}/src/axio/stream.py +4 -0
- {axio-0.11.2 → axio-0.12.0}/src/axio/testing.py +33 -1
- {axio-0.11.2 → axio-0.12.0}/src/axio/tool.py +83 -3
- axio-0.12.0/src/axio/tool_access.py +50 -0
- axio-0.12.0/src/axio/transport.py +383 -0
- axio-0.12.0/tests/test_agent_instructions.py +304 -0
- axio-0.12.0/tests/test_argument_coercion.py +152 -0
- axio-0.12.0/tests/test_credentials.py +189 -0
- axio-0.12.0/tests/test_instructions.py +107 -0
- axio-0.12.0/tests/test_permission_policy.py +281 -0
- axio-0.12.0/tests/test_plugins.py +220 -0
- axio-0.12.0/tests/test_preflight.py +137 -0
- {axio-0.11.2 → axio-0.12.0}/tests/test_stream.py +13 -0
- {axio-0.11.2 → axio-0.12.0}/tests/test_testing.py +68 -0
- axio-0.12.0/tests/test_transport_plugins.py +113 -0
- axio-0.11.2/src/axio/transport.py +0 -211
- {axio-0.11.2 → axio-0.12.0}/LICENSE +0 -0
- {axio-0.11.2 → axio-0.12.0}/Makefile +0 -0
- {axio-0.11.2 → axio-0.12.0}/src/axio/__init__.py +0 -0
- {axio-0.11.2 → axio-0.12.0}/src/axio/agent_loader.py +0 -0
- {axio-0.11.2 → axio-0.12.0}/src/axio/blocks.py +0 -0
- {axio-0.11.2 → axio-0.12.0}/src/axio/compaction.py +0 -0
- {axio-0.11.2 → axio-0.12.0}/src/axio/context.py +0 -0
- {axio-0.11.2 → axio-0.12.0}/src/axio/events.py +0 -0
- {axio-0.11.2 → axio-0.12.0}/src/axio/exceptions.py +0 -0
- {axio-0.11.2 → axio-0.12.0}/src/axio/field.py +0 -0
- {axio-0.11.2 → axio-0.12.0}/src/axio/messages.py +0 -0
- {axio-0.11.2 → axio-0.12.0}/src/axio/models.py +0 -0
- {axio-0.11.2 → axio-0.12.0}/src/axio/permission.py +0 -0
- {axio-0.11.2 → axio-0.12.0}/src/axio/py.typed +0 -0
- {axio-0.11.2 → axio-0.12.0}/src/axio/realtime.py +0 -0
- {axio-0.11.2 → axio-0.12.0}/src/axio/retry.py +0 -0
- {axio-0.11.2 → axio-0.12.0}/src/axio/schema.py +0 -0
- {axio-0.11.2 → axio-0.12.0}/src/axio/selector.py +0 -0
- {axio-0.11.2 → axio-0.12.0}/src/axio/tool_args.py +0 -0
- {axio-0.11.2 → axio-0.12.0}/src/axio/types.py +0 -0
- {axio-0.11.2 → axio-0.12.0}/tests/conftest.py +0 -0
- {axio-0.11.2 → axio-0.12.0}/tests/test_agent_branch.py +0 -0
- {axio-0.11.2 → axio-0.12.0}/tests/test_agent_capabilities.py +0 -0
- {axio-0.11.2 → axio-0.12.0}/tests/test_agent_loader.py +0 -0
- {axio-0.11.2 → axio-0.12.0}/tests/test_agent_permission.py +0 -0
- {axio-0.11.2 → axio-0.12.0}/tests/test_agent_run.py +0 -0
- {axio-0.11.2 → axio-0.12.0}/tests/test_agent_stream.py +0 -0
- {axio-0.11.2 → axio-0.12.0}/tests/test_agent_tools.py +0 -0
- {axio-0.11.2 → axio-0.12.0}/tests/test_blocks.py +0 -0
- {axio-0.11.2 → axio-0.12.0}/tests/test_compaction.py +0 -0
- {axio-0.11.2 → axio-0.12.0}/tests/test_context.py +0 -0
- {axio-0.11.2 → axio-0.12.0}/tests/test_events.py +0 -0
- {axio-0.11.2 → axio-0.12.0}/tests/test_exceptions.py +0 -0
- {axio-0.11.2 → axio-0.12.0}/tests/test_field.py +0 -0
- {axio-0.11.2 → axio-0.12.0}/tests/test_init.py +0 -0
- {axio-0.11.2 → axio-0.12.0}/tests/test_models.py +0 -0
- {axio-0.11.2 → axio-0.12.0}/tests/test_permission.py +0 -0
- {axio-0.11.2 → axio-0.12.0}/tests/test_realtime.py +0 -0
- {axio-0.11.2 → axio-0.12.0}/tests/test_retry.py +0 -0
- {axio-0.11.2 → axio-0.12.0}/tests/test_schema.py +0 -0
- {axio-0.11.2 → axio-0.12.0}/tests/test_selector.py +0 -0
- {axio-0.11.2 → axio-0.12.0}/tests/test_tool.py +0 -0
- {axio-0.11.2 → axio-0.12.0}/tests/test_tool_args.py +0 -0
- {axio-0.11.2 → axio-0.12.0}/tests/test_transport.py +0 -0
- {axio-0.11.2 → axio-0.12.0}/tests/test_types.py +0 -0
- {axio-0.11.2 → axio-0.12.0}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: axio
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.12.0
|
|
4
4
|
Summary: Minimal, streaming-first, protocol-driven foundation for LLM-powered agents
|
|
5
5
|
Project-URL: Documentation, https://docs.axio-agent.com
|
|
6
6
|
Project-URL: Homepage, https://github.com/mosquito/axio-agent
|
|
@@ -49,9 +49,13 @@ import sys, types
|
|
|
49
49
|
from axio.testing import StubTransport, make_text_response
|
|
50
50
|
|
|
51
51
|
_m = types.ModuleType("axio_transport_openai")
|
|
52
|
+
|
|
53
|
+
|
|
52
54
|
class OpenAITransport(StubTransport):
|
|
53
55
|
def __init__(self, api_key: str = "", model: str = "") -> None:
|
|
54
56
|
super().__init__([make_text_response("Hello, Alice!")])
|
|
57
|
+
|
|
58
|
+
|
|
55
59
|
_m.OpenAITransport = OpenAITransport # type: ignore[attr-defined]
|
|
56
60
|
sys.modules["axio_transport_openai"] = _m
|
|
57
61
|
```
|
|
@@ -63,11 +67,13 @@ from axio.agent import Agent
|
|
|
63
67
|
from axio.context import MemoryContextStore
|
|
64
68
|
from axio.tool import Tool
|
|
65
69
|
|
|
70
|
+
|
|
66
71
|
# 1. Define a tool
|
|
67
72
|
async def greet(name: str) -> str:
|
|
68
73
|
"""Return a greeting for the given name."""
|
|
69
74
|
return f"Hello, {name}!"
|
|
70
75
|
|
|
76
|
+
|
|
71
77
|
greet_tool = Tool(name="greet", description="Greet someone by name", handler=greet)
|
|
72
78
|
|
|
73
79
|
# 2. Wire up the agent (transport comes from an axio-transport-* package)
|
|
@@ -76,12 +82,14 @@ from axio_transport_openai import OpenAITransport
|
|
|
76
82
|
transport = OpenAITransport(api_key="sk-...", model="gpt-4o-mini")
|
|
77
83
|
agent = Agent(system="You are helpful.", transport=transport, tools=[greet_tool])
|
|
78
84
|
|
|
85
|
+
|
|
79
86
|
# 3. Run
|
|
80
87
|
async def main() -> None:
|
|
81
88
|
ctx = MemoryContextStore()
|
|
82
89
|
async for event in agent.run_stream("Please greet Alice", ctx):
|
|
83
90
|
print(event)
|
|
84
91
|
|
|
92
|
+
|
|
85
93
|
asyncio.run(main())
|
|
86
94
|
```
|
|
87
95
|
|
|
@@ -121,11 +129,10 @@ from axio.events import StreamEvent
|
|
|
121
129
|
from axio.messages import Message
|
|
122
130
|
from axio.tool import Tool
|
|
123
131
|
|
|
132
|
+
|
|
124
133
|
@runtime_checkable
|
|
125
134
|
class CompletionTransport(Protocol):
|
|
126
|
-
def stream(
|
|
127
|
-
self, messages: list[Message], tools: list[Tool], system: str
|
|
128
|
-
) -> AsyncIterator[StreamEvent]: ...
|
|
135
|
+
def stream(self, messages: list[Message], tools: list[Tool], system: str) -> AsyncIterator[StreamEvent]: ...
|
|
129
136
|
```
|
|
130
137
|
|
|
131
138
|
### ContextStore
|
|
@@ -135,6 +142,7 @@ class CompletionTransport(Protocol):
|
|
|
135
142
|
from axio.context import ContextStore
|
|
136
143
|
from axio.messages import Message
|
|
137
144
|
|
|
145
|
+
|
|
138
146
|
class MyContextStore(ContextStore):
|
|
139
147
|
def __init__(self) -> None:
|
|
140
148
|
self._messages: list[Message] = []
|
|
@@ -161,6 +169,7 @@ from typing import Any
|
|
|
161
169
|
from axio.permission import PermissionGuard
|
|
162
170
|
from axio.tool import Tool
|
|
163
171
|
|
|
172
|
+
|
|
164
173
|
class MyGuard(PermissionGuard):
|
|
165
174
|
async def check(self, tool: Tool[Any], **kwargs: Any) -> dict[str, Any]:
|
|
166
175
|
# return kwargs to allow, raise GuardError to deny
|
|
@@ -185,16 +194,18 @@ class MyGuard(PermissionGuard):
|
|
|
185
194
|
```python
|
|
186
195
|
from axio.tool import Tool
|
|
187
196
|
|
|
197
|
+
|
|
188
198
|
async def summarise(text: str, max_words: int = 20) -> str:
|
|
189
199
|
"""Summarise the given text in one sentence."""
|
|
190
200
|
# your implementation
|
|
191
201
|
return "..."
|
|
192
202
|
|
|
203
|
+
|
|
193
204
|
tool = Tool(
|
|
194
205
|
name="summarise",
|
|
195
|
-
description="Summarise text",
|
|
206
|
+
description="Summarise text", # overrides docstring if set
|
|
196
207
|
handler=summarise,
|
|
197
|
-
concurrency=4,
|
|
208
|
+
concurrency=4, # max parallel executions
|
|
198
209
|
)
|
|
199
210
|
```
|
|
200
211
|
|
|
@@ -211,11 +222,14 @@ from axio.testing import (
|
|
|
211
222
|
make_echo_tool,
|
|
212
223
|
)
|
|
213
224
|
|
|
225
|
+
|
|
214
226
|
async def test_agent_calls_tool():
|
|
215
|
-
transport = StubTransport(
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
227
|
+
transport = StubTransport(
|
|
228
|
+
[
|
|
229
|
+
make_tool_use_response("echo", tool_input={"msg": "hi"}),
|
|
230
|
+
make_text_response("Done"),
|
|
231
|
+
]
|
|
232
|
+
)
|
|
219
233
|
agent = Agent(system="", tools=[make_echo_tool()], transport=transport)
|
|
220
234
|
result = await agent.run("say hi", make_ephemeral_context())
|
|
221
235
|
assert result == "Done"
|
|
@@ -32,9 +32,13 @@ import sys, types
|
|
|
32
32
|
from axio.testing import StubTransport, make_text_response
|
|
33
33
|
|
|
34
34
|
_m = types.ModuleType("axio_transport_openai")
|
|
35
|
+
|
|
36
|
+
|
|
35
37
|
class OpenAITransport(StubTransport):
|
|
36
38
|
def __init__(self, api_key: str = "", model: str = "") -> None:
|
|
37
39
|
super().__init__([make_text_response("Hello, Alice!")])
|
|
40
|
+
|
|
41
|
+
|
|
38
42
|
_m.OpenAITransport = OpenAITransport # type: ignore[attr-defined]
|
|
39
43
|
sys.modules["axio_transport_openai"] = _m
|
|
40
44
|
```
|
|
@@ -46,11 +50,13 @@ from axio.agent import Agent
|
|
|
46
50
|
from axio.context import MemoryContextStore
|
|
47
51
|
from axio.tool import Tool
|
|
48
52
|
|
|
53
|
+
|
|
49
54
|
# 1. Define a tool
|
|
50
55
|
async def greet(name: str) -> str:
|
|
51
56
|
"""Return a greeting for the given name."""
|
|
52
57
|
return f"Hello, {name}!"
|
|
53
58
|
|
|
59
|
+
|
|
54
60
|
greet_tool = Tool(name="greet", description="Greet someone by name", handler=greet)
|
|
55
61
|
|
|
56
62
|
# 2. Wire up the agent (transport comes from an axio-transport-* package)
|
|
@@ -59,12 +65,14 @@ from axio_transport_openai import OpenAITransport
|
|
|
59
65
|
transport = OpenAITransport(api_key="sk-...", model="gpt-4o-mini")
|
|
60
66
|
agent = Agent(system="You are helpful.", transport=transport, tools=[greet_tool])
|
|
61
67
|
|
|
68
|
+
|
|
62
69
|
# 3. Run
|
|
63
70
|
async def main() -> None:
|
|
64
71
|
ctx = MemoryContextStore()
|
|
65
72
|
async for event in agent.run_stream("Please greet Alice", ctx):
|
|
66
73
|
print(event)
|
|
67
74
|
|
|
75
|
+
|
|
68
76
|
asyncio.run(main())
|
|
69
77
|
```
|
|
70
78
|
|
|
@@ -104,11 +112,10 @@ from axio.events import StreamEvent
|
|
|
104
112
|
from axio.messages import Message
|
|
105
113
|
from axio.tool import Tool
|
|
106
114
|
|
|
115
|
+
|
|
107
116
|
@runtime_checkable
|
|
108
117
|
class CompletionTransport(Protocol):
|
|
109
|
-
def stream(
|
|
110
|
-
self, messages: list[Message], tools: list[Tool], system: str
|
|
111
|
-
) -> AsyncIterator[StreamEvent]: ...
|
|
118
|
+
def stream(self, messages: list[Message], tools: list[Tool], system: str) -> AsyncIterator[StreamEvent]: ...
|
|
112
119
|
```
|
|
113
120
|
|
|
114
121
|
### ContextStore
|
|
@@ -118,6 +125,7 @@ class CompletionTransport(Protocol):
|
|
|
118
125
|
from axio.context import ContextStore
|
|
119
126
|
from axio.messages import Message
|
|
120
127
|
|
|
128
|
+
|
|
121
129
|
class MyContextStore(ContextStore):
|
|
122
130
|
def __init__(self) -> None:
|
|
123
131
|
self._messages: list[Message] = []
|
|
@@ -144,6 +152,7 @@ from typing import Any
|
|
|
144
152
|
from axio.permission import PermissionGuard
|
|
145
153
|
from axio.tool import Tool
|
|
146
154
|
|
|
155
|
+
|
|
147
156
|
class MyGuard(PermissionGuard):
|
|
148
157
|
async def check(self, tool: Tool[Any], **kwargs: Any) -> dict[str, Any]:
|
|
149
158
|
# return kwargs to allow, raise GuardError to deny
|
|
@@ -168,16 +177,18 @@ class MyGuard(PermissionGuard):
|
|
|
168
177
|
```python
|
|
169
178
|
from axio.tool import Tool
|
|
170
179
|
|
|
180
|
+
|
|
171
181
|
async def summarise(text: str, max_words: int = 20) -> str:
|
|
172
182
|
"""Summarise the given text in one sentence."""
|
|
173
183
|
# your implementation
|
|
174
184
|
return "..."
|
|
175
185
|
|
|
186
|
+
|
|
176
187
|
tool = Tool(
|
|
177
188
|
name="summarise",
|
|
178
|
-
description="Summarise text",
|
|
189
|
+
description="Summarise text", # overrides docstring if set
|
|
179
190
|
handler=summarise,
|
|
180
|
-
concurrency=4,
|
|
191
|
+
concurrency=4, # max parallel executions
|
|
181
192
|
)
|
|
182
193
|
```
|
|
183
194
|
|
|
@@ -194,11 +205,14 @@ from axio.testing import (
|
|
|
194
205
|
make_echo_tool,
|
|
195
206
|
)
|
|
196
207
|
|
|
208
|
+
|
|
197
209
|
async def test_agent_calls_tool():
|
|
198
|
-
transport = StubTransport(
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
210
|
+
transport = StubTransport(
|
|
211
|
+
[
|
|
212
|
+
make_tool_use_response("echo", tool_input={"msg": "hi"}),
|
|
213
|
+
make_text_response("Done"),
|
|
214
|
+
]
|
|
215
|
+
)
|
|
202
216
|
agent = Agent(system="", tools=[make_echo_tool()], transport=transport)
|
|
203
217
|
result = await agent.run("say hi", make_ephemeral_context())
|
|
204
218
|
assert result == "Done"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "axio"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.12.0" # bumped to the git tag by CI on publish
|
|
4
4
|
description = "Minimal, streaming-first, protocol-driven foundation for LLM-powered agents"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.12"
|
|
@@ -7,7 +7,7 @@ import dataclasses
|
|
|
7
7
|
import json
|
|
8
8
|
import logging
|
|
9
9
|
import time
|
|
10
|
-
from collections.abc import AsyncGenerator, AsyncIterator, Iterable, Iterator
|
|
10
|
+
from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable, Iterable, Iterator, Mapping
|
|
11
11
|
from contextlib import aclosing, suppress
|
|
12
12
|
from dataclasses import dataclass, field, replace
|
|
13
13
|
from datetime import datetime
|
|
@@ -45,11 +45,15 @@ from .events import (
|
|
|
45
45
|
VideoOutput,
|
|
46
46
|
)
|
|
47
47
|
from .exceptions import StreamError
|
|
48
|
+
from .instructions import LOAD_INSTRUCTION_TOOL, Instruction, InstructionState, LoadedInstruction
|
|
48
49
|
from .messages import Message
|
|
49
50
|
from .models import Capability
|
|
51
|
+
from .permission import PermissionGuard
|
|
52
|
+
from .plugins import ToolProvider, collect_tools
|
|
50
53
|
from .selector import ToolSelector
|
|
51
54
|
from .stream import AgentStream
|
|
52
55
|
from .tool import Tool
|
|
56
|
+
from .tool_access import Access
|
|
53
57
|
from .transport import CompletionTransport
|
|
54
58
|
from .types import StopReason, Usage
|
|
55
59
|
|
|
@@ -281,9 +285,100 @@ class Agent:
|
|
|
281
285
|
selector: ToolSelector | None = field(default=None)
|
|
282
286
|
max_iterations: int = field(default=50)
|
|
283
287
|
last_iteration_message: Message | None = field(default=None)
|
|
288
|
+
before_turn: Callable[[int], Awaitable[None]] | None = field(default=None, kw_only=True)
|
|
289
|
+
before_tools: Callable[[list[ToolUseBlock], int], Awaitable[list[ToolResultBlock]]] | None = field(
|
|
290
|
+
default=None, kw_only=True
|
|
291
|
+
)
|
|
292
|
+
providers: list[ToolProvider] = field(default_factory=list, kw_only=True)
|
|
293
|
+
instructions: list[Instruction] = field(default_factory=list, kw_only=True)
|
|
294
|
+
required_instructions: tuple[str, ...] = field(default=(), kw_only=True)
|
|
295
|
+
before_turn_hooks: tuple[Callable[[int], Awaitable[None]], ...] = field(default=(), kw_only=True)
|
|
296
|
+
before_tools_hooks: tuple[Callable[[list[ToolUseBlock], int], Awaitable[list[ToolResultBlock]]], ...] = field(
|
|
297
|
+
default=(), kw_only=True
|
|
298
|
+
)
|
|
299
|
+
system_contributions: tuple[Callable[[], str], ...] = field(default=(), kw_only=True)
|
|
300
|
+
tool_guards: tuple[PermissionGuard, ...] = field(default=(), kw_only=True)
|
|
301
|
+
_instruction_state: InstructionState = field(default_factory=InstructionState, init=False, repr=False)
|
|
302
|
+
_instruction_tool: Tool[Any] | None = field(default=None, init=False, repr=False)
|
|
303
|
+
_iteration_tools: list[Tool[Any]] | None = field(default=None, init=False, repr=False)
|
|
304
|
+
_guarded_tools: dict[int, tuple[Tool[Any], tuple[PermissionGuard, ...], Tool[Any]]] = field(
|
|
305
|
+
default_factory=dict, init=False, repr=False
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
def _catalog(self) -> list[Instruction]:
|
|
309
|
+
return [*self.instructions, *(item for provider in self.providers for item in provider.get_instructions())]
|
|
310
|
+
|
|
311
|
+
def _base_tools(self) -> list[Tool[Any]]:
|
|
312
|
+
tools = collect_tools(self.providers, tools=self.tools)
|
|
313
|
+
if not self._catalog():
|
|
314
|
+
return tools
|
|
315
|
+
if self._instruction_tool is None:
|
|
316
|
+
self._instruction_tool = Tool(
|
|
317
|
+
name=LOAD_INSTRUCTION_TOOL, handler=self._load_instruction_tool, access=self._instruction_access
|
|
318
|
+
)
|
|
319
|
+
return [*tools, self._instruction_tool]
|
|
320
|
+
|
|
321
|
+
def get_tools(self) -> list[Tool[Any]]:
|
|
322
|
+
"""Return collected tools before selection, including activated instructions."""
|
|
323
|
+
self._instruction_state.synchronize(self._catalog())
|
|
324
|
+
return self._apply_tool_guards(
|
|
325
|
+
self._instruction_state.collect_tools(self._base_tools(), self._instruction_state.loaded.values())
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
def _instruction_access(self, values: Mapping[str, Any]) -> tuple[Access, ...]:
|
|
329
|
+
id = values.get("id")
|
|
330
|
+
for instruction in self._catalog():
|
|
331
|
+
if instruction.id == id:
|
|
332
|
+
return instruction.load_access()
|
|
333
|
+
raise ValueError(f"Unknown instruction: {id!r}")
|
|
334
|
+
|
|
335
|
+
async def load_instruction(self, id: str) -> LoadedInstruction:
|
|
336
|
+
"""Load provider content and stage it for the next model iteration."""
|
|
337
|
+
self._instruction_state.synchronize(self._catalog())
|
|
338
|
+
return await self._instruction_state.load(id, self._base_tools())
|
|
339
|
+
|
|
340
|
+
async def _load_instruction_tool(self, id: str) -> str:
|
|
341
|
+
"""Load a listed instruction and make its associated tools available on the next turn."""
|
|
342
|
+
return (await self.load_instruction(id)).text
|
|
343
|
+
|
|
344
|
+
async def _prepare_instructions(self) -> list[Tool[Any]]:
|
|
345
|
+
self._instruction_state.synchronize(self._catalog())
|
|
346
|
+
try:
|
|
347
|
+
for id in self.required_instructions:
|
|
348
|
+
await self.load_instruction(id)
|
|
349
|
+
return self._instruction_state.commit(self._base_tools())
|
|
350
|
+
except BaseException:
|
|
351
|
+
self._instruction_state.pending.clear()
|
|
352
|
+
raise
|
|
353
|
+
|
|
354
|
+
def _effective_system(self) -> str:
|
|
355
|
+
parts = (self.system, *(part() for part in self.system_contributions), self._instruction_state.render())
|
|
356
|
+
return "\n\n".join(filter(None, parts))
|
|
357
|
+
|
|
358
|
+
def _apply_tool_guards(self, tools: list[Tool[Any]]) -> list[Tool[Any]]:
|
|
359
|
+
"""Append application guards to every tool, including newly loaded tools."""
|
|
360
|
+
if not self.tool_guards:
|
|
361
|
+
self._guarded_tools.clear()
|
|
362
|
+
return tools
|
|
363
|
+
cached: dict[int, tuple[Tool[Any], tuple[PermissionGuard, ...], Tool[Any]]] = {}
|
|
364
|
+
result: list[Tool[Any]] = []
|
|
365
|
+
for tool in tools:
|
|
366
|
+
entry = self._guarded_tools.get(id(tool))
|
|
367
|
+
if entry is None or entry[0] is not tool or entry[1] != self.tool_guards:
|
|
368
|
+
guards = (*tool.guards, *(guard for guard in self.tool_guards if guard not in tool.guards))
|
|
369
|
+
guarded = replace(tool, guards=guards)
|
|
370
|
+
# Guard wrappers share the provider tool's concurrency budget across agents.
|
|
371
|
+
object.__setattr__(guarded, "_semaphore", tool._semaphore)
|
|
372
|
+
entry = (tool, self.tool_guards, guarded)
|
|
373
|
+
cached[id(tool)] = entry
|
|
374
|
+
result.append(entry[2])
|
|
375
|
+
self._guarded_tools = cached
|
|
376
|
+
return result
|
|
284
377
|
|
|
285
378
|
def copy(self, **overrides: Any) -> Self:
|
|
286
|
-
"""Return a new Agent with *overrides* applied."""
|
|
379
|
+
"""Return a new Agent with fresh instruction state and *overrides* applied."""
|
|
380
|
+
overrides.setdefault("instructions", list(self.instructions))
|
|
381
|
+
overrides.setdefault("providers", list(self.providers))
|
|
287
382
|
return dataclasses.replace(self, **overrides)
|
|
288
383
|
|
|
289
384
|
def run_stream(self, user_message: str, context: ContextStore) -> AgentStream:
|
|
@@ -315,11 +410,54 @@ class Agent:
|
|
|
315
410
|
|
|
316
411
|
async def dispatch_tools(self, blocks: list[ToolUseBlock], iteration: int) -> list[ToolResultBlock]:
|
|
317
412
|
logger.info("Dispatching %d tool(s): %r", len(blocks), blocks)
|
|
318
|
-
|
|
413
|
+
runnable, deferred = await self._preflight_tools(blocks, iteration)
|
|
414
|
+
results = [*deferred, *await asyncio.gather(*[self._call_one(b) for b in runnable])]
|
|
319
415
|
error_count = sum(1 for r in results if r.is_error)
|
|
320
416
|
logger.info("Tools complete: %d total, %d errors", len(results), error_count)
|
|
321
417
|
return results
|
|
322
418
|
|
|
419
|
+
async def _preflight_tools(
|
|
420
|
+
self, blocks: list[ToolUseBlock], iteration: int
|
|
421
|
+
) -> tuple[list[ToolUseBlock], list[ToolResultBlock]]:
|
|
422
|
+
"""Get replacement results before any handler in this batch can run.
|
|
423
|
+
|
|
424
|
+
The callback can defer all calls or a subset. Each returned result must
|
|
425
|
+
refer to a distinct call in this batch. Exceptions fail the whole batch;
|
|
426
|
+
cancellation propagates to the normal interrupted-result handling.
|
|
427
|
+
"""
|
|
428
|
+
hooks = (*((self.before_tools,) if self.before_tools is not None else ()), *self.before_tools_hooks)
|
|
429
|
+
if not hooks:
|
|
430
|
+
return blocks, []
|
|
431
|
+
try:
|
|
432
|
+
results: list[ToolResultBlock] = []
|
|
433
|
+
remaining = blocks
|
|
434
|
+
for hook in hooks:
|
|
435
|
+
if not remaining:
|
|
436
|
+
break
|
|
437
|
+
replacement = await hook(remaining, iteration)
|
|
438
|
+
ids = [result.tool_use_id for result in replacement]
|
|
439
|
+
deferred_ids = set(ids)
|
|
440
|
+
if len(deferred_ids) != len(ids) or not deferred_ids <= {block.id for block in remaining}:
|
|
441
|
+
raise ValueError("Tool preflight returned duplicate or unknown call IDs")
|
|
442
|
+
results.extend(replacement)
|
|
443
|
+
remaining = [block for block in remaining if block.id not in deferred_ids]
|
|
444
|
+
if self._instruction_state.pending:
|
|
445
|
+
return [], [
|
|
446
|
+
ToolResultBlock(
|
|
447
|
+
tool_use_id=block.id,
|
|
448
|
+
content="Instructions changed. Review the updated instructions before retrying this batch.",
|
|
449
|
+
is_error=True,
|
|
450
|
+
)
|
|
451
|
+
for block in blocks
|
|
452
|
+
]
|
|
453
|
+
except Exception as error:
|
|
454
|
+
self._instruction_state.pending.clear()
|
|
455
|
+
return [], [
|
|
456
|
+
ToolResultBlock(tool_use_id=block.id, content=f"Tool preflight failed: {error}", is_error=True)
|
|
457
|
+
for block in blocks
|
|
458
|
+
]
|
|
459
|
+
return remaining, results
|
|
460
|
+
|
|
323
461
|
async def _dispatch_tools_streaming(
|
|
324
462
|
self,
|
|
325
463
|
blocks: list[ToolUseBlock],
|
|
@@ -328,6 +466,7 @@ class Agent:
|
|
|
328
466
|
) -> list[ToolResultBlock]:
|
|
329
467
|
"""Like dispatch_tools but pushes ToolOutputDelta events for streaming tools."""
|
|
330
468
|
logger.info("Dispatching %d tool(s) with streaming: %r", len(blocks), blocks)
|
|
469
|
+
runnable, deferred = await self._preflight_tools(blocks, iteration)
|
|
331
470
|
|
|
332
471
|
async def _run_one(block: ToolUseBlock) -> ToolResultBlock:
|
|
333
472
|
tool = self._find_tool(block.name)
|
|
@@ -345,13 +484,24 @@ class Agent:
|
|
|
345
484
|
return ToolResultBlock(tool_use_id=block.id, content=str(exc), is_error=True)
|
|
346
485
|
return ToolResultBlock(tool_use_id=block.id, content=tool.format_stream_result(chunks))
|
|
347
486
|
|
|
348
|
-
results =
|
|
487
|
+
results = [*deferred, *await asyncio.gather(*[_run_one(b) for b in runnable])]
|
|
349
488
|
error_count = sum(1 for r in results if r.is_error)
|
|
350
489
|
logger.info("Tools complete: %d total, %d errors", len(results), error_count)
|
|
351
490
|
return results
|
|
352
491
|
|
|
353
492
|
def _find_tool(self, name: str) -> Tool[Any] | None:
|
|
354
|
-
|
|
493
|
+
return self.get_tool(name)
|
|
494
|
+
|
|
495
|
+
def get_tool(self, name: str) -> Tool[Any] | None:
|
|
496
|
+
"""Return the dispatchable tool, including its access declaration and guards.
|
|
497
|
+
|
|
498
|
+
During a run this uses the current iteration's selected tool set.
|
|
499
|
+
Preflight hooks can inspect effects without accessing a second registry.
|
|
500
|
+
"""
|
|
501
|
+
tools = self._iteration_tools
|
|
502
|
+
if tools is None:
|
|
503
|
+
tools = self.get_tools()
|
|
504
|
+
for tool in tools:
|
|
355
505
|
if tool.name == name:
|
|
356
506
|
return tool
|
|
357
507
|
return None
|
|
@@ -679,6 +829,18 @@ class Agent:
|
|
|
679
829
|
|
|
680
830
|
try:
|
|
681
831
|
for iteration in range(1, self.max_iterations + 1):
|
|
832
|
+
try:
|
|
833
|
+
if self.before_turn is not None:
|
|
834
|
+
await self.before_turn(iteration)
|
|
835
|
+
for hook in self.before_turn_hooks:
|
|
836
|
+
await hook(iteration)
|
|
837
|
+
tools = self._apply_tool_guards(await self._prepare_instructions())
|
|
838
|
+
system = self._effective_system()
|
|
839
|
+
except Exception as exc:
|
|
840
|
+
yield Error(exception=exc)
|
|
841
|
+
yield SessionEndEvent(stop_reason=StopReason.error, total_usage=total_usage)
|
|
842
|
+
session_end_emitted = True
|
|
843
|
+
return
|
|
682
844
|
history = await context.get_history()
|
|
683
845
|
logger.info("Iteration %d, history length=%d", iteration, len(history))
|
|
684
846
|
effective_history = (
|
|
@@ -691,10 +853,11 @@ class Agent:
|
|
|
691
853
|
if model_caps is not None and Capability.tool_use not in model_caps:
|
|
692
854
|
active_tools: list[Tool[Any]] = []
|
|
693
855
|
else:
|
|
694
|
-
active_tools = list(await self._select_tools(effective_history,
|
|
856
|
+
active_tools = list(await self._select_tools(effective_history, tools))
|
|
857
|
+
self._iteration_tools = active_tools
|
|
695
858
|
|
|
696
859
|
turn = _Turn()
|
|
697
|
-
stream = self.transport.stream(effective_history, active_tools,
|
|
860
|
+
stream = self.transport.stream(effective_history, active_tools, system)
|
|
698
861
|
try:
|
|
699
862
|
# `_consume` closes the stream in its `finally`, which runs only on close.
|
|
700
863
|
# Left to the collector, a caller that walked away held the response open.
|
|
@@ -828,3 +991,6 @@ class Agent:
|
|
|
828
991
|
if not session_end_emitted:
|
|
829
992
|
yield SessionEndEvent(stop_reason=StopReason.error, total_usage=total_usage)
|
|
830
993
|
raise
|
|
994
|
+
finally:
|
|
995
|
+
self._iteration_tools = None
|
|
996
|
+
self._instruction_state.pending.clear()
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Typed credential storage shared by transport providers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .codec import CredentialCodec as CredentialCodec
|
|
6
|
+
from .codec import record_id as record_id
|
|
7
|
+
from .codec import record_key as record_key
|
|
8
|
+
from .stores import Credentials as Credentials
|
|
9
|
+
from .stores import FileCredentialStore as FileCredentialStore
|
|
10
|
+
from .stores import SQLiteCredentialStore as SQLiteCredentialStore
|
|
11
|
+
from .stores import default_credentials_path as default_credentials_path
|
|
12
|
+
from .types import CredentialConflict as CredentialConflict
|
|
13
|
+
from .types import CredentialError as CredentialError
|
|
14
|
+
from .types import CredentialStore as CredentialStore
|
|
15
|
+
from .types import complete_operation as complete_operation
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Validate and serialize typed credential dataclasses without provider codecs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
import types
|
|
9
|
+
from dataclasses import asdict, fields, is_dataclass
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from typing import Annotated, Any, Literal, Union, cast, get_args, get_origin, get_type_hints
|
|
12
|
+
|
|
13
|
+
from .types import CredentialError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def record_id(record: object) -> str:
|
|
17
|
+
"""Find the one dataclass field marked with metadata={\"credential_key\": True}."""
|
|
18
|
+
keys: list[object] = []
|
|
19
|
+
|
|
20
|
+
def visit(value: object) -> None:
|
|
21
|
+
if not is_dataclass(value) or isinstance(value, type):
|
|
22
|
+
return
|
|
23
|
+
for item in fields(value):
|
|
24
|
+
child = getattr(value, item.name)
|
|
25
|
+
if item.metadata.get("credential_key"):
|
|
26
|
+
keys.append(child)
|
|
27
|
+
else:
|
|
28
|
+
visit(child)
|
|
29
|
+
|
|
30
|
+
visit(record)
|
|
31
|
+
if len(keys) != 1 or not isinstance(keys[0], str) or not keys[0]:
|
|
32
|
+
raise CredentialError("Credentials require one nonempty string key.")
|
|
33
|
+
return keys[0]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def record_key(key: str) -> str:
|
|
37
|
+
"""Hash record identifiers to keep them out of filesystem paths."""
|
|
38
|
+
if not isinstance(key, str) or not key:
|
|
39
|
+
raise CredentialError("A nonempty credential key is required.")
|
|
40
|
+
# Keep the original prefix so existing stores use the same process locks.
|
|
41
|
+
return "account-" + hashlib.sha256(key.encode()).hexdigest()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def json_value(value: Any) -> Any:
|
|
45
|
+
"""Convert dataclass containers to JSON values without stringifying secrets."""
|
|
46
|
+
if isinstance(value, Enum):
|
|
47
|
+
return json_value(value.value)
|
|
48
|
+
if isinstance(value, dict):
|
|
49
|
+
if any(not isinstance(key, str) for key in value):
|
|
50
|
+
raise ValueError
|
|
51
|
+
return {key: json_value(item) for key, item in value.items()}
|
|
52
|
+
if isinstance(value, (set, frozenset)):
|
|
53
|
+
return sorted((json_value(item) for item in value), key=lambda item: json.dumps(item, sort_keys=True))
|
|
54
|
+
if isinstance(value, (tuple, list)):
|
|
55
|
+
return [json_value(item) for item in value]
|
|
56
|
+
if value is None or type(value) in (str, int, bool):
|
|
57
|
+
return value
|
|
58
|
+
if type(value) is float and math.isfinite(value):
|
|
59
|
+
return value
|
|
60
|
+
raise ValueError
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def restore(value: Any, annotation: Any) -> Any:
|
|
64
|
+
"""Restore declared JSON-compatible types without coercing invalid values."""
|
|
65
|
+
origin, args = get_origin(annotation), get_args(annotation)
|
|
66
|
+
if origin is Annotated:
|
|
67
|
+
return restore(value, args[0])
|
|
68
|
+
if origin in (types.UnionType, Union):
|
|
69
|
+
for member in args:
|
|
70
|
+
try:
|
|
71
|
+
return restore(value, member)
|
|
72
|
+
except (TypeError, ValueError):
|
|
73
|
+
pass
|
|
74
|
+
raise ValueError
|
|
75
|
+
if origin is Literal:
|
|
76
|
+
if any(type(value) is type(choice) and value == choice for choice in args):
|
|
77
|
+
return value
|
|
78
|
+
raise ValueError
|
|
79
|
+
if annotation is type(None) and value is None:
|
|
80
|
+
return None
|
|
81
|
+
if annotation in (str, int, bool) and type(value) is annotation:
|
|
82
|
+
return value
|
|
83
|
+
if annotation is float and type(value) in (int, float) and math.isfinite(value):
|
|
84
|
+
return float(value)
|
|
85
|
+
if isinstance(annotation, type) and issubclass(annotation, Enum):
|
|
86
|
+
for member in annotation:
|
|
87
|
+
if type(value) is type(member.value) and value == member.value:
|
|
88
|
+
return member
|
|
89
|
+
raise ValueError
|
|
90
|
+
if origin in (list, set, frozenset, tuple) and isinstance(value, list):
|
|
91
|
+
if origin is tuple and (not args or args[-1] is not Ellipsis):
|
|
92
|
+
if len(value) != len(args):
|
|
93
|
+
raise ValueError
|
|
94
|
+
return tuple(restore(item, kind) for item, kind in zip(value, args, strict=True))
|
|
95
|
+
return origin(restore(item, args[0]) for item in value)
|
|
96
|
+
if origin is dict and args[0] is str and isinstance(value, dict):
|
|
97
|
+
if any(not isinstance(key, str) for key in value):
|
|
98
|
+
raise ValueError
|
|
99
|
+
return {key: restore(item, args[1]) for key, item in value.items()}
|
|
100
|
+
if isinstance(annotation, type) and is_dataclass(annotation) and isinstance(value, dict):
|
|
101
|
+
declared = {item.name for item in fields(annotation) if item.init}
|
|
102
|
+
if value.keys() != declared:
|
|
103
|
+
raise ValueError
|
|
104
|
+
hints = get_type_hints(annotation, include_extras=True)
|
|
105
|
+
return annotation(**{name: restore(value[name], hints[name]) for name in declared})
|
|
106
|
+
raise ValueError
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class CredentialCodec[T]:
|
|
110
|
+
"""Versioned dataclass records with safe errors and strict type checks."""
|
|
111
|
+
|
|
112
|
+
def __init__(self, record_type: type[T]) -> None:
|
|
113
|
+
if not isinstance(record_type, type) or not is_dataclass(record_type):
|
|
114
|
+
raise TypeError("Credential records must be dataclasses.")
|
|
115
|
+
self.record_type = record_type
|
|
116
|
+
|
|
117
|
+
def from_dict(self, data: Any, key: str | None = None) -> T:
|
|
118
|
+
try:
|
|
119
|
+
if not isinstance(data, dict) or type(data.get("version")) is not int or data["version"] != 1:
|
|
120
|
+
raise ValueError
|
|
121
|
+
record = cast(
|
|
122
|
+
T, restore({name: value for name, value in data.items() if name != "version"}, self.record_type)
|
|
123
|
+
)
|
|
124
|
+
identity = record_id(record)
|
|
125
|
+
if key is not None and identity != key:
|
|
126
|
+
raise ValueError
|
|
127
|
+
return record
|
|
128
|
+
except (ValueError, TypeError, KeyError, OverflowError, RecursionError):
|
|
129
|
+
raise CredentialError("Invalid stored credential record.") from None
|
|
130
|
+
|
|
131
|
+
def to_dict(self, record: T) -> dict[str, Any]:
|
|
132
|
+
try:
|
|
133
|
+
if not isinstance(record, self.record_type) or not is_dataclass(record) or isinstance(record, type):
|
|
134
|
+
raise ValueError
|
|
135
|
+
data: dict[str, Any] = {"version": 1, **json_value(asdict(record))}
|
|
136
|
+
self.from_dict(data, record_id(record))
|
|
137
|
+
return data
|
|
138
|
+
except (ValueError, TypeError, OverflowError, RecursionError):
|
|
139
|
+
raise CredentialError("Invalid credential record.") from None
|
|
140
|
+
|
|
141
|
+
def decode(self, raw: str, key: str | None = None) -> T:
|
|
142
|
+
try:
|
|
143
|
+
return self.from_dict(json.loads(raw), key)
|
|
144
|
+
except (ValueError, TypeError, RecursionError):
|
|
145
|
+
raise CredentialError("Invalid stored credential record.") from None
|
|
146
|
+
|
|
147
|
+
def encode(self, record: T) -> str:
|
|
148
|
+
return json.dumps(self.to_dict(record), allow_nan=False, separators=(",", ":"))
|