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.
Files changed (75) hide show
  1. {axio-0.11.2 → axio-0.12.0}/.gitignore +1 -0
  2. {axio-0.11.2 → axio-0.12.0}/PKG-INFO +24 -10
  3. {axio-0.11.2 → axio-0.12.0}/README.md +23 -9
  4. {axio-0.11.2 → axio-0.12.0}/pyproject.toml +1 -1
  5. {axio-0.11.2 → axio-0.12.0}/src/axio/agent.py +173 -7
  6. axio-0.12.0/src/axio/credentials/__init__.py +15 -0
  7. axio-0.12.0/src/axio/credentials/codec.py +148 -0
  8. axio-0.12.0/src/axio/credentials/files.py +148 -0
  9. axio-0.12.0/src/axio/credentials/stores.py +326 -0
  10. axio-0.12.0/src/axio/credentials/types.py +64 -0
  11. axio-0.12.0/src/axio/instructions.py +151 -0
  12. axio-0.12.0/src/axio/permission_policy.py +319 -0
  13. axio-0.12.0/src/axio/plugins.py +233 -0
  14. {axio-0.11.2 → axio-0.12.0}/src/axio/stream.py +4 -0
  15. {axio-0.11.2 → axio-0.12.0}/src/axio/testing.py +33 -1
  16. {axio-0.11.2 → axio-0.12.0}/src/axio/tool.py +83 -3
  17. axio-0.12.0/src/axio/tool_access.py +50 -0
  18. axio-0.12.0/src/axio/transport.py +383 -0
  19. axio-0.12.0/tests/test_agent_instructions.py +304 -0
  20. axio-0.12.0/tests/test_argument_coercion.py +152 -0
  21. axio-0.12.0/tests/test_credentials.py +189 -0
  22. axio-0.12.0/tests/test_instructions.py +107 -0
  23. axio-0.12.0/tests/test_permission_policy.py +281 -0
  24. axio-0.12.0/tests/test_plugins.py +220 -0
  25. axio-0.12.0/tests/test_preflight.py +137 -0
  26. {axio-0.11.2 → axio-0.12.0}/tests/test_stream.py +13 -0
  27. {axio-0.11.2 → axio-0.12.0}/tests/test_testing.py +68 -0
  28. axio-0.12.0/tests/test_transport_plugins.py +113 -0
  29. axio-0.11.2/src/axio/transport.py +0 -211
  30. {axio-0.11.2 → axio-0.12.0}/LICENSE +0 -0
  31. {axio-0.11.2 → axio-0.12.0}/Makefile +0 -0
  32. {axio-0.11.2 → axio-0.12.0}/src/axio/__init__.py +0 -0
  33. {axio-0.11.2 → axio-0.12.0}/src/axio/agent_loader.py +0 -0
  34. {axio-0.11.2 → axio-0.12.0}/src/axio/blocks.py +0 -0
  35. {axio-0.11.2 → axio-0.12.0}/src/axio/compaction.py +0 -0
  36. {axio-0.11.2 → axio-0.12.0}/src/axio/context.py +0 -0
  37. {axio-0.11.2 → axio-0.12.0}/src/axio/events.py +0 -0
  38. {axio-0.11.2 → axio-0.12.0}/src/axio/exceptions.py +0 -0
  39. {axio-0.11.2 → axio-0.12.0}/src/axio/field.py +0 -0
  40. {axio-0.11.2 → axio-0.12.0}/src/axio/messages.py +0 -0
  41. {axio-0.11.2 → axio-0.12.0}/src/axio/models.py +0 -0
  42. {axio-0.11.2 → axio-0.12.0}/src/axio/permission.py +0 -0
  43. {axio-0.11.2 → axio-0.12.0}/src/axio/py.typed +0 -0
  44. {axio-0.11.2 → axio-0.12.0}/src/axio/realtime.py +0 -0
  45. {axio-0.11.2 → axio-0.12.0}/src/axio/retry.py +0 -0
  46. {axio-0.11.2 → axio-0.12.0}/src/axio/schema.py +0 -0
  47. {axio-0.11.2 → axio-0.12.0}/src/axio/selector.py +0 -0
  48. {axio-0.11.2 → axio-0.12.0}/src/axio/tool_args.py +0 -0
  49. {axio-0.11.2 → axio-0.12.0}/src/axio/types.py +0 -0
  50. {axio-0.11.2 → axio-0.12.0}/tests/conftest.py +0 -0
  51. {axio-0.11.2 → axio-0.12.0}/tests/test_agent_branch.py +0 -0
  52. {axio-0.11.2 → axio-0.12.0}/tests/test_agent_capabilities.py +0 -0
  53. {axio-0.11.2 → axio-0.12.0}/tests/test_agent_loader.py +0 -0
  54. {axio-0.11.2 → axio-0.12.0}/tests/test_agent_permission.py +0 -0
  55. {axio-0.11.2 → axio-0.12.0}/tests/test_agent_run.py +0 -0
  56. {axio-0.11.2 → axio-0.12.0}/tests/test_agent_stream.py +0 -0
  57. {axio-0.11.2 → axio-0.12.0}/tests/test_agent_tools.py +0 -0
  58. {axio-0.11.2 → axio-0.12.0}/tests/test_blocks.py +0 -0
  59. {axio-0.11.2 → axio-0.12.0}/tests/test_compaction.py +0 -0
  60. {axio-0.11.2 → axio-0.12.0}/tests/test_context.py +0 -0
  61. {axio-0.11.2 → axio-0.12.0}/tests/test_events.py +0 -0
  62. {axio-0.11.2 → axio-0.12.0}/tests/test_exceptions.py +0 -0
  63. {axio-0.11.2 → axio-0.12.0}/tests/test_field.py +0 -0
  64. {axio-0.11.2 → axio-0.12.0}/tests/test_init.py +0 -0
  65. {axio-0.11.2 → axio-0.12.0}/tests/test_models.py +0 -0
  66. {axio-0.11.2 → axio-0.12.0}/tests/test_permission.py +0 -0
  67. {axio-0.11.2 → axio-0.12.0}/tests/test_realtime.py +0 -0
  68. {axio-0.11.2 → axio-0.12.0}/tests/test_retry.py +0 -0
  69. {axio-0.11.2 → axio-0.12.0}/tests/test_schema.py +0 -0
  70. {axio-0.11.2 → axio-0.12.0}/tests/test_selector.py +0 -0
  71. {axio-0.11.2 → axio-0.12.0}/tests/test_tool.py +0 -0
  72. {axio-0.11.2 → axio-0.12.0}/tests/test_tool_args.py +0 -0
  73. {axio-0.11.2 → axio-0.12.0}/tests/test_transport.py +0 -0
  74. {axio-0.11.2 → axio-0.12.0}/tests/test_types.py +0 -0
  75. {axio-0.11.2 → axio-0.12.0}/uv.lock +0 -0
@@ -12,3 +12,4 @@ _build/
12
12
  .DS_Store
13
13
  *.sqlite
14
14
  *.db
15
+ .axio/mcp.local.json
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: axio
3
- Version: 0.11.2
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", # overrides docstring if set
206
+ description="Summarise text", # overrides docstring if set
196
207
  handler=summarise,
197
- concurrency=4, # max parallel executions
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
- make_tool_use_response("echo", tool_input={"msg": "hi"}),
217
- make_text_response("Done"),
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", # overrides docstring if set
189
+ description="Summarise text", # overrides docstring if set
179
190
  handler=summarise,
180
- concurrency=4, # max parallel executions
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
- make_tool_use_response("echo", tool_input={"msg": "hi"}),
200
- make_text_response("Done"),
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.11.2" # bumped to the git tag by CI on publish
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
- results = list(await asyncio.gather(*[self._call_one(b) for b in blocks]))
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 = list(await asyncio.gather(*[_run_one(b) for b in blocks]))
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
- for tool in self.tools:
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, self.tools))
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, self.system)
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=(",", ":"))