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
vtx/turn.py
ADDED
|
@@ -0,0 +1,789 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Single turn execution - one LLM request/response cycle with streaming.
|
|
3
|
+
|
|
4
|
+
Streams chunks from the LLM and yields typed events as they arrive:
|
|
5
|
+
- ThinkingStartEvent/DeltaEvent/EndEvent - model's reasoning
|
|
6
|
+
- TextStartEvent/DeltaEvent/EndEvent - response text
|
|
7
|
+
- ToolStartEvent/ArgsDeltaEvent/EndEvent - tool calls being built
|
|
8
|
+
- ToolApprovalEvent - when a tool requires user approval
|
|
9
|
+
- ToolResultEvent - after each tool execution
|
|
10
|
+
- TurnEndEvent - final event with complete AssistantMessage
|
|
11
|
+
|
|
12
|
+
Tool execution strategy:
|
|
13
|
+
- All tool calls are collected during streaming
|
|
14
|
+
- After streaming completes, all ToolEndEvents are yielded first (UI shows pending state)
|
|
15
|
+
- Each tool is permission-checked; safe read-only tools auto-approve while
|
|
16
|
+
mutating tools yield ToolApprovalEvent and await user approval before executing
|
|
17
|
+
- Then ToolResultEvent is yielded with the result (or denial reason)
|
|
18
|
+
|
|
19
|
+
Cancellation handling:
|
|
20
|
+
- Races each stream chunk against cancel_event using asyncio.wait(FIRST_COMPLETED)
|
|
21
|
+
- ESC takes effect immediately, not just when the next chunk arrives
|
|
22
|
+
- Finalizes any partial content (thinking/text/tool call in progress)
|
|
23
|
+
- Skips remaining tool executions with "Interrupted by user" placeholder
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import asyncio
|
|
27
|
+
import contextlib
|
|
28
|
+
import json
|
|
29
|
+
from collections.abc import AsyncIterator
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from enum import Enum, StrEnum, auto
|
|
32
|
+
|
|
33
|
+
from pydantic import ValidationError
|
|
34
|
+
|
|
35
|
+
from . import config as vtx_config
|
|
36
|
+
from .async_utils import OperationCancelledError, await_or_cancel
|
|
37
|
+
from .core.errors import format_error
|
|
38
|
+
from .core.types import (
|
|
39
|
+
AssistantMessage,
|
|
40
|
+
FileChanges,
|
|
41
|
+
ImageContent,
|
|
42
|
+
Message,
|
|
43
|
+
StopReason,
|
|
44
|
+
StreamDone,
|
|
45
|
+
StreamError,
|
|
46
|
+
TextContent,
|
|
47
|
+
TextPart,
|
|
48
|
+
ThinkingContent,
|
|
49
|
+
ThinkPart,
|
|
50
|
+
ToolCall,
|
|
51
|
+
ToolCallDelta,
|
|
52
|
+
ToolCallStart,
|
|
53
|
+
ToolResult,
|
|
54
|
+
ToolResultMessage,
|
|
55
|
+
)
|
|
56
|
+
from .events import (
|
|
57
|
+
ErrorEvent,
|
|
58
|
+
InterruptedEvent,
|
|
59
|
+
RetryEvent,
|
|
60
|
+
StreamEvent,
|
|
61
|
+
TextDeltaEvent,
|
|
62
|
+
TextEndEvent,
|
|
63
|
+
TextStartEvent,
|
|
64
|
+
ThinkingDeltaEvent,
|
|
65
|
+
ThinkingEndEvent,
|
|
66
|
+
ThinkingStartEvent,
|
|
67
|
+
ToolApprovalEvent,
|
|
68
|
+
ToolArgsDeltaEvent,
|
|
69
|
+
ToolArgsTokenUpdateEvent,
|
|
70
|
+
ToolEndEvent,
|
|
71
|
+
ToolResultEvent,
|
|
72
|
+
ToolStartEvent,
|
|
73
|
+
TurnEndEvent,
|
|
74
|
+
WarningEvent,
|
|
75
|
+
)
|
|
76
|
+
from .llm import BaseProvider
|
|
77
|
+
from .llm.base import LLMStream
|
|
78
|
+
from .permissions import ApprovalResponse, PermissionDecision, check_permission
|
|
79
|
+
from .tools import BaseTool, get_tool, get_tool_definitions
|
|
80
|
+
|
|
81
|
+
_STREAM_EXHAUSTED = object()
|
|
82
|
+
_TOOL_ARGS_TOKEN_DISPLAY_THRESHOLD = 20
|
|
83
|
+
_TOOL_ARGS_TOKEN_CHUNK_UPDATE_INTERVAL = 4
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _count_tokens(text: str) -> int:
|
|
87
|
+
return len(text) // 4
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class StreamState(StrEnum):
|
|
91
|
+
THINK = "think"
|
|
92
|
+
TEXT = "text"
|
|
93
|
+
TOOL_CALL = "tool_call"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class PendingToolCall:
|
|
98
|
+
tool_call: ToolCall
|
|
99
|
+
tool: BaseTool | None
|
|
100
|
+
display: str
|
|
101
|
+
approval_preview: str = ""
|
|
102
|
+
preflight_error: str | None = None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
async def _safe_anext(aiter):
|
|
106
|
+
"""
|
|
107
|
+
Get next item, returning _STREAM_EXHAUSTED on StopAsyncIteration.
|
|
108
|
+
|
|
109
|
+
StopAsyncIteration cannot propagate out of an asyncio task,
|
|
110
|
+
so we catch it and return a sentinel instead.
|
|
111
|
+
"""
|
|
112
|
+
try:
|
|
113
|
+
return await aiter.__anext__()
|
|
114
|
+
except StopAsyncIteration:
|
|
115
|
+
return _STREAM_EXHAUSTED
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
async def _cancel_and_reap(task: asyncio.Task) -> None:
|
|
119
|
+
task.cancel()
|
|
120
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
121
|
+
await task
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
async def _close_stream(stream: LLMStream) -> None:
|
|
125
|
+
with contextlib.suppress(Exception):
|
|
126
|
+
await stream.aclose()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def tool_call_idle_timeout_seconds() -> float | None:
|
|
130
|
+
timeout = vtx_config.llm.tool_call_idle_timeout_seconds
|
|
131
|
+
return None if timeout <= 0 else timeout
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _create_skipped_tool_result(
|
|
135
|
+
tool_call: ToolCall, reason: str = "Interrupted by user"
|
|
136
|
+
) -> ToolResultMessage:
|
|
137
|
+
return ToolResultMessage(
|
|
138
|
+
tool_call_id=tool_call.id,
|
|
139
|
+
tool_name=tool_call.name,
|
|
140
|
+
content=[TextContent(text=reason)],
|
|
141
|
+
is_error=True,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _finalize_tool_call_data(tool_call_data: dict, tools: list[BaseTool]) -> PendingToolCall:
|
|
146
|
+
arguments_raw = tool_call_data["arguments"]
|
|
147
|
+
initial_arguments = tool_call_data.get("initial_arguments")
|
|
148
|
+
initial_arguments_dict = initial_arguments if isinstance(initial_arguments, dict) else {}
|
|
149
|
+
stalled = tool_call_data.get("stalled", False)
|
|
150
|
+
preflight_error: str | None = None
|
|
151
|
+
|
|
152
|
+
stripped_args = arguments_raw.strip()
|
|
153
|
+
if stripped_args:
|
|
154
|
+
try:
|
|
155
|
+
arguments = json.loads(arguments_raw)
|
|
156
|
+
except json.JSONDecodeError:
|
|
157
|
+
if stalled:
|
|
158
|
+
# The stream timed out mid-arguments, so whatever we collected is
|
|
159
|
+
# truncated and initial_arguments is a stale snapshot from the
|
|
160
|
+
# start of the call. Refuse to execute rather than guess.
|
|
161
|
+
arguments = {}
|
|
162
|
+
preflight_error = (
|
|
163
|
+
"Tool call arguments were cut off when the stream stalled; "
|
|
164
|
+
"skipping execution instead of running with truncated arguments."
|
|
165
|
+
)
|
|
166
|
+
elif initial_arguments_dict:
|
|
167
|
+
arguments = initial_arguments_dict
|
|
168
|
+
else:
|
|
169
|
+
arguments = {}
|
|
170
|
+
preflight_error = (
|
|
171
|
+
"Tool call arguments were incomplete or invalid JSON; "
|
|
172
|
+
"skipping execution instead of running with empty arguments."
|
|
173
|
+
)
|
|
174
|
+
else:
|
|
175
|
+
arguments = initial_arguments_dict
|
|
176
|
+
|
|
177
|
+
tool_call = ToolCall(id=tool_call_data["id"], name=tool_call_data["name"], arguments=arguments)
|
|
178
|
+
|
|
179
|
+
tool = get_tool(tool_call.name)
|
|
180
|
+
display = ""
|
|
181
|
+
approval_preview = ""
|
|
182
|
+
if tool and preflight_error is None:
|
|
183
|
+
try:
|
|
184
|
+
params = tool.params(**arguments)
|
|
185
|
+
display = tool.format_call(params)
|
|
186
|
+
approval_preview = tool.format_preview(params) or ""
|
|
187
|
+
except (TypeError, KeyError, ValueError, ValidationError):
|
|
188
|
+
if stalled:
|
|
189
|
+
preflight_error = (
|
|
190
|
+
"Tool call arguments failed validation after the stream stalled "
|
|
191
|
+
"mid-call, so they are likely incomplete; skipping execution."
|
|
192
|
+
)
|
|
193
|
+
else:
|
|
194
|
+
preflight_error = (
|
|
195
|
+
"Tool call arguments failed validation before execution; skipping execution."
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
return PendingToolCall(
|
|
199
|
+
tool_call=tool_call,
|
|
200
|
+
tool=tool,
|
|
201
|
+
display=display,
|
|
202
|
+
approval_preview=approval_preview,
|
|
203
|
+
preflight_error=preflight_error,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
async def _execute_tool(
|
|
208
|
+
tool_call: ToolCall, tool: BaseTool | None, cancel_event: asyncio.Event | None = None
|
|
209
|
+
) -> tuple[ToolResultMessage, FileChanges | None]:
|
|
210
|
+
if not tool:
|
|
211
|
+
return ToolResultMessage(
|
|
212
|
+
tool_call_id=tool_call.id,
|
|
213
|
+
tool_name=tool_call.name,
|
|
214
|
+
content=[TextContent(text=f"Unknown tool: {tool_call.name}")],
|
|
215
|
+
is_error=True,
|
|
216
|
+
), None
|
|
217
|
+
|
|
218
|
+
try:
|
|
219
|
+
params = tool.params(**tool_call.arguments)
|
|
220
|
+
result: ToolResult = await tool.execute(params, cancel_event=cancel_event)
|
|
221
|
+
|
|
222
|
+
content: list[TextContent | ImageContent] = []
|
|
223
|
+
if result.result:
|
|
224
|
+
content.append(TextContent(text=result.result))
|
|
225
|
+
if result.images:
|
|
226
|
+
content.extend(result.images)
|
|
227
|
+
if not content:
|
|
228
|
+
content.append(TextContent(text="(no output)"))
|
|
229
|
+
|
|
230
|
+
return ToolResultMessage(
|
|
231
|
+
tool_call_id=tool_call.id,
|
|
232
|
+
tool_name=tool_call.name,
|
|
233
|
+
content=content,
|
|
234
|
+
ui_summary=result.ui_summary,
|
|
235
|
+
ui_details=result.ui_details,
|
|
236
|
+
ui_details_full=result.ui_details_full,
|
|
237
|
+
is_error=not result.success,
|
|
238
|
+
file_changes=result.file_changes,
|
|
239
|
+
), result.file_changes
|
|
240
|
+
except Exception as e:
|
|
241
|
+
return ToolResultMessage(
|
|
242
|
+
tool_call_id=tool_call.id,
|
|
243
|
+
tool_name=tool_call.name,
|
|
244
|
+
content=[TextContent(text=f"Error executing tool: {e}")],
|
|
245
|
+
is_error=True,
|
|
246
|
+
), None
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
async def _await_approval(
|
|
250
|
+
future: asyncio.Future[ApprovalResponse], cancel_event: asyncio.Event | None
|
|
251
|
+
) -> ApprovalResponse | None:
|
|
252
|
+
try:
|
|
253
|
+
return await await_or_cancel(future, cancel_event)
|
|
254
|
+
except OperationCancelledError:
|
|
255
|
+
return None
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
async def _sleep_or_cancel(delay: float, cancel_event: asyncio.Event | None) -> bool:
|
|
259
|
+
try:
|
|
260
|
+
await await_or_cancel(asyncio.create_task(asyncio.sleep(delay)), cancel_event)
|
|
261
|
+
return False
|
|
262
|
+
except OperationCancelledError:
|
|
263
|
+
return True
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
class _ChunkOutcome(Enum):
|
|
267
|
+
CHUNK = auto()
|
|
268
|
+
EXHAUSTED = auto()
|
|
269
|
+
CANCELLED = auto()
|
|
270
|
+
STALLED = auto()
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
class _TurnRunner:
|
|
274
|
+
"""
|
|
275
|
+
State for one streaming turn, split into phases:
|
|
276
|
+
|
|
277
|
+
1. _open_stream() - request the stream, retrying transient failures
|
|
278
|
+
2. _consume_stream() - drain chunks, buffering content and tool calls
|
|
279
|
+
3. _run_pending_tools() - permission-check and execute collected tool calls
|
|
280
|
+
|
|
281
|
+
run() orchestrates the phases and emits the final TurnEndEvent.
|
|
282
|
+
"""
|
|
283
|
+
|
|
284
|
+
def __init__(
|
|
285
|
+
self,
|
|
286
|
+
provider: BaseProvider,
|
|
287
|
+
messages: list[Message],
|
|
288
|
+
tools: list[BaseTool],
|
|
289
|
+
system_prompt: str | None,
|
|
290
|
+
turn: int,
|
|
291
|
+
cancel_event: asyncio.Event | None,
|
|
292
|
+
retry_delays: list[int] | None,
|
|
293
|
+
):
|
|
294
|
+
self._provider = provider
|
|
295
|
+
self._messages = messages
|
|
296
|
+
self._tools = tools
|
|
297
|
+
self._system_prompt = system_prompt
|
|
298
|
+
self._turn = turn
|
|
299
|
+
self._cancel_event = cancel_event
|
|
300
|
+
self._retry_delays = retry_delays if retry_delays is not None else [2, 4, 8]
|
|
301
|
+
|
|
302
|
+
self._stream: LLMStream | None = None
|
|
303
|
+
|
|
304
|
+
self._content: list[TextContent | ThinkingContent | ToolCall] = []
|
|
305
|
+
self._tool_results: list[ToolResultMessage] = []
|
|
306
|
+
self._tool_call_count = 0
|
|
307
|
+
|
|
308
|
+
self._think_buffer: list[str] = []
|
|
309
|
+
self._think_signature: str | None = None
|
|
310
|
+
self._text_buffer: list[str] = []
|
|
311
|
+
|
|
312
|
+
# Collect tool calls during streaming, execute after stream completes
|
|
313
|
+
self._pending_tool_calls: list[dict] = []
|
|
314
|
+
self._active_tool_calls: dict[int, dict] = {}
|
|
315
|
+
|
|
316
|
+
# Token counting for tool argument streaming
|
|
317
|
+
self._tool_arg_counters: dict[int, tuple[int, int]] = {}
|
|
318
|
+
|
|
319
|
+
self._current_state: StreamState | None = None
|
|
320
|
+
self._stop_reason: StopReason = StopReason.STOP
|
|
321
|
+
self._interrupted = False
|
|
322
|
+
|
|
323
|
+
async def run(self) -> AsyncIterator[StreamEvent]:
|
|
324
|
+
if self._is_cancelled():
|
|
325
|
+
for event in self._interrupted_turn_end():
|
|
326
|
+
yield event
|
|
327
|
+
return
|
|
328
|
+
|
|
329
|
+
async for event in self._open_stream():
|
|
330
|
+
yield event
|
|
331
|
+
if self._stream is None:
|
|
332
|
+
return
|
|
333
|
+
|
|
334
|
+
async for event in self._consume_stream():
|
|
335
|
+
yield event
|
|
336
|
+
|
|
337
|
+
async for event in self._run_pending_tools():
|
|
338
|
+
yield event
|
|
339
|
+
|
|
340
|
+
if self._interrupted:
|
|
341
|
+
yield InterruptedEvent(message="Interrupted by user")
|
|
342
|
+
|
|
343
|
+
assistant_message = AssistantMessage(
|
|
344
|
+
content=self._content, usage=self._stream.usage, stop_reason=self._stop_reason
|
|
345
|
+
)
|
|
346
|
+
yield TurnEndEvent(
|
|
347
|
+
turn=self._turn,
|
|
348
|
+
assistant_message=assistant_message,
|
|
349
|
+
tool_results=self._tool_results,
|
|
350
|
+
stop_reason=self._stop_reason,
|
|
351
|
+
tool_call_count=self._tool_call_count,
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
# -- Phase 1: open the stream, retrying transient failures ----------------
|
|
355
|
+
|
|
356
|
+
async def _open_stream(self) -> AsyncIterator[StreamEvent]:
|
|
357
|
+
"""Request the LLM stream. Leaves self._stream as None on terminal failure."""
|
|
358
|
+
tool_defs = get_tool_definitions(self._tools) if self._tools else None
|
|
359
|
+
|
|
360
|
+
for attempt_num, delay in enumerate([*self._retry_delays, None]):
|
|
361
|
+
if self._is_cancelled():
|
|
362
|
+
for event in self._interrupted_turn_end():
|
|
363
|
+
yield event
|
|
364
|
+
return
|
|
365
|
+
|
|
366
|
+
try:
|
|
367
|
+
self._stream = await self._provider.stream(
|
|
368
|
+
self._messages, system_prompt=self._system_prompt, tools=tool_defs
|
|
369
|
+
)
|
|
370
|
+
return
|
|
371
|
+
except Exception as e:
|
|
372
|
+
if self._provider.should_retry_for_error(e) and delay is not None:
|
|
373
|
+
yield RetryEvent(
|
|
374
|
+
attempt=attempt_num + 1,
|
|
375
|
+
total_attempts=len(self._retry_delays),
|
|
376
|
+
delay=delay,
|
|
377
|
+
error=format_error(e),
|
|
378
|
+
)
|
|
379
|
+
if await _sleep_or_cancel(delay, self._cancel_event):
|
|
380
|
+
for event in self._interrupted_turn_end():
|
|
381
|
+
yield event
|
|
382
|
+
return
|
|
383
|
+
continue
|
|
384
|
+
yield ErrorEvent(error=format_error(e)) # Not retryable or retries exhausted
|
|
385
|
+
yield TurnEndEvent(
|
|
386
|
+
turn=self._turn,
|
|
387
|
+
assistant_message=None,
|
|
388
|
+
tool_results=[],
|
|
389
|
+
stop_reason=StopReason.ERROR,
|
|
390
|
+
)
|
|
391
|
+
return
|
|
392
|
+
|
|
393
|
+
# -- Phase 2: drain the stream, buffering content ---------------------------
|
|
394
|
+
|
|
395
|
+
async def _consume_stream(self) -> AsyncIterator[StreamEvent]:
|
|
396
|
+
assert self._stream is not None
|
|
397
|
+
stream_iter = self._stream.__aiter__()
|
|
398
|
+
# Race stream chunks against cancel_event so ESC takes effect immediately,
|
|
399
|
+
# not just when the next chunk happens to arrive from the API.
|
|
400
|
+
cancel_task = (
|
|
401
|
+
asyncio.create_task(self._cancel_event.wait()) if self._cancel_event else None
|
|
402
|
+
)
|
|
403
|
+
tool_call_timeout = tool_call_idle_timeout_seconds()
|
|
404
|
+
|
|
405
|
+
try:
|
|
406
|
+
while True:
|
|
407
|
+
if self._is_cancelled():
|
|
408
|
+
self._mark_interrupted()
|
|
409
|
+
break
|
|
410
|
+
|
|
411
|
+
chunk_timeout = (
|
|
412
|
+
tool_call_timeout
|
|
413
|
+
if (
|
|
414
|
+
tool_call_timeout is not None
|
|
415
|
+
and (
|
|
416
|
+
self._current_state == StreamState.TOOL_CALL
|
|
417
|
+
or self._pending_tool_calls
|
|
418
|
+
)
|
|
419
|
+
)
|
|
420
|
+
else None
|
|
421
|
+
)
|
|
422
|
+
outcome, chunk = await self._next_chunk(stream_iter, cancel_task, chunk_timeout)
|
|
423
|
+
|
|
424
|
+
if outcome is _ChunkOutcome.STALLED:
|
|
425
|
+
await _close_stream(self._stream)
|
|
426
|
+
# Calls still streaming arguments may be truncated; mark them
|
|
427
|
+
# so finalization can skip execution instead of running with
|
|
428
|
+
# partial arguments.
|
|
429
|
+
for tool_call_data in self._active_tool_calls.values():
|
|
430
|
+
tool_call_data["stalled"] = True
|
|
431
|
+
timeout_secs = chunk_timeout or 0
|
|
432
|
+
yield WarningEvent(
|
|
433
|
+
warning=(
|
|
434
|
+
f"Tool-call stream stalled for {timeout_secs:g}s; "
|
|
435
|
+
"continuing with collected arguments."
|
|
436
|
+
)
|
|
437
|
+
)
|
|
438
|
+
# Some local providers intermittently miss terminal stream events
|
|
439
|
+
# after a tool call is fully emitted. If we're already in a tool
|
|
440
|
+
# call path, finalize what we have and continue execution.
|
|
441
|
+
for event in self._finalize_current_state(include_empty=False):
|
|
442
|
+
yield event
|
|
443
|
+
self._promote_tool_use_stop_reason()
|
|
444
|
+
break
|
|
445
|
+
|
|
446
|
+
if outcome is _ChunkOutcome.CANCELLED:
|
|
447
|
+
await _close_stream(self._stream)
|
|
448
|
+
self._mark_interrupted()
|
|
449
|
+
break
|
|
450
|
+
|
|
451
|
+
if outcome is _ChunkOutcome.EXHAUSTED:
|
|
452
|
+
for event in self._finalize_current_state():
|
|
453
|
+
yield event
|
|
454
|
+
self._promote_tool_use_stop_reason()
|
|
455
|
+
break
|
|
456
|
+
|
|
457
|
+
for event in self._handle_chunk(chunk):
|
|
458
|
+
yield event
|
|
459
|
+
finally:
|
|
460
|
+
# Clean up the cancel waiter task
|
|
461
|
+
if cancel_task and not cancel_task.done():
|
|
462
|
+
await _cancel_and_reap(cancel_task)
|
|
463
|
+
|
|
464
|
+
# Handle interruption - finalize partial content
|
|
465
|
+
if self._interrupted:
|
|
466
|
+
for event in self._finalize_current_state(include_empty=False):
|
|
467
|
+
yield event
|
|
468
|
+
|
|
469
|
+
async def _next_chunk(
|
|
470
|
+
self, stream_iter, cancel_task: asyncio.Task | None, chunk_timeout: float | None
|
|
471
|
+
) -> tuple[_ChunkOutcome, object]:
|
|
472
|
+
next_task = asyncio.create_task(_safe_anext(stream_iter))
|
|
473
|
+
|
|
474
|
+
if cancel_task and not cancel_task.done():
|
|
475
|
+
done, _ = await asyncio.wait(
|
|
476
|
+
{next_task, cancel_task},
|
|
477
|
+
timeout=chunk_timeout,
|
|
478
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
479
|
+
)
|
|
480
|
+
if not done:
|
|
481
|
+
await _cancel_and_reap(next_task)
|
|
482
|
+
return _ChunkOutcome.STALLED, None
|
|
483
|
+
if cancel_task in done:
|
|
484
|
+
await _cancel_and_reap(next_task)
|
|
485
|
+
return _ChunkOutcome.CANCELLED, None
|
|
486
|
+
chunk = next_task.result()
|
|
487
|
+
elif chunk_timeout is not None:
|
|
488
|
+
try:
|
|
489
|
+
chunk = await asyncio.wait_for(next_task, timeout=chunk_timeout)
|
|
490
|
+
except TimeoutError:
|
|
491
|
+
await _cancel_and_reap(next_task)
|
|
492
|
+
return _ChunkOutcome.STALLED, None
|
|
493
|
+
else:
|
|
494
|
+
chunk = await next_task
|
|
495
|
+
|
|
496
|
+
if chunk is _STREAM_EXHAUSTED:
|
|
497
|
+
return _ChunkOutcome.EXHAUSTED, None
|
|
498
|
+
return _ChunkOutcome.CHUNK, chunk
|
|
499
|
+
|
|
500
|
+
def _handle_chunk(self, chunk: object) -> list[StreamEvent]:
|
|
501
|
+
match chunk:
|
|
502
|
+
case ThinkPart(think=t, signature=sig):
|
|
503
|
+
return self._on_think_part(t, sig)
|
|
504
|
+
case TextPart(text=t):
|
|
505
|
+
return self._on_text_part(t)
|
|
506
|
+
case ToolCallStart(id=id, name=name, index=index, arguments=initial_arguments):
|
|
507
|
+
return self._on_tool_call_start(id, name, index, initial_arguments)
|
|
508
|
+
case ToolCallDelta(index=index, arguments_delta=delta, replace=replace):
|
|
509
|
+
return self._on_tool_call_delta(index, delta, replace)
|
|
510
|
+
case StreamDone(stop_reason=reason):
|
|
511
|
+
self._stop_reason = reason
|
|
512
|
+
return self._finalize_current_state()
|
|
513
|
+
case StreamError(error=err):
|
|
514
|
+
self._stop_reason = StopReason.ERROR
|
|
515
|
+
return [ErrorEvent(error=err)]
|
|
516
|
+
return []
|
|
517
|
+
|
|
518
|
+
def _on_think_part(self, think: str, signature: str | None) -> list[StreamEvent]:
|
|
519
|
+
# Anthropic can emit signature-only ThinkParts (redacted/
|
|
520
|
+
# encrypted reasoning with no plain-text). Capture the
|
|
521
|
+
# signature but don't open a thinking UI block, otherwise
|
|
522
|
+
# the renderer shows an empty bordered stub. Also trim any
|
|
523
|
+
# leading whitespace from the first visible thinking delta;
|
|
524
|
+
# Anthropic may emit an initial empty/space delta.
|
|
525
|
+
if self._current_state != StreamState.THINK:
|
|
526
|
+
think = think.lstrip()
|
|
527
|
+
if not think:
|
|
528
|
+
if signature:
|
|
529
|
+
self._think_signature = signature
|
|
530
|
+
return []
|
|
531
|
+
|
|
532
|
+
events: list[StreamEvent] = []
|
|
533
|
+
if self._current_state and self._current_state != StreamState.THINK:
|
|
534
|
+
events.extend(self._finalize_current_state())
|
|
535
|
+
|
|
536
|
+
if self._current_state != StreamState.THINK:
|
|
537
|
+
events.append(ThinkingStartEvent())
|
|
538
|
+
|
|
539
|
+
self._current_state = StreamState.THINK
|
|
540
|
+
self._think_buffer.append(think)
|
|
541
|
+
if signature:
|
|
542
|
+
self._think_signature = signature
|
|
543
|
+
|
|
544
|
+
events.append(ThinkingDeltaEvent(delta=think))
|
|
545
|
+
return events
|
|
546
|
+
|
|
547
|
+
def _on_text_part(self, text: str) -> list[StreamEvent]:
|
|
548
|
+
# Skip whitespace-only text that would start a new (empty)
|
|
549
|
+
# content block — prevents phantom gaps between thinking
|
|
550
|
+
# and tool-call blocks.
|
|
551
|
+
if not text.strip() and self._current_state != StreamState.TEXT:
|
|
552
|
+
return []
|
|
553
|
+
|
|
554
|
+
events: list[StreamEvent] = []
|
|
555
|
+
if self._current_state and self._current_state != StreamState.TEXT:
|
|
556
|
+
events.extend(self._finalize_current_state())
|
|
557
|
+
|
|
558
|
+
if self._current_state != StreamState.TEXT:
|
|
559
|
+
events.append(TextStartEvent())
|
|
560
|
+
|
|
561
|
+
self._current_state = StreamState.TEXT
|
|
562
|
+
self._text_buffer.append(text)
|
|
563
|
+
|
|
564
|
+
events.append(TextDeltaEvent(delta=text))
|
|
565
|
+
return events
|
|
566
|
+
|
|
567
|
+
def _on_tool_call_start(
|
|
568
|
+
self, tool_call_id: str, name: str, index: int, initial_arguments: dict | None
|
|
569
|
+
) -> list[StreamEvent]:
|
|
570
|
+
self._tool_call_count += 1
|
|
571
|
+
events: list[StreamEvent] = []
|
|
572
|
+
if self._current_state and self._current_state != StreamState.TOOL_CALL:
|
|
573
|
+
events.extend(self._finalize_current_state())
|
|
574
|
+
|
|
575
|
+
initial_arguments_json = ""
|
|
576
|
+
if initial_arguments:
|
|
577
|
+
try:
|
|
578
|
+
initial_arguments_json = json.dumps(initial_arguments)
|
|
579
|
+
except (TypeError, ValueError):
|
|
580
|
+
initial_arguments_json = ""
|
|
581
|
+
|
|
582
|
+
self._current_state = StreamState.TOOL_CALL
|
|
583
|
+
self._active_tool_calls[index] = {
|
|
584
|
+
"id": tool_call_id,
|
|
585
|
+
"name": name,
|
|
586
|
+
"arguments": initial_arguments_json,
|
|
587
|
+
"initial_arguments": initial_arguments or {},
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
events.append(ToolStartEvent(tool_call_id=tool_call_id, tool_name=name))
|
|
591
|
+
return events
|
|
592
|
+
|
|
593
|
+
def _on_tool_call_delta(self, index: int, delta: str, replace: bool) -> list[StreamEvent]:
|
|
594
|
+
tool_call = self._active_tool_calls.get(index)
|
|
595
|
+
if not tool_call:
|
|
596
|
+
return []
|
|
597
|
+
|
|
598
|
+
if replace:
|
|
599
|
+
tool_call["arguments"] = delta
|
|
600
|
+
chunk_count, token_count = 0, 0
|
|
601
|
+
else:
|
|
602
|
+
tool_call["arguments"] += delta
|
|
603
|
+
chunk_count, token_count = self._tool_arg_counters.get(index, (0, 0))
|
|
604
|
+
|
|
605
|
+
events: list[StreamEvent] = [ToolArgsDeltaEvent(tool_call_id=tool_call["id"], delta=delta)]
|
|
606
|
+
|
|
607
|
+
# Count tokens and fire update event every Nth chunk after threshold tokens
|
|
608
|
+
chunk_count += 1
|
|
609
|
+
token_count += _count_tokens(delta)
|
|
610
|
+
self._tool_arg_counters[index] = (chunk_count, token_count)
|
|
611
|
+
|
|
612
|
+
if (
|
|
613
|
+
token_count > _TOOL_ARGS_TOKEN_DISPLAY_THRESHOLD
|
|
614
|
+
and chunk_count % _TOOL_ARGS_TOKEN_CHUNK_UPDATE_INTERVAL == 0
|
|
615
|
+
):
|
|
616
|
+
events.append(
|
|
617
|
+
ToolArgsTokenUpdateEvent(
|
|
618
|
+
tool_call_id=tool_call["id"],
|
|
619
|
+
tool_name=tool_call["name"],
|
|
620
|
+
token_count=token_count,
|
|
621
|
+
)
|
|
622
|
+
)
|
|
623
|
+
return events
|
|
624
|
+
|
|
625
|
+
def _finalize_current_state(self, include_empty: bool = True) -> list[StreamEvent]:
|
|
626
|
+
events: list[StreamEvent] = []
|
|
627
|
+
|
|
628
|
+
if self._current_state == StreamState.THINK:
|
|
629
|
+
full_thinking = "".join(self._think_buffer)
|
|
630
|
+
if include_empty or full_thinking:
|
|
631
|
+
self._content.append(
|
|
632
|
+
ThinkingContent(thinking=full_thinking, signature=self._think_signature)
|
|
633
|
+
)
|
|
634
|
+
events.append(
|
|
635
|
+
ThinkingEndEvent(thinking=full_thinking, signature=self._think_signature)
|
|
636
|
+
)
|
|
637
|
+
self._think_buffer = []
|
|
638
|
+
self._think_signature = None
|
|
639
|
+
elif self._current_state == StreamState.TEXT:
|
|
640
|
+
full_text = "".join(self._text_buffer)
|
|
641
|
+
from .llm.tool_parser import extract_text_and_tool_calls, has_text_tool_calls
|
|
642
|
+
|
|
643
|
+
if has_text_tool_calls(full_text):
|
|
644
|
+
cleaned_text, parsed_calls = extract_text_and_tool_calls(full_text)
|
|
645
|
+
if include_empty or cleaned_text:
|
|
646
|
+
self._content.append(TextContent(text=cleaned_text))
|
|
647
|
+
events.append(TextEndEvent(text=cleaned_text))
|
|
648
|
+
|
|
649
|
+
for tc in parsed_calls:
|
|
650
|
+
import uuid
|
|
651
|
+
|
|
652
|
+
tc_id = f"call_{uuid.uuid4().hex[:12]}"
|
|
653
|
+
tc_data = {
|
|
654
|
+
"id": tc_id,
|
|
655
|
+
"name": tc["name"],
|
|
656
|
+
"arguments": json.dumps(tc["arguments"]),
|
|
657
|
+
"initial_arguments": tc["arguments"],
|
|
658
|
+
}
|
|
659
|
+
self._pending_tool_calls.append(tc_data)
|
|
660
|
+
events.append(ToolStartEvent(tool_call_id=tc_id, tool_name=tc["name"]))
|
|
661
|
+
else:
|
|
662
|
+
if include_empty or full_text:
|
|
663
|
+
self._content.append(TextContent(text=full_text))
|
|
664
|
+
events.append(TextEndEvent(text=full_text))
|
|
665
|
+
self._text_buffer = []
|
|
666
|
+
elif self._current_state == StreamState.TOOL_CALL and self._active_tool_calls:
|
|
667
|
+
self._pending_tool_calls.extend(self._active_tool_calls.values())
|
|
668
|
+
self._active_tool_calls.clear()
|
|
669
|
+
self._tool_arg_counters.clear()
|
|
670
|
+
|
|
671
|
+
self._current_state = None
|
|
672
|
+
return events
|
|
673
|
+
|
|
674
|
+
# -- Phase 3: execute collected tool calls ---------------------------------
|
|
675
|
+
|
|
676
|
+
async def _run_pending_tools(self) -> AsyncIterator[StreamEvent]:
|
|
677
|
+
# 1. First, yield all ToolEndEvents (UI shows all tools in pending state)
|
|
678
|
+
# 2. Then execute each tool and yield ToolResultEvent
|
|
679
|
+
finalized_tools: list[PendingToolCall] = []
|
|
680
|
+
for tool_data in self._pending_tool_calls:
|
|
681
|
+
pending = _finalize_tool_call_data(tool_data, self._tools)
|
|
682
|
+
finalized_tools.append(pending)
|
|
683
|
+
self._content.append(pending.tool_call)
|
|
684
|
+
|
|
685
|
+
yield ToolEndEvent(
|
|
686
|
+
tool_call_id=pending.tool_call.id,
|
|
687
|
+
tool_name=pending.tool_call.name,
|
|
688
|
+
arguments=pending.tool_call.arguments,
|
|
689
|
+
display=pending.display,
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
for pending in finalized_tools:
|
|
693
|
+
async for event in self._run_one_tool(pending):
|
|
694
|
+
yield event
|
|
695
|
+
|
|
696
|
+
async def _run_one_tool(self, pending: PendingToolCall) -> AsyncIterator[StreamEvent]:
|
|
697
|
+
file_changes = None
|
|
698
|
+
if self._is_cancelled():
|
|
699
|
+
result = _create_skipped_tool_result(pending.tool_call)
|
|
700
|
+
elif pending.preflight_error is not None:
|
|
701
|
+
result = _create_skipped_tool_result(pending.tool_call, reason=pending.preflight_error)
|
|
702
|
+
else:
|
|
703
|
+
approved = True
|
|
704
|
+
if self._needs_approval(pending):
|
|
705
|
+
loop = asyncio.get_running_loop()
|
|
706
|
+
future: asyncio.Future[ApprovalResponse] = loop.create_future()
|
|
707
|
+
yield ToolApprovalEvent(
|
|
708
|
+
tool_call_id=pending.tool_call.id,
|
|
709
|
+
tool_name=pending.tool_call.name,
|
|
710
|
+
display=pending.approval_preview,
|
|
711
|
+
future=future,
|
|
712
|
+
)
|
|
713
|
+
approved = (
|
|
714
|
+
await _await_approval(future, self._cancel_event) == ApprovalResponse.APPROVE
|
|
715
|
+
)
|
|
716
|
+
|
|
717
|
+
if approved:
|
|
718
|
+
result, file_changes = await _execute_tool(
|
|
719
|
+
pending.tool_call, pending.tool, self._cancel_event
|
|
720
|
+
)
|
|
721
|
+
else:
|
|
722
|
+
result = _create_skipped_tool_result(
|
|
723
|
+
pending.tool_call,
|
|
724
|
+
reason=(
|
|
725
|
+
"Tool call denied by user. Ask them what they'd like you to do instead."
|
|
726
|
+
),
|
|
727
|
+
)
|
|
728
|
+
|
|
729
|
+
self._tool_results.append(result)
|
|
730
|
+
yield ToolResultEvent(
|
|
731
|
+
tool_call_id=pending.tool_call.id,
|
|
732
|
+
tool_name=pending.tool_call.name,
|
|
733
|
+
result=result,
|
|
734
|
+
file_changes=file_changes,
|
|
735
|
+
)
|
|
736
|
+
|
|
737
|
+
@staticmethod
|
|
738
|
+
def _needs_approval(pending: PendingToolCall) -> bool:
|
|
739
|
+
# Unknown tools get ALLOW; they'll error in _execute_tool anyway
|
|
740
|
+
if not pending.tool:
|
|
741
|
+
return False
|
|
742
|
+
decision = check_permission(pending.tool, pending.tool_call.arguments)
|
|
743
|
+
return decision == PermissionDecision.PROMPT
|
|
744
|
+
|
|
745
|
+
# -- Shared state helpers ---------------------------------------------------
|
|
746
|
+
|
|
747
|
+
def _is_cancelled(self) -> bool:
|
|
748
|
+
return self._cancel_event is not None and self._cancel_event.is_set()
|
|
749
|
+
|
|
750
|
+
def _mark_interrupted(self) -> None:
|
|
751
|
+
self._interrupted = True
|
|
752
|
+
self._stop_reason = StopReason.INTERRUPTED
|
|
753
|
+
|
|
754
|
+
def _promote_tool_use_stop_reason(self) -> None:
|
|
755
|
+
if self._pending_tool_calls and self._stop_reason == StopReason.STOP:
|
|
756
|
+
self._stop_reason = StopReason.TOOL_USE
|
|
757
|
+
|
|
758
|
+
def _interrupted_turn_end(self) -> list[StreamEvent]:
|
|
759
|
+
return [
|
|
760
|
+
InterruptedEvent(message="Interrupted by user"),
|
|
761
|
+
TurnEndEvent(
|
|
762
|
+
turn=self._turn,
|
|
763
|
+
assistant_message=None,
|
|
764
|
+
tool_results=[],
|
|
765
|
+
stop_reason=StopReason.INTERRUPTED,
|
|
766
|
+
),
|
|
767
|
+
]
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
async def run_single_turn(
|
|
771
|
+
provider: BaseProvider,
|
|
772
|
+
messages: list[Message],
|
|
773
|
+
tools: list[BaseTool],
|
|
774
|
+
system_prompt: str | None = None,
|
|
775
|
+
turn: int = 0,
|
|
776
|
+
cancel_event: asyncio.Event | None = None,
|
|
777
|
+
retry_delays: list[int] | None = None,
|
|
778
|
+
) -> AsyncIterator[StreamEvent]:
|
|
779
|
+
runner = _TurnRunner(
|
|
780
|
+
provider=provider,
|
|
781
|
+
messages=messages,
|
|
782
|
+
tools=tools,
|
|
783
|
+
system_prompt=system_prompt,
|
|
784
|
+
turn=turn,
|
|
785
|
+
cancel_event=cancel_event,
|
|
786
|
+
retry_delays=retry_delays,
|
|
787
|
+
)
|
|
788
|
+
async for event in runner.run():
|
|
789
|
+
yield event
|