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.
Files changed (117) hide show
  1. vtx/__init__.py +63 -0
  2. vtx/async_utils.py +40 -0
  3. vtx/builtin_skills/github/SKILL.md +139 -0
  4. vtx/builtin_skills/init/SKILL.md +74 -0
  5. vtx/builtin_skills/review/SKILL.md +73 -0
  6. vtx/builtin_skills/skill-builder/SKILL.md +133 -0
  7. vtx/cli.py +90 -0
  8. vtx/config.py +741 -0
  9. vtx/context/__init__.py +15 -0
  10. vtx/context/_xml.py +8 -0
  11. vtx/context/agent_mds.py +128 -0
  12. vtx/context/git.py +64 -0
  13. vtx/context/loader.py +41 -0
  14. vtx/context/skills.py +423 -0
  15. vtx/core/__init__.py +47 -0
  16. vtx/core/compaction.py +89 -0
  17. vtx/core/errors.py +17 -0
  18. vtx/core/handoff.py +51 -0
  19. vtx/core/scratchpad.py +54 -0
  20. vtx/core/types.py +197 -0
  21. vtx/defaults/__init__.py +0 -0
  22. vtx/defaults/config.yml +53 -0
  23. vtx/diff_display.py +12 -0
  24. vtx/events.py +224 -0
  25. vtx/gh_cli.py +82 -0
  26. vtx/git_branch.py +90 -0
  27. vtx/headless.py +127 -0
  28. vtx/llm/__init__.py +93 -0
  29. vtx/llm/base.py +217 -0
  30. vtx/llm/context_length.py +150 -0
  31. vtx/llm/dynamic_models.py +735 -0
  32. vtx/llm/model_fetcher.py +279 -0
  33. vtx/llm/models.py +78 -0
  34. vtx/llm/oauth/__init__.py +59 -0
  35. vtx/llm/oauth/copilot.py +358 -0
  36. vtx/llm/oauth/dynamic.py +236 -0
  37. vtx/llm/oauth/openai.py +400 -0
  38. vtx/llm/phase_parser.py +270 -0
  39. vtx/llm/provider.yaml +280 -0
  40. vtx/llm/provider_catalog.py +230 -0
  41. vtx/llm/providers/__init__.py +45 -0
  42. vtx/llm/providers/anthropic_sdk.py +256 -0
  43. vtx/llm/providers/mock.py +249 -0
  44. vtx/llm/providers/openai_sdk.py +246 -0
  45. vtx/llm/providers/sanitize.py +14 -0
  46. vtx/llm/sdk/__init__.py +13 -0
  47. vtx/llm/sdk/anthropic.py +382 -0
  48. vtx/llm/sdk/base.py +82 -0
  49. vtx/llm/sdk/openai.py +344 -0
  50. vtx/llm/tool_parser.py +161 -0
  51. vtx/loop.py +272 -0
  52. vtx/notify.py +109 -0
  53. vtx/permissions.py +114 -0
  54. vtx/prompts/__init__.py +45 -0
  55. vtx/prompts/builder.py +86 -0
  56. vtx/prompts/env.py +58 -0
  57. vtx/prompts/identity.py +166 -0
  58. vtx/prompts/tooling.py +36 -0
  59. vtx/py.typed +0 -0
  60. vtx/runtime.py +580 -0
  61. vtx/session.py +868 -0
  62. vtx/sounds/completion.wav +0 -0
  63. vtx/sounds/error.wav +0 -0
  64. vtx/sounds/permission.wav +0 -0
  65. vtx/themes.py +1104 -0
  66. vtx/tools/__init__.py +68 -0
  67. vtx/tools/_read_image.py +106 -0
  68. vtx/tools/_tool_utils.py +90 -0
  69. vtx/tools/base.py +36 -0
  70. vtx/tools/bash.py +371 -0
  71. vtx/tools/edit.py +261 -0
  72. vtx/tools/find.py +132 -0
  73. vtx/tools/read.py +238 -0
  74. vtx/tools/skill.py +278 -0
  75. vtx/tools/web.py +238 -0
  76. vtx/tools/write.py +88 -0
  77. vtx/tools_manager.py +216 -0
  78. vtx/turn.py +789 -0
  79. vtx/ui/__init__.py +0 -0
  80. vtx/ui/agent_runner.py +417 -0
  81. vtx/ui/app.py +665 -0
  82. vtx/ui/app_protocol.py +29 -0
  83. vtx/ui/autocomplete.py +440 -0
  84. vtx/ui/blocks.py +735 -0
  85. vtx/ui/chat.py +613 -0
  86. vtx/ui/clipboard.py +59 -0
  87. vtx/ui/commands/__init__.py +100 -0
  88. vtx/ui/commands/auth.py +306 -0
  89. vtx/ui/commands/base.py +122 -0
  90. vtx/ui/commands/models.py +144 -0
  91. vtx/ui/commands/sessions.py +388 -0
  92. vtx/ui/commands/settings.py +286 -0
  93. vtx/ui/completion_ui.py +313 -0
  94. vtx/ui/export.py +703 -0
  95. vtx/ui/floating_list.py +370 -0
  96. vtx/ui/formatting.py +287 -0
  97. vtx/ui/input.py +760 -0
  98. vtx/ui/latex.py +349 -0
  99. vtx/ui/launch.py +108 -0
  100. vtx/ui/path_complete.py +228 -0
  101. vtx/ui/prompt_history.py +102 -0
  102. vtx/ui/queue_ui.py +141 -0
  103. vtx/ui/selection_mode.py +18 -0
  104. vtx/ui/session_ui.py +235 -0
  105. vtx/ui/startup.py +124 -0
  106. vtx/ui/styles.py +327 -0
  107. vtx/ui/tool_output.py +34 -0
  108. vtx/ui/tree.py +437 -0
  109. vtx/ui/welcome.py +51 -0
  110. vtx/ui/widgets.py +558 -0
  111. vtx/update_check.py +49 -0
  112. vtx/version.py +22 -0
  113. vtx_coding_agent-0.1.1.dist-info/METADATA +259 -0
  114. vtx_coding_agent-0.1.1.dist-info/RECORD +117 -0
  115. vtx_coding_agent-0.1.1.dist-info/WHEEL +4 -0
  116. vtx_coding_agent-0.1.1.dist-info/entry_points.txt +2 -0
  117. 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