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.
Files changed (59) hide show
  1. {axio-0.7.0 → axio-0.9.0}/PKG-INFO +26 -33
  2. {axio-0.7.0 → axio-0.9.0}/README.md +24 -30
  3. {axio-0.7.0 → axio-0.9.0}/pyproject.toml +4 -3
  4. axio-0.9.0/src/axio/__init__.py +52 -0
  5. {axio-0.7.0 → axio-0.9.0}/src/axio/agent.py +2 -2
  6. {axio-0.7.0 → axio-0.9.0}/src/axio/agent_loader.py +21 -28
  7. {axio-0.7.0 → axio-0.9.0}/src/axio/compaction.py +2 -2
  8. axio-0.9.0/src/axio/field.py +179 -0
  9. axio-0.9.0/src/axio/permission.py +66 -0
  10. axio-0.9.0/src/axio/schema.py +145 -0
  11. {axio-0.7.0 → axio-0.9.0}/src/axio/testing.py +4 -7
  12. axio-0.9.0/src/axio/tool.py +199 -0
  13. {axio-0.7.0 → axio-0.9.0}/src/axio/transport.py +2 -2
  14. {axio-0.7.0 → axio-0.9.0}/tests/test_agent_branch.py +10 -13
  15. {axio-0.7.0 → axio-0.9.0}/tests/test_agent_permission.py +37 -49
  16. {axio-0.7.0 → axio-0.9.0}/tests/test_agent_run.py +10 -13
  17. {axio-0.7.0 → axio-0.9.0}/tests/test_agent_tools.py +33 -48
  18. {axio-0.7.0 → axio-0.9.0}/tests/test_blocks.py +1 -1
  19. {axio-0.7.0 → axio-0.9.0}/tests/test_compaction.py +1 -1
  20. {axio-0.7.0 → axio-0.9.0}/tests/test_context.py +1 -1
  21. {axio-0.7.0 → axio-0.9.0}/tests/test_events.py +1 -1
  22. {axio-0.7.0 → axio-0.9.0}/tests/test_exceptions.py +8 -10
  23. axio-0.9.0/tests/test_field.py +328 -0
  24. axio-0.9.0/tests/test_init.py +13 -0
  25. axio-0.9.0/tests/test_permission.py +92 -0
  26. axio-0.9.0/tests/test_schema.py +394 -0
  27. axio-0.9.0/tests/test_selector.py +67 -0
  28. {axio-0.7.0 → axio-0.9.0}/tests/test_stream.py +1 -1
  29. axio-0.9.0/tests/test_testing.py +150 -0
  30. axio-0.9.0/tests/test_tool.py +654 -0
  31. {axio-0.7.0 → axio-0.9.0}/tests/test_tool_args.py +5 -5
  32. {axio-0.7.0 → axio-0.9.0}/tests/test_transport.py +1 -1
  33. {axio-0.7.0 → axio-0.9.0}/tests/test_types.py +4 -5
  34. axio-0.7.0/src/axio/__init__.py +0 -1
  35. axio-0.7.0/src/axio/permission.py +0 -61
  36. axio-0.7.0/src/axio/tool.py +0 -76
  37. axio-0.7.0/tests/test_permission.py +0 -29
  38. axio-0.7.0/tests/test_tool.py +0 -161
  39. {axio-0.7.0 → axio-0.9.0}/.github/workflows/publish.yml +0 -0
  40. {axio-0.7.0 → axio-0.9.0}/.github/workflows/tests.yml +0 -0
  41. {axio-0.7.0 → axio-0.9.0}/.gitignore +0 -0
  42. {axio-0.7.0 → axio-0.9.0}/LICENSE +0 -0
  43. {axio-0.7.0 → axio-0.9.0}/Makefile +0 -0
  44. {axio-0.7.0 → axio-0.9.0}/src/axio/blocks.py +0 -0
  45. {axio-0.7.0 → axio-0.9.0}/src/axio/context.py +0 -0
  46. {axio-0.7.0 → axio-0.9.0}/src/axio/events.py +0 -0
  47. {axio-0.7.0 → axio-0.9.0}/src/axio/exceptions.py +0 -0
  48. {axio-0.7.0 → axio-0.9.0}/src/axio/messages.py +0 -0
  49. {axio-0.7.0 → axio-0.9.0}/src/axio/models.py +0 -0
  50. {axio-0.7.0 → axio-0.9.0}/src/axio/py.typed +0 -0
  51. {axio-0.7.0 → axio-0.9.0}/src/axio/selector.py +0 -0
  52. {axio-0.7.0 → axio-0.9.0}/src/axio/stream.py +0 -0
  53. {axio-0.7.0 → axio-0.9.0}/src/axio/tool_args.py +0 -0
  54. {axio-0.7.0 → axio-0.9.0}/src/axio/types.py +0 -0
  55. {axio-0.7.0 → axio-0.9.0}/tests/conftest.py +0 -0
  56. {axio-0.7.0 → axio-0.9.0}/tests/test_agent_loader.py +0 -0
  57. {axio-0.7.0 → axio-0.9.0}/tests/test_agent_stream.py +0 -0
  58. {axio-0.7.0 → axio-0.9.0}/tests/test_models.py +0 -0
  59. {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.7.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,pydantic,streaming
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
- One dependency (`pydantic`). Three protocols. An agent loop that just works.
21
+ No dependencies. Three protocols. An agent loop that just works.
23
22
 
24
23
  ## Features
25
24
 
26
- - **Streaming agent loop** `run_stream()` yields typed events as they arrive; no buffering, no polling
27
- - **Three clean protocols** `CompletionTransport`, `ContextStore`, `PermissionGuard`; swap any piece without touching the rest
28
- - **Concurrent tool dispatch** all tool calls in a turn run via `asyncio.gather` automatically
29
- - **Context compaction** `compact_context()` summarises old history to stay within token limits
30
- - **Testing helpers** `StubTransport`, `make_tool_use_response()`, `make_echo_tool()` ship in `axio.testing`
31
- - **Plugin-ready** entry-point groups (`axio.tools`, `axio.transport`, `axio.guards`) for drop-in extensions
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, ToolHandler
59
+ from axio.tool import Tool
61
60
 
62
61
  # 1. Define a tool
63
- class Greet(ToolHandler):
62
+ async def greet(name: str) -> str:
64
63
  """Return a greeting for the given name."""
65
- name: str
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=Greet)
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 session_id, close(), fork(), clear(),
143
+ # Everything else - session_id, close(), fork(), clear(),
148
144
  # get/set_context_tokens(), add_context_tokens(), list_sessions()
149
- # has a default implementation.
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, handler: Any) -> Any:
164
- # return handler to allow, raise GuardError to deny
165
- return handler
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 carries `Usage` + `StopReason` |
173
+ | `IterationEnd` | One LLM round complete - carries `Usage` + `StopReason` |
177
174
  | `Error` | Transport or tool exception |
178
- | `SessionEndEvent` | Agent loop finished carries total `Usage` |
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, ToolHandler
181
+ from axio.tool import Tool
185
182
 
186
- class Summarise(ToolHandler):
183
+ async def summarise(text: str, max_words: int = 20) -> str:
187
184
  """Summarise the given text in one sentence."""
188
- text: str
189
- max_words: int = 20
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=Summarise,
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
- One dependency (`pydantic`). Three protocols. An agent loop that just works.
9
+ No dependencies. Three protocols. An agent loop that just works.
10
10
 
11
11
  ## Features
12
12
 
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
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, ToolHandler
47
+ from axio.tool import Tool
48
48
 
49
49
  # 1. Define a tool
50
- class Greet(ToolHandler):
50
+ async def greet(name: str) -> str:
51
51
  """Return a greeting for the given name."""
52
- name: str
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=Greet)
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 session_id, close(), fork(), clear(),
131
+ # Everything else - session_id, close(), fork(), clear(),
135
132
  # get/set_context_tokens(), add_context_tokens(), list_sessions()
136
- # has a default implementation.
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, handler: Any) -> Any:
151
- # return handler to allow, raise GuardError to deny
152
- return handler
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 carries `Usage` + `StopReason` |
161
+ | `IterationEnd` | One LLM round complete - carries `Usage` + `StopReason` |
164
162
  | `Error` | Transport or tool exception |
165
- | `SessionEndEvent` | Agent loop finished carries total `Usage` |
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, ToolHandler
169
+ from axio.tool import Tool
172
170
 
173
- class Summarise(ToolHandler):
171
+ async def summarise(text: str, max_words: int = 20) -> str:
174
172
  """Summarise the given text in one sentence."""
175
- text: str
176
- max_words: int = 20
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=Summarise,
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.7.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", "pydantic", "protocol"]
9
- dependencies = ["pydantic>=2"]
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 merge into last TextBlock or start a new one."""
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 cancellation
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` the
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 .tool import Tool, ToolHandler
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 files, databases, HTTP, etc.
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)]`` same shape as the
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
- class AgentTool(ToolHandler[AgentContext]):
304
- """Handler for a single delegate agent — receives runtime deps via Tool.context."""
305
-
306
- task: Annotated[str, Field(description="Full task instructions.")]
307
-
308
- async def __call__(
309
- self,
310
- context: AgentContext,
311
- ) -> str:
312
- agent = context["proto"].copy(transport=context["transport"])
313
- ctx = context["context_factory"]()
314
- if context["on_event"] is None:
315
- return await agent.run(self.task, ctx)
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)]`` e.g. the value
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=AgentTool,
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 ``MemoryContextStore``, ``SQLiteContextStore``, etc.
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`` ``input_tokens`` there equals
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