axio 0.7.0__tar.gz → 0.9.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.7.0 → axio-0.9.0}/PKG-INFO +26 -33
- {axio-0.7.0 → axio-0.9.0}/README.md +24 -30
- {axio-0.7.0 → axio-0.9.0}/pyproject.toml +4 -3
- axio-0.9.0/src/axio/__init__.py +52 -0
- {axio-0.7.0 → axio-0.9.0}/src/axio/agent.py +2 -2
- {axio-0.7.0 → axio-0.9.0}/src/axio/agent_loader.py +21 -28
- {axio-0.7.0 → axio-0.9.0}/src/axio/compaction.py +2 -2
- axio-0.9.0/src/axio/field.py +179 -0
- axio-0.9.0/src/axio/permission.py +66 -0
- axio-0.9.0/src/axio/schema.py +145 -0
- {axio-0.7.0 → axio-0.9.0}/src/axio/testing.py +4 -7
- axio-0.9.0/src/axio/tool.py +199 -0
- {axio-0.7.0 → axio-0.9.0}/src/axio/transport.py +2 -2
- {axio-0.7.0 → axio-0.9.0}/tests/test_agent_branch.py +10 -13
- {axio-0.7.0 → axio-0.9.0}/tests/test_agent_permission.py +37 -49
- {axio-0.7.0 → axio-0.9.0}/tests/test_agent_run.py +10 -13
- {axio-0.7.0 → axio-0.9.0}/tests/test_agent_tools.py +33 -48
- {axio-0.7.0 → axio-0.9.0}/tests/test_blocks.py +1 -1
- {axio-0.7.0 → axio-0.9.0}/tests/test_compaction.py +1 -1
- {axio-0.7.0 → axio-0.9.0}/tests/test_context.py +1 -1
- {axio-0.7.0 → axio-0.9.0}/tests/test_events.py +1 -1
- {axio-0.7.0 → axio-0.9.0}/tests/test_exceptions.py +8 -10
- axio-0.9.0/tests/test_field.py +328 -0
- axio-0.9.0/tests/test_init.py +13 -0
- axio-0.9.0/tests/test_permission.py +92 -0
- axio-0.9.0/tests/test_schema.py +394 -0
- axio-0.9.0/tests/test_selector.py +67 -0
- {axio-0.7.0 → axio-0.9.0}/tests/test_stream.py +1 -1
- axio-0.9.0/tests/test_testing.py +150 -0
- axio-0.9.0/tests/test_tool.py +654 -0
- {axio-0.7.0 → axio-0.9.0}/tests/test_tool_args.py +5 -5
- {axio-0.7.0 → axio-0.9.0}/tests/test_transport.py +1 -1
- {axio-0.7.0 → axio-0.9.0}/tests/test_types.py +4 -5
- axio-0.7.0/src/axio/__init__.py +0 -1
- axio-0.7.0/src/axio/permission.py +0 -61
- axio-0.7.0/src/axio/tool.py +0 -76
- axio-0.7.0/tests/test_permission.py +0 -29
- axio-0.7.0/tests/test_tool.py +0 -161
- {axio-0.7.0 → axio-0.9.0}/.github/workflows/publish.yml +0 -0
- {axio-0.7.0 → axio-0.9.0}/.github/workflows/tests.yml +0 -0
- {axio-0.7.0 → axio-0.9.0}/.gitignore +0 -0
- {axio-0.7.0 → axio-0.9.0}/LICENSE +0 -0
- {axio-0.7.0 → axio-0.9.0}/Makefile +0 -0
- {axio-0.7.0 → axio-0.9.0}/src/axio/blocks.py +0 -0
- {axio-0.7.0 → axio-0.9.0}/src/axio/context.py +0 -0
- {axio-0.7.0 → axio-0.9.0}/src/axio/events.py +0 -0
- {axio-0.7.0 → axio-0.9.0}/src/axio/exceptions.py +0 -0
- {axio-0.7.0 → axio-0.9.0}/src/axio/messages.py +0 -0
- {axio-0.7.0 → axio-0.9.0}/src/axio/models.py +0 -0
- {axio-0.7.0 → axio-0.9.0}/src/axio/py.typed +0 -0
- {axio-0.7.0 → axio-0.9.0}/src/axio/selector.py +0 -0
- {axio-0.7.0 → axio-0.9.0}/src/axio/stream.py +0 -0
- {axio-0.7.0 → axio-0.9.0}/src/axio/tool_args.py +0 -0
- {axio-0.7.0 → axio-0.9.0}/src/axio/types.py +0 -0
- {axio-0.7.0 → axio-0.9.0}/tests/conftest.py +0 -0
- {axio-0.7.0 → axio-0.9.0}/tests/test_agent_loader.py +0 -0
- {axio-0.7.0 → axio-0.9.0}/tests/test_agent_stream.py +0 -0
- {axio-0.7.0 → axio-0.9.0}/tests/test_models.py +0 -0
- {axio-0.7.0 → axio-0.9.0}/uv.lock +0 -0
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: axio
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.0
|
|
4
4
|
Summary: Minimal, streaming-first, protocol-driven foundation for LLM-powered agents
|
|
5
5
|
Project-URL: Homepage, https://github.com/mosquito/axio-agent
|
|
6
6
|
Project-URL: Repository, https://github.com/mosquito/axio-agent
|
|
7
7
|
License: MIT
|
|
8
8
|
License-File: LICENSE
|
|
9
|
-
Keywords: agent,ai,async,llm,protocol,
|
|
9
|
+
Keywords: agent,ai,async,llm,protocol,streaming
|
|
10
10
|
Requires-Python: >=3.12
|
|
11
|
-
Requires-Dist: pydantic>=2
|
|
12
11
|
Description-Content-Type: text/markdown
|
|
13
12
|
|
|
14
13
|
# axio
|
|
@@ -19,16 +18,16 @@ Description-Content-Type: text/markdown
|
|
|
19
18
|
|
|
20
19
|
Minimal, streaming-first, protocol-driven foundation for LLM-powered agents.
|
|
21
20
|
|
|
22
|
-
|
|
21
|
+
No dependencies. Three protocols. An agent loop that just works.
|
|
23
22
|
|
|
24
23
|
## Features
|
|
25
24
|
|
|
26
|
-
- **Streaming agent loop**
|
|
27
|
-
- **Three clean protocols**
|
|
28
|
-
- **Concurrent tool dispatch**
|
|
29
|
-
- **Context compaction**
|
|
30
|
-
- **Testing helpers**
|
|
31
|
-
- **Plugin-ready**
|
|
25
|
+
- **Streaming agent loop** - `run_stream()` yields typed events as they arrive; no buffering, no polling
|
|
26
|
+
- **Three clean protocols** - `CompletionTransport`, `ContextStore`, `PermissionGuard`; swap any piece without touching the rest
|
|
27
|
+
- **Concurrent tool dispatch** - all tool calls in a turn run via `asyncio.gather` automatically
|
|
28
|
+
- **Context compaction** - `compact_context()` summarises old history to stay within token limits
|
|
29
|
+
- **Testing helpers** - `StubTransport`, `make_tool_use_response()`, `make_echo_tool()` ship in `axio.testing`
|
|
30
|
+
- **Plugin-ready** - entry-point groups (`axio.tools`, `axio.transport`, `axio.guards`) for drop-in extensions
|
|
32
31
|
|
|
33
32
|
## Installation
|
|
34
33
|
|
|
@@ -57,17 +56,14 @@ sys.modules["axio_transport_openai"] = _m
|
|
|
57
56
|
import asyncio
|
|
58
57
|
from axio.agent import Agent
|
|
59
58
|
from axio.context import MemoryContextStore
|
|
60
|
-
from axio.tool import Tool
|
|
59
|
+
from axio.tool import Tool
|
|
61
60
|
|
|
62
61
|
# 1. Define a tool
|
|
63
|
-
|
|
62
|
+
async def greet(name: str) -> str:
|
|
64
63
|
"""Return a greeting for the given name."""
|
|
65
|
-
name
|
|
66
|
-
|
|
67
|
-
async def __call__(self) -> str:
|
|
68
|
-
return f"Hello, {self.name}!"
|
|
64
|
+
return f"Hello, {name}!"
|
|
69
65
|
|
|
70
|
-
greet_tool = Tool(name="greet", description="Greet someone by name", handler=
|
|
66
|
+
greet_tool = Tool(name="greet", description="Greet someone by name", handler=greet)
|
|
71
67
|
|
|
72
68
|
# 2. Wire up the agent (transport comes from an axio-transport-* package)
|
|
73
69
|
from axio_transport_openai import OpenAITransport
|
|
@@ -144,9 +140,9 @@ class MyContextStore(ContextStore):
|
|
|
144
140
|
async def get_history(self) -> list[Message]:
|
|
145
141
|
return list(self._messages)
|
|
146
142
|
|
|
147
|
-
# Everything else
|
|
143
|
+
# Everything else - session_id, close(), fork(), clear(),
|
|
148
144
|
# get/set_context_tokens(), add_context_tokens(), list_sessions()
|
|
149
|
-
#
|
|
145
|
+
# - has a default implementation.
|
|
150
146
|
```
|
|
151
147
|
|
|
152
148
|
### PermissionGuard
|
|
@@ -158,11 +154,12 @@ class MyContextStore(ContextStore):
|
|
|
158
154
|
```python
|
|
159
155
|
from typing import Any
|
|
160
156
|
from axio.permission import PermissionGuard
|
|
157
|
+
from axio.tool import Tool
|
|
161
158
|
|
|
162
159
|
class MyGuard(PermissionGuard):
|
|
163
|
-
async def check(self,
|
|
164
|
-
# return
|
|
165
|
-
return
|
|
160
|
+
async def check(self, tool: Tool[Any], **kwargs: Any) -> dict[str, Any]:
|
|
161
|
+
# return kwargs to allow, raise GuardError to deny
|
|
162
|
+
return kwargs
|
|
166
163
|
```
|
|
167
164
|
|
|
168
165
|
## Stream events
|
|
@@ -173,29 +170,25 @@ class MyGuard(PermissionGuard):
|
|
|
173
170
|
| `ToolUseStart` | Tool call begins (name + id) |
|
|
174
171
|
| `ToolInputDelta` | Streaming JSON fragment for tool arguments |
|
|
175
172
|
| `ToolResult` | Tool execution result |
|
|
176
|
-
| `IterationEnd` | One LLM round complete
|
|
173
|
+
| `IterationEnd` | One LLM round complete - carries `Usage` + `StopReason` |
|
|
177
174
|
| `Error` | Transport or tool exception |
|
|
178
|
-
| `SessionEndEvent` | Agent loop finished
|
|
175
|
+
| `SessionEndEvent` | Agent loop finished - carries total `Usage` |
|
|
179
176
|
|
|
180
177
|
## Tools
|
|
181
178
|
|
|
182
179
|
<!-- name: test_readme_tools -->
|
|
183
180
|
```python
|
|
184
|
-
from axio.tool import Tool
|
|
181
|
+
from axio.tool import Tool
|
|
185
182
|
|
|
186
|
-
|
|
183
|
+
async def summarise(text: str, max_words: int = 20) -> str:
|
|
187
184
|
"""Summarise the given text in one sentence."""
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
async def __call__(self) -> str:
|
|
192
|
-
# your implementation
|
|
193
|
-
return "..."
|
|
185
|
+
# your implementation
|
|
186
|
+
return "..."
|
|
194
187
|
|
|
195
188
|
tool = Tool(
|
|
196
189
|
name="summarise",
|
|
197
190
|
description="Summarise text", # overrides docstring if set
|
|
198
|
-
handler=
|
|
191
|
+
handler=summarise,
|
|
199
192
|
concurrency=4, # max parallel executions
|
|
200
193
|
)
|
|
201
194
|
```
|
|
@@ -6,16 +6,16 @@
|
|
|
6
6
|
|
|
7
7
|
Minimal, streaming-first, protocol-driven foundation for LLM-powered agents.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
No dependencies. Three protocols. An agent loop that just works.
|
|
10
10
|
|
|
11
11
|
## Features
|
|
12
12
|
|
|
13
|
-
- **Streaming agent loop**
|
|
14
|
-
- **Three clean protocols**
|
|
15
|
-
- **Concurrent tool dispatch**
|
|
16
|
-
- **Context compaction**
|
|
17
|
-
- **Testing helpers**
|
|
18
|
-
- **Plugin-ready**
|
|
13
|
+
- **Streaming agent loop** - `run_stream()` yields typed events as they arrive; no buffering, no polling
|
|
14
|
+
- **Three clean protocols** - `CompletionTransport`, `ContextStore`, `PermissionGuard`; swap any piece without touching the rest
|
|
15
|
+
- **Concurrent tool dispatch** - all tool calls in a turn run via `asyncio.gather` automatically
|
|
16
|
+
- **Context compaction** - `compact_context()` summarises old history to stay within token limits
|
|
17
|
+
- **Testing helpers** - `StubTransport`, `make_tool_use_response()`, `make_echo_tool()` ship in `axio.testing`
|
|
18
|
+
- **Plugin-ready** - entry-point groups (`axio.tools`, `axio.transport`, `axio.guards`) for drop-in extensions
|
|
19
19
|
|
|
20
20
|
## Installation
|
|
21
21
|
|
|
@@ -44,17 +44,14 @@ sys.modules["axio_transport_openai"] = _m
|
|
|
44
44
|
import asyncio
|
|
45
45
|
from axio.agent import Agent
|
|
46
46
|
from axio.context import MemoryContextStore
|
|
47
|
-
from axio.tool import Tool
|
|
47
|
+
from axio.tool import Tool
|
|
48
48
|
|
|
49
49
|
# 1. Define a tool
|
|
50
|
-
|
|
50
|
+
async def greet(name: str) -> str:
|
|
51
51
|
"""Return a greeting for the given name."""
|
|
52
|
-
name
|
|
53
|
-
|
|
54
|
-
async def __call__(self) -> str:
|
|
55
|
-
return f"Hello, {self.name}!"
|
|
52
|
+
return f"Hello, {name}!"
|
|
56
53
|
|
|
57
|
-
greet_tool = Tool(name="greet", description="Greet someone by name", handler=
|
|
54
|
+
greet_tool = Tool(name="greet", description="Greet someone by name", handler=greet)
|
|
58
55
|
|
|
59
56
|
# 2. Wire up the agent (transport comes from an axio-transport-* package)
|
|
60
57
|
from axio_transport_openai import OpenAITransport
|
|
@@ -131,9 +128,9 @@ class MyContextStore(ContextStore):
|
|
|
131
128
|
async def get_history(self) -> list[Message]:
|
|
132
129
|
return list(self._messages)
|
|
133
130
|
|
|
134
|
-
# Everything else
|
|
131
|
+
# Everything else - session_id, close(), fork(), clear(),
|
|
135
132
|
# get/set_context_tokens(), add_context_tokens(), list_sessions()
|
|
136
|
-
#
|
|
133
|
+
# - has a default implementation.
|
|
137
134
|
```
|
|
138
135
|
|
|
139
136
|
### PermissionGuard
|
|
@@ -145,11 +142,12 @@ class MyContextStore(ContextStore):
|
|
|
145
142
|
```python
|
|
146
143
|
from typing import Any
|
|
147
144
|
from axio.permission import PermissionGuard
|
|
145
|
+
from axio.tool import Tool
|
|
148
146
|
|
|
149
147
|
class MyGuard(PermissionGuard):
|
|
150
|
-
async def check(self,
|
|
151
|
-
# return
|
|
152
|
-
return
|
|
148
|
+
async def check(self, tool: Tool[Any], **kwargs: Any) -> dict[str, Any]:
|
|
149
|
+
# return kwargs to allow, raise GuardError to deny
|
|
150
|
+
return kwargs
|
|
153
151
|
```
|
|
154
152
|
|
|
155
153
|
## Stream events
|
|
@@ -160,29 +158,25 @@ class MyGuard(PermissionGuard):
|
|
|
160
158
|
| `ToolUseStart` | Tool call begins (name + id) |
|
|
161
159
|
| `ToolInputDelta` | Streaming JSON fragment for tool arguments |
|
|
162
160
|
| `ToolResult` | Tool execution result |
|
|
163
|
-
| `IterationEnd` | One LLM round complete
|
|
161
|
+
| `IterationEnd` | One LLM round complete - carries `Usage` + `StopReason` |
|
|
164
162
|
| `Error` | Transport or tool exception |
|
|
165
|
-
| `SessionEndEvent` | Agent loop finished
|
|
163
|
+
| `SessionEndEvent` | Agent loop finished - carries total `Usage` |
|
|
166
164
|
|
|
167
165
|
## Tools
|
|
168
166
|
|
|
169
167
|
<!-- name: test_readme_tools -->
|
|
170
168
|
```python
|
|
171
|
-
from axio.tool import Tool
|
|
169
|
+
from axio.tool import Tool
|
|
172
170
|
|
|
173
|
-
|
|
171
|
+
async def summarise(text: str, max_words: int = 20) -> str:
|
|
174
172
|
"""Summarise the given text in one sentence."""
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
async def __call__(self) -> str:
|
|
179
|
-
# your implementation
|
|
180
|
-
return "..."
|
|
173
|
+
# your implementation
|
|
174
|
+
return "..."
|
|
181
175
|
|
|
182
176
|
tool = Tool(
|
|
183
177
|
name="summarise",
|
|
184
178
|
description="Summarise text", # overrides docstring if set
|
|
185
|
-
handler=
|
|
179
|
+
handler=summarise,
|
|
186
180
|
concurrency=4, # max parallel executions
|
|
187
181
|
)
|
|
188
182
|
```
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "axio"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.9.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
|
license = {text = "MIT"}
|
|
8
|
-
keywords = ["llm", "agent", "ai", "async", "streaming", "
|
|
9
|
-
dependencies = [
|
|
8
|
+
keywords = ["llm", "agent", "ai", "async", "streaming", "protocol"]
|
|
9
|
+
dependencies = []
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
[project.urls]
|
|
@@ -34,6 +34,7 @@ select = ["E", "F", "I", "UP"]
|
|
|
34
34
|
strict = true
|
|
35
35
|
python_version = "3.12"
|
|
36
36
|
|
|
37
|
+
|
|
37
38
|
[dependency-groups]
|
|
38
39
|
dev = [
|
|
39
40
|
"pytest>=8",
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""axio - public API."""
|
|
2
|
+
|
|
3
|
+
from .agent import Agent
|
|
4
|
+
from .blocks import TextBlock, ToolResultBlock, ToolUseBlock
|
|
5
|
+
from .context import ContextStore, MemoryContextStore
|
|
6
|
+
from .events import IterationEnd, StreamEvent, TextDelta, ToolInputDelta, ToolResult, ToolUseStart
|
|
7
|
+
from .exceptions import GuardError, HandlerError
|
|
8
|
+
from .field import Field, FieldInfo, StrictStr
|
|
9
|
+
from .messages import Message
|
|
10
|
+
from .permission import ConcurrentGuard, PermissionGuard
|
|
11
|
+
from .selector import ToolSelector
|
|
12
|
+
from .stream import AgentStream
|
|
13
|
+
from .tool import CONTEXT, Tool
|
|
14
|
+
from .transport import CompletionTransport
|
|
15
|
+
from .types import StopReason, Usage
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
# core
|
|
19
|
+
"Agent",
|
|
20
|
+
"Tool",
|
|
21
|
+
"CONTEXT",
|
|
22
|
+
"ContextStore",
|
|
23
|
+
"MemoryContextStore",
|
|
24
|
+
"CompletionTransport",
|
|
25
|
+
# events
|
|
26
|
+
"StreamEvent",
|
|
27
|
+
"TextDelta",
|
|
28
|
+
"IterationEnd",
|
|
29
|
+
"ToolUseStart",
|
|
30
|
+
"ToolInputDelta",
|
|
31
|
+
"ToolResult",
|
|
32
|
+
# messages & blocks
|
|
33
|
+
"Message",
|
|
34
|
+
"TextBlock",
|
|
35
|
+
"ToolUseBlock",
|
|
36
|
+
"ToolResultBlock",
|
|
37
|
+
# types & errors
|
|
38
|
+
"StopReason",
|
|
39
|
+
"Usage",
|
|
40
|
+
"GuardError",
|
|
41
|
+
"HandlerError",
|
|
42
|
+
# permissions
|
|
43
|
+
"PermissionGuard",
|
|
44
|
+
"ConcurrentGuard",
|
|
45
|
+
# field annotations
|
|
46
|
+
"Field",
|
|
47
|
+
"FieldInfo",
|
|
48
|
+
"StrictStr",
|
|
49
|
+
# advanced
|
|
50
|
+
"ToolSelector",
|
|
51
|
+
"AgentStream",
|
|
52
|
+
]
|
|
@@ -85,7 +85,7 @@ class Agent:
|
|
|
85
85
|
|
|
86
86
|
@staticmethod
|
|
87
87
|
def _accumulate_text(content: list[TextBlock | ToolUseBlock], delta: str) -> None:
|
|
88
|
-
"""Append text delta
|
|
88
|
+
"""Append text delta - merge into last TextBlock or start a new one."""
|
|
89
89
|
if content and isinstance(content[-1], TextBlock):
|
|
90
90
|
content[-1] = TextBlock(text=content[-1].text + delta)
|
|
91
91
|
else:
|
|
@@ -190,7 +190,7 @@ class Agent:
|
|
|
190
190
|
stop_reason,
|
|
191
191
|
)
|
|
192
192
|
|
|
193
|
-
# Dispatch tools BEFORE appending to context
|
|
193
|
+
# Dispatch tools BEFORE appending to context - cancellation
|
|
194
194
|
# between here and the two appends below cannot leave orphan
|
|
195
195
|
# ToolUseBlocks in the persistent context store.
|
|
196
196
|
valid = [b for b in tool_blocks if b.id not in malformed]
|
|
@@ -46,7 +46,7 @@ are comma-separated.
|
|
|
46
46
|
|
|
47
47
|
Custom sources
|
|
48
48
|
--------------
|
|
49
|
-
Subclass :class:`AgentLoader` and implement :meth:`~AgentLoader.load`
|
|
49
|
+
Subclass :class:`AgentLoader` and implement :meth:`~AgentLoader.load` - the
|
|
50
50
|
base :meth:`~AgentLoader.load_file` will handle reading the file and calling
|
|
51
51
|
your implementation automatically::
|
|
52
52
|
|
|
@@ -68,12 +68,11 @@ from pathlib import Path
|
|
|
68
68
|
from types import MappingProxyType
|
|
69
69
|
from typing import Annotated, Any, TypedDict
|
|
70
70
|
|
|
71
|
-
from pydantic import Field
|
|
72
|
-
|
|
73
71
|
from .agent import Agent
|
|
74
72
|
from .context import ContextStore, MemoryContextStore
|
|
75
73
|
from .events import StreamEvent, TextDelta
|
|
76
|
-
from .
|
|
74
|
+
from .field import Field
|
|
75
|
+
from .tool import CONTEXT, Tool
|
|
77
76
|
from .transport import CompletionTransport, DummyCompletionTransport
|
|
78
77
|
|
|
79
78
|
|
|
@@ -91,7 +90,7 @@ class AgentSpec:
|
|
|
91
90
|
def to_agent(self, toolbox: Mapping[str, Tool[Any]] = MappingProxyType({})) -> Agent:
|
|
92
91
|
"""Return a prototype Agent with *toolbox* tools attached.
|
|
93
92
|
|
|
94
|
-
The agent uses :class:`~axio.transport.DummyCompletionTransport`
|
|
93
|
+
The agent uses :class:`~axio.transport.DummyCompletionTransport` -
|
|
95
94
|
call ``agent.copy(transport=real_transport)`` before running it.
|
|
96
95
|
|
|
97
96
|
Raises :exc:`KeyError` if any name in ``self.tools`` is absent from
|
|
@@ -114,7 +113,7 @@ class AgentLoader:
|
|
|
114
113
|
"""Base class for format-specific agent loaders.
|
|
115
114
|
|
|
116
115
|
Subclasses implement :meth:`load` to parse a raw string. The source of
|
|
117
|
-
that string is entirely up to the caller
|
|
116
|
+
that string is entirely up to the caller - files, databases, HTTP, etc.
|
|
118
117
|
:meth:`load_file` is provided on the base class and calls :meth:`load`
|
|
119
118
|
automatically.
|
|
120
119
|
"""
|
|
@@ -276,7 +275,7 @@ def load_agents(
|
|
|
276
275
|
) -> dict[str, tuple[str, Agent]]:
|
|
277
276
|
"""Scan *directory* for ``.toml``, ``.json``, and ``.ini`` agent files.
|
|
278
277
|
|
|
279
|
-
Returns ``dict[name, (description, agent)]``
|
|
278
|
+
Returns ``dict[name, (description, agent)]`` - same shape as the
|
|
280
279
|
``AGENTS`` registry used in ``agent_swarm`` and similar examples.
|
|
281
280
|
|
|
282
281
|
Example::
|
|
@@ -300,25 +299,19 @@ class AgentContext(TypedDict):
|
|
|
300
299
|
on_event: Callable[[str, StreamEvent], None] | None
|
|
301
300
|
|
|
302
301
|
|
|
303
|
-
|
|
304
|
-
"""
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
parts: list[str] = []
|
|
317
|
-
async for event in agent.run_stream(self.task, ctx):
|
|
318
|
-
context["on_event"](context["agent_name"], event)
|
|
319
|
-
if isinstance(event, TextDelta):
|
|
320
|
-
parts.append(event.delta)
|
|
321
|
-
return "".join(parts)
|
|
302
|
+
async def agent_tool(task: Annotated[str, Field(description="Full task instructions.")]) -> str:
|
|
303
|
+
"""Delegate a task to a sub-agent."""
|
|
304
|
+
context: AgentContext = CONTEXT.get()
|
|
305
|
+
agent = context["proto"].copy(transport=context["transport"])
|
|
306
|
+
ctx = context["context_factory"]()
|
|
307
|
+
if context["on_event"] is None:
|
|
308
|
+
return await agent.run(task, ctx)
|
|
309
|
+
parts: list[str] = []
|
|
310
|
+
async for event in agent.run_stream(task, ctx):
|
|
311
|
+
context["on_event"](context["agent_name"], event)
|
|
312
|
+
if isinstance(event, TextDelta):
|
|
313
|
+
parts.append(event.delta)
|
|
314
|
+
return "".join(parts)
|
|
322
315
|
|
|
323
316
|
|
|
324
317
|
def make_agent_tools(
|
|
@@ -337,7 +330,7 @@ def make_agent_tools(
|
|
|
337
330
|
Parameters
|
|
338
331
|
----------
|
|
339
332
|
agents:
|
|
340
|
-
``dict[name, (description, prototype_agent)]``
|
|
333
|
+
``dict[name, (description, prototype_agent)]`` - e.g. the value
|
|
341
334
|
returned by :func:`load_agents`.
|
|
342
335
|
transport:
|
|
343
336
|
Transport assigned to the selected agent via ``agent.copy()``.
|
|
@@ -364,7 +357,7 @@ def make_agent_tools(
|
|
|
364
357
|
tool = Tool(
|
|
365
358
|
name=f"{agent_name_prefix}{agent_name}",
|
|
366
359
|
description=desc,
|
|
367
|
-
handler=
|
|
360
|
+
handler=agent_tool,
|
|
368
361
|
context=AgentContext(
|
|
369
362
|
proto=proto,
|
|
370
363
|
transport=transport,
|
|
@@ -70,14 +70,14 @@ def _find_safe_boundary(history: list[Message], keep_recent: int) -> int:
|
|
|
70
70
|
class AutoCompactStore(ContextStore):
|
|
71
71
|
"""Delegating ``ContextStore`` wrapper that auto-compacts the inner store
|
|
72
72
|
when token usage exceeds a threshold. Works with any ``ContextStore``
|
|
73
|
-
backend
|
|
73
|
+
backend - ``MemoryContextStore``, ``SQLiteContextStore``, etc.
|
|
74
74
|
|
|
75
75
|
The threshold defaults to 75 % of ``transport.model.context_window``
|
|
76
76
|
(read via ``getattr``; falls back to 128 000 if the transport has no
|
|
77
77
|
``model`` attribute). Pass ``max_tokens`` explicitly to override.
|
|
78
78
|
|
|
79
79
|
Compaction fires from :meth:`add_context_tokens`, which the agent loop
|
|
80
|
-
calls immediately after ``IterationEnd``
|
|
80
|
+
calls immediately after ``IterationEnd`` - ``input_tokens`` there equals
|
|
81
81
|
the real context size sent to the model in that iteration.
|
|
82
82
|
|
|
83
83
|
Internally, :meth:`_do_compact` forks the inner store before calling
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Field metadata for tool handler functions - lightweight replacement for pydantic.Field."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import types
|
|
6
|
+
import typing
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Annotated, Any, Final, Literal, get_args, get_origin
|
|
9
|
+
|
|
10
|
+
# Types for which basic isinstance checks are applied in non-strict mode.
|
|
11
|
+
PRIMITIVE_TYPES: frozenset[type] = frozenset({str, int, float, bool, list, dict})
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def unwrap_hint(hint: Any) -> tuple[Any, bool]:
|
|
15
|
+
"""Strip ``Annotated`` and ``Optional`` wrappers.
|
|
16
|
+
|
|
17
|
+
Returns ``(inner_type, is_optional)`` where *inner_type* has no Annotated or
|
|
18
|
+
Union-with-None wrappers and *is_optional* is True when ``None`` was one of
|
|
19
|
+
the Union members.
|
|
20
|
+
"""
|
|
21
|
+
inner = get_args(hint)[0] if get_origin(hint) is typing.Annotated else hint
|
|
22
|
+
origin = get_origin(inner)
|
|
23
|
+
if origin is types.UnionType or origin is typing.Union:
|
|
24
|
+
inner_args = get_args(inner)
|
|
25
|
+
non_none = [a for a in inner_args if a is not type(None)]
|
|
26
|
+
is_optional = len(non_none) < len(inner_args)
|
|
27
|
+
inner = non_none[0] if len(non_none) == 1 else inner
|
|
28
|
+
return inner, is_optional
|
|
29
|
+
return inner, False
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def check_scalar(value: Any, name: str, b: type, strict: bool) -> None:
|
|
33
|
+
"""Raise TypeError when *value* does not satisfy the scalar type *b*."""
|
|
34
|
+
if strict:
|
|
35
|
+
if not isinstance(value, b):
|
|
36
|
+
raise TypeError(f"Field '{name}' requires {b.__name__}, got {type(value).__name__}")
|
|
37
|
+
elif b is float and isinstance(value, int):
|
|
38
|
+
pass # int is a valid JSON "number"
|
|
39
|
+
elif b in PRIMITIVE_TYPES and value is not None and not isinstance(value, b):
|
|
40
|
+
raise TypeError(f"Field '{name}' requires {b.__name__}, got {type(value).__name__}")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def check_list_items(value: list[Any], name: str, inner: Any) -> None:
|
|
44
|
+
"""Raise TypeError when any list element violates the generic item type."""
|
|
45
|
+
item_args = get_args(inner)
|
|
46
|
+
if not item_args:
|
|
47
|
+
return
|
|
48
|
+
item_hint = item_args[0]
|
|
49
|
+
item_b = bare_type(item_hint)
|
|
50
|
+
if item_b not in PRIMITIVE_TYPES:
|
|
51
|
+
return
|
|
52
|
+
for idx, elem in enumerate(value):
|
|
53
|
+
try:
|
|
54
|
+
check_type(elem, name, item_hint, strict=False)
|
|
55
|
+
except (TypeError, ValueError):
|
|
56
|
+
raise TypeError(f"Field '{name}' element {idx} requires {item_b.__name__}, got {type(elem).__name__}")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def check_type(value: Any, name: str, inner: Any, *, strict: bool) -> None:
|
|
60
|
+
"""Dispatch type validation for *value* against *inner* (already unwrapped)."""
|
|
61
|
+
origin = get_origin(inner)
|
|
62
|
+
b = bare_type(inner)
|
|
63
|
+
|
|
64
|
+
# Multi-branch union (e.g. str | int): value must satisfy at least one branch.
|
|
65
|
+
# unwrap_hint already stripped Optional (| None), so non-None multi-unions land here.
|
|
66
|
+
if b is object and (origin is types.UnionType or origin is typing.Union):
|
|
67
|
+
branches = [br for br in get_args(inner) if br is not type(None)]
|
|
68
|
+
for branch in branches:
|
|
69
|
+
try:
|
|
70
|
+
check_type(value, name, branch, strict=strict)
|
|
71
|
+
return
|
|
72
|
+
except (TypeError, ValueError):
|
|
73
|
+
continue
|
|
74
|
+
branch_names = " | ".join(getattr(bare_type(br), "__name__", str(br)) for br in branches)
|
|
75
|
+
raise TypeError(f"Field '{name}' requires {branch_names}, got {type(value).__name__}")
|
|
76
|
+
|
|
77
|
+
# bool is an int subclass - reject it for non-bool hints before numeric dispatch.
|
|
78
|
+
if isinstance(value, bool) and b is not bool:
|
|
79
|
+
raise TypeError(f"Field '{name}' requires {b.__name__}, got bool")
|
|
80
|
+
|
|
81
|
+
if origin is Literal:
|
|
82
|
+
allowed = get_args(inner)
|
|
83
|
+
if value not in allowed:
|
|
84
|
+
raise ValueError(f"Field '{name}' must be one of {list(allowed)!r}, got {value!r}")
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
check_scalar(value, name, b, strict)
|
|
88
|
+
|
|
89
|
+
if origin is list and isinstance(value, list):
|
|
90
|
+
check_list_items(value, name, inner)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class MissingSentinel:
|
|
94
|
+
"""Singleton sentinel - distinguishes 'no default' from ``None``."""
|
|
95
|
+
|
|
96
|
+
__slots__ = ()
|
|
97
|
+
|
|
98
|
+
def __repr__(self) -> str:
|
|
99
|
+
return "MISSING"
|
|
100
|
+
|
|
101
|
+
def __bool__(self) -> bool:
|
|
102
|
+
return False
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
#: Sentinel value meaning "this field has no default and is required".
|
|
106
|
+
MISSING: Final[MissingSentinel] = MissingSentinel()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True)
|
|
110
|
+
class FieldInfo:
|
|
111
|
+
"""Metadata attached to a handler parameter via ``Annotated[T, FieldInfo(...)]``."""
|
|
112
|
+
|
|
113
|
+
description: str = ""
|
|
114
|
+
default: Any = MISSING
|
|
115
|
+
ge: int | float | None = None # minimum (≥)
|
|
116
|
+
le: int | float | None = None # maximum (≤)
|
|
117
|
+
strict: bool = False # reject implicit type coercion
|
|
118
|
+
|
|
119
|
+
def validate(self, value: Any, name: str, hint: Any) -> None:
|
|
120
|
+
"""Validate *value* against this FieldInfo's constraints, raising if invalid."""
|
|
121
|
+
inner, is_optional = unwrap_hint(hint)
|
|
122
|
+
if value is None and is_optional:
|
|
123
|
+
return
|
|
124
|
+
check_type(value, name, inner, strict=self.strict)
|
|
125
|
+
if self.ge is not None and value < self.ge:
|
|
126
|
+
raise ValueError(f"Field '{name}' must be >= {self.ge}")
|
|
127
|
+
if self.le is not None and value > self.le:
|
|
128
|
+
raise ValueError(f"Field '{name}' must be <= {self.le}")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# noinspection PyPep8Naming
|
|
132
|
+
def Field(
|
|
133
|
+
description: str = "",
|
|
134
|
+
default: Any = MISSING,
|
|
135
|
+
ge: int | float | None = None,
|
|
136
|
+
le: int | float | None = None,
|
|
137
|
+
) -> FieldInfo:
|
|
138
|
+
"""Annotate a handler parameter with metadata (description, default, constraints).
|
|
139
|
+
|
|
140
|
+
Usage::
|
|
141
|
+
|
|
142
|
+
async def search(
|
|
143
|
+
query: Annotated[str, Field(description="Search query")],
|
|
144
|
+
limit: Annotated[int, Field(default=10, ge=1, le=100)],
|
|
145
|
+
) -> str: ...
|
|
146
|
+
"""
|
|
147
|
+
return FieldInfo(description=description, default=default, ge=ge, le=le)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
#: Drop-in replacement for ``from pydantic import StrictStr``.
|
|
151
|
+
#: Rejects non-string values (e.g. integers) without coercion.
|
|
152
|
+
StrictStr = Annotated[str, FieldInfo(strict=True)]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def is_classvar(annotation: Any) -> bool:
|
|
156
|
+
"""Return True if *annotation* is ``ClassVar`` or ``ClassVar[X]``."""
|
|
157
|
+
return get_origin(annotation) is typing.ClassVar or annotation is typing.ClassVar
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def get_field_info(annotation: Any) -> FieldInfo | None:
|
|
161
|
+
"""Extract a ``FieldInfo`` from an ``Annotated`` annotation, or return ``None``."""
|
|
162
|
+
if get_origin(annotation) is not typing.Annotated:
|
|
163
|
+
return None
|
|
164
|
+
return next((a for a in get_args(annotation)[1:] if isinstance(a, FieldInfo)), None)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def bare_type(hint: Any) -> type:
|
|
168
|
+
"""Return the base Python type, stripping ``Annotated``, ``Optional``, and generic wrappers."""
|
|
169
|
+
origin = get_origin(hint)
|
|
170
|
+
args = get_args(hint)
|
|
171
|
+
if origin is typing.Annotated:
|
|
172
|
+
return bare_type(args[0])
|
|
173
|
+
if origin is types.UnionType or origin is typing.Union:
|
|
174
|
+
non_none = [a for a in args if a is not type(None)]
|
|
175
|
+
return bare_type(non_none[0]) if len(non_none) == 1 else object
|
|
176
|
+
if origin is not None:
|
|
177
|
+
# Generic alias: list[int] → list, dict[str, Any] → dict, etc.
|
|
178
|
+
return origin if isinstance(origin, type) else object
|
|
179
|
+
return hint if isinstance(hint, type) else object
|