axio 0.9.0__tar.gz → 0.9.1__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 (60) hide show
  1. {axio-0.9.0 → axio-0.9.1}/PKG-INFO +1 -1
  2. {axio-0.9.0 → axio-0.9.1}/pyproject.toml +2 -1
  3. {axio-0.9.0 → axio-0.9.1}/src/axio/__init__.py +24 -2
  4. axio-0.9.1/src/axio/agent.py +523 -0
  5. {axio-0.9.0 → axio-0.9.1}/src/axio/blocks.py +56 -4
  6. axio-0.9.1/src/axio/events.py +183 -0
  7. {axio-0.9.0 → axio-0.9.1}/src/axio/models.py +9 -0
  8. axio-0.9.1/src/axio/realtime.py +225 -0
  9. {axio-0.9.0 → axio-0.9.1}/src/axio/tool.py +94 -37
  10. {axio-0.9.0 → axio-0.9.1}/src/axio/tool_args.py +9 -0
  11. axio-0.9.1/src/axio/transport.py +211 -0
  12. axio-0.9.1/tests/test_agent_capabilities.py +106 -0
  13. {axio-0.9.0 → axio-0.9.1}/tests/test_agent_tools.py +95 -0
  14. {axio-0.9.0 → axio-0.9.1}/tests/test_blocks.py +37 -1
  15. axio-0.9.1/tests/test_realtime.py +463 -0
  16. {axio-0.9.0 → axio-0.9.1}/tests/test_tool.py +4 -8
  17. {axio-0.9.0 → axio-0.9.1}/tests/test_transport.py +30 -2
  18. axio-0.9.0/src/axio/agent.py +0 -258
  19. axio-0.9.0/src/axio/events.py +0 -98
  20. axio-0.9.0/src/axio/transport.py +0 -107
  21. {axio-0.9.0 → axio-0.9.1}/.github/workflows/publish.yml +0 -0
  22. {axio-0.9.0 → axio-0.9.1}/.github/workflows/tests.yml +0 -0
  23. {axio-0.9.0 → axio-0.9.1}/.gitignore +0 -0
  24. {axio-0.9.0 → axio-0.9.1}/LICENSE +0 -0
  25. {axio-0.9.0 → axio-0.9.1}/Makefile +0 -0
  26. {axio-0.9.0 → axio-0.9.1}/README.md +0 -0
  27. {axio-0.9.0 → axio-0.9.1}/src/axio/agent_loader.py +0 -0
  28. {axio-0.9.0 → axio-0.9.1}/src/axio/compaction.py +0 -0
  29. {axio-0.9.0 → axio-0.9.1}/src/axio/context.py +0 -0
  30. {axio-0.9.0 → axio-0.9.1}/src/axio/exceptions.py +0 -0
  31. {axio-0.9.0 → axio-0.9.1}/src/axio/field.py +0 -0
  32. {axio-0.9.0 → axio-0.9.1}/src/axio/messages.py +0 -0
  33. {axio-0.9.0 → axio-0.9.1}/src/axio/permission.py +0 -0
  34. {axio-0.9.0 → axio-0.9.1}/src/axio/py.typed +0 -0
  35. {axio-0.9.0 → axio-0.9.1}/src/axio/schema.py +0 -0
  36. {axio-0.9.0 → axio-0.9.1}/src/axio/selector.py +0 -0
  37. {axio-0.9.0 → axio-0.9.1}/src/axio/stream.py +0 -0
  38. {axio-0.9.0 → axio-0.9.1}/src/axio/testing.py +0 -0
  39. {axio-0.9.0 → axio-0.9.1}/src/axio/types.py +0 -0
  40. {axio-0.9.0 → axio-0.9.1}/tests/conftest.py +0 -0
  41. {axio-0.9.0 → axio-0.9.1}/tests/test_agent_branch.py +0 -0
  42. {axio-0.9.0 → axio-0.9.1}/tests/test_agent_loader.py +0 -0
  43. {axio-0.9.0 → axio-0.9.1}/tests/test_agent_permission.py +0 -0
  44. {axio-0.9.0 → axio-0.9.1}/tests/test_agent_run.py +0 -0
  45. {axio-0.9.0 → axio-0.9.1}/tests/test_agent_stream.py +0 -0
  46. {axio-0.9.0 → axio-0.9.1}/tests/test_compaction.py +0 -0
  47. {axio-0.9.0 → axio-0.9.1}/tests/test_context.py +0 -0
  48. {axio-0.9.0 → axio-0.9.1}/tests/test_events.py +0 -0
  49. {axio-0.9.0 → axio-0.9.1}/tests/test_exceptions.py +0 -0
  50. {axio-0.9.0 → axio-0.9.1}/tests/test_field.py +0 -0
  51. {axio-0.9.0 → axio-0.9.1}/tests/test_init.py +0 -0
  52. {axio-0.9.0 → axio-0.9.1}/tests/test_models.py +0 -0
  53. {axio-0.9.0 → axio-0.9.1}/tests/test_permission.py +0 -0
  54. {axio-0.9.0 → axio-0.9.1}/tests/test_schema.py +0 -0
  55. {axio-0.9.0 → axio-0.9.1}/tests/test_selector.py +0 -0
  56. {axio-0.9.0 → axio-0.9.1}/tests/test_stream.py +0 -0
  57. {axio-0.9.0 → axio-0.9.1}/tests/test_testing.py +0 -0
  58. {axio-0.9.0 → axio-0.9.1}/tests/test_tool_args.py +0 -0
  59. {axio-0.9.0 → axio-0.9.1}/tests/test_types.py +0 -0
  60. {axio-0.9.0 → axio-0.9.1}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: axio
3
- Version: 0.9.0
3
+ Version: 0.9.1
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "axio"
3
- version = "0.9.0" # bumped to the git tag by CI on publish
3
+ version = "0.9.1" # 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"
@@ -25,6 +25,7 @@ testpaths = ["tests", "README.md"]
25
25
 
26
26
  [tool.ruff]
27
27
  line-length = 119
28
+ output-format = "concise"
28
29
  target-version = "py312"
29
30
 
30
31
  [tool.ruff.lint]
@@ -3,15 +3,28 @@
3
3
  from .agent import Agent
4
4
  from .blocks import TextBlock, ToolResultBlock, ToolUseBlock
5
5
  from .context import ContextStore, MemoryContextStore
6
- from .events import IterationEnd, StreamEvent, TextDelta, ToolInputDelta, ToolResult, ToolUseStart
6
+ from .events import (
7
+ AudioOutputDelta,
8
+ IterationEnd,
9
+ SpeechStarted,
10
+ SpeechStopped,
11
+ StreamEvent,
12
+ TextDelta,
13
+ ToolInputDelta,
14
+ ToolResult,
15
+ ToolUseStart,
16
+ TranscriptDelta,
17
+ TurnComplete,
18
+ )
7
19
  from .exceptions import GuardError, HandlerError
8
20
  from .field import Field, FieldInfo, StrictStr
9
21
  from .messages import Message
10
22
  from .permission import ConcurrentGuard, PermissionGuard
23
+ from .realtime import RealtimeAgent
11
24
  from .selector import ToolSelector
12
25
  from .stream import AgentStream
13
26
  from .tool import CONTEXT, Tool
14
- from .transport import CompletionTransport
27
+ from .transport import CompletionTransport, RealtimeSession, RealtimeTransport
15
28
  from .types import StopReason, Usage
16
29
 
17
30
  __all__ = [
@@ -29,6 +42,15 @@ __all__ = [
29
42
  "ToolUseStart",
30
43
  "ToolInputDelta",
31
44
  "ToolResult",
45
+ # realtime
46
+ "RealtimeAgent",
47
+ "RealtimeTransport",
48
+ "RealtimeSession",
49
+ "AudioOutputDelta",
50
+ "TranscriptDelta",
51
+ "SpeechStarted",
52
+ "SpeechStopped",
53
+ "TurnComplete",
32
54
  # messages & blocks
33
55
  "Message",
34
56
  "TextBlock",
@@ -0,0 +1,523 @@
1
+ """Agent: the core agentic loop orchestrating transport, tools, and context."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import dataclasses
7
+ import json
8
+ import logging
9
+ import time
10
+ from collections.abc import AsyncGenerator, Iterable
11
+ from dataclasses import dataclass, field
12
+ from datetime import datetime
13
+ from typing import Any, Self
14
+
15
+ from .blocks import AudioBlock, ContentBlock, ImageBlock, TextBlock, ToolResultBlock, ToolUseBlock, VideoBlock
16
+ from .context import ContextStore
17
+ from .events import (
18
+ AudioOutput,
19
+ Error,
20
+ ImageOutput,
21
+ IterationEnd,
22
+ SessionEndEvent,
23
+ StreamEvent,
24
+ TextDelta,
25
+ ToolInputDelta,
26
+ ToolOutputDelta,
27
+ ToolResult,
28
+ ToolUseStart,
29
+ VideoOutput,
30
+ )
31
+ from .messages import Message
32
+ from .models import Capability
33
+ from .selector import ToolSelector
34
+ from .stream import AgentStream
35
+ from .tool import Tool
36
+ from .transport import CompletionTransport
37
+ from .types import StopReason, Usage
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+
42
+ class _RepetitionDetector:
43
+ """Detects when model output is stuck in a repetitive loop.
44
+
45
+ Two complementary checks run periodically on accumulated text:
46
+
47
+ 1. **Short-period**: counts trailing consecutive repetitions of
48
+ patterns from 1 to ``max_period`` chars. Triggers when repetitions
49
+ span >= ``min_repeat_span`` chars. Catches single-token and
50
+ short-phrase loops quickly.
51
+
52
+ 2. **Long-period**: checks whether the last ``long_window`` chars
53
+ appear verbatim earlier in the output. Catches paragraph-level
54
+ repetition that the short-period check would miss.
55
+ """
56
+
57
+ __slots__ = (
58
+ "_parts",
59
+ "_total_len",
60
+ "_last_check",
61
+ "_interval",
62
+ "_min_len",
63
+ "_max_period",
64
+ "_min_repeat_span",
65
+ "_long_window",
66
+ )
67
+
68
+ def __init__(
69
+ self,
70
+ interval: int = 200,
71
+ min_len: int = 800,
72
+ max_period: int = 150,
73
+ min_repeat_span: int = 200,
74
+ long_window: int = 500,
75
+ ) -> None:
76
+ self._parts: list[str] = []
77
+ self._total_len = 0
78
+ self._last_check = 0
79
+ self._interval = interval
80
+ self._min_len = min_len
81
+ self._max_period = max_period
82
+ self._min_repeat_span = min_repeat_span
83
+ self._long_window = long_window
84
+
85
+ def feed(self, delta: str) -> bool:
86
+ """Feed a text delta. Returns ``True`` when a loop is detected."""
87
+ self._parts.append(delta)
88
+ self._total_len += len(delta)
89
+
90
+ if self._total_len < self._min_len:
91
+ return False
92
+ if self._total_len - self._last_check < self._interval:
93
+ return False
94
+ self._last_check = self._total_len
95
+
96
+ full = "".join(self._parts)
97
+ self._parts = [full]
98
+ n = len(full)
99
+
100
+ # --- Short-period: trailing repetition of a small pattern ---
101
+ max_p = min(self._max_period, n // 3)
102
+ for p in range(1, max_p + 1):
103
+ chunk = full[n - p : n]
104
+ count = 1
105
+ pos = n - 2 * p
106
+ while pos >= 0 and full[pos : pos + p] == chunk:
107
+ count += 1
108
+ pos -= p
109
+ if count >= 3 and count * p >= self._min_repeat_span:
110
+ return True
111
+
112
+ # --- Long-period: trailing window found earlier verbatim ---
113
+ w = min(self._long_window, n // 2)
114
+ if w >= self._min_repeat_span:
115
+ window = full[-w:]
116
+ if full.find(window, 0, n - w) >= 0:
117
+ return True
118
+
119
+ return False
120
+
121
+
122
+ @dataclass(slots=True)
123
+ class Agent:
124
+ system: str
125
+ transport: CompletionTransport
126
+ tools: list[Tool[Any]] = field(default_factory=list)
127
+ selector: ToolSelector | None = field(default=None)
128
+ max_iterations: int = field(default=50)
129
+ last_iteration_message: Message | None = field(default=None)
130
+
131
+ def copy(self, **overrides: Any) -> Self:
132
+ """Return a new Agent with *overrides* applied."""
133
+ return dataclasses.replace(self, **overrides)
134
+
135
+ def run_stream(self, user_message: str, context: ContextStore) -> AgentStream:
136
+ return AgentStream(self._run_loop(user_message, context))
137
+
138
+ async def run(self, user_message: str, context: ContextStore) -> str:
139
+ return await self.run_stream(user_message, context).get_final_text()
140
+
141
+ async def dispatch_tools(self, blocks: list[ToolUseBlock], iteration: int) -> list[ToolResultBlock]:
142
+ tool_names = [b.name for b in blocks]
143
+ logger.info("Dispatching %d tool(s): %s", len(blocks), tool_names)
144
+
145
+ async def _run_one(block: ToolUseBlock) -> ToolResultBlock:
146
+ tool = self._find_tool(block.name)
147
+ if tool is None:
148
+ logger.warning("Unknown tool requested: %s", block.name)
149
+ return ToolResultBlock(tool_use_id=block.id, content=f"Unknown tool: {block.name}", is_error=True)
150
+ logger.debug("Tool %s (id=%s) args=%s", block.name, block.id, json.dumps(block.input)[:200])
151
+ try:
152
+ result = await tool(**block.input)
153
+ if isinstance(result, str):
154
+ content: str | list[TextBlock | ImageBlock | AudioBlock | VideoBlock] = result
155
+ elif isinstance(result, list) and all(isinstance(b, ContentBlock) for b in result):
156
+ content = result
157
+ else:
158
+ content = str(result)
159
+ except Exception as exc:
160
+ logger.error("Tool %s raised %s: %s", block.name, type(exc).__name__, exc, exc_info=True)
161
+ return ToolResultBlock(tool_use_id=block.id, content=str(exc), is_error=True)
162
+ return ToolResultBlock(tool_use_id=block.id, content=content)
163
+
164
+ results = list(await asyncio.gather(*[_run_one(b) for b in blocks]))
165
+ error_count = sum(1 for r in results if r.is_error)
166
+ logger.info("Tools complete: %d total, %d errors", len(results), error_count)
167
+ return results
168
+
169
+ async def _dispatch_tools_streaming(
170
+ self,
171
+ blocks: list[ToolUseBlock],
172
+ iteration: int,
173
+ output_queue: asyncio.Queue[ToolOutputDelta | None],
174
+ ) -> list[ToolResultBlock]:
175
+ """Like dispatch_tools but pushes ToolOutputDelta events for streaming tools."""
176
+ tool_names = [b.name for b in blocks]
177
+ logger.info("Dispatching %d tool(s) with streaming: %s", len(blocks), tool_names)
178
+
179
+ async def _run_one(block: ToolUseBlock) -> ToolResultBlock:
180
+ tool = self._find_tool(block.name)
181
+ if tool is None:
182
+ logger.warning("Unknown tool requested: %s", block.name)
183
+ return ToolResultBlock(tool_use_id=block.id, content=f"Unknown tool: {block.name}", is_error=True)
184
+ logger.debug("Tool %s (id=%s) args=%s", block.name, block.id, json.dumps(block.input)[:200])
185
+
186
+ if tool.supports_streaming:
187
+ chunks: list[tuple[float, str, str]] = []
188
+ t0 = time.monotonic()
189
+ try:
190
+ async for key, text in tool.call_streaming(**block.input):
191
+ chunks.append((time.monotonic() - t0, key, text))
192
+ await output_queue.put(
193
+ ToolOutputDelta(tool_use_id=block.id, name=block.name, key=key, delta=text)
194
+ )
195
+ except Exception as exc:
196
+ logger.error("Tool %s raised %s: %s", block.name, type(exc).__name__, exc, exc_info=True)
197
+ return ToolResultBlock(tool_use_id=block.id, content=str(exc), is_error=True)
198
+ return ToolResultBlock(tool_use_id=block.id, content=tool.format_stream_result(chunks))
199
+ else:
200
+ try:
201
+ result = await tool(**block.input)
202
+ if isinstance(result, str):
203
+ content: str | list[TextBlock | ImageBlock | AudioBlock | VideoBlock] = result
204
+ elif isinstance(result, list) and all(isinstance(b, ContentBlock) for b in result):
205
+ content = result
206
+ else:
207
+ content = str(result)
208
+ except Exception as exc:
209
+ logger.error("Tool %s raised %s: %s", block.name, type(exc).__name__, exc, exc_info=True)
210
+ return ToolResultBlock(tool_use_id=block.id, content=str(exc), is_error=True)
211
+ return ToolResultBlock(tool_use_id=block.id, content=content)
212
+
213
+ results = list(await asyncio.gather(*[_run_one(b) for b in blocks]))
214
+ error_count = sum(1 for r in results if r.is_error)
215
+ logger.info("Tools complete: %d total, %d errors", len(results), error_count)
216
+ return results
217
+
218
+ def _find_tool(self, name: str) -> Tool[Any] | None:
219
+ for tool in self.tools:
220
+ if tool.name == name:
221
+ return tool
222
+ return None
223
+
224
+ async def _append(self, context: ContextStore, message: Message) -> None:
225
+ await context.append(message)
226
+
227
+ @staticmethod
228
+ def _accumulate_text(
229
+ content: list[TextBlock | ImageBlock | AudioBlock | VideoBlock | ToolUseBlock],
230
+ delta: str,
231
+ ) -> None:
232
+ """Append text delta — merge into last TextBlock or start a new one."""
233
+ if content and isinstance(content[-1], TextBlock):
234
+ content[-1] = TextBlock(text=content[-1].text + delta)
235
+ else:
236
+ content.append(TextBlock(text=delta))
237
+
238
+ @staticmethod
239
+ def _finalize_pending_tools(
240
+ pending: dict[str, dict[str, Any]],
241
+ usage: Usage,
242
+ ) -> tuple[list[ToolUseBlock], set[str]]:
243
+ """Convert streamed tool-call fragments into ToolUseBlocks.
244
+
245
+ Returns (blocks, malformed_ids). Malformed IDs arise when
246
+ max_tokens truncates the response mid-tool-call, producing
247
+ incomplete JSON (expected with eager_input_streaming). The
248
+ caller is responsible for not executing malformed tools.
249
+ """
250
+ blocks: list[ToolUseBlock] = []
251
+ malformed: set[str] = set()
252
+ for tid, info in pending.items():
253
+ raw = "".join(info["json_parts"])
254
+ if not raw:
255
+ logger.warning(
256
+ "Tool %s (id=%s) received empty arguments (output may be truncated, output_tokens=%d)",
257
+ info["name"],
258
+ tid,
259
+ usage.output_tokens,
260
+ )
261
+ inp: dict[str, Any] = {}
262
+ else:
263
+ try:
264
+ inp = json.loads(raw)
265
+ except json.JSONDecodeError as exc:
266
+ logger.warning(
267
+ "Tool %s (id=%s) has malformed JSON arguments: %s\nRaw: %s",
268
+ info["name"],
269
+ tid,
270
+ exc,
271
+ raw,
272
+ )
273
+ malformed.add(tid)
274
+ inp = {}
275
+ blocks.append(ToolUseBlock(id=tid, name=info["name"], input=inp))
276
+ return blocks, malformed
277
+
278
+ async def _select_tools(self, history: list[Message], tools: list[Tool[Any]]) -> Iterable[Tool[Any]]:
279
+ if not tools:
280
+ return []
281
+ if not self.selector:
282
+ return tools
283
+ return await self.selector.select(history, tools)
284
+
285
+ async def _run_loop(self, user_message: str, context: ContextStore) -> AsyncGenerator[StreamEvent, None]:
286
+ total_usage = Usage(0, 0)
287
+ session_end_emitted = False
288
+ ts = datetime.now().astimezone().strftime("%Y-%m-%d %H:%M:%S %Z")
289
+ await self._append(context, Message(role="user", content=[TextBlock(text=f"[{ts}] {user_message}")]))
290
+
291
+ try:
292
+ for iteration in range(1, self.max_iterations + 1):
293
+ history = await context.get_history()
294
+ logger.info("Iteration %d, history length=%d", iteration, len(history))
295
+ effective_history = (
296
+ [*history, self.last_iteration_message]
297
+ if self.last_iteration_message and iteration == self.max_iterations
298
+ else history
299
+ )
300
+ model = getattr(self.transport, "model", None)
301
+ model_caps = getattr(model, "capabilities", None)
302
+ if model_caps is not None and Capability.tool_use not in model_caps:
303
+ active_tools: list[Tool[Any]] = []
304
+ else:
305
+ active_tools = list(await self._select_tools(effective_history, self.tools))
306
+
307
+ content: list[TextBlock | ImageBlock | AudioBlock | VideoBlock | ToolUseBlock] = []
308
+ pending: dict[str, dict[str, Any]] = {}
309
+ stop_reason = StopReason.end_turn
310
+ malformed: set[str] = set()
311
+ repetition_detected = False
312
+ rep_detector = _RepetitionDetector()
313
+
314
+ try:
315
+ async for event in self.transport.stream(effective_history, active_tools, self.system):
316
+ yield event
317
+ match event:
318
+ case TextDelta(delta=delta):
319
+ self._accumulate_text(content, delta)
320
+ if rep_detector.feed(delta):
321
+ note = "\n\n[Output truncated: repetitive content detected]"
322
+ self._accumulate_text(content, note)
323
+ yield TextDelta(index=0, delta=note)
324
+ repetition_detected = True
325
+ break
326
+ case ImageOutput(data=data, media_type=mt):
327
+ content.append(ImageBlock(media_type=mt, data=data))
328
+ case VideoOutput(data=data, media_type=mt):
329
+ content.append(VideoBlock(media_type=mt, data=data))
330
+ case ToolUseStart(tool_use_id=tid, name=name):
331
+ pending[tid] = {"name": name, "json_parts": []}
332
+ case ToolInputDelta(tool_use_id=tid, partial_json=pj):
333
+ if tid in pending:
334
+ pending[tid]["json_parts"].append(pj)
335
+ case IterationEnd(usage=usage, stop_reason=sr):
336
+ blocks, malformed = self._finalize_pending_tools(pending, usage)
337
+ content.extend(blocks)
338
+ pending.clear()
339
+ total_usage = total_usage + usage
340
+ await context.add_context_tokens(usage.input_tokens, usage.output_tokens)
341
+ stop_reason = sr
342
+ except Exception as exc:
343
+ logger.error("Transport error: %s", exc, exc_info=True)
344
+ yield Error(exception=exc)
345
+ yield SessionEndEvent(stop_reason=StopReason.error, total_usage=total_usage)
346
+ session_end_emitted = True
347
+ return
348
+
349
+ if repetition_detected:
350
+ await self._append(context, Message(role="assistant", content=list(content)))
351
+ partial = getattr(self.transport, "last_usage", None)
352
+ if partial:
353
+ total_usage = total_usage + partial
354
+ yield SessionEndEvent(stop_reason=StopReason.end_turn, total_usage=total_usage)
355
+ session_end_emitted = True
356
+ return
357
+
358
+ tool_blocks = [b for b in content if isinstance(b, ToolUseBlock)]
359
+
360
+ if tool_blocks:
361
+ if stop_reason != StopReason.tool_use:
362
+ logger.warning(
363
+ "Dispatching %d tool(s) despite stop_reason=%s",
364
+ len(tool_blocks),
365
+ stop_reason,
366
+ )
367
+
368
+ # Dispatch tools BEFORE appending to context - cancellation
369
+ # between here and the two appends below cannot leave orphan
370
+ # ToolUseBlocks in the persistent context store.
371
+ valid = [b for b in tool_blocks if b.id not in malformed]
372
+ error_results = [
373
+ ToolResultBlock(
374
+ tool_use_id=b.id,
375
+ content=(
376
+ f"Malformed JSON arguments for tool {b.name}."
377
+ f" Raw input could not be parsed. Please retry the tool call"
378
+ f" with valid JSON arguments."
379
+ ),
380
+ is_error=True,
381
+ )
382
+ for b in tool_blocks
383
+ if b.id in malformed
384
+ ]
385
+
386
+ partial_output: dict[str, list[tuple[float, str, str]]] = {}
387
+ t0_map: dict[str, float] = {}
388
+ dispatch_task: asyncio.Task[list[ToolResultBlock]] | None = None
389
+ try:
390
+ if valid:
391
+ has_streaming = any(
392
+ (t := self._find_tool(b.name)) is not None and t.supports_streaming for b in valid
393
+ )
394
+ if has_streaming:
395
+ output_queue: asyncio.Queue[ToolOutputDelta | None] = asyncio.Queue()
396
+
397
+ async def _dispatch_and_signal() -> list[ToolResultBlock]:
398
+ result = await self._dispatch_tools_streaming(valid, iteration, output_queue)
399
+ await output_queue.put(None)
400
+ return result
401
+
402
+ dispatch_task = asyncio.create_task(_dispatch_and_signal())
403
+ while True:
404
+ ev = await output_queue.get()
405
+ if ev is None:
406
+ break
407
+ if ev.tool_use_id not in t0_map:
408
+ t0_map[ev.tool_use_id] = time.monotonic()
409
+ partial_output.setdefault(ev.tool_use_id, []).append(
410
+ (time.monotonic() - t0_map[ev.tool_use_id], ev.key, ev.delta)
411
+ )
412
+ yield ev
413
+ dispatched = await dispatch_task
414
+ else:
415
+ dispatched = await self.dispatch_tools(valid, iteration)
416
+ else:
417
+ dispatched = []
418
+ results = dispatched + error_results
419
+ except asyncio.CancelledError:
420
+ if dispatch_task is not None and not dispatch_task.done():
421
+ dispatch_task.cancel()
422
+ try:
423
+ await dispatch_task
424
+ except (asyncio.CancelledError, Exception):
425
+ pass
426
+ interrupted_results: list[ToolResultBlock] = []
427
+ for b in tool_blocks:
428
+ chunks = partial_output.get(b.id, [])
429
+ tool = self._find_tool(b.name)
430
+ if chunks and tool:
431
+ msg = tool.format_stream_result(chunks) + "\n[interrupted by user]"
432
+ elif chunks:
433
+ msg = "".join(text for _, _, text in chunks) + "\n[interrupted by user]"
434
+ else:
435
+ msg = "[interrupted by user]"
436
+ interrupted_results.append(ToolResultBlock(tool_use_id=b.id, content=msg, is_error=True))
437
+ await self._append(context, Message(role="assistant", content=list(content)))
438
+ await self._append(context, Message(role="user", content=list(interrupted_results)))
439
+ raise
440
+
441
+ # Append both messages atomically (assistant + tool results)
442
+ await self._append(context, Message(role="assistant", content=list(content)))
443
+ await self._append(context, Message(role="user", content=list(results)))
444
+
445
+ # Gemini stops generating (~20 tokens, end_turn) after receiving
446
+ # media as sibling inlineData parts alongside functionResponse.
447
+ # A "Proceed." user message nudges it to actually analyze the content.
448
+ if getattr(self.transport, "nudge_on_media_tool_result", False) and any(
449
+ not isinstance(r.content, str)
450
+ and any(isinstance(b, (AudioBlock, ImageBlock, VideoBlock)) for b in r.content)
451
+ for r in results
452
+ ):
453
+ await self._append(
454
+ context,
455
+ Message(
456
+ role="user",
457
+ content=[
458
+ TextBlock(
459
+ text="You now have the media file above in your context. Proceed.",
460
+ )
461
+ ],
462
+ ),
463
+ )
464
+
465
+ # Yield ToolResult events + media output events.
466
+ # Non-streaming tools return full content (str or list of
467
+ # TextBlock/ImageBlock/VideoBlock) — no information is lost.
468
+ # Images/videos are yielded as separate ImageOutput/VideoOutput
469
+ # events so the REPL can save them to disk; the model sees the
470
+ # actual pixel data via ImageBlock/VideoBlock in the tool result.
471
+ by_id = {b.id: b for b in tool_blocks}
472
+ for r in results:
473
+ block = by_id.get(r.tool_use_id)
474
+ if isinstance(r.content, str):
475
+ result_content = r.content
476
+ else:
477
+ result_content = "\n".join(b.text for b in r.content if isinstance(b, TextBlock))
478
+ for media_block in r.content:
479
+ if isinstance(media_block, ImageBlock):
480
+ yield ImageOutput(
481
+ index=0, data=media_block.data, media_type=media_block.media_type
482
+ )
483
+ elif isinstance(media_block, AudioBlock):
484
+ yield AudioOutput(
485
+ index=0, data=media_block.data, media_type=media_block.media_type
486
+ )
487
+ elif isinstance(media_block, VideoBlock):
488
+ yield VideoOutput(
489
+ index=0, data=media_block.data, media_type=media_block.media_type
490
+ )
491
+ yield ToolResult(
492
+ tool_use_id=r.tool_use_id,
493
+ name=block.name if block else "",
494
+ is_error=r.is_error,
495
+ content=result_content,
496
+ input=block.input if block else {},
497
+ )
498
+ continue
499
+
500
+ await self._append(context, Message(role="assistant", content=list(content)))
501
+
502
+ match stop_reason:
503
+ case StopReason.end_turn:
504
+ logger.debug("End turn: total_usage=%s", total_usage)
505
+ yield SessionEndEvent(stop_reason=StopReason.end_turn, total_usage=total_usage)
506
+ session_end_emitted = True
507
+ return
508
+ case StopReason.max_tokens | StopReason.error:
509
+ yield Error(exception=RuntimeError(f"Transport stopped with: {stop_reason}"))
510
+ yield SessionEndEvent(stop_reason=StopReason.error, total_usage=total_usage)
511
+ session_end_emitted = True
512
+ return
513
+
514
+ logger.warning("Max iterations (%d) reached", self.max_iterations)
515
+ yield SessionEndEvent(stop_reason=StopReason.error, total_usage=total_usage)
516
+ session_end_emitted = True
517
+
518
+ except GeneratorExit:
519
+ return
520
+ except BaseException:
521
+ if not session_end_emitted:
522
+ yield SessionEndEvent(stop_reason=StopReason.error, total_usage=total_usage)
523
+ raise