Drive the canonical query() loop and adapt to AgentLoopResult. Parameters mirror the existing ``run_agent_loop`` signature where practical, but the adapter takes ``initial_messages`` directly instead of a ``Conversation`` — the typed messages are the same shape query() consumes nati
(
*,
initial_messages: list[Message],
provider: BaseProvider,
tool_registry: ToolRegistry,
tool_context: ToolContext,
system_prompt: str | list[dict[str, Any]] = "You are a helpful assistant.",
max_turns: int = 20,
on_event: ToolEventHandler | None = None,
on_text_chunk: TextChunkHandler | None = None,
on_thinking_chunk: TextChunkHandler | None = None,
on_message: Callable[[Message], None] | None = None,
cancel_signal: AbortSignal | None = None,
abort_controller: AbortController | None = None,
extended_thinking: bool | None = None,
)
| 271 | |
| 272 | |
| 273 | async def run_query_as_agent_loop( |
| 274 | *, |
| 275 | initial_messages: list[Message], |
| 276 | provider: BaseProvider, |
| 277 | tool_registry: ToolRegistry, |
| 278 | tool_context: ToolContext, |
| 279 | system_prompt: str | list[dict[str, Any]] = "You are a helpful assistant.", |
| 280 | max_turns: int = 20, |
| 281 | on_event: ToolEventHandler | None = None, |
| 282 | on_text_chunk: TextChunkHandler | None = None, |
| 283 | on_thinking_chunk: TextChunkHandler | None = None, |
| 284 | on_message: Callable[[Message], None] | None = None, |
| 285 | cancel_signal: AbortSignal | None = None, |
| 286 | abort_controller: AbortController | None = None, |
| 287 | extended_thinking: bool | None = None, |
| 288 | ) -> AgentLoopRunResult: |
| 289 | """Drive the canonical query() loop and adapt to AgentLoopResult. |
| 290 | |
| 291 | Parameters mirror the existing ``run_agent_loop`` signature where |
| 292 | practical, but the adapter takes ``initial_messages`` directly |
| 293 | instead of a ``Conversation`` — the typed messages are the same |
| 294 | shape query() consumes natively. |
| 295 | |
| 296 | The ``on_event`` callback receives :class:`ToolEvent` instances |
| 297 | for every tool_use observed in the model's responses and every |
| 298 | tool_result yielded by the loop. |
| 299 | |
| 300 | ``on_text_chunk`` is forwarded into the QueryParams so the |
| 301 | provider's streaming layer fires text chunks LIVE (per-delta). |
| 302 | Callers MUST provide this if they need real-time text rendering |
| 303 | (TUI live streaming, ESC-mid-stream cancel teardown). |
| 304 | |
| 305 | ``on_message`` is fired for EVERY :class:`Message` yielded by the |
| 306 | loop (Anthropic-shape AssistantMessage with full content blocks |
| 307 | including tool_use, UserMessage with tool_result blocks, etc.). |
| 308 | Use this to persist the full conversation transcript faithfully — |
| 309 | `response_text` alone loses tool_use/tool_result structure across |
| 310 | multi-turn sessions. |
| 311 | |
| 312 | ``cancel_signal`` is bridged into the loop's abort_controller so |
| 313 | user-initiated cancels (Ctrl+C, /exit) propagate cleanly. When |
| 314 | not supplied, the function constructs its own AbortController. |
| 315 | """ |
| 316 | # Critic C2 fix: do NOT mint a fresh controller when the caller |
| 317 | # provided one. The provider's chat_stream_response listens on |
| 318 | # ``QueryParams.abort_controller.signal`` to tear down HTTP streams |
| 319 | # mid-flight on ESC. A fresh controller breaks that wiring — ESC |
| 320 | # would flip the user's signal but the provider would never see it |
| 321 | # because the per-message bridge below only fires when query() |
| 322 | # yields a message, and a tool-use-only turn yields nothing during |
| 323 | # the multi-second generation. Caller's controller IS the user's |
| 324 | # signal source; reuse it. |
| 325 | if abort_controller is None: |
| 326 | if cancel_signal is not None: |
| 327 | # We received only the signal, not its owning controller. |
| 328 | # Mint a new controller and bridge cancellation into it |
| 329 | # both pre- and per-iteration (legacy fallback path). |
| 330 | abort_controller = AbortController() |