axio 0.9.0__tar.gz → 0.9.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {axio-0.9.0 → axio-0.9.2}/PKG-INFO +2 -1
- {axio-0.9.0 → axio-0.9.2}/pyproject.toml +3 -1
- {axio-0.9.0 → axio-0.9.2}/src/axio/__init__.py +24 -2
- axio-0.9.2/src/axio/agent.py +523 -0
- {axio-0.9.0 → axio-0.9.2}/src/axio/blocks.py +56 -4
- axio-0.9.2/src/axio/events.py +183 -0
- {axio-0.9.0 → axio-0.9.2}/src/axio/models.py +9 -0
- axio-0.9.2/src/axio/realtime.py +225 -0
- {axio-0.9.0 → axio-0.9.2}/src/axio/tool.py +94 -37
- {axio-0.9.0 → axio-0.9.2}/src/axio/tool_args.py +9 -0
- axio-0.9.2/src/axio/transport.py +211 -0
- axio-0.9.2/tests/test_agent_capabilities.py +106 -0
- {axio-0.9.0 → axio-0.9.2}/tests/test_agent_tools.py +95 -0
- {axio-0.9.0 → axio-0.9.2}/tests/test_blocks.py +37 -1
- axio-0.9.2/tests/test_realtime.py +463 -0
- {axio-0.9.0 → axio-0.9.2}/tests/test_tool.py +4 -8
- {axio-0.9.0 → axio-0.9.2}/tests/test_transport.py +30 -2
- axio-0.9.0/src/axio/agent.py +0 -258
- axio-0.9.0/src/axio/events.py +0 -98
- axio-0.9.0/src/axio/transport.py +0 -107
- {axio-0.9.0 → axio-0.9.2}/.github/workflows/publish.yml +0 -0
- {axio-0.9.0 → axio-0.9.2}/.github/workflows/tests.yml +0 -0
- {axio-0.9.0 → axio-0.9.2}/.gitignore +0 -0
- {axio-0.9.0 → axio-0.9.2}/LICENSE +0 -0
- {axio-0.9.0 → axio-0.9.2}/Makefile +0 -0
- {axio-0.9.0 → axio-0.9.2}/README.md +0 -0
- {axio-0.9.0 → axio-0.9.2}/src/axio/agent_loader.py +0 -0
- {axio-0.9.0 → axio-0.9.2}/src/axio/compaction.py +0 -0
- {axio-0.9.0 → axio-0.9.2}/src/axio/context.py +0 -0
- {axio-0.9.0 → axio-0.9.2}/src/axio/exceptions.py +0 -0
- {axio-0.9.0 → axio-0.9.2}/src/axio/field.py +0 -0
- {axio-0.9.0 → axio-0.9.2}/src/axio/messages.py +0 -0
- {axio-0.9.0 → axio-0.9.2}/src/axio/permission.py +0 -0
- {axio-0.9.0 → axio-0.9.2}/src/axio/py.typed +0 -0
- {axio-0.9.0 → axio-0.9.2}/src/axio/schema.py +0 -0
- {axio-0.9.0 → axio-0.9.2}/src/axio/selector.py +0 -0
- {axio-0.9.0 → axio-0.9.2}/src/axio/stream.py +0 -0
- {axio-0.9.0 → axio-0.9.2}/src/axio/testing.py +0 -0
- {axio-0.9.0 → axio-0.9.2}/src/axio/types.py +0 -0
- {axio-0.9.0 → axio-0.9.2}/tests/conftest.py +0 -0
- {axio-0.9.0 → axio-0.9.2}/tests/test_agent_branch.py +0 -0
- {axio-0.9.0 → axio-0.9.2}/tests/test_agent_loader.py +0 -0
- {axio-0.9.0 → axio-0.9.2}/tests/test_agent_permission.py +0 -0
- {axio-0.9.0 → axio-0.9.2}/tests/test_agent_run.py +0 -0
- {axio-0.9.0 → axio-0.9.2}/tests/test_agent_stream.py +0 -0
- {axio-0.9.0 → axio-0.9.2}/tests/test_compaction.py +0 -0
- {axio-0.9.0 → axio-0.9.2}/tests/test_context.py +0 -0
- {axio-0.9.0 → axio-0.9.2}/tests/test_events.py +0 -0
- {axio-0.9.0 → axio-0.9.2}/tests/test_exceptions.py +0 -0
- {axio-0.9.0 → axio-0.9.2}/tests/test_field.py +0 -0
- {axio-0.9.0 → axio-0.9.2}/tests/test_init.py +0 -0
- {axio-0.9.0 → axio-0.9.2}/tests/test_models.py +0 -0
- {axio-0.9.0 → axio-0.9.2}/tests/test_permission.py +0 -0
- {axio-0.9.0 → axio-0.9.2}/tests/test_schema.py +0 -0
- {axio-0.9.0 → axio-0.9.2}/tests/test_selector.py +0 -0
- {axio-0.9.0 → axio-0.9.2}/tests/test_stream.py +0 -0
- {axio-0.9.0 → axio-0.9.2}/tests/test_testing.py +0 -0
- {axio-0.9.0 → axio-0.9.2}/tests/test_tool_args.py +0 -0
- {axio-0.9.0 → axio-0.9.2}/tests/test_types.py +0 -0
- {axio-0.9.0 → axio-0.9.2}/uv.lock +0 -0
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: axio
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.2
|
|
4
4
|
Summary: Minimal, streaming-first, protocol-driven foundation for LLM-powered agents
|
|
5
|
+
Project-URL: Documentation, https://docs.axio-agent.com
|
|
5
6
|
Project-URL: Homepage, https://github.com/mosquito/axio-agent
|
|
6
7
|
Project-URL: Repository, https://github.com/mosquito/axio-agent
|
|
7
8
|
License: MIT
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "axio"
|
|
3
|
-
version = "0.9.
|
|
3
|
+
version = "0.9.2" # 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"
|
|
@@ -10,6 +10,7 @@ dependencies = []
|
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
[project.urls]
|
|
13
|
+
Documentation = "https://docs.axio-agent.com"
|
|
13
14
|
Homepage = "https://github.com/mosquito/axio-agent"
|
|
14
15
|
Repository = "https://github.com/mosquito/axio-agent"
|
|
15
16
|
[build-system]
|
|
@@ -25,6 +26,7 @@ testpaths = ["tests", "README.md"]
|
|
|
25
26
|
|
|
26
27
|
[tool.ruff]
|
|
27
28
|
line-length = 119
|
|
29
|
+
output-format = "concise"
|
|
28
30
|
target-version = "py312"
|
|
29
31
|
|
|
30
32
|
[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
|
|
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
|