axio 0.6.1__tar.gz → 0.7.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 (49) hide show
  1. {axio-0.6.1 → axio-0.7.0}/.gitignore +2 -0
  2. {axio-0.6.1 → axio-0.7.0}/PKG-INFO +32 -25
  3. {axio-0.6.1 → axio-0.7.0}/README.md +29 -22
  4. {axio-0.6.1 → axio-0.7.0}/pyproject.toml +3 -3
  5. {axio-0.6.1 → axio-0.7.0}/src/axio/agent.py +12 -12
  6. axio-0.7.0/src/axio/agent_loader.py +386 -0
  7. {axio-0.6.1 → axio-0.7.0}/src/axio/blocks.py +1 -1
  8. axio-0.7.0/src/axio/compaction.py +167 -0
  9. {axio-0.6.1 → axio-0.7.0}/src/axio/context.py +2 -2
  10. {axio-0.6.1 → axio-0.7.0}/src/axio/events.py +1 -1
  11. {axio-0.6.1 → axio-0.7.0}/src/axio/messages.py +1 -1
  12. {axio-0.6.1 → axio-0.7.0}/src/axio/permission.py +12 -1
  13. {axio-0.6.1 → axio-0.7.0}/src/axio/selector.py +4 -4
  14. {axio-0.6.1 → axio-0.7.0}/src/axio/stream.py +2 -2
  15. {axio-0.6.1 → axio-0.7.0}/src/axio/testing.py +9 -9
  16. {axio-0.6.1 → axio-0.7.0}/src/axio/tool.py +10 -8
  17. {axio-0.6.1 → axio-0.7.0}/src/axio/tool_args.py +1 -1
  18. axio-0.7.0/src/axio/transport.py +107 -0
  19. {axio-0.6.1 → axio-0.7.0}/tests/conftest.py +3 -1
  20. {axio-0.6.1 → axio-0.7.0}/tests/test_agent_branch.py +7 -6
  21. axio-0.7.0/tests/test_agent_loader.py +342 -0
  22. {axio-0.6.1 → axio-0.7.0}/tests/test_agent_permission.py +12 -12
  23. {axio-0.6.1 → axio-0.7.0}/tests/test_agent_run.py +4 -3
  24. {axio-0.6.1 → axio-0.7.0}/tests/test_agent_tools.py +12 -12
  25. axio-0.7.0/tests/test_compaction.py +287 -0
  26. {axio-0.6.1 → axio-0.7.0}/tests/test_context.py +16 -16
  27. {axio-0.6.1 → axio-0.7.0}/tests/test_tool.py +9 -8
  28. {axio-0.6.1 → axio-0.7.0}/tests/test_transport.py +2 -1
  29. axio-0.6.1/src/axio/compaction.py +0 -67
  30. axio-0.6.1/src/axio/transport.py +0 -43
  31. {axio-0.6.1 → axio-0.7.0}/.github/workflows/publish.yml +0 -0
  32. {axio-0.6.1 → axio-0.7.0}/.github/workflows/tests.yml +0 -0
  33. {axio-0.6.1 → axio-0.7.0}/LICENSE +0 -0
  34. {axio-0.6.1 → axio-0.7.0}/Makefile +0 -0
  35. {axio-0.6.1 → axio-0.7.0}/src/axio/__init__.py +0 -0
  36. {axio-0.6.1 → axio-0.7.0}/src/axio/exceptions.py +0 -0
  37. {axio-0.6.1 → axio-0.7.0}/src/axio/models.py +0 -0
  38. {axio-0.6.1 → axio-0.7.0}/src/axio/py.typed +0 -0
  39. {axio-0.6.1 → axio-0.7.0}/src/axio/types.py +0 -0
  40. {axio-0.6.1 → axio-0.7.0}/tests/test_agent_stream.py +0 -0
  41. {axio-0.6.1 → axio-0.7.0}/tests/test_blocks.py +0 -0
  42. {axio-0.6.1 → axio-0.7.0}/tests/test_events.py +0 -0
  43. {axio-0.6.1 → axio-0.7.0}/tests/test_exceptions.py +0 -0
  44. {axio-0.6.1 → axio-0.7.0}/tests/test_models.py +0 -0
  45. {axio-0.6.1 → axio-0.7.0}/tests/test_permission.py +0 -0
  46. {axio-0.6.1 → axio-0.7.0}/tests/test_stream.py +0 -0
  47. {axio-0.6.1 → axio-0.7.0}/tests/test_tool_args.py +0 -0
  48. {axio-0.6.1 → axio-0.7.0}/tests/test_types.py +0 -0
  49. {axio-0.6.1 → axio-0.7.0}/uv.lock +0 -0
@@ -10,3 +10,5 @@ dist/
10
10
  build/
11
11
  _build/
12
12
  .DS_Store
13
+ *.sqlite
14
+ *.db
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: axio
3
- Version: 0.6.1
3
+ Version: 0.7.0
4
4
  Summary: Minimal, streaming-first, protocol-driven foundation for LLM-powered agents
5
- Project-URL: Homepage, https://github.com/axio-agent/axio
6
- Project-URL: Repository, https://github.com/axio-agent/axio
5
+ Project-URL: Homepage, https://github.com/mosquito/axio-agent
6
+ Project-URL: Repository, https://github.com/mosquito/axio-agent
7
7
  License: MIT
8
8
  License-File: LICENSE
9
9
  Keywords: agent,ai,async,llm,protocol,pydantic,streaming
@@ -73,7 +73,7 @@ greet_tool = Tool(name="greet", description="Greet someone by name", handler=Gre
73
73
  from axio_transport_openai import OpenAITransport
74
74
 
75
75
  transport = OpenAITransport(api_key="sk-...", model="gpt-4o-mini")
76
- agent = Agent(system="You are helpful.", tools=[greet_tool], transport=transport)
76
+ agent = Agent(system="You are helpful.", transport=transport, tools=[greet_tool])
77
77
 
78
78
  # 3. Run
79
79
  async def main() -> None:
@@ -131,31 +131,38 @@ class CompletionTransport(Protocol):
131
131
 
132
132
  <!-- name: test_readme_context_store -->
133
133
  ```python
134
- from abc import ABC, abstractmethod
135
134
  from axio.context import ContextStore
136
135
  from axio.messages import Message
137
136
 
138
- class MyContextStore(ContextStore, ABC):
139
- @abstractmethod
140
- async def append(self, message: Message) -> None: ...
137
+ class MyContextStore(ContextStore):
138
+ def __init__(self) -> None:
139
+ self._messages: list[Message] = []
141
140
 
142
- @abstractmethod
143
- async def get_history(self) -> list[Message]: ...
141
+ async def append(self, message: Message) -> None:
142
+ self._messages.append(message)
143
+
144
+ async def get_history(self) -> list[Message]:
145
+ return list(self._messages)
144
146
 
145
147
  # Everything else — session_id, close(), fork(), clear(),
146
- # get/set_context_tokens() has a default implementation.
148
+ # get/set_context_tokens(), add_context_tokens(), list_sessions()
149
+ # — has a default implementation.
147
150
  ```
148
151
 
149
152
  ### PermissionGuard
150
153
 
154
+ `PermissionGuard` is an abstract base class (ABC). Subclass it and implement
155
+ `check()`:
156
+
151
157
  <!-- name: test_readme_permission_guard -->
152
158
  ```python
153
- from typing import Protocol, runtime_checkable
154
- from axio.tool import ToolHandler
159
+ from typing import Any
160
+ from axio.permission import PermissionGuard
155
161
 
156
- @runtime_checkable
157
- class PermissionGuard(Protocol):
158
- async def check(self, handler: ToolHandler) -> ToolHandler: ...
162
+ class MyGuard(PermissionGuard):
163
+ async def check(self, handler: Any) -> Any:
164
+ # return handler to allow, raise GuardError to deny
165
+ return handler
159
166
  ```
160
167
 
161
168
  ## Stream events
@@ -233,15 +240,15 @@ my_guard = "my_package:MyGuard"
233
240
 
234
241
  | Package | Purpose |
235
242
  |---|---|
236
- | [axio-transport-anthropic](https://github.com/axio-agent/axio-transport-anthropic) | Anthropic Claude transport |
237
- | [axio-transport-openai](https://github.com/axio-agent/axio-transport-openai) | OpenAI-compatible transport (OpenAI, Nebius, OpenRouter, custom) |
238
- | [axio-transport-codex](https://github.com/axio-agent/axio-transport-codex) | ChatGPT OAuth transport |
239
- | [axio-tools-local](https://github.com/axio-agent/axio-tools-local) | Shell, file, Python tools |
240
- | [axio-tools-mcp](https://github.com/axio-agent/axio-tools-mcp) | MCP server bridge |
241
- | [axio-tools-docker](https://github.com/axio-agent/axio-tools-docker) | Docker sandbox tools |
242
- | [axio-tui](https://github.com/axio-agent/axio-tui) | Textual TUI application |
243
- | [axio-tui-rag](https://github.com/axio-agent/axio-tui-rag) | RAG / semantic search plugin |
244
- | [axio-tui-guards](https://github.com/axio-agent/axio-tui-guards) | Permission guard plugins |
243
+ | [axio-transport-anthropic](https://github.com/mosquito/axio-agent) | Anthropic Claude transport |
244
+ | [axio-transport-openai](https://github.com/mosquito/axio-agent) | OpenAI-compatible transport (OpenAI, Nebius, OpenRouter, custom) |
245
+ | [axio-transport-codex](https://github.com/mosquito/axio-agent) | ChatGPT OAuth transport |
246
+ | [axio-context-sqlite](https://github.com/mosquito/axio-agent) | SQLite-backed persistent context store |
247
+ | [axio-tools-local](https://github.com/mosquito/axio-agent) | Shell, file, Python tools |
248
+ | [axio-tools-mcp](https://github.com/mosquito/axio-agent) | MCP server bridge |
249
+ | [axio-tools-docker](https://github.com/mosquito/axio-agent) | Docker sandbox tools |
250
+ | [axio-tui](https://github.com/mosquito/axio-agent) | Textual TUI application |
251
+ | [axio-tui-guards](https://github.com/mosquito/axio-agent) | Permission guard plugins |
245
252
 
246
253
  ## License
247
254
 
@@ -60,7 +60,7 @@ greet_tool = Tool(name="greet", description="Greet someone by name", handler=Gre
60
60
  from axio_transport_openai import OpenAITransport
61
61
 
62
62
  transport = OpenAITransport(api_key="sk-...", model="gpt-4o-mini")
63
- agent = Agent(system="You are helpful.", tools=[greet_tool], transport=transport)
63
+ agent = Agent(system="You are helpful.", transport=transport, tools=[greet_tool])
64
64
 
65
65
  # 3. Run
66
66
  async def main() -> None:
@@ -118,31 +118,38 @@ class CompletionTransport(Protocol):
118
118
 
119
119
  <!-- name: test_readme_context_store -->
120
120
  ```python
121
- from abc import ABC, abstractmethod
122
121
  from axio.context import ContextStore
123
122
  from axio.messages import Message
124
123
 
125
- class MyContextStore(ContextStore, ABC):
126
- @abstractmethod
127
- async def append(self, message: Message) -> None: ...
124
+ class MyContextStore(ContextStore):
125
+ def __init__(self) -> None:
126
+ self._messages: list[Message] = []
128
127
 
129
- @abstractmethod
130
- async def get_history(self) -> list[Message]: ...
128
+ async def append(self, message: Message) -> None:
129
+ self._messages.append(message)
130
+
131
+ async def get_history(self) -> list[Message]:
132
+ return list(self._messages)
131
133
 
132
134
  # Everything else — session_id, close(), fork(), clear(),
133
- # get/set_context_tokens() has a default implementation.
135
+ # get/set_context_tokens(), add_context_tokens(), list_sessions()
136
+ # — has a default implementation.
134
137
  ```
135
138
 
136
139
  ### PermissionGuard
137
140
 
141
+ `PermissionGuard` is an abstract base class (ABC). Subclass it and implement
142
+ `check()`:
143
+
138
144
  <!-- name: test_readme_permission_guard -->
139
145
  ```python
140
- from typing import Protocol, runtime_checkable
141
- from axio.tool import ToolHandler
146
+ from typing import Any
147
+ from axio.permission import PermissionGuard
142
148
 
143
- @runtime_checkable
144
- class PermissionGuard(Protocol):
145
- async def check(self, handler: ToolHandler) -> ToolHandler: ...
149
+ class MyGuard(PermissionGuard):
150
+ async def check(self, handler: Any) -> Any:
151
+ # return handler to allow, raise GuardError to deny
152
+ return handler
146
153
  ```
147
154
 
148
155
  ## Stream events
@@ -220,15 +227,15 @@ my_guard = "my_package:MyGuard"
220
227
 
221
228
  | Package | Purpose |
222
229
  |---|---|
223
- | [axio-transport-anthropic](https://github.com/axio-agent/axio-transport-anthropic) | Anthropic Claude transport |
224
- | [axio-transport-openai](https://github.com/axio-agent/axio-transport-openai) | OpenAI-compatible transport (OpenAI, Nebius, OpenRouter, custom) |
225
- | [axio-transport-codex](https://github.com/axio-agent/axio-transport-codex) | ChatGPT OAuth transport |
226
- | [axio-tools-local](https://github.com/axio-agent/axio-tools-local) | Shell, file, Python tools |
227
- | [axio-tools-mcp](https://github.com/axio-agent/axio-tools-mcp) | MCP server bridge |
228
- | [axio-tools-docker](https://github.com/axio-agent/axio-tools-docker) | Docker sandbox tools |
229
- | [axio-tui](https://github.com/axio-agent/axio-tui) | Textual TUI application |
230
- | [axio-tui-rag](https://github.com/axio-agent/axio-tui-rag) | RAG / semantic search plugin |
231
- | [axio-tui-guards](https://github.com/axio-agent/axio-tui-guards) | Permission guard plugins |
230
+ | [axio-transport-anthropic](https://github.com/mosquito/axio-agent) | Anthropic Claude transport |
231
+ | [axio-transport-openai](https://github.com/mosquito/axio-agent) | OpenAI-compatible transport (OpenAI, Nebius, OpenRouter, custom) |
232
+ | [axio-transport-codex](https://github.com/mosquito/axio-agent) | ChatGPT OAuth transport |
233
+ | [axio-context-sqlite](https://github.com/mosquito/axio-agent) | SQLite-backed persistent context store |
234
+ | [axio-tools-local](https://github.com/mosquito/axio-agent) | Shell, file, Python tools |
235
+ | [axio-tools-mcp](https://github.com/mosquito/axio-agent) | MCP server bridge |
236
+ | [axio-tools-docker](https://github.com/mosquito/axio-agent) | Docker sandbox tools |
237
+ | [axio-tui](https://github.com/mosquito/axio-agent) | Textual TUI application |
238
+ | [axio-tui-guards](https://github.com/mosquito/axio-agent) | Permission guard plugins |
232
239
 
233
240
  ## License
234
241
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "axio"
3
- version = "0.6.1"
3
+ version = "0.7.0"
4
4
  description = "Minimal, streaming-first, protocol-driven foundation for LLM-powered agents"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -10,8 +10,8 @@ dependencies = ["pydantic>=2"]
10
10
 
11
11
 
12
12
  [project.urls]
13
- Homepage = "https://github.com/axio-agent/axio"
14
- Repository = "https://github.com/axio-agent/axio"
13
+ Homepage = "https://github.com/mosquito/axio-agent"
14
+ Repository = "https://github.com/mosquito/axio-agent"
15
15
  [build-system]
16
16
  requires = ["hatchling"]
17
17
  build-backend = "hatchling.build"
@@ -10,9 +10,9 @@ from collections.abc import AsyncGenerator, Iterable
10
10
  from dataclasses import dataclass, field
11
11
  from typing import Any, Self
12
12
 
13
- from axio.blocks import TextBlock, ToolResultBlock, ToolUseBlock
14
- from axio.context import ContextStore
15
- from axio.events import (
13
+ from .blocks import TextBlock, ToolResultBlock, ToolUseBlock
14
+ from .context import ContextStore
15
+ from .events import (
16
16
  Error,
17
17
  IterationEnd,
18
18
  SessionEndEvent,
@@ -22,12 +22,12 @@ from axio.events import (
22
22
  ToolResult,
23
23
  ToolUseStart,
24
24
  )
25
- from axio.messages import Message
26
- from axio.selector import ToolSelector
27
- from axio.stream import AgentStream
28
- from axio.tool import Tool
29
- from axio.transport import CompletionTransport
30
- from axio.types import StopReason, Usage
25
+ from .messages import Message
26
+ from .selector import ToolSelector
27
+ from .stream import AgentStream
28
+ from .tool import Tool
29
+ from .transport import CompletionTransport
30
+ from .types import StopReason, Usage
31
31
 
32
32
  logger = logging.getLogger(__name__)
33
33
 
@@ -36,7 +36,7 @@ logger = logging.getLogger(__name__)
36
36
  class Agent:
37
37
  system: str
38
38
  transport: CompletionTransport
39
- tools: list[Tool] = field(default_factory=list)
39
+ tools: list[Tool[Any]] = field(default_factory=list)
40
40
  selector: ToolSelector | None = field(default=None)
41
41
  max_iterations: int = field(default=50)
42
42
  last_iteration_message: Message | None = field(default=None)
@@ -74,7 +74,7 @@ class Agent:
74
74
  logger.info("Tools complete: %d total, %d errors", len(results), error_count)
75
75
  return results
76
76
 
77
- def _find_tool(self, name: str) -> Tool | None:
77
+ def _find_tool(self, name: str) -> Tool[Any] | None:
78
78
  for tool in self.tools:
79
79
  if tool.name == name:
80
80
  return tool
@@ -128,7 +128,7 @@ class Agent:
128
128
  blocks.append(ToolUseBlock(id=tid, name=info["name"], input=inp))
129
129
  return blocks, malformed
130
130
 
131
- async def _select_tools(self, history: list[Message], tools: list[Tool]) -> Iterable[Tool]:
131
+ async def _select_tools(self, history: list[Message], tools: list[Tool[Any]]) -> Iterable[Tool[Any]]:
132
132
  if not tools:
133
133
  return []
134
134
  if not self.selector:
@@ -0,0 +1,386 @@
1
+ """Load Agent prototypes from declarative config files (TOML, JSON, INI).
2
+
3
+ Each file describes one agent. The loader scans a directory, parses every
4
+ recognised file, resolves tool names against a caller-supplied toolbox, and
5
+ returns a ``dict[str, tuple[str, Agent]]`` that matches the shape used by
6
+ ``agent_swarm`` and similar orchestrators.
7
+
8
+ Supported formats
9
+ -----------------
10
+ TOML::
11
+
12
+ name = "architect"
13
+ description = "System design and interface specs"
14
+ max_iterations = 100
15
+ tools = ["read_file", "write_file"]
16
+
17
+ [system]
18
+ text = \"\"\"
19
+ You are an expert software architect...
20
+ \"\"\"
21
+
22
+ JSON::
23
+
24
+ {
25
+ "name": "architect",
26
+ "description": "System design and interface specs",
27
+ "max_iterations": 100,
28
+ "tools": ["read_file", "write_file"],
29
+ "system": "You are an expert software architect..."
30
+ }
31
+
32
+ INI::
33
+
34
+ [agent]
35
+ name = architect
36
+ description = System design and interface specs
37
+ max_iterations = 100
38
+ tools = read_file, write_file
39
+
40
+ [system]
41
+ text = You are an expert software architect...
42
+
43
+ In all formats ``name`` falls back to the file stem when omitted. ``system``
44
+ may be a plain string or a ``{"text": "..."}`` mapping (TOML/JSON). INI tools
45
+ are comma-separated.
46
+
47
+ Custom sources
48
+ --------------
49
+ Subclass :class:`AgentLoader` and implement :meth:`~AgentLoader.load` — the
50
+ base :meth:`~AgentLoader.load_file` will handle reading the file and calling
51
+ your implementation automatically::
52
+
53
+ class DbAgentLoader(AgentLoader):
54
+ def load(self, content: str) -> AgentSpec:
55
+ row = json.loads(content)
56
+ return AgentSpec(name=row["name"], ...)
57
+ """
58
+
59
+ from __future__ import annotations
60
+
61
+ import configparser
62
+ import dataclasses
63
+ import json
64
+ import tomllib
65
+ from collections.abc import Callable, Mapping
66
+ from dataclasses import dataclass, field
67
+ from pathlib import Path
68
+ from types import MappingProxyType
69
+ from typing import Annotated, Any, TypedDict
70
+
71
+ from pydantic import Field
72
+
73
+ from .agent import Agent
74
+ from .context import ContextStore, MemoryContextStore
75
+ from .events import StreamEvent, TextDelta
76
+ from .tool import Tool, ToolHandler
77
+ from .transport import CompletionTransport, DummyCompletionTransport
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class AgentSpec:
82
+ """Parsed agent definition before transport/tools are injected."""
83
+
84
+ name: str
85
+ description: str
86
+ system: str
87
+ max_iterations: int = 50
88
+ tools: tuple[str, ...] = ()
89
+ model: str | None = None
90
+
91
+ def to_agent(self, toolbox: Mapping[str, Tool[Any]] = MappingProxyType({})) -> Agent:
92
+ """Return a prototype Agent with *toolbox* tools attached.
93
+
94
+ The agent uses :class:`~axio.transport.DummyCompletionTransport` —
95
+ call ``agent.copy(transport=real_transport)`` before running it.
96
+
97
+ Raises :exc:`KeyError` if any name in ``self.tools`` is absent from
98
+ *toolbox*.
99
+ """
100
+ resolved: list[Tool[Any]] = []
101
+ for name in self.tools:
102
+ if name not in toolbox:
103
+ raise KeyError(f"Tool {name!r} not found in toolbox")
104
+ resolved.append(toolbox[name])
105
+ return Agent(
106
+ system=self.system,
107
+ transport=DummyCompletionTransport(),
108
+ tools=resolved,
109
+ max_iterations=self.max_iterations,
110
+ )
111
+
112
+
113
+ class AgentLoader:
114
+ """Base class for format-specific agent loaders.
115
+
116
+ 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.
118
+ :meth:`load_file` is provided on the base class and calls :meth:`load`
119
+ automatically.
120
+ """
121
+
122
+ extensions: tuple[str, ...] = ()
123
+
124
+ def load(self, content: str) -> AgentSpec:
125
+ """Parse *content* and return an :class:`AgentSpec`.
126
+
127
+ ``name`` defaults to ``""`` when not present; :meth:`load_file`
128
+ patches it from the file stem after calling this method.
129
+ """
130
+ raise NotImplementedError
131
+
132
+ def load_file(self, path: Path) -> AgentSpec:
133
+ """Read *path* and delegate to :meth:`load`, patching ``name`` from the stem.
134
+
135
+ Any :exc:`ValueError` raised by :meth:`load` is re-raised with *path*
136
+ prepended so errors are easy to locate.
137
+ """
138
+ try:
139
+ spec = self.load(path.read_text(encoding="utf-8"))
140
+ except ValueError as exc:
141
+ raise ValueError(f"{path}: {exc}") from exc
142
+ if not spec.name:
143
+ return dataclasses.replace(spec, name=path.stem)
144
+ return spec
145
+
146
+ def scan(
147
+ self,
148
+ directory: Path,
149
+ toolbox: Mapping[str, Tool[Any]] = MappingProxyType({}),
150
+ ) -> dict[str, tuple[str, Agent]]:
151
+ """Scan *directory* for agent files and return a name→(description, agent) dict."""
152
+ result: dict[str, tuple[str, Agent]] = {}
153
+ for ext in self.extensions:
154
+ for path in sorted(directory.glob(f"*.{ext}")):
155
+ spec = self.load_file(path)
156
+ result[spec.name] = (spec.description, spec.to_agent(toolbox))
157
+ return result
158
+
159
+ def _parse_dict(self, data: dict[str, object]) -> AgentSpec:
160
+ """Normalise a parsed dict (any format) into an :class:`AgentSpec`."""
161
+ name = str(data.get("name", ""))
162
+ description = str(data.get("description", ""))
163
+
164
+ system_raw = data.get("system", "")
165
+ if isinstance(system_raw, dict):
166
+ system = str(system_raw.get("text", ""))
167
+ else:
168
+ system = str(system_raw)
169
+
170
+ raw_iter = data.get("max_iterations", 50)
171
+ max_iterations = int(raw_iter) # type: ignore[call-overload]
172
+
173
+ tools_raw = data.get("tools", ())
174
+ tools = tuple(tools_raw) if isinstance(tools_raw, (list, tuple)) else ()
175
+
176
+ model_raw = data.get("model")
177
+ model = str(model_raw) if model_raw is not None else None
178
+
179
+ return AgentSpec(
180
+ name=name,
181
+ description=description,
182
+ system=system,
183
+ max_iterations=max_iterations,
184
+ tools=tools,
185
+ model=model,
186
+ )
187
+
188
+
189
+ class TomlAgentLoader(AgentLoader):
190
+ """Load agent definitions from ``.toml`` files."""
191
+
192
+ extensions = ("toml",)
193
+
194
+ def load(self, content: str) -> AgentSpec:
195
+ try:
196
+ data = tomllib.loads(content)
197
+ except tomllib.TOMLDecodeError as exc:
198
+ raise ValueError(f"Invalid TOML: {exc}") from exc
199
+ return self._parse_dict(data)
200
+
201
+
202
+ class JsonAgentLoader(AgentLoader):
203
+ """Load agent definitions from ``.json`` files."""
204
+
205
+ extensions = ("json",)
206
+
207
+ def load(self, content: str) -> AgentSpec:
208
+ try:
209
+ data = json.loads(content)
210
+ except json.JSONDecodeError as exc:
211
+ raise ValueError(f"Invalid JSON: {exc}") from exc
212
+ if not isinstance(data, dict):
213
+ raise ValueError(f"Expected a JSON object, got {type(data).__name__}")
214
+ return self._parse_dict(data)
215
+
216
+
217
+ class IniAgentLoader(AgentLoader):
218
+ """Load agent definitions from ``.ini`` files.
219
+
220
+ Expected sections: ``[agent]`` for metadata, ``[system]`` for the prompt.
221
+ The ``tools`` key is comma-separated. Multiline system prompts work via
222
+ standard ConfigParser continuation (indent subsequent lines).
223
+ """
224
+
225
+ extensions = ("ini",)
226
+
227
+ def load(self, content: str) -> AgentSpec:
228
+ cp = configparser.ConfigParser(interpolation=None)
229
+ cp.read_string(content)
230
+
231
+ agent_section = cp["agent"] if cp.has_section("agent") else {}
232
+ system_section = cp["system"] if cp.has_section("system") else {}
233
+
234
+ name = agent_section.get("name", "")
235
+ description = agent_section.get("description", "")
236
+ max_iterations = int(agent_section.get("max_iterations", 50))
237
+ model_raw = agent_section.get("model")
238
+ model = model_raw if model_raw else None
239
+ system = system_section.get("text", agent_section.get("system", ""))
240
+ tools_raw = agent_section.get("tools", "")
241
+ tools = tuple(t.strip() for t in tools_raw.split(",") if t.strip()) if tools_raw else ()
242
+
243
+ return AgentSpec(
244
+ name=name,
245
+ description=description,
246
+ system=system,
247
+ max_iterations=max_iterations,
248
+ tools=tools,
249
+ model=model,
250
+ )
251
+
252
+
253
+ @dataclass
254
+ class MultiFormatLoader:
255
+ """Aggregate loader that handles TOML, JSON, and INI files."""
256
+
257
+ loaders: list[AgentLoader] = field(
258
+ default_factory=lambda: [TomlAgentLoader(), JsonAgentLoader(), IniAgentLoader()]
259
+ )
260
+
261
+ def scan(
262
+ self,
263
+ directory: Path,
264
+ toolbox: Mapping[str, Tool[Any]] = MappingProxyType({}),
265
+ ) -> dict[str, tuple[str, Agent]]:
266
+ """Scan *directory* with all registered loaders, later loaders win on name collision."""
267
+ result: dict[str, tuple[str, Agent]] = {}
268
+ for loader in self.loaders:
269
+ result.update(loader.scan(directory, toolbox))
270
+ return result
271
+
272
+
273
+ def load_agents(
274
+ directory: Path,
275
+ toolbox: Mapping[str, Tool[Any]] = MappingProxyType({}),
276
+ ) -> dict[str, tuple[str, Agent]]:
277
+ """Scan *directory* for ``.toml``, ``.json``, and ``.ini`` agent files.
278
+
279
+ Returns ``dict[name, (description, agent)]`` — same shape as the
280
+ ``AGENTS`` registry used in ``agent_swarm`` and similar examples.
281
+
282
+ Example::
283
+
284
+ from pathlib import Path
285
+ from axio.agent_loader import load_agents
286
+
287
+ AGENTS = load_agents(
288
+ Path(__file__).parent / "roles",
289
+ toolbox={"read_file": read_file_tool, "write_file": write_file_tool},
290
+ )
291
+ """
292
+ return MultiFormatLoader().scan(directory, toolbox)
293
+
294
+
295
+ class AgentContext(TypedDict):
296
+ agent_name: str
297
+ proto: Agent
298
+ transport: CompletionTransport
299
+ context_factory: Callable[[], ContextStore]
300
+ on_event: Callable[[str, StreamEvent], None] | None
301
+
302
+
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)
322
+
323
+
324
+ def make_agent_tools(
325
+ agents: dict[str, tuple[str, Agent]],
326
+ transport: CompletionTransport,
327
+ context_factory: Callable[[], ContextStore] = MemoryContextStore,
328
+ on_event: Callable[[str, StreamEvent], None] | None = None,
329
+ agent_name_prefix: str = "",
330
+ ) -> list[Tool[AgentContext]]:
331
+ """Convert each agent into its own :class:`~axio.tool.Tool`.
332
+
333
+ Each tool is named after the agent and accepts a single ``task`` field.
334
+ Runtime dependencies (transport, context factory, event callback) are
335
+ stored in :attr:`~axio.tool.Tool.context` and injected on each call.
336
+
337
+ Parameters
338
+ ----------
339
+ agents:
340
+ ``dict[name, (description, prototype_agent)]`` — e.g. the value
341
+ returned by :func:`load_agents`.
342
+ transport:
343
+ Transport assigned to the selected agent via ``agent.copy()``.
344
+ context_factory:
345
+ Called once per invocation to produce a fresh
346
+ :class:`~axio.context.ContextStore`. Defaults to
347
+ :class:`~axio.context.MemoryContextStore`.
348
+ on_event:
349
+ Optional callback receiving ``(agent_name, event)`` for every
350
+ :class:`~axio.events.StreamEvent` the agent emits.
351
+ agent_name_prefix:
352
+ Prefix to prepend to each agent name.
353
+
354
+ Example::
355
+
356
+ from axio.agent_loader import load_agents, make_agent_tools
357
+
358
+ agents = load_agents(Path("roles"), toolbox={"read_file": read_file_tool})
359
+ tools = make_agent_tools(agents, transport=my_transport)
360
+ orchestrator = Agent(system="...", transport=my_transport, tools=tools)
361
+ """
362
+ result = []
363
+ for agent_name, (desc, proto) in agents.items():
364
+ tool = Tool(
365
+ name=f"{agent_name_prefix}{agent_name}",
366
+ description=desc,
367
+ handler=AgentTool,
368
+ context=AgentContext(
369
+ proto=proto,
370
+ transport=transport,
371
+ context_factory=context_factory,
372
+ on_event=on_event,
373
+ agent_name=agent_name,
374
+ ),
375
+ )
376
+ result.append(tool)
377
+ return result
378
+
379
+
380
+ def load_agents_from_dir(
381
+ directory: Path,
382
+ transport: CompletionTransport,
383
+ context_factory: Callable[[], ContextStore] = MemoryContextStore,
384
+ on_event: Callable[[str, StreamEvent], None] | None = None,
385
+ ) -> list[Tool[AgentContext]]:
386
+ return make_agent_tools(load_agents(directory), transport, context_factory, on_event)
@@ -7,7 +7,7 @@ from dataclasses import dataclass
7
7
  from functools import singledispatch
8
8
  from typing import Any, Literal
9
9
 
10
- from axio.types import ToolCallID, ToolName
10
+ from .types import ToolCallID, ToolName
11
11
 
12
12
 
13
13
  class ContentBlock: