vtx-coding-agent 0.1.1__py3-none-any.whl
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.
- vtx/__init__.py +63 -0
- vtx/async_utils.py +40 -0
- vtx/builtin_skills/github/SKILL.md +139 -0
- vtx/builtin_skills/init/SKILL.md +74 -0
- vtx/builtin_skills/review/SKILL.md +73 -0
- vtx/builtin_skills/skill-builder/SKILL.md +133 -0
- vtx/cli.py +90 -0
- vtx/config.py +741 -0
- vtx/context/__init__.py +15 -0
- vtx/context/_xml.py +8 -0
- vtx/context/agent_mds.py +128 -0
- vtx/context/git.py +64 -0
- vtx/context/loader.py +41 -0
- vtx/context/skills.py +423 -0
- vtx/core/__init__.py +47 -0
- vtx/core/compaction.py +89 -0
- vtx/core/errors.py +17 -0
- vtx/core/handoff.py +51 -0
- vtx/core/scratchpad.py +54 -0
- vtx/core/types.py +197 -0
- vtx/defaults/__init__.py +0 -0
- vtx/defaults/config.yml +53 -0
- vtx/diff_display.py +12 -0
- vtx/events.py +224 -0
- vtx/gh_cli.py +82 -0
- vtx/git_branch.py +90 -0
- vtx/headless.py +127 -0
- vtx/llm/__init__.py +93 -0
- vtx/llm/base.py +217 -0
- vtx/llm/context_length.py +150 -0
- vtx/llm/dynamic_models.py +735 -0
- vtx/llm/model_fetcher.py +279 -0
- vtx/llm/models.py +78 -0
- vtx/llm/oauth/__init__.py +59 -0
- vtx/llm/oauth/copilot.py +358 -0
- vtx/llm/oauth/dynamic.py +236 -0
- vtx/llm/oauth/openai.py +400 -0
- vtx/llm/phase_parser.py +270 -0
- vtx/llm/provider.yaml +280 -0
- vtx/llm/provider_catalog.py +230 -0
- vtx/llm/providers/__init__.py +45 -0
- vtx/llm/providers/anthropic_sdk.py +256 -0
- vtx/llm/providers/mock.py +249 -0
- vtx/llm/providers/openai_sdk.py +246 -0
- vtx/llm/providers/sanitize.py +14 -0
- vtx/llm/sdk/__init__.py +13 -0
- vtx/llm/sdk/anthropic.py +382 -0
- vtx/llm/sdk/base.py +82 -0
- vtx/llm/sdk/openai.py +344 -0
- vtx/llm/tool_parser.py +161 -0
- vtx/loop.py +272 -0
- vtx/notify.py +109 -0
- vtx/permissions.py +114 -0
- vtx/prompts/__init__.py +45 -0
- vtx/prompts/builder.py +86 -0
- vtx/prompts/env.py +58 -0
- vtx/prompts/identity.py +166 -0
- vtx/prompts/tooling.py +36 -0
- vtx/py.typed +0 -0
- vtx/runtime.py +580 -0
- vtx/session.py +868 -0
- vtx/sounds/completion.wav +0 -0
- vtx/sounds/error.wav +0 -0
- vtx/sounds/permission.wav +0 -0
- vtx/themes.py +1104 -0
- vtx/tools/__init__.py +68 -0
- vtx/tools/_read_image.py +106 -0
- vtx/tools/_tool_utils.py +90 -0
- vtx/tools/base.py +36 -0
- vtx/tools/bash.py +371 -0
- vtx/tools/edit.py +261 -0
- vtx/tools/find.py +132 -0
- vtx/tools/read.py +238 -0
- vtx/tools/skill.py +278 -0
- vtx/tools/web.py +238 -0
- vtx/tools/write.py +88 -0
- vtx/tools_manager.py +216 -0
- vtx/turn.py +789 -0
- vtx/ui/__init__.py +0 -0
- vtx/ui/agent_runner.py +417 -0
- vtx/ui/app.py +665 -0
- vtx/ui/app_protocol.py +29 -0
- vtx/ui/autocomplete.py +440 -0
- vtx/ui/blocks.py +735 -0
- vtx/ui/chat.py +613 -0
- vtx/ui/clipboard.py +59 -0
- vtx/ui/commands/__init__.py +100 -0
- vtx/ui/commands/auth.py +306 -0
- vtx/ui/commands/base.py +122 -0
- vtx/ui/commands/models.py +144 -0
- vtx/ui/commands/sessions.py +388 -0
- vtx/ui/commands/settings.py +286 -0
- vtx/ui/completion_ui.py +313 -0
- vtx/ui/export.py +703 -0
- vtx/ui/floating_list.py +370 -0
- vtx/ui/formatting.py +287 -0
- vtx/ui/input.py +760 -0
- vtx/ui/latex.py +349 -0
- vtx/ui/launch.py +108 -0
- vtx/ui/path_complete.py +228 -0
- vtx/ui/prompt_history.py +102 -0
- vtx/ui/queue_ui.py +141 -0
- vtx/ui/selection_mode.py +18 -0
- vtx/ui/session_ui.py +235 -0
- vtx/ui/startup.py +124 -0
- vtx/ui/styles.py +327 -0
- vtx/ui/tool_output.py +34 -0
- vtx/ui/tree.py +437 -0
- vtx/ui/welcome.py +51 -0
- vtx/ui/widgets.py +558 -0
- vtx/update_check.py +49 -0
- vtx/version.py +22 -0
- vtx_coding_agent-0.1.1.dist-info/METADATA +259 -0
- vtx_coding_agent-0.1.1.dist-info/RECORD +117 -0
- vtx_coding_agent-0.1.1.dist-info/WHEEL +4 -0
- vtx_coding_agent-0.1.1.dist-info/entry_points.txt +2 -0
- vtx_coding_agent-0.1.1.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Anthropic SDK provider - wraps the SDK layer into vtx's BaseProvider interface."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncIterator
|
|
4
|
+
from typing import Any, ClassVar
|
|
5
|
+
|
|
6
|
+
from anthropic import APIConnectionError, APIStatusError, RateLimitError
|
|
7
|
+
|
|
8
|
+
from ...core.errors import format_error
|
|
9
|
+
from ...core.types import (
|
|
10
|
+
AssistantMessage,
|
|
11
|
+
ImageContent,
|
|
12
|
+
Message,
|
|
13
|
+
StopReason,
|
|
14
|
+
StreamDone,
|
|
15
|
+
StreamError,
|
|
16
|
+
StreamPart,
|
|
17
|
+
TextContent,
|
|
18
|
+
TextPart,
|
|
19
|
+
ThinkingContent,
|
|
20
|
+
ThinkPart,
|
|
21
|
+
ToolCall,
|
|
22
|
+
ToolCallDelta,
|
|
23
|
+
ToolCallStart,
|
|
24
|
+
ToolDefinition,
|
|
25
|
+
ToolResultMessage,
|
|
26
|
+
Usage,
|
|
27
|
+
UserMessage,
|
|
28
|
+
)
|
|
29
|
+
from ..base import BaseProvider, LLMStream, ProviderConfig, resolve_api_key
|
|
30
|
+
from ..sdk.anthropic import AnthropicSDK
|
|
31
|
+
from ..sdk.base import GenerationConfig
|
|
32
|
+
from ..sdk.base import Message as SDKMessage
|
|
33
|
+
from .sanitize import sanitize_surrogates
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AnthropicSDKProvider(BaseProvider):
|
|
37
|
+
name = "anthropic"
|
|
38
|
+
thinking_levels: ClassVar[list[str]] = ["none", "minimal", "low", "medium", "high", "xhigh"]
|
|
39
|
+
|
|
40
|
+
def __init__(self, config: ProviderConfig):
|
|
41
|
+
super().__init__(config)
|
|
42
|
+
|
|
43
|
+
api_key = resolve_api_key(
|
|
44
|
+
config.api_key,
|
|
45
|
+
env_vars=("ANTHROPIC_API_KEY",),
|
|
46
|
+
base_url=config.base_url,
|
|
47
|
+
auth_mode=config.anthropic_compat_auth_mode,
|
|
48
|
+
)
|
|
49
|
+
if not api_key:
|
|
50
|
+
raise ValueError(
|
|
51
|
+
f"No API key found for {self.name}. "
|
|
52
|
+
"Set ANTHROPIC_API_KEY environment variable or pass api_key in config, "
|
|
53
|
+
'or configure llm.auth.anthropic_compat = "auto"/"none" for local endpoints.'
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
self._sdk = AnthropicSDK(api_key=api_key, base_url=config.base_url)
|
|
57
|
+
|
|
58
|
+
def _convert_messages(self, messages: list[Message]) -> list[SDKMessage]:
|
|
59
|
+
result: list[SDKMessage] = []
|
|
60
|
+
for msg in messages:
|
|
61
|
+
if isinstance(msg, UserMessage):
|
|
62
|
+
result.append(self._convert_user_message(msg))
|
|
63
|
+
elif isinstance(msg, AssistantMessage):
|
|
64
|
+
result.append(self._convert_assistant_message(msg))
|
|
65
|
+
elif isinstance(msg, ToolResultMessage):
|
|
66
|
+
result.append(self._convert_tool_result(msg))
|
|
67
|
+
return result
|
|
68
|
+
|
|
69
|
+
def _convert_user_message(self, msg: UserMessage) -> SDKMessage:
|
|
70
|
+
if isinstance(msg.content, str):
|
|
71
|
+
content = sanitize_surrogates(msg.content)
|
|
72
|
+
if not content or content.isspace():
|
|
73
|
+
raise ValueError("User message content cannot be empty or whitespace-only")
|
|
74
|
+
return SDKMessage(role="user", content=content)
|
|
75
|
+
|
|
76
|
+
parts: list[str] = []
|
|
77
|
+
image_parts: list[str] = []
|
|
78
|
+
for item in msg.content:
|
|
79
|
+
if isinstance(item, TextContent):
|
|
80
|
+
text = sanitize_surrogates(item.text)
|
|
81
|
+
if text and not text.isspace():
|
|
82
|
+
parts.append(text)
|
|
83
|
+
elif isinstance(item, ImageContent):
|
|
84
|
+
image_parts.append(f"data:{item.mime_type};base64,{item.data}")
|
|
85
|
+
|
|
86
|
+
content = "\n".join(parts) if parts else ""
|
|
87
|
+
if not content and not image_parts:
|
|
88
|
+
raise ValueError("User message content cannot be empty or whitespace-only")
|
|
89
|
+
|
|
90
|
+
return SDKMessage(role="user", content=content, image_parts=image_parts or None)
|
|
91
|
+
|
|
92
|
+
def _convert_assistant_message(self, msg: AssistantMessage) -> SDKMessage:
|
|
93
|
+
content_parts: list[str] = []
|
|
94
|
+
tool_calls: list[dict[str, Any]] = []
|
|
95
|
+
|
|
96
|
+
for item in msg.content:
|
|
97
|
+
if isinstance(item, TextContent):
|
|
98
|
+
if item.text.strip():
|
|
99
|
+
content_parts.append(sanitize_surrogates(item.text))
|
|
100
|
+
elif isinstance(item, ThinkingContent):
|
|
101
|
+
pass
|
|
102
|
+
elif isinstance(item, ToolCall):
|
|
103
|
+
tool_calls.append({"id": item.id, "name": item.name, "arguments": item.arguments})
|
|
104
|
+
|
|
105
|
+
return SDKMessage(
|
|
106
|
+
role="assistant",
|
|
107
|
+
content="".join(content_parts) if content_parts else "",
|
|
108
|
+
metadata={"tool_calls": tool_calls} if tool_calls else None,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def _convert_tool_result(self, msg: ToolResultMessage) -> SDKMessage:
|
|
112
|
+
text_parts = [item.text for item in msg.content if isinstance(item, TextContent)]
|
|
113
|
+
has_images = any(isinstance(item, ImageContent) for item in msg.content)
|
|
114
|
+
|
|
115
|
+
if text_parts:
|
|
116
|
+
content = "\n".join(text_parts)
|
|
117
|
+
elif has_images:
|
|
118
|
+
content = "(see attached image)"
|
|
119
|
+
else:
|
|
120
|
+
content = "(no output)"
|
|
121
|
+
|
|
122
|
+
return SDKMessage(
|
|
123
|
+
role="tool",
|
|
124
|
+
content=content,
|
|
125
|
+
metadata={"tool_call_id": msg.tool_call_id, "is_error": msg.is_error},
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def _convert_tools(self, tools: list[ToolDefinition]) -> list[dict[str, Any]]:
|
|
129
|
+
return [
|
|
130
|
+
{
|
|
131
|
+
"type": "function",
|
|
132
|
+
"function": {
|
|
133
|
+
"name": tool.name,
|
|
134
|
+
"description": tool.description,
|
|
135
|
+
"parameters": tool.parameters,
|
|
136
|
+
},
|
|
137
|
+
}
|
|
138
|
+
for tool in tools
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
async def _stream_impl(
|
|
142
|
+
self,
|
|
143
|
+
messages: list[Message],
|
|
144
|
+
*,
|
|
145
|
+
system_prompt: str | None = None,
|
|
146
|
+
tools: list[ToolDefinition] | None = None,
|
|
147
|
+
temperature: float | None = None,
|
|
148
|
+
max_tokens: int | None = None,
|
|
149
|
+
) -> LLMStream:
|
|
150
|
+
sdk_messages = self._convert_messages(messages)
|
|
151
|
+
sdk_tools = self._convert_tools(tools) if tools else None
|
|
152
|
+
temp = temperature if temperature is not None else self.config.temperature
|
|
153
|
+
max_tok = max_tokens if max_tokens is not None else self.config.max_tokens
|
|
154
|
+
|
|
155
|
+
config = GenerationConfig(
|
|
156
|
+
model=self.config.model,
|
|
157
|
+
temperature=temp if temp is not None else 0.7,
|
|
158
|
+
max_tokens=max_tok,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
response = await self._sdk.generate_with_tools(
|
|
162
|
+
sdk_messages, sdk_tools or [], config, stream=True
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
llm_stream = LLMStream()
|
|
166
|
+
llm_stream.set_iterator(self._process_stream(response, llm_stream))
|
|
167
|
+
return llm_stream
|
|
168
|
+
|
|
169
|
+
async def _process_stream(
|
|
170
|
+
self, response: Any, llm_stream: LLMStream
|
|
171
|
+
) -> AsyncIterator[StreamPart]:
|
|
172
|
+
stop_reason: StopReason = StopReason.STOP
|
|
173
|
+
current_tool_index: int = -1
|
|
174
|
+
tool_use_blocks: dict[int, dict[str, Any]] = {}
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
async for event in response:
|
|
178
|
+
event_type = event.get("type", "")
|
|
179
|
+
|
|
180
|
+
if event_type == "message_start":
|
|
181
|
+
if event.get("id"):
|
|
182
|
+
llm_stream._id = event["id"]
|
|
183
|
+
usage = event.get("usage") or {}
|
|
184
|
+
llm_stream._usage = Usage(
|
|
185
|
+
input_tokens=usage.get("input_tokens", 0),
|
|
186
|
+
output_tokens=usage.get("output_tokens", 0),
|
|
187
|
+
)
|
|
188
|
+
elif event_type == "content_block_start":
|
|
189
|
+
block = event.get("content_block", {})
|
|
190
|
+
if block.get("type") == "tool_use":
|
|
191
|
+
current_tool_index += 1
|
|
192
|
+
tool_use_blocks[event.get("index", 0)] = {
|
|
193
|
+
"id": block.get("id", ""),
|
|
194
|
+
"name": block.get("name", ""),
|
|
195
|
+
}
|
|
196
|
+
yield ToolCallStart(
|
|
197
|
+
id=block.get("id", ""),
|
|
198
|
+
name=block.get("name", ""),
|
|
199
|
+
index=current_tool_index,
|
|
200
|
+
)
|
|
201
|
+
elif event_type == "content_block_delta":
|
|
202
|
+
delta = event.get("delta", {})
|
|
203
|
+
delta_type = delta.get("type", "")
|
|
204
|
+
|
|
205
|
+
if delta_type == "text_delta":
|
|
206
|
+
yield TextPart(text=delta.get("text", ""))
|
|
207
|
+
elif delta_type == "thinking_delta":
|
|
208
|
+
yield ThinkPart(think=delta.get("thinking", ""))
|
|
209
|
+
elif delta_type == "signature_delta":
|
|
210
|
+
yield ThinkPart(think="", signature=delta.get("signature", ""))
|
|
211
|
+
elif delta_type == "input_json_delta":
|
|
212
|
+
tool_info = tool_use_blocks.get(event.get("index", 0))
|
|
213
|
+
if tool_info:
|
|
214
|
+
logical_index = list(tool_use_blocks.keys()).index(
|
|
215
|
+
event.get("index", 0)
|
|
216
|
+
)
|
|
217
|
+
yield ToolCallDelta(
|
|
218
|
+
index=logical_index, arguments_delta=delta.get("partial_json", "")
|
|
219
|
+
)
|
|
220
|
+
elif event_type == "message_delta":
|
|
221
|
+
delta = event.get("delta", {})
|
|
222
|
+
if delta.get("stop_reason"):
|
|
223
|
+
stop_reason = self._map_stop_reason(delta["stop_reason"])
|
|
224
|
+
usage = event.get("usage") or {}
|
|
225
|
+
if usage and llm_stream._usage:
|
|
226
|
+
llm_stream._usage = Usage(
|
|
227
|
+
input_tokens=llm_stream._usage.input_tokens,
|
|
228
|
+
output_tokens=usage.get("output_tokens", 0),
|
|
229
|
+
cache_read_tokens=llm_stream._usage.cache_read_tokens,
|
|
230
|
+
cache_write_tokens=llm_stream._usage.cache_write_tokens,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
yield StreamDone(stop_reason=stop_reason)
|
|
234
|
+
|
|
235
|
+
except Exception as e:
|
|
236
|
+
yield StreamError(error=format_error(e))
|
|
237
|
+
|
|
238
|
+
def _map_stop_reason(self, reason: str) -> StopReason:
|
|
239
|
+
match reason:
|
|
240
|
+
case "end_turn":
|
|
241
|
+
return StopReason.STOP
|
|
242
|
+
case "max_tokens":
|
|
243
|
+
return StopReason.LENGTH
|
|
244
|
+
case "tool_use":
|
|
245
|
+
return StopReason.TOOL_USE
|
|
246
|
+
case _:
|
|
247
|
+
return StopReason.STOP
|
|
248
|
+
|
|
249
|
+
def should_retry_for_error(self, error: Exception) -> bool:
|
|
250
|
+
if isinstance(error, RateLimitError):
|
|
251
|
+
return True
|
|
252
|
+
if isinstance(error, APIConnectionError):
|
|
253
|
+
return True
|
|
254
|
+
if isinstance(error, APIStatusError):
|
|
255
|
+
return error.status_code >= 500
|
|
256
|
+
return False
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Mock LLM provider for testing.
|
|
3
|
+
|
|
4
|
+
Yields a realistic streaming response (thinking, text, tool calls) for testing
|
|
5
|
+
agent loop and turn execution without making real API calls.
|
|
6
|
+
|
|
7
|
+
Scenarios (set via scenario parameter):
|
|
8
|
+
- "default": thinking → text → multiple tool calls
|
|
9
|
+
- "simple_text": just text, no thinking or tools
|
|
10
|
+
- "thinking_text_tool": thinking → text → single tool call
|
|
11
|
+
- "retries": fail twice, then succeed
|
|
12
|
+
- "retry_exhausted": always fail
|
|
13
|
+
- "non_retryable": fail with non-retryable error
|
|
14
|
+
- "stream_error": emit StreamError during streaming
|
|
15
|
+
- "unknown_tool": call unknown tool
|
|
16
|
+
- "long_text": multiple text chunks
|
|
17
|
+
- "tool_hang": emits a tool call and then never sends StreamDone
|
|
18
|
+
- "tool_hang_invalid_json": emits an incomplete tool-call JSON payload and hangs
|
|
19
|
+
- "tool_hang_with_initial_args": tool call with initial args, then an incomplete delta, then hangs
|
|
20
|
+
- "tool_with_many_chunks": tool call with many argument chunks for token counting tests
|
|
21
|
+
- "leading_empty_text_then_think": emits leading newlines before thinking
|
|
22
|
+
- "leading_empty_text_then_text": emits leading newlines before text
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import asyncio
|
|
26
|
+
from collections.abc import AsyncIterator
|
|
27
|
+
|
|
28
|
+
from ...core.types import (
|
|
29
|
+
Message,
|
|
30
|
+
StopReason,
|
|
31
|
+
StreamDone,
|
|
32
|
+
StreamError,
|
|
33
|
+
TextPart,
|
|
34
|
+
ThinkPart,
|
|
35
|
+
ToolCallDelta,
|
|
36
|
+
ToolCallStart,
|
|
37
|
+
ToolDefinition,
|
|
38
|
+
Usage,
|
|
39
|
+
)
|
|
40
|
+
from ..base import BaseProvider, LLMStream, ProviderConfig
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class MockProvider(BaseProvider):
|
|
44
|
+
name = "mock"
|
|
45
|
+
|
|
46
|
+
def __init__(self, config: ProviderConfig | None = None, scenario: str = "default"):
|
|
47
|
+
super().__init__(config or ProviderConfig())
|
|
48
|
+
self.scenario = scenario
|
|
49
|
+
self._attempt_count = 0
|
|
50
|
+
|
|
51
|
+
async def _stream_impl(
|
|
52
|
+
self,
|
|
53
|
+
messages: list[Message],
|
|
54
|
+
*,
|
|
55
|
+
system_prompt: str | None = None,
|
|
56
|
+
tools: list[ToolDefinition] | None = None,
|
|
57
|
+
temperature: float | None = None,
|
|
58
|
+
max_tokens: int | None = None,
|
|
59
|
+
) -> LLMStream:
|
|
60
|
+
self._attempt_count += 1
|
|
61
|
+
|
|
62
|
+
if self.scenario == "retries":
|
|
63
|
+
if self._attempt_count < 3:
|
|
64
|
+
raise ConnectionError("Rate limit")
|
|
65
|
+
elif self.scenario == "retry_exhausted":
|
|
66
|
+
raise ConnectionError("Always fails")
|
|
67
|
+
elif self.scenario == "non_retryable":
|
|
68
|
+
raise ValueError("Invalid input")
|
|
69
|
+
|
|
70
|
+
llm_stream = LLMStream()
|
|
71
|
+
llm_stream.set_iterator(self._get_iterator())
|
|
72
|
+
llm_stream._id = "mock-1"
|
|
73
|
+
llm_stream._usage = Usage(input_tokens=10, output_tokens=5, cache_read_tokens=2)
|
|
74
|
+
|
|
75
|
+
return llm_stream
|
|
76
|
+
|
|
77
|
+
def _get_iterator(self) -> AsyncIterator:
|
|
78
|
+
match self.scenario:
|
|
79
|
+
case "default":
|
|
80
|
+
|
|
81
|
+
async def default_iter():
|
|
82
|
+
yield ThinkPart(think="Let me think about this...")
|
|
83
|
+
yield TextPart(text="I'll help you with that.")
|
|
84
|
+
yield ToolCallStart(id="call-1", name="read", index=0, arguments={})
|
|
85
|
+
yield ToolCallDelta(index=0, arguments_delta='{"path": "file.txt"}')
|
|
86
|
+
yield ToolCallStart(id="call-2", name="bash", index=1, arguments={})
|
|
87
|
+
yield ToolCallDelta(index=1, arguments_delta='{"command": "ls -la"}')
|
|
88
|
+
yield StreamDone(stop_reason=StopReason.TOOL_USE)
|
|
89
|
+
|
|
90
|
+
return default_iter()
|
|
91
|
+
|
|
92
|
+
case "simple_text":
|
|
93
|
+
|
|
94
|
+
async def simple_iter():
|
|
95
|
+
yield TextPart(text="Hello, world!")
|
|
96
|
+
yield StreamDone(stop_reason=StopReason.STOP)
|
|
97
|
+
|
|
98
|
+
return simple_iter()
|
|
99
|
+
|
|
100
|
+
case "thinking_text_tool":
|
|
101
|
+
|
|
102
|
+
async def flow_iter():
|
|
103
|
+
yield ThinkPart(think="I need to read the file")
|
|
104
|
+
yield TextPart(text="Let me check the file.")
|
|
105
|
+
yield ToolCallStart(id="call-1", name="read", index=0, arguments={})
|
|
106
|
+
yield ToolCallDelta(index=0, arguments_delta='{"path": "test.txt"}')
|
|
107
|
+
yield StreamDone(stop_reason=StopReason.TOOL_USE)
|
|
108
|
+
|
|
109
|
+
return flow_iter()
|
|
110
|
+
|
|
111
|
+
case "stream_error":
|
|
112
|
+
|
|
113
|
+
async def error_iter():
|
|
114
|
+
yield TextPart(text="Before error")
|
|
115
|
+
yield StreamError(error="Something went wrong")
|
|
116
|
+
|
|
117
|
+
return error_iter()
|
|
118
|
+
|
|
119
|
+
case "unknown_tool":
|
|
120
|
+
|
|
121
|
+
async def unknown_iter():
|
|
122
|
+
yield ToolCallStart(id="call-1", name="unknown_tool", index=0, arguments={})
|
|
123
|
+
yield ToolCallDelta(index=0, arguments_delta='{"arg": "value"}')
|
|
124
|
+
yield StreamDone(stop_reason=StopReason.TOOL_USE)
|
|
125
|
+
|
|
126
|
+
return unknown_iter()
|
|
127
|
+
|
|
128
|
+
case "long_text":
|
|
129
|
+
|
|
130
|
+
async def long_iter():
|
|
131
|
+
for chunk in ["This ", "is ", "a ", "long ", "response", "."]:
|
|
132
|
+
yield TextPart(text=chunk)
|
|
133
|
+
yield StreamDone(stop_reason=StopReason.STOP)
|
|
134
|
+
|
|
135
|
+
return long_iter()
|
|
136
|
+
|
|
137
|
+
case "tool_hang":
|
|
138
|
+
|
|
139
|
+
async def tool_hang_iter():
|
|
140
|
+
yield ToolCallStart(id="call-1", name="read", index=0, arguments={})
|
|
141
|
+
yield ToolCallDelta(index=0, arguments_delta='{"path": "test.txt"}')
|
|
142
|
+
await asyncio.sleep(3600)
|
|
143
|
+
|
|
144
|
+
return tool_hang_iter()
|
|
145
|
+
|
|
146
|
+
case "tool_hang_invalid_json":
|
|
147
|
+
|
|
148
|
+
async def tool_hang_invalid_json_iter():
|
|
149
|
+
yield ToolCallStart(id="call-1", name="write", index=0, arguments={})
|
|
150
|
+
yield ToolCallDelta(
|
|
151
|
+
index=0, arguments_delta='{"path": "/tmp/test.txt", "content": "incomplete'
|
|
152
|
+
)
|
|
153
|
+
await asyncio.sleep(3600)
|
|
154
|
+
|
|
155
|
+
return tool_hang_invalid_json_iter()
|
|
156
|
+
|
|
157
|
+
case "tool_hang_with_initial_args":
|
|
158
|
+
|
|
159
|
+
async def tool_hang_with_initial_args_iter():
|
|
160
|
+
yield ToolCallStart(
|
|
161
|
+
id="call-1",
|
|
162
|
+
name="write",
|
|
163
|
+
index=0,
|
|
164
|
+
arguments={"path": "/tmp/stale.txt", "content": "stale snapshot"},
|
|
165
|
+
)
|
|
166
|
+
yield ToolCallDelta(
|
|
167
|
+
index=0,
|
|
168
|
+
arguments_delta='{"path": "/tmp/test.txt", "content": "incomplete',
|
|
169
|
+
replace=True,
|
|
170
|
+
)
|
|
171
|
+
await asyncio.sleep(3600)
|
|
172
|
+
|
|
173
|
+
return tool_hang_with_initial_args_iter()
|
|
174
|
+
|
|
175
|
+
case "tool_with_many_chunks":
|
|
176
|
+
|
|
177
|
+
async def tool_with_many_chunks_iter():
|
|
178
|
+
# Tool call with many chunks to test token counting
|
|
179
|
+
# 24 chunks of 8 chars each = 192 chars = 48 tokens
|
|
180
|
+
# Should trigger token update events at chunks 12, 16, 20, 24
|
|
181
|
+
yield ToolCallStart(id="call-1", name="bash", index=0, arguments={})
|
|
182
|
+
chunks = [
|
|
183
|
+
"aaaaaaa",
|
|
184
|
+
"bbbbbbb",
|
|
185
|
+
"ccccccc",
|
|
186
|
+
"ddddddd",
|
|
187
|
+
"eeeeeee",
|
|
188
|
+
"fffffff",
|
|
189
|
+
"ggggggg",
|
|
190
|
+
"hhhhhhh",
|
|
191
|
+
"iiiiiii",
|
|
192
|
+
"jjjjjjj",
|
|
193
|
+
"kkkkkkk",
|
|
194
|
+
"lllllll",
|
|
195
|
+
"mmmmmmm",
|
|
196
|
+
"nnnnnnn",
|
|
197
|
+
"ooooooo",
|
|
198
|
+
"ppppppp",
|
|
199
|
+
"qqqqqqq",
|
|
200
|
+
"rrrrrrr",
|
|
201
|
+
"sssssss",
|
|
202
|
+
"ttttttt",
|
|
203
|
+
"uuuuuuu",
|
|
204
|
+
"vvvvvvv",
|
|
205
|
+
"wwwwwww",
|
|
206
|
+
"xxxxxxxx",
|
|
207
|
+
]
|
|
208
|
+
for chunk in chunks:
|
|
209
|
+
yield ToolCallDelta(index=0, arguments_delta=chunk)
|
|
210
|
+
yield StreamDone(stop_reason=StopReason.TOOL_USE)
|
|
211
|
+
|
|
212
|
+
return tool_with_many_chunks_iter()
|
|
213
|
+
|
|
214
|
+
case "leading_empty_text_then_think":
|
|
215
|
+
|
|
216
|
+
async def leading_empty_text_then_think_iter():
|
|
217
|
+
yield TextPart(text="\n\n")
|
|
218
|
+
yield ThinkPart(think="Let me think about this...")
|
|
219
|
+
yield TextPart(text="I'll help you with that.")
|
|
220
|
+
yield StreamDone(stop_reason=StopReason.STOP)
|
|
221
|
+
|
|
222
|
+
return leading_empty_text_then_think_iter()
|
|
223
|
+
|
|
224
|
+
case "leading_empty_text_then_text":
|
|
225
|
+
|
|
226
|
+
async def leading_empty_text_then_text_iter():
|
|
227
|
+
yield TextPart(text="\n\n")
|
|
228
|
+
yield TextPart(text="Hello, world!")
|
|
229
|
+
yield StreamDone(stop_reason=StopReason.STOP)
|
|
230
|
+
|
|
231
|
+
return leading_empty_text_then_text_iter()
|
|
232
|
+
|
|
233
|
+
case _:
|
|
234
|
+
# Fallback to default
|
|
235
|
+
async def default_iter():
|
|
236
|
+
yield ThinkPart(think="Let me think about this...")
|
|
237
|
+
yield TextPart(text="I'll help you with that.")
|
|
238
|
+
yield ToolCallStart(id="call-1", name="read", index=0, arguments={})
|
|
239
|
+
yield ToolCallDelta(index=0, arguments_delta='{"path": "file.txt"}')
|
|
240
|
+
yield ToolCallStart(id="call-2", name="bash", index=1, arguments={})
|
|
241
|
+
yield ToolCallDelta(index=1, arguments_delta='{"command": "ls -la"}')
|
|
242
|
+
yield StreamDone(stop_reason=StopReason.TOOL_USE)
|
|
243
|
+
|
|
244
|
+
return default_iter()
|
|
245
|
+
|
|
246
|
+
def should_retry_for_error(self, error: Exception) -> bool:
|
|
247
|
+
if self.scenario == "retries" or self.scenario == "retry_exhausted":
|
|
248
|
+
return isinstance(error, ConnectionError)
|
|
249
|
+
return False
|