zjcode 0.0.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 (163) hide show
  1. deepagents_code/__init__.py +42 -0
  2. deepagents_code/__main__.py +6 -0
  3. deepagents_code/_ask_user_types.py +90 -0
  4. deepagents_code/_cli_context.py +96 -0
  5. deepagents_code/_constants.py +41 -0
  6. deepagents_code/_debug.py +148 -0
  7. deepagents_code/_debug_buffer.py +204 -0
  8. deepagents_code/_env_vars.py +411 -0
  9. deepagents_code/_fake_models.py +66 -0
  10. deepagents_code/_git.py +521 -0
  11. deepagents_code/_paths.py +69 -0
  12. deepagents_code/_server_config.py +576 -0
  13. deepagents_code/_session_stats.py +235 -0
  14. deepagents_code/_startup_error.py +45 -0
  15. deepagents_code/_testing_models.py +305 -0
  16. deepagents_code/_textual_patches.py +420 -0
  17. deepagents_code/_tool_stream.py +694 -0
  18. deepagents_code/_version.py +46 -0
  19. deepagents_code/agent.py +1976 -0
  20. deepagents_code/app.py +19239 -0
  21. deepagents_code/app.tcss +438 -0
  22. deepagents_code/approval_mode.py +131 -0
  23. deepagents_code/ask_user.py +306 -0
  24. deepagents_code/auth_display.py +185 -0
  25. deepagents_code/auth_store.py +545 -0
  26. deepagents_code/built_in_skills/__init__.py +5 -0
  27. deepagents_code/built_in_skills/remember/SKILL.md +118 -0
  28. deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
  29. deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
  30. deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
  31. deepagents_code/client/__init__.py +1 -0
  32. deepagents_code/client/commands/__init__.py +1 -0
  33. deepagents_code/client/commands/auth.py +541 -0
  34. deepagents_code/client/commands/config.py +714 -0
  35. deepagents_code/client/commands/mcp.py +250 -0
  36. deepagents_code/client/commands/tools.py +416 -0
  37. deepagents_code/client/launch/__init__.py +1 -0
  38. deepagents_code/client/launch/server.py +978 -0
  39. deepagents_code/client/launch/server_manager.py +540 -0
  40. deepagents_code/client/non_interactive.py +1758 -0
  41. deepagents_code/client/remote_client.py +794 -0
  42. deepagents_code/clipboard.py +217 -0
  43. deepagents_code/command_registry.py +450 -0
  44. deepagents_code/config.py +4829 -0
  45. deepagents_code/config_manifest.py +1451 -0
  46. deepagents_code/configurable_model.py +577 -0
  47. deepagents_code/default_agent_prompt.md +12 -0
  48. deepagents_code/doctor.py +559 -0
  49. deepagents_code/editor.py +142 -0
  50. deepagents_code/event_bus.py +411 -0
  51. deepagents_code/extras_info.py +661 -0
  52. deepagents_code/file_ops.py +576 -0
  53. deepagents_code/formatting.py +135 -0
  54. deepagents_code/goal_rubric.py +497 -0
  55. deepagents_code/goal_tools.py +459 -0
  56. deepagents_code/hooks.py +348 -0
  57. deepagents_code/input.py +1041 -0
  58. deepagents_code/integrations/__init__.py +1 -0
  59. deepagents_code/integrations/openai_codex.py +551 -0
  60. deepagents_code/integrations/sandbox_config.py +198 -0
  61. deepagents_code/integrations/sandbox_factory.py +1124 -0
  62. deepagents_code/integrations/sandbox_provider.py +137 -0
  63. deepagents_code/integrations/sandbox_registry.py +350 -0
  64. deepagents_code/iterm_cursor_guide.py +176 -0
  65. deepagents_code/local_context.py +926 -0
  66. deepagents_code/main.py +3985 -0
  67. deepagents_code/managed_tools.py +642 -0
  68. deepagents_code/mcp_auth.py +1950 -0
  69. deepagents_code/mcp_config.py +176 -0
  70. deepagents_code/mcp_disabled.py +212 -0
  71. deepagents_code/mcp_login_service.py +281 -0
  72. deepagents_code/mcp_oauth_ui.py +199 -0
  73. deepagents_code/mcp_providers/__init__.py +23 -0
  74. deepagents_code/mcp_providers/_registry.py +39 -0
  75. deepagents_code/mcp_providers/base.py +133 -0
  76. deepagents_code/mcp_providers/github.py +102 -0
  77. deepagents_code/mcp_providers/slack.py +175 -0
  78. deepagents_code/mcp_tools.py +2427 -0
  79. deepagents_code/mcp_trust.py +207 -0
  80. deepagents_code/media_utils.py +826 -0
  81. deepagents_code/memory_guard.py +474 -0
  82. deepagents_code/model_config.py +4156 -0
  83. deepagents_code/notifications.py +247 -0
  84. deepagents_code/offload.py +402 -0
  85. deepagents_code/onboarding.py +223 -0
  86. deepagents_code/output.py +69 -0
  87. deepagents_code/paste_collapse.py +103 -0
  88. deepagents_code/project_utils.py +231 -0
  89. deepagents_code/py.typed +0 -0
  90. deepagents_code/reasoning_effort.py +641 -0
  91. deepagents_code/reliable_rubric.py +97 -0
  92. deepagents_code/resume_state.py +237 -0
  93. deepagents_code/server_graph.py +310 -0
  94. deepagents_code/session_end_summary.py +329 -0
  95. deepagents_code/sessions.py +1716 -0
  96. deepagents_code/skills/__init__.py +18 -0
  97. deepagents_code/skills/commands.py +1252 -0
  98. deepagents_code/skills/invocation.py +112 -0
  99. deepagents_code/skills/load.py +196 -0
  100. deepagents_code/skills/trust.py +547 -0
  101. deepagents_code/state_migration.py +136 -0
  102. deepagents_code/subagents.py +278 -0
  103. deepagents_code/system_prompt.md +204 -0
  104. deepagents_code/terminal_capabilities.py +115 -0
  105. deepagents_code/terminal_escape.py +287 -0
  106. deepagents_code/theme.py +891 -0
  107. deepagents_code/todo_list_prompt.md +12 -0
  108. deepagents_code/tool_catalog.py +509 -0
  109. deepagents_code/tool_display.py +367 -0
  110. deepagents_code/tools.py +516 -0
  111. deepagents_code/tui/__init__.py +1 -0
  112. deepagents_code/tui/textual_adapter.py +2553 -0
  113. deepagents_code/tui/widgets/__init__.py +9 -0
  114. deepagents_code/tui/widgets/_js_eval_display.py +139 -0
  115. deepagents_code/tui/widgets/_links.py +261 -0
  116. deepagents_code/tui/widgets/agent_selector.py +420 -0
  117. deepagents_code/tui/widgets/approval.py +602 -0
  118. deepagents_code/tui/widgets/ask_user.py +515 -0
  119. deepagents_code/tui/widgets/auth.py +1997 -0
  120. deepagents_code/tui/widgets/autocomplete.py +890 -0
  121. deepagents_code/tui/widgets/chat_input.py +3181 -0
  122. deepagents_code/tui/widgets/codex_auth.py +452 -0
  123. deepagents_code/tui/widgets/cwd_switch.py +242 -0
  124. deepagents_code/tui/widgets/debug_console.py +868 -0
  125. deepagents_code/tui/widgets/diff.py +252 -0
  126. deepagents_code/tui/widgets/effort_selector.py +189 -0
  127. deepagents_code/tui/widgets/goal_review.py +390 -0
  128. deepagents_code/tui/widgets/goal_status.py +50 -0
  129. deepagents_code/tui/widgets/history.py +194 -0
  130. deepagents_code/tui/widgets/install_confirm.py +248 -0
  131. deepagents_code/tui/widgets/launch_init.py +482 -0
  132. deepagents_code/tui/widgets/loading.py +227 -0
  133. deepagents_code/tui/widgets/mcp_login.py +539 -0
  134. deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
  135. deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
  136. deepagents_code/tui/widgets/message_store.py +999 -0
  137. deepagents_code/tui/widgets/messages.py +3846 -0
  138. deepagents_code/tui/widgets/model_selector.py +2343 -0
  139. deepagents_code/tui/widgets/notification_center.py +456 -0
  140. deepagents_code/tui/widgets/notification_detail.py +257 -0
  141. deepagents_code/tui/widgets/notification_settings.py +170 -0
  142. deepagents_code/tui/widgets/restart_prompt.py +158 -0
  143. deepagents_code/tui/widgets/skill_trust.py +131 -0
  144. deepagents_code/tui/widgets/startup_tip.py +91 -0
  145. deepagents_code/tui/widgets/status.py +781 -0
  146. deepagents_code/tui/widgets/subagent_panel.py +969 -0
  147. deepagents_code/tui/widgets/theme_selector.py +401 -0
  148. deepagents_code/tui/widgets/thread_selector.py +2564 -0
  149. deepagents_code/tui/widgets/tool_renderers.py +190 -0
  150. deepagents_code/tui/widgets/tool_widgets.py +304 -0
  151. deepagents_code/tui/widgets/update_available.py +311 -0
  152. deepagents_code/tui/widgets/update_confirm.py +184 -0
  153. deepagents_code/tui/widgets/update_progress.py +327 -0
  154. deepagents_code/tui/widgets/welcome.py +508 -0
  155. deepagents_code/turn_end_summary.py +522 -0
  156. deepagents_code/ui.py +858 -0
  157. deepagents_code/unicode_security.py +563 -0
  158. deepagents_code/update_check.py +3124 -0
  159. zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
  160. zjcode-0.0.1.dist-info/METADATA +220 -0
  161. zjcode-0.0.1.dist-info/RECORD +163 -0
  162. zjcode-0.0.1.dist-info/WHEEL +4 -0
  163. zjcode-0.0.1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,2553 @@
1
+ """Textual UI adapter for agent execution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import logging
8
+ import sys
9
+ import time
10
+ import uuid
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+ import httpx
14
+
15
+ from deepagents_code.formatting import format_duration
16
+
17
+ if TYPE_CHECKING:
18
+ from collections.abc import Awaitable, Callable
19
+ from pathlib import Path
20
+ from typing import Protocol
21
+
22
+ from langchain.agents.middleware.human_in_the_loop import (
23
+ ApproveDecision,
24
+ EditDecision,
25
+ HITLRequest,
26
+ RejectDecision,
27
+ )
28
+ from langchain_core.messages import AIMessage
29
+ from langchain_core.runnables import RunnableConfig
30
+ from langgraph.types import Command, Interrupt
31
+ from pydantic import TypeAdapter
32
+
33
+ from deepagents_code._ask_user_types import AskUserWidgetResult, Question
34
+
35
+ # Type alias matching HITLResponse["decisions"] element type
36
+ HITLDecision = ApproveDecision | EditDecision | RejectDecision
37
+
38
+ class _TokensUpdateCallback(Protocol):
39
+ """Callback signature for `_on_tokens_update`."""
40
+
41
+ def __call__(self, count: int, *, approximate: bool = False) -> None: ...
42
+
43
+ class _TokensShowCallback(Protocol):
44
+ """Callback signature for `_on_tokens_show`."""
45
+
46
+ def __call__(self, *, approximate: bool = False) -> None: ...
47
+
48
+
49
+ from deepagents_code._ask_user_types import AskUserRequest
50
+ from deepagents_code._cli_context import CLIContext
51
+ from deepagents_code._constants import SYSTEM_MESSAGE_PREFIX
52
+ from deepagents_code._session_stats import (
53
+ ModelStats as ModelStats,
54
+ ModelStatsKey as ModelStatsKey,
55
+ SessionStats as SessionStats,
56
+ SpinnerStatus as SpinnerStatus,
57
+ format_token_count as format_token_count,
58
+ print_usage_table as print_usage_table,
59
+ )
60
+ from deepagents_code._tool_stream import (
61
+ UNRENDERABLE_TOOL_OUTPUT,
62
+ ToolCallBuffer,
63
+ ToolCallBufferKey,
64
+ ToolStatus,
65
+ build_tool_error_payload,
66
+ build_tool_result_payload,
67
+ build_tool_use_payload,
68
+ count_unemitted_tool_calls,
69
+ normalize_tool_status,
70
+ tool_call_buffer_key,
71
+ )
72
+ from deepagents_code.config import build_stream_config, get_glyphs
73
+ from deepagents_code.file_ops import FileOpTracker
74
+ from deepagents_code.hooks import (
75
+ dispatch_hook,
76
+ dispatch_hook_fire_and_forget,
77
+ )
78
+ from deepagents_code.input import MediaTracker, parse_file_mentions
79
+ from deepagents_code.media_utils import create_multimodal_content
80
+ from deepagents_code.tool_display import format_tool_message_content
81
+ from deepagents_code.tui.widgets.messages import (
82
+ AppMessage,
83
+ AssistantMessage,
84
+ DiffMessage,
85
+ RubricResultMessage,
86
+ SummarizationMessage,
87
+ ToolCallMessage,
88
+ )
89
+
90
+ logger = logging.getLogger(__name__)
91
+
92
+ _hitl_adapter_cache: TypeAdapter | None = None
93
+ """Lazy singleton for the HITL request validator."""
94
+
95
+ _ASK_USER_UNSUPPORTED_ERROR = "ask_user not supported by this UI"
96
+
97
+
98
+ def _dispatch_tool_use_hook(
99
+ tool_name: str, tool_id: str, tool_args: dict[str, Any]
100
+ ) -> None:
101
+ """Dispatch a `tool.use` hook with the payload documented in `hooks`."""
102
+ dispatch_hook_fire_and_forget(
103
+ "tool.use", build_tool_use_payload(tool_name, tool_id, tool_args)
104
+ )
105
+
106
+
107
+ def _dispatch_tool_error_hook(tool_name: str) -> None:
108
+ """Dispatch a `tool.error` hook with the payload documented in `hooks`."""
109
+ dispatch_hook_fire_and_forget("tool.error", build_tool_error_payload(tool_name))
110
+
111
+
112
+ def _dispatch_tool_result_hook(
113
+ tool_name: str,
114
+ tool_id: str | None,
115
+ tool_args: dict[str, Any],
116
+ tool_status: ToolStatus,
117
+ tool_output: str,
118
+ ) -> None:
119
+ """Dispatch a `tool.result` hook with the payload documented in `hooks`.
120
+
121
+ `tool_output` is truncated to `HOOK_TOOL_OUTPUT_LIMIT` inside the shared
122
+ payload builder.
123
+ """
124
+ dispatch_hook_fire_and_forget(
125
+ "tool.result",
126
+ build_tool_result_payload(
127
+ tool_name, tool_id, tool_args, tool_status, tool_output
128
+ ),
129
+ )
130
+
131
+
132
+ def _dispatch_terminal_tool_result_hooks(
133
+ tool_messages: dict[str, ToolCallMessage],
134
+ tool_output: str,
135
+ ) -> list[str]:
136
+ """Emit terminal `tool.error`/`tool.result` for still-pending tool widgets.
137
+
138
+ Every widget in `tool_messages` already had its `tool.use` dispatched (that
139
+ is when the widget is mounted), so any tool that reaches a terminal outcome
140
+ *without* a streamed `ToolMessage` — a HITL rejection, a cancelled turn, or
141
+ an aborted stream — would otherwise leave its `tool.use` unterminated. This
142
+ closes each one with a `tool_status="error"` result carrying the widget's
143
+ real `tool_name`/`args`, so the "every `tool.use` is closed by a matching
144
+ terminal event" guarantee holds on those paths too.
145
+
146
+ TUI-only: the headless surface reaches the equivalent state through
147
+ `_run_agent_loop`'s orphan drain rather than widgets.
148
+
149
+ Args:
150
+ tool_messages: Map of tool-call id to its widget for the pending tools.
151
+ tool_output: Terminal output string recorded on each `tool.result`.
152
+
153
+ Returns:
154
+ The tool-call ids that received terminal hooks. Callers track these
155
+ (via `completed_tool_result_ids`) so a later synthetic `ToolMessage`
156
+ — when the turn still resumes, e.g. alongside an answered `ask_user`
157
+ — does not double-dispatch.
158
+ """
159
+ dispatched: list[str] = []
160
+ for tool_id, tool_msg in list(tool_messages.items()):
161
+ _dispatch_tool_error_hook(tool_msg.tool_name)
162
+ _dispatch_tool_result_hook(
163
+ tool_msg.tool_name,
164
+ tool_id,
165
+ tool_msg.args,
166
+ "error",
167
+ tool_output,
168
+ )
169
+ dispatched.append(tool_id)
170
+ return dispatched
171
+
172
+
173
+ def _get_hitl_request_adapter(hitl_request_type: type) -> TypeAdapter:
174
+ """Return a cached `TypeAdapter(HITLRequest)`.
175
+
176
+ Avoids re-compiling the pydantic schema on every `execute_task_textual` call.
177
+
178
+ Args:
179
+ hitl_request_type: The `HITLRequest` class (passed in because
180
+ it is imported locally by the caller).
181
+
182
+ Returns:
183
+ Shared `TypeAdapter` instance.
184
+ """
185
+ global _hitl_adapter_cache # noqa: PLW0603
186
+ if _hitl_adapter_cache is None:
187
+ from pydantic import TypeAdapter
188
+
189
+ _hitl_adapter_cache = TypeAdapter(hitl_request_type)
190
+ return _hitl_adapter_cache
191
+
192
+
193
+ _ask_user_adapter_cache: TypeAdapter | None = None
194
+ """Lazy singleton for the `ask_user` interrupt validator."""
195
+
196
+
197
+ def _get_ask_user_adapter() -> TypeAdapter:
198
+ """Return a cached `TypeAdapter(AskUserRequest)`.
199
+
200
+ Returns:
201
+ Shared `TypeAdapter` instance.
202
+ """
203
+ global _ask_user_adapter_cache # noqa: PLW0603
204
+ if _ask_user_adapter_cache is None:
205
+ from pydantic import TypeAdapter
206
+
207
+ _ask_user_adapter_cache = TypeAdapter(AskUserRequest)
208
+ return _ask_user_adapter_cache
209
+
210
+
211
+ def _is_summarization_chunk(metadata: dict | None) -> bool:
212
+ """Check if a message chunk is from summarization middleware.
213
+
214
+ The summarization model is invoked with
215
+ `config={"metadata": {"lc_source": "summarization"}}`
216
+ (see `langchain.agents.middleware.summarization`), which
217
+ LangChain's callback system merges into the stream metadata dict.
218
+
219
+ Args:
220
+ metadata: The metadata dict from the stream chunk.
221
+
222
+ Returns:
223
+ Whether the chunk is from summarization and should be filtered.
224
+ """
225
+ if metadata is None:
226
+ return False
227
+ return metadata.get("lc_source") == "summarization"
228
+
229
+
230
+ def _format_model_call_info(
231
+ *,
232
+ usage: dict | None,
233
+ response_metadata: dict | None,
234
+ elapsed: float | None,
235
+ requested_model: str | None = None,
236
+ context_limit: int | None = None,
237
+ ) -> str | None:
238
+ """Build a compact one-line summary of a completed model call.
239
+
240
+ The line is rendered as an `AppMessage` (muted italic) right after the
241
+ assistant reply so the user can see model id, usage_metadata,
242
+ finish_reason, and turn latency inline. Returns `None` when there is
243
+ nothing to show - callers should skip mounting in that case.
244
+
245
+ Args:
246
+ usage: Latest `usage_metadata` observed for the call.
247
+ response_metadata: Latest `response_metadata` observed for the call.
248
+ elapsed: Wall time (seconds) from the first streamed chunk to the
249
+ terminal chunk. `None` when unavailable.
250
+ requested_model: The model name the user asked for (from LangGraph
251
+ stream metadata `ls_model_name`). Compared against the served
252
+ model name echoed by the provider to surface aliasing.
253
+ context_limit: Maximum context window (tokens) from the model
254
+ profile (`max_input_tokens`). Not returned by the API - it's
255
+ a declared capability value. When set alongside a non-zero
256
+ `input_tokens`, rendered as ``ctx=used/limit~ (pct%)`` with
257
+ a trailing ``~`` marking the limit as a profile estimate.
258
+
259
+ Returns:
260
+ Formatted single-line string, or `None` if no data is available.
261
+ """
262
+ parts: list[str] = []
263
+
264
+ served: str | None = None
265
+ if isinstance(response_metadata, dict):
266
+ served_raw = (
267
+ response_metadata.get("model_name")
268
+ or response_metadata.get("model")
269
+ or response_metadata.get("model_id")
270
+ )
271
+ if isinstance(served_raw, str) and served_raw:
272
+ served = served_raw
273
+
274
+ model_label = _render_model_label(requested_model, served)
275
+ if model_label:
276
+ parts.append(f"model={model_label}")
277
+
278
+ if isinstance(response_metadata, dict):
279
+ finish_reason = response_metadata.get("finish_reason") or response_metadata.get(
280
+ "stop_reason"
281
+ )
282
+ if isinstance(finish_reason, str) and finish_reason:
283
+ parts.append(f"finish={finish_reason}")
284
+
285
+ if isinstance(usage, dict) and usage:
286
+ input_toks = usage.get("input_tokens") or 0
287
+ output_toks = usage.get("output_tokens") or 0
288
+ total_toks = usage.get("total_tokens") or (input_toks + output_toks)
289
+ if input_toks or output_toks or total_toks:
290
+ parts.append(f"tokens in/out/total={input_toks}/{output_toks}/{total_toks}")
291
+ details = usage.get("input_token_details")
292
+ if isinstance(details, dict):
293
+ cached = details.get("cache_read") or details.get("cache_read_input_tokens")
294
+ if cached:
295
+ parts.append(f"cache_read={cached}")
296
+
297
+ # Context usage/limit. `input_tokens` is the ground-truth per-call
298
+ # value returned by the API; `context_limit` is a profile-declared
299
+ # capability (marked with a trailing `~`) since the API does not
300
+ # return the model's context window size.
301
+ ctx_used = usage.get("input_tokens") or 0 if isinstance(usage, dict) else 0
302
+ if isinstance(context_limit, int) and context_limit > 0:
303
+ pct = ctx_used * 100 // context_limit
304
+ parts.append(f"ctx={ctx_used}/{context_limit}~ ({pct}%)")
305
+ elif ctx_used:
306
+ parts.append(f"ctx={ctx_used}")
307
+
308
+ if isinstance(elapsed, (int, float)) and elapsed >= 0:
309
+ parts.append(f"elapsed={format_duration(elapsed)}")
310
+
311
+ if not parts:
312
+ return None
313
+ return "· " + " ".join(parts)
314
+
315
+
316
+ # Sentinel values that some gateways echo back in place of the real model
317
+ # name (e.g. Volcengine routing aliases return ``"auto"``). When we see
318
+ # one of these we treat the served model as unknown rather than as ground
319
+ # truth.
320
+ _SERVED_MODEL_SENTINELS = frozenset({"auto", "none", "null", ""})
321
+
322
+
323
+ def _render_model_label(requested: str | None, served: str | None) -> str | None:
324
+ """Render the model portion of the info line.
325
+
326
+ Pure data-driven display - no vendor-specific heuristics.
327
+
328
+ - Only one of the two is known: show it.
329
+ - Both known and equal: show one name.
330
+ - Both known, served is a sentinel (``"auto"``, ``"none"`` ...): show
331
+ just *requested* - the gateway hid the underlying model.
332
+ - Both known and different: show ``requested -> served`` so the user
333
+ can see the aliasing (e.g. ``my-alias -> actual-model-name``).
334
+
335
+ Args:
336
+ requested: Model name the caller asked for (from ``ls_model_name``).
337
+ served: Model name echoed back by the provider (from
338
+ ``response_metadata.model_name``).
339
+
340
+ Returns:
341
+ A short label suitable for the ``model=...`` field, or `None` if
342
+ neither input carried useful data.
343
+ """
344
+ req = requested.strip() if isinstance(requested, str) else ""
345
+ srv = served.strip() if isinstance(served, str) else ""
346
+
347
+ if not req and not srv:
348
+ return None
349
+ if not req:
350
+ return srv
351
+ if not srv or srv.lower() in _SERVED_MODEL_SENTINELS:
352
+ return req
353
+ if srv == req:
354
+ return req
355
+ return f"{req}->{srv}"
356
+
357
+
358
+ def _format_rubric_event(data: dict[str, Any]) -> str | None:
359
+ """Format a concise rubric custom-stream event for the transcript.
360
+
361
+ Args:
362
+ data: Custom-stream rubric event payload.
363
+
364
+ Returns:
365
+ A user-visible summary for rubric events, or `None` for custom-stream
366
+ events that are not rubric events.
367
+ """
368
+ glyphs = get_glyphs()
369
+ event_type = data.get("type")
370
+ if event_type == "rubric_evaluation_start":
371
+ iteration = data.get("iteration", 0)
372
+ show_iteration = data.get("show_iteration") is True
373
+ label = (
374
+ f" (iteration {iteration + 1})"
375
+ if show_iteration and isinstance(iteration, int)
376
+ else ""
377
+ )
378
+ return (
379
+ f"{glyphs.hourglass} Checking acceptance criteria{label}{glyphs.ellipsis}"
380
+ )
381
+ if event_type != "rubric_evaluation_end":
382
+ return None
383
+
384
+ result = data.get("result")
385
+ if result is None:
386
+ return None
387
+ if result == "satisfied":
388
+ return f"{glyphs.checkmark} Acceptance criteria satisfied"
389
+ if result == "needs_revision":
390
+ return f"{glyphs.retry} Acceptance criteria not yet satisfied"
391
+ if result == "max_iterations_reached":
392
+ return (
393
+ f"{glyphs.warning} Acceptance criteria not yet satisfied "
394
+ "(iteration limit reached)"
395
+ )
396
+ if result == "failed":
397
+ return f"{glyphs.warning} Rubric is invalid or cannot be evaluated"
398
+ if result == "grader_error":
399
+ return f"{glyphs.warning} Acceptance criteria check failed"
400
+ # A `rubric_evaluation_end` with an unrecognized result is still a terminal
401
+ # grading event; surface it rather than silently dropping it (e.g. if the
402
+ # SDK adds a new verdict the chat would otherwise go quiet mid-turn).
403
+ return f"{glyphs.warning} Acceptance criteria check ended"
404
+
405
+
406
+ def _format_rubric_details(data: dict[str, Any], *, goal_active: bool = False) -> str:
407
+ """Format complete grader details without serializing or truncating payloads.
408
+
409
+ Args:
410
+ data: Custom-stream rubric event payload.
411
+ goal_active: Whether the rubric belongs to an unfinished `/goal`.
412
+
413
+ Returns:
414
+ Plain text containing the full explanation, unmet criteria, and next step.
415
+ """
416
+ result = data.get("result")
417
+ if result in {None, "satisfied"}:
418
+ return ""
419
+
420
+ sections: list[str] = []
421
+ explanation = str(data.get("explanation") or "").strip()
422
+ if explanation:
423
+ sections.append(f"Explanation\n{explanation}")
424
+
425
+ criteria = data.get("criteria")
426
+ failing: list[tuple[str, str]] = []
427
+ if isinstance(criteria, list):
428
+ for criterion in criteria:
429
+ if isinstance(criterion, dict) and criterion.get("passed") is False:
430
+ name = str(criterion.get("name") or "Unnamed criterion").strip()
431
+ gap = str(criterion.get("gap") or "").strip()
432
+ failing.append((name, gap))
433
+ if failing:
434
+ lines = ["Unmet criteria"]
435
+ for name, gap in failing:
436
+ lines.append(f"- {name}" + (f"\n {gap}" if gap else ""))
437
+ sections.append("\n".join(lines))
438
+
439
+ if result == "max_iterations_reached" and goal_active:
440
+ next_step = (
441
+ "The goal remains active. Continue with another prompt to resume or "
442
+ "retry, use `/goal <objective>` to amend it, or `/goal clear` to clear it."
443
+ )
444
+ elif result in {"needs_revision", "max_iterations_reached"}:
445
+ next_step = "Address every unmet criterion, then retry the check."
446
+ elif result == "failed":
447
+ next_step = "Review or replace the rubric before grading again."
448
+ elif result == "grader_error":
449
+ next_step = "Retry the check, or choose a different grader model."
450
+ else:
451
+ next_step = "Review the grader details before continuing."
452
+ sections.append(f"Next step\n{next_step}")
453
+ return "\n\n".join(sections)
454
+
455
+
456
+ class TextualUIAdapter:
457
+ """Adapter for rendering agent output to Textual widgets.
458
+
459
+ This adapter provides an abstraction layer between the agent execution and the
460
+ Textual UI, allowing streaming output to be rendered as widgets.
461
+ """
462
+
463
+ def __init__(
464
+ self,
465
+ mount_message: Callable[..., Awaitable[None]],
466
+ update_status: Callable[[str], None],
467
+ request_approval: Callable[..., Awaitable[Any]],
468
+ on_auto_approve_enabled: Callable[[], Awaitable[None] | None] | None = None,
469
+ set_spinner: Callable[[SpinnerStatus], Awaitable[None]] | None = None,
470
+ set_active_message: Callable[[str | None], None] | None = None,
471
+ on_user_visible_output_started: Callable[[], None] | None = None,
472
+ sync_message_content: Callable[[str, str], None] | None = None,
473
+ sync_tool_message: Callable[[ToolCallMessage], None] | None = None,
474
+ request_ask_user: (
475
+ Callable[
476
+ [list[Question]],
477
+ Awaitable[asyncio.Future[AskUserWidgetResult] | None],
478
+ ]
479
+ | None
480
+ ) = None,
481
+ on_tool_complete: Callable[[], None] | None = None,
482
+ on_subagent_event: Callable[[dict[str, Any]], None] | None = None,
483
+ ) -> None:
484
+ """Initialize the adapter."""
485
+ self._mount_message = mount_message
486
+ """Async callback to mount a message widget to the chat."""
487
+
488
+ self._update_status = update_status
489
+ """Callback to update the status bar text."""
490
+
491
+ self._request_approval = request_approval
492
+ """Async callback that returns a Future for HITL approval."""
493
+
494
+ self._on_auto_approve_enabled = on_auto_approve_enabled
495
+ """Callback invoked when auto-approve is enabled via the HITL approval
496
+ menu.
497
+
498
+ Fired when the user selects "Auto-approve all" from an approval dialog,
499
+ allowing the app to sync its status bar and session state.
500
+ """
501
+
502
+ self._set_spinner = set_spinner
503
+ """Callback to show/hide loading spinner."""
504
+
505
+ self._set_active_message = set_active_message
506
+ """Callback to set the active streaming message ID (pass `None` to clear)."""
507
+
508
+ self._on_user_visible_output_started = on_user_visible_output_started
509
+ """Callback fired after the first model text or tool-call widget renders.
510
+
511
+ Hidden model and subagent output does not trigger it. A turn interrupted
512
+ before any user-visible model output produces zero firings.
513
+ """
514
+
515
+ self._sync_message_content = sync_message_content
516
+ """Callback to sync final message content back to the store after streaming."""
517
+
518
+ self._sync_tool_message = sync_tool_message
519
+ """Callback to sync a tool widget's mutable state back to the store."""
520
+
521
+ self._request_ask_user = request_ask_user
522
+ """Async callback for `ask_user` interrupts.
523
+
524
+ When awaited, returns a `Future` that resolves to user answers.
525
+ """
526
+
527
+ self._on_tool_complete = on_tool_complete
528
+ """Sync callback fired after each `ToolMessage` is processed.
529
+
530
+ The app uses this to refresh the footer's git branch as soon as an
531
+ agent-executed tool (e.g. `git checkout`) returns, instead of waiting
532
+ for the full turn to finish.
533
+ """
534
+
535
+ self._on_subagent_event = on_subagent_event
536
+ """Sync callback fired for each validated `subagent` custom-stream event.
537
+
538
+ Drives the live subagent fan-out panel. Events originate from the
539
+ QuickJS `task()` bridge during a `js_eval` call; payload strings are
540
+ LLM/JS-authored and treated as untrusted by the panel renderer.
541
+ """
542
+
543
+ # State tracking
544
+ self._current_tool_messages: dict[str, ToolCallMessage] = {}
545
+ """Map of tool call IDs to their message widgets."""
546
+
547
+ # Token display callbacks (set by the app after construction)
548
+ self._on_tokens_update: _TokensUpdateCallback | None = None
549
+ """Called with total context tokens after each LLM response."""
550
+
551
+ self._on_tokens_pending: Callable[[], None] | None = None
552
+ """Called to show an unknown token count during streaming."""
553
+
554
+ self._on_tokens_show: _TokensShowCallback | None = None
555
+ """Called to restore the token display with the cached value."""
556
+
557
+ def _sync_tool_widget(self, tool_msg: ToolCallMessage) -> None:
558
+ """Sync a tool widget when the app provided a store callback.
559
+
560
+ Total by contract: never raises. Call sites are scattered across the
561
+ turn loop, some outside try/except, so a sync failure must not abort
562
+ the turn — it is logged and swallowed here.
563
+ """
564
+ if self._sync_tool_message is None:
565
+ return
566
+ try:
567
+ self._sync_tool_message(tool_msg)
568
+ except Exception:
569
+ logger.exception("Failed to sync tool widget state to store")
570
+
571
+ def finalize_pending_tools_with_error(self, error: str) -> None:
572
+ """Mark all pending/running tool widgets as error and clear tracking.
573
+
574
+ This is used as a safety net when an unexpected exception aborts
575
+ streaming before matching `ToolMessage` results are received.
576
+
577
+ Args:
578
+ error: Error text to display in each pending tool widget.
579
+ """
580
+ # Each pending widget already had its `tool.use` dispatched at mount, so
581
+ # emit terminal hooks before dropping them — otherwise an aborted stream
582
+ # leaves those `tool.use` events unterminated for audit consumers. Runs
583
+ # before the widget updates so a `set_error` failure can't skip it.
584
+ _dispatch_terminal_tool_result_hooks(self._current_tool_messages, error)
585
+ for tool_msg in list(self._current_tool_messages.values()):
586
+ tool_msg.set_error(error)
587
+ self._sync_tool_widget(tool_msg)
588
+ self._current_tool_messages.clear()
589
+
590
+ # Clear active streaming message to avoid stale "active" state in the store.
591
+ if self._set_active_message:
592
+ self._set_active_message(None)
593
+
594
+
595
+ def _build_interrupted_ai_message(
596
+ pending_text_by_namespace: dict[tuple, str],
597
+ current_tool_messages: dict[str, Any],
598
+ ) -> AIMessage | None:
599
+ """Build an AIMessage capturing interrupted state (text + tool calls).
600
+
601
+ Args:
602
+ pending_text_by_namespace: Dict of accumulated text by namespace
603
+ current_tool_messages: Dict of tool_id -> ToolCallMessage widget
604
+
605
+ Returns:
606
+ AIMessage with accumulated content and tool calls, or None if empty.
607
+ """
608
+ from langchain_core.messages import AIMessage
609
+
610
+ main_ns_key = ()
611
+ accumulated_text = pending_text_by_namespace.get(main_ns_key, "").strip()
612
+
613
+ # Reconstruct tool_calls from displayed tool messages
614
+ tool_calls = []
615
+ for tool_id, tool_widget in list(current_tool_messages.items()):
616
+ tool_calls.append(
617
+ {
618
+ "id": tool_id,
619
+ "name": tool_widget._tool_name,
620
+ "args": tool_widget._args,
621
+ }
622
+ )
623
+
624
+ if not accumulated_text and not tool_calls:
625
+ return None
626
+
627
+ return AIMessage(
628
+ content=accumulated_text,
629
+ tool_calls=tool_calls or [],
630
+ )
631
+
632
+
633
+ def _read_mentioned_file(file_path: Path, max_embed_bytes: int) -> str:
634
+ """Read a mentioned file for inline embedding (sync, for use with to_thread).
635
+
636
+ Args:
637
+ file_path: Resolved path to the file.
638
+ max_embed_bytes: Size threshold; larger files get a reference only.
639
+
640
+ Returns:
641
+ Markdown snippet with the file content or a size-exceeded reference.
642
+ """
643
+ file_size = file_path.stat().st_size
644
+ if file_size > max_embed_bytes:
645
+ size_kb = file_size // 1024
646
+ return (
647
+ f"\n### {file_path.name}\n"
648
+ f"Path: `{file_path}`\n"
649
+ f"Size: {size_kb}KB (too large to embed, "
650
+ "use read_file tool to view)"
651
+ )
652
+ content = file_path.read_text(encoding="utf-8")
653
+ return f"\n### {file_path.name}\nPath: `{file_path}`\n```text\n{content}\n```"
654
+
655
+
656
+ def _is_renderable_subagent_event(data: Any, *, is_main_agent: bool) -> bool: # noqa: ANN401 # custom-stream payload is dynamic
657
+ """Whether a `custom` payload is a subagent event this UI can render.
658
+
659
+ Guards the live panel against unrelated/malformed custom events and against
660
+ nested (subagent-to-subagent) emissions.
661
+
662
+ Args:
663
+ data: The `custom` stream payload.
664
+ is_main_agent: Whether the event came from the main agent's namespace
665
+ (the empty namespace). Nested emissions are ignored.
666
+
667
+ Returns:
668
+ True only for a well-formed subagent event from the main agent.
669
+ """
670
+ return is_main_agent and isinstance(data, dict) and data.get("type") == "subagent"
671
+
672
+
673
+ async def execute_task_textual(
674
+ user_input: str,
675
+ agent: Any, # noqa: ANN401 # Dynamic agent graph type
676
+ assistant_id: str | None,
677
+ session_state: Any, # noqa: ANN401 # Dynamic session state type
678
+ adapter: TextualUIAdapter,
679
+ backend: Any = None, # noqa: ANN401 # Dynamic backend type
680
+ image_tracker: MediaTracker | None = None,
681
+ context: CLIContext | None = None,
682
+ *,
683
+ sandbox_type: str | None = None,
684
+ message_kwargs: dict[str, Any] | None = None,
685
+ rubric: str | None = None,
686
+ goal_active: bool = False,
687
+ blocked_goal_retry_context: str | None = None,
688
+ turn_stats: SessionStats | None = None,
689
+ ) -> SessionStats:
690
+ """Execute a task with output directed to Textual UI.
691
+
692
+ This is the Textual-compatible version of execute_task() that uses
693
+ the TextualUIAdapter for all UI operations.
694
+
695
+ Args:
696
+ user_input: The user's input message
697
+ agent: The LangGraph agent to execute
698
+ assistant_id: The agent identifier
699
+ session_state: Session state with auto_approve flag
700
+ adapter: The TextualUIAdapter for UI operations
701
+ backend: Optional backend for file operations
702
+ image_tracker: Optional tracker for images
703
+ context: Optional `CLIContext` with model override and params. The
704
+ current approval mode (`session_state.auto_approve`) is written
705
+ into `context["auto_approve"]` on every stream iteration before it
706
+ is passed to the graph via `context=`, so the `interrupt_on` `when`
707
+ predicate can suppress interrupts at the source.
708
+ sandbox_type: Sandbox provider name for trace metadata, or `None`
709
+ if no sandbox is active.
710
+ message_kwargs: Extra fields merged into the stream input message
711
+ dict (e.g., `additional_kwargs` for persisting skill metadata
712
+ in the checkpoint).
713
+ rubric: Acceptance criteria supplied to `RubricMiddleware` via graph
714
+ input state.
715
+ goal_active: Whether the rubric belongs to an unfinished `/goal`.
716
+ blocked_goal_retry_context: One-turn model context for retrying a
717
+ previously blocked goal. This is carried via runtime context so it
718
+ is not parsed for file mentions or checkpointed as human input.
719
+ turn_stats: Pre-created `SessionStats` to accumulate into.
720
+
721
+ When the caller holds a reference to the same object, stats are
722
+ available even if this coroutine is cancelled before it can return.
723
+
724
+ If `None`, a new instance is created internally.
725
+
726
+ Returns:
727
+ Stats accumulated over this turn (request count, token counts,
728
+ wall-clock time).
729
+
730
+ Raises:
731
+ ValidationError: If HITL request validation fails (re-raised).
732
+ """
733
+ from langchain.agents.middleware.human_in_the_loop import (
734
+ ApproveDecision,
735
+ HITLRequest,
736
+ RejectDecision,
737
+ )
738
+ from langchain_core.messages import HumanMessage, ToolMessage
739
+ from langgraph.types import Command
740
+ from pydantic import ValidationError
741
+
742
+ from deepagents_code.approval_mode import awrite_approval_mode
743
+
744
+ hitl_request_adapter = _get_hitl_request_adapter(HITLRequest)
745
+ ask_user_adapter = _get_ask_user_adapter()
746
+
747
+ # Parse file mentions and inject content if any — offload blocking I/O
748
+ prompt_text, mentioned_files = await asyncio.to_thread(
749
+ parse_file_mentions, user_input
750
+ )
751
+
752
+ # Max file size to embed inline (256KB, matching mistral-vibe)
753
+ # Larger files get a reference instead - use read_file tool to view them
754
+ max_embed_bytes = 256 * 1024
755
+
756
+ if mentioned_files:
757
+ context_parts = [prompt_text, "\n\n## Referenced Files\n"]
758
+ for file_path in mentioned_files:
759
+ try:
760
+ part = await asyncio.to_thread(
761
+ _read_mentioned_file, file_path, max_embed_bytes
762
+ )
763
+ context_parts.append(part)
764
+ except Exception as e: # noqa: BLE001 # Resilient adapter error handling
765
+ context_parts.append(
766
+ f"\n### {file_path.name}\n[Error reading file: {e}]"
767
+ )
768
+ final_input = "\n".join(context_parts)
769
+ else:
770
+ final_input = prompt_text
771
+
772
+ # Include images and videos in the message content
773
+ images_to_send = []
774
+ videos_to_send = []
775
+ if image_tracker:
776
+ images_to_send = image_tracker.get_images()
777
+ videos_to_send = image_tracker.get_videos()
778
+ if images_to_send or videos_to_send:
779
+ message_content = create_multimodal_content(
780
+ final_input, images_to_send, videos_to_send
781
+ )
782
+ else:
783
+ message_content = final_input
784
+
785
+ thread_id = session_state.thread_id
786
+ # Advance the per-thread turn markers (coding-agent-v1 turn_id/turn_number)
787
+ # once per user prompt, before building the stream config. `session_state`
788
+ # is duck-typed (`Any`): the production `TextualSessionState` always has
789
+ # `advance_turn`, but lightweight callers/test doubles may not, so probe for
790
+ # it and degrade to no turn markers rather than raising.
791
+ advance_turn = getattr(session_state, "advance_turn", None)
792
+ if callable(advance_turn):
793
+ turn_id, turn_number = advance_turn()
794
+ else:
795
+ turn_id, turn_number = None, None
796
+ # `build_stream_config` does blocking git filesystem reads and may shell out
797
+ # to `git`; offload it so the Textual event loop stays responsive. Advancing
798
+ # the turn markers above is pure/cheap and stays on the loop.
799
+ config = await asyncio.to_thread(
800
+ build_stream_config,
801
+ thread_id,
802
+ assistant_id,
803
+ sandbox_type=sandbox_type,
804
+ turn_id=turn_id,
805
+ turn_number=turn_number,
806
+ )
807
+
808
+ await dispatch_hook("session.start", {"thread_id": thread_id})
809
+
810
+ captured_input_tokens = 0
811
+ captured_output_tokens = 0
812
+ if turn_stats is None:
813
+ turn_stats = SessionStats()
814
+ start_time = time.monotonic()
815
+
816
+ # Begin per-turn end-status tracking. Fires for every prompt; guaranteed
817
+ # marker emit runs from the finally block below regardless of exit path.
818
+ from deepagents_code import turn_end_summary
819
+
820
+ turn_end_summary.mark_turn_start(
821
+ thread_id=thread_id or "",
822
+ turn_id=turn_id or "",
823
+ turn_number=turn_number,
824
+ )
825
+
826
+ # Warn if token display callbacks are only partially wired — all three
827
+ # should be set together to avoid inconsistent status-bar behavior.
828
+ token_cbs = (
829
+ adapter._on_tokens_update,
830
+ adapter._on_tokens_pending,
831
+ adapter._on_tokens_show,
832
+ )
833
+ if any(token_cbs) and not all(token_cbs):
834
+ logger.warning(
835
+ "Token callbacks partially wired (update=%s, pending=%s, show=%s); "
836
+ "token display may behave inconsistently",
837
+ adapter._on_tokens_update is not None,
838
+ adapter._on_tokens_pending is not None,
839
+ adapter._on_tokens_show is not None,
840
+ )
841
+
842
+ # Show unknown token count during streaming; the accurate count arrives at turn end.
843
+ if adapter._on_tokens_pending:
844
+ adapter._on_tokens_pending()
845
+
846
+ file_op_tracker = FileOpTracker(assistant_id=assistant_id, backend=backend)
847
+ # Fires at most once per turn, after the first main-agent text or tool-call
848
+ # widget becomes visible, so hidden model activity cannot block prompt restore.
849
+ user_visible_output_started = False
850
+
851
+ def _notify_user_visible_output_started() -> None:
852
+ """Fire the output-started callback once, on the first visible output.
853
+
854
+ Call only from main-agent, post-filter paths: the "hidden output does
855
+ not count" guarantee lives in the placement of the call sites (all sit
856
+ after the subagent and summarization `continue`s), not in any check
857
+ here — this helper only dedupes.
858
+ """
859
+ nonlocal user_visible_output_started
860
+ if user_visible_output_started:
861
+ return
862
+ user_visible_output_started = True
863
+ if adapter._on_user_visible_output_started:
864
+ try:
865
+ adapter._on_user_visible_output_started()
866
+ except Exception:
867
+ # A prompt-restore gate update must never abort agent
868
+ # streaming — log and keep going (mirrors `_on_tool_complete`).
869
+ logger.warning(
870
+ "on_user_visible_output_started callback failed",
871
+ exc_info=True,
872
+ )
873
+
874
+ displayed_tool_ids: set[str] = set()
875
+ tool_call_buffers: dict[ToolCallBufferKey, ToolCallBuffer] = {}
876
+ # Tool-call ids that already received terminal hooks before a resumed
877
+ # `ToolMessage` can stream. When the turn still resumes, middleware
878
+ # synthetic messages would otherwise re-dispatch `tool.result`; this set
879
+ # suppresses those duplicates.
880
+ completed_tool_result_ids: set[str] = set()
881
+
882
+ # Track pending text and assistant messages PER NAMESPACE to avoid interleaving
883
+ # when multiple subagents stream in parallel
884
+ pending_text_by_namespace: dict[tuple, str] = {}
885
+ assistant_message_by_namespace: dict[tuple, Any] = {}
886
+
887
+ # Track per-model-call metadata (start time, latest usage_metadata,
888
+ # latest response_metadata) so we can print a short info line at the end
889
+ # of each model call (chunk_position == "last"). Keyed by namespace so
890
+ # main agent and any subagent runs stay independent.
891
+ call_start_by_namespace: dict[tuple, float] = {}
892
+ call_usage_by_namespace: dict[tuple, dict] = {}
893
+ call_metadata_by_namespace: dict[tuple, dict] = {}
894
+ # Per-namespace requested model alias (`ls_model_name` from LangGraph
895
+ # stream metadata). Used by `_format_model_call_info` to distinguish
896
+ # routing aliases (gateway hides real model) from compat aliases
897
+ # (gateway rewrites to real model).
898
+ call_requested_model_by_namespace: dict[tuple, str] = {}
899
+
900
+ # Clear media from tracker after creating the message
901
+ if image_tracker:
902
+ image_tracker.clear()
903
+
904
+ user_msg: dict[str, Any] = {"role": "user", "content": message_content}
905
+ if message_kwargs:
906
+ user_msg.update(message_kwargs)
907
+ # Auto-approve is carried via run context (set per stream iteration below),
908
+ # not graph state — so the initial input is a plain dict. A first-turn
909
+ # `Command(update=...)` would be rebuilt with `goto=None` by the LangGraph
910
+ # API server and crash `_control_branch` on a fresh thread.
911
+ stream_input: dict | Command = {"messages": [user_msg]}
912
+ if rubric:
913
+ stream_input["rubric"] = rubric
914
+
915
+ # Track summarization lifecycle so spinner status and notification stay in sync.
916
+ summarization_in_progress = False
917
+
918
+ try:
919
+ while True:
920
+ interrupt_occurred = False
921
+ suppress_resumed_output = False
922
+ pending_interrupts: dict[str, HITLRequest] = {}
923
+ pending_ask_user: dict[str, AskUserRequest] = {}
924
+
925
+ # Carry the current approval mode into run context so the
926
+ # `interrupt_on` `when` predicate can suppress interrupts at the
927
+ # source. Also write the live store item that the server-side
928
+ # predicate re-reads on each tool call, so toggling approval mode
929
+ # mid-stream (either direction) takes effect before the current
930
+ # stream returns. Turning auto-approve off is the safety-critical
931
+ # direction, but the same store write also propagates turning it on.
932
+ if context is None:
933
+ context = CLIContext()
934
+ context["thread_id"] = thread_id
935
+ if blocked_goal_retry_context is not None:
936
+ context["blocked_goal_retry_context"] = blocked_goal_retry_context
937
+ else:
938
+ context.pop("blocked_goal_retry_context", None)
939
+ auto_approve = bool(session_state.auto_approve)
940
+ context["auto_approve"] = auto_approve
941
+ try:
942
+ live_key = await awrite_approval_mode(
943
+ agent,
944
+ thread_id,
945
+ auto_approve=auto_approve,
946
+ )
947
+ except Exception:
948
+ logger.warning(
949
+ "Failed to write live approval mode; interrupting for safety",
950
+ exc_info=True,
951
+ )
952
+ context["auto_approve"] = False
953
+ context.pop("approval_mode_key", None)
954
+ session_state.approval_mode_key = None
955
+ else:
956
+ if live_key is None:
957
+ context.pop("approval_mode_key", None)
958
+ else:
959
+ context["approval_mode_key"] = live_key
960
+ session_state.approval_mode_key = live_key
961
+
962
+ # Show the Thinking spinner before each astream iteration so
963
+ # both the first turn and HITL/ask_user resumes surface feedback
964
+ # while the model processes input. Skip when
965
+ # `_current_tool_messages` is non-empty so running-tool
966
+ # indicators remain the dominant signal.
967
+ if adapter._set_spinner and not adapter._current_tool_messages:
968
+ await adapter._set_spinner("Thinking")
969
+
970
+ async for chunk in agent.astream(
971
+ stream_input,
972
+ stream_mode=["messages", "updates", "custom"],
973
+ subgraphs=True,
974
+ config=config,
975
+ context=context,
976
+ durability="exit",
977
+ ):
978
+ if not isinstance(chunk, tuple) or len(chunk) != 3: # noqa: PLR2004 # stream chunk is a 3-tuple (namespace, mode, data)
979
+ logger.debug("Skipping non-3-tuple chunk: %s", type(chunk).__name__)
980
+ continue
981
+
982
+ namespace, current_stream_mode, data = chunk
983
+
984
+ # Convert namespace to hashable tuple for dict keys
985
+ ns_key = tuple(namespace) if namespace else ()
986
+
987
+ # Filter out subagent outputs - only show main agent (empty
988
+ # namespace). Subagents run via Task tool and should only
989
+ # report back to the main agent
990
+ is_main_agent = ns_key == ()
991
+
992
+ # Handle CUSTOM stream - live subagent fan-out events emitted by
993
+ # the QuickJS task() bridge during a js_eval call. Validate at
994
+ # this boundary before forwarding so unrelated/malformed or
995
+ # nested custom events never reach the panel; forwarding must
996
+ # never raise into the stream loop.
997
+ if current_stream_mode == "custom":
998
+ rubric_message = data if isinstance(data, dict) else None
999
+ formatted_rubric_event = (
1000
+ _format_rubric_event(rubric_message) if rubric_message else None
1001
+ )
1002
+ if (
1003
+ formatted_rubric_event is not None
1004
+ and rubric_message is not None
1005
+ and is_main_agent
1006
+ ):
1007
+ details = (
1008
+ _format_rubric_details(
1009
+ rubric_message,
1010
+ goal_active=goal_active,
1011
+ )
1012
+ if rubric_message.get("type") == "rubric_evaluation_end"
1013
+ else ""
1014
+ )
1015
+ message = (
1016
+ RubricResultMessage(formatted_rubric_event, details)
1017
+ if details
1018
+ else AppMessage(formatted_rubric_event)
1019
+ )
1020
+ await adapter._mount_message(message)
1021
+ continue
1022
+ if formatted_rubric_event is not None:
1023
+ # Rubric events come from the main agent today; a
1024
+ # non-main namespace would be dropped by the gate above,
1025
+ # so leave a breadcrumb if that ever changes.
1026
+ logger.debug(
1027
+ "Dropping rubric event from non-main namespace %r",
1028
+ ns_key,
1029
+ )
1030
+ if (
1031
+ adapter._on_subagent_event is not None
1032
+ and _is_renderable_subagent_event(
1033
+ data, is_main_agent=is_main_agent
1034
+ )
1035
+ ):
1036
+ try:
1037
+ adapter._on_subagent_event(data)
1038
+ except Exception:
1039
+ # Panel rendering must never crash the stream loop.
1040
+ logger.exception("subagent panel event handler failed")
1041
+ continue
1042
+
1043
+ # Handle UPDATES stream - for interrupts and todos
1044
+ if current_stream_mode == "updates":
1045
+ if not isinstance(data, dict):
1046
+ continue
1047
+
1048
+ # Check for interrupts
1049
+ if "__interrupt__" in data:
1050
+ interrupts: list[Interrupt] = data["__interrupt__"]
1051
+ if interrupts:
1052
+ for interrupt_obj in interrupts:
1053
+ iv = interrupt_obj.value
1054
+ if (
1055
+ isinstance(iv, dict)
1056
+ and iv.get("type") == "ask_user"
1057
+ ):
1058
+ try:
1059
+ validated_ask_user = (
1060
+ ask_user_adapter.validate_python(iv)
1061
+ )
1062
+ pending_ask_user[interrupt_obj.id] = (
1063
+ validated_ask_user
1064
+ )
1065
+ tool_id = validated_ask_user["tool_call_id"]
1066
+ if tool_id not in displayed_tool_ids:
1067
+ if adapter._set_spinner:
1068
+ await adapter._set_spinner(None)
1069
+ tool_args = {
1070
+ "questions": validated_ask_user[
1071
+ "questions"
1072
+ ]
1073
+ }
1074
+ tool_msg = ToolCallMessage(
1075
+ "ask_user",
1076
+ tool_args,
1077
+ )
1078
+ try:
1079
+ await adapter._mount_message(tool_msg)
1080
+ except Exception:
1081
+ # Mount failed (e.g. a torn-down
1082
+ # DOM during shutdown). tool.use
1083
+ # is dispatched only on mount
1084
+ # success (below), so a failed
1085
+ # mount leaves no unterminated
1086
+ # tool.use to orphan if the turn
1087
+ # is then cancelled before the
1088
+ # ask_user resolution loop runs.
1089
+ # The id is left unlatched so a
1090
+ # re-observed interrupt can retry
1091
+ # the mount; the question is still
1092
+ # asked and closed by the
1093
+ # resolution loop, which
1094
+ # dispatches the terminal
1095
+ # tool.result independently of
1096
+ # this widget.
1097
+ logger.exception(
1098
+ "Failed to mount ask_user "
1099
+ "tool row for %s",
1100
+ tool_id,
1101
+ )
1102
+ else:
1103
+ _notify_user_visible_output_started()
1104
+ # Fire tool.use and latch the id
1105
+ # together, only once the widget
1106
+ # is mounted, so the "every
1107
+ # tool.use is closed" guarantee
1108
+ # holds with no widget-less orphan
1109
+ # on the mount-failure path.
1110
+ # Gating on mount success also
1111
+ # keeps tool.use fire-once: a
1112
+ # failed mount never fires it, and
1113
+ # a successful mount latches the
1114
+ # id so a re-observed interrupt is
1115
+ # skipped.
1116
+ _dispatch_tool_use_hook(
1117
+ "ask_user", tool_id, tool_args
1118
+ )
1119
+ displayed_tool_ids.add(tool_id)
1120
+ adapter._current_tool_messages[
1121
+ tool_id
1122
+ ] = tool_msg
1123
+ interrupt_occurred = True
1124
+ await dispatch_hook("input.required", {})
1125
+ except ValidationError:
1126
+ logger.exception(
1127
+ "Invalid ask_user interrupt payload"
1128
+ )
1129
+ raise
1130
+ else:
1131
+ try:
1132
+ validated_request = (
1133
+ hitl_request_adapter.validate_python(iv)
1134
+ )
1135
+ pending_interrupts[interrupt_obj.id] = (
1136
+ validated_request
1137
+ )
1138
+ interrupt_occurred = True
1139
+ await dispatch_hook("input.required", {})
1140
+ except ValidationError: # noqa: TRY203 # Re-raise preserves exception context in handler
1141
+ raise
1142
+
1143
+ # Check for todo updates (not yet implemented in Textual UI)
1144
+ chunk_data = next(iter(data.values())) if data else None
1145
+ if (
1146
+ chunk_data
1147
+ and isinstance(chunk_data, dict)
1148
+ and "todos" in chunk_data
1149
+ ):
1150
+ pass # Future: render todo list widget
1151
+
1152
+ # Handle MESSAGES stream - for content and tool calls
1153
+ elif current_stream_mode == "messages":
1154
+ # Skip subagent outputs - only render main agent content in chat
1155
+ if not is_main_agent:
1156
+ logger.debug("Skipping subagent message ns=%s", ns_key)
1157
+ continue
1158
+
1159
+ if not isinstance(data, tuple) or len(data) != 2: # noqa: PLR2004 # message stream data is a 2-tuple (message, metadata)
1160
+ logger.debug(
1161
+ "Skipping non-2-tuple message data: type=%s",
1162
+ type(data).__name__,
1163
+ )
1164
+ continue
1165
+
1166
+ message, metadata = data
1167
+ logger.debug(
1168
+ "Processing message: type=%s id=%s has_content_blocks=%s",
1169
+ type(message).__name__,
1170
+ getattr(message, "id", None),
1171
+ hasattr(message, "content_blocks"),
1172
+ )
1173
+
1174
+ # Record model-call start time on the first chunk we see
1175
+ # for this namespace. `chunk_position == "last"` clears
1176
+ # the entry below so the next call starts a fresh timer.
1177
+ if ns_key not in call_start_by_namespace:
1178
+ call_start_by_namespace[ns_key] = time.monotonic()
1179
+ # Remember the latest non-empty response_metadata so we
1180
+ # can surface it once the call finishes.
1181
+ resp_meta = getattr(message, "response_metadata", None)
1182
+ if isinstance(resp_meta, dict) and resp_meta:
1183
+ call_metadata_by_namespace[ns_key] = resp_meta
1184
+
1185
+ # Capture the requested model alias from LangGraph stream
1186
+ # metadata. `ls_model_name` is set by the LangChain
1187
+ # callback layer to whatever the caller passed to
1188
+ # `init_chat_model()` / the `ChatOpenAI(model=…)` ctor,
1189
+ # so it survives gateway aliasing.
1190
+ if isinstance(metadata, dict):
1191
+ requested = metadata.get("ls_model_name")
1192
+ if isinstance(requested, str) and requested:
1193
+ call_requested_model_by_namespace[ns_key] = requested
1194
+
1195
+ # Filter out summarization model output, but keep UI feedback.
1196
+ # The summarization model streams AIMessage chunks tagged
1197
+ # with lc_source="summarization" in the callback metadata.
1198
+ # These are hidden from the user; only the spinner and a
1199
+ # notification widget provide feedback.
1200
+ if _is_summarization_chunk(metadata):
1201
+ if not summarization_in_progress:
1202
+ summarization_in_progress = True
1203
+ if adapter._set_spinner:
1204
+ await adapter._set_spinner("Offloading")
1205
+ continue
1206
+
1207
+ # Regular (non-summarization) chunks resumed — summarization
1208
+ # has finished. Mount the notification and reset the spinner.
1209
+ if summarization_in_progress:
1210
+ summarization_in_progress = False
1211
+ try:
1212
+ await adapter._mount_message(SummarizationMessage())
1213
+ except Exception:
1214
+ logger.debug(
1215
+ "Failed to mount summarization notification",
1216
+ exc_info=True,
1217
+ )
1218
+ if adapter._set_spinner and not adapter._current_tool_messages:
1219
+ await adapter._set_spinner("Thinking")
1220
+
1221
+ if isinstance(message, HumanMessage):
1222
+ content = message.text
1223
+ # Flush pending text for this namespace
1224
+ pending_text = pending_text_by_namespace.get(ns_key, "")
1225
+ if content and pending_text:
1226
+ await _flush_assistant_text_ns(
1227
+ adapter,
1228
+ pending_text,
1229
+ ns_key,
1230
+ assistant_message_by_namespace,
1231
+ )
1232
+ pending_text_by_namespace[ns_key] = ""
1233
+ # Drop the cached assistant bubble too, not just the
1234
+ # pending text: a mid-turn HumanMessage (e.g. the
1235
+ # rubric revision loop re-prompting the agent) means
1236
+ # the next assistant text is a fresh response and
1237
+ # must start a new bubble rather than appending to
1238
+ # the pre-revision one.
1239
+ assistant_message_by_namespace.pop(ns_key, None)
1240
+ continue
1241
+
1242
+ if isinstance(message, ToolMessage):
1243
+ tool_name = getattr(message, "name", "")
1244
+ # Normalize to the two-value hook domain, fail-closed: an
1245
+ # unexpected provider status is logged and treated as an
1246
+ # error (see `normalize_tool_status`) rather than silently
1247
+ # reported as success.
1248
+ tool_status: ToolStatus = normalize_tool_status(
1249
+ getattr(message, "status", "success"), tool_name
1250
+ )
1251
+ # Guard formatting *and* the str() coercion so a
1252
+ # pathological __str__ on the content can't re-raise and
1253
+ # skip the tool.result dispatch below. On failure use a
1254
+ # sentinel rather than re-touching the offending content,
1255
+ # so the terminal dispatch is genuinely unconditional.
1256
+ try:
1257
+ tool_content = format_tool_message_content(message.content)
1258
+ output_str = str(tool_content) if tool_content else ""
1259
+ except Exception:
1260
+ logger.exception("Failed to format tool output")
1261
+ output_str = UNRENDERABLE_TOOL_OUTPUT
1262
+ record = file_op_tracker.complete_with_message(message)
1263
+
1264
+ # Update tool call status with output
1265
+ tool_id = getattr(message, "tool_call_id", None)
1266
+ if tool_id and tool_id in adapter._current_tool_messages:
1267
+ # Pop before the widget calls so the dict drains even
1268
+ # if set_success/set_error raises.
1269
+ tool_msg = adapter._current_tool_messages.pop(tool_id)
1270
+ # Dispatch the terminal hooks *before* touching the
1271
+ # widget: a render failure must never drop this tool's
1272
+ # tool.result/tool.error (which would leave its
1273
+ # tool.use unterminated). The headless path likewise
1274
+ # dispatches without depending on any widget.
1275
+ if tool_status == "error":
1276
+ _dispatch_tool_error_hook(tool_msg.tool_name)
1277
+ _dispatch_tool_result_hook(
1278
+ tool_msg.tool_name,
1279
+ tool_id,
1280
+ tool_msg.args,
1281
+ tool_status,
1282
+ output_str,
1283
+ )
1284
+ # Update the widget last, guarded: a set_success/
1285
+ # set_error failure must not abort the turn and drop
1286
+ # the remaining tools' hooks.
1287
+ try:
1288
+ if tool_status == "success":
1289
+ tool_msg.set_success(output_str)
1290
+ else:
1291
+ tool_msg.set_error(output_str or "Error")
1292
+ adapter._sync_tool_widget(tool_msg)
1293
+ except Exception:
1294
+ logger.exception(
1295
+ "Failed to update tool row for %s", tool_id
1296
+ )
1297
+ elif tool_id and tool_id in completed_tool_result_ids:
1298
+ # This is a middleware synthetic ToolMessage for a
1299
+ # tool whose terminal hooks already fired while the
1300
+ # turn was resolving interrupts. Its widget was
1301
+ # cleared, so it lands here — consume the id and skip
1302
+ # re-dispatch to avoid a duplicate tool.result (with
1303
+ # mismatched `{}` args).
1304
+ completed_tool_result_ids.discard(tool_id)
1305
+ else:
1306
+ # The tool call was never mounted — either it has no
1307
+ # tool_call_id, or its streamed args never parsed so
1308
+ # no tool.use fired and no widget exists. Still emit
1309
+ # tool.result (with {} args, since without a widget
1310
+ # we lack the parsed args) so audit hooks observe
1311
+ # every executed tool, matching the headless path.
1312
+ # tool_id may be None here, mirroring headless.
1313
+ # Reciprocal: headless always dispatches tool.result
1314
+ # from `_process_message_chunk` since it has no
1315
+ # widget concept; see `non_interactive.py`. The
1316
+ # parity contract is documented in `_tool_stream`.
1317
+ if tool_id:
1318
+ # Warning, not info/debug: a real-id result with
1319
+ # no mounted widget (its args never parsed, so no
1320
+ # tool.use fired) means a hook consumer sees a
1321
+ # `tool.result` with empty args for a tool that
1322
+ # actually executed — degraded audit fidelity worth
1323
+ # surfacing at default log levels, matching the
1324
+ # headless path.
1325
+ logger.warning(
1326
+ "ToolMessage tool_call_id=%s not in "
1327
+ "_current_tool_messages; no correlated "
1328
+ "tool.use, sending empty tool_args",
1329
+ tool_id,
1330
+ )
1331
+ if tool_status == "error":
1332
+ _dispatch_tool_error_hook(tool_name)
1333
+ _dispatch_tool_result_hook(
1334
+ tool_name, tool_id, {}, tool_status, output_str
1335
+ )
1336
+
1337
+ # Show file operation results - always show diffs in chat
1338
+ if record:
1339
+ pending_text = pending_text_by_namespace.get(ns_key, "")
1340
+ if pending_text:
1341
+ await _flush_assistant_text_ns(
1342
+ adapter,
1343
+ pending_text,
1344
+ ns_key,
1345
+ assistant_message_by_namespace,
1346
+ )
1347
+ pending_text_by_namespace[ns_key] = ""
1348
+ if record.diff:
1349
+ await adapter._mount_message(
1350
+ DiffMessage(
1351
+ record.diff,
1352
+ record.display_path,
1353
+ tool_name=record.tool_name,
1354
+ )
1355
+ )
1356
+
1357
+ # Reshow spinner only when all in-flight tools have
1358
+ # completed (avoids premature "Thinking..." when
1359
+ # parallel tool calls are active). Must happen after
1360
+ # the diff is mounted so the spinner stays at the
1361
+ # bottom of the messages container.
1362
+ if adapter._set_spinner and not adapter._current_tool_messages:
1363
+ await adapter._set_spinner("Thinking")
1364
+
1365
+ if adapter._on_tool_complete is not None:
1366
+ try:
1367
+ adapter._on_tool_complete()
1368
+ except Exception:
1369
+ # A footer refresh failure must never abort
1370
+ # agent streaming — log and keep going.
1371
+ logger.warning(
1372
+ "on_tool_complete callback failed",
1373
+ exc_info=True,
1374
+ )
1375
+ continue
1376
+
1377
+ # Extract token usage (before content_blocks check
1378
+ # - usage may be on any chunk)
1379
+ if hasattr(message, "usage_metadata"):
1380
+ usage = message.usage_metadata
1381
+ if usage:
1382
+ # Remember the latest non-empty usage for this
1383
+ # model call so we can print it on chunk_position
1384
+ # == "last".
1385
+ call_usage_by_namespace[ns_key] = usage
1386
+ input_toks = usage.get("input_tokens", 0)
1387
+ output_toks = usage.get("output_tokens", 0)
1388
+ total_toks = usage.get("total_tokens", 0)
1389
+ from deepagents_code.config import settings
1390
+
1391
+ active_model = settings.model_name or ""
1392
+ active_provider = settings.model_provider or ""
1393
+ if input_toks or output_toks:
1394
+ # Model gives split counts — preferred path
1395
+ turn_stats.record_request(
1396
+ active_model,
1397
+ input_toks,
1398
+ output_toks,
1399
+ active_provider,
1400
+ )
1401
+ captured_input_tokens = max(
1402
+ captured_input_tokens, input_toks + output_toks
1403
+ )
1404
+ elif total_toks:
1405
+ # Fallback: model gives only total (no split)
1406
+ turn_stats.record_request(
1407
+ active_model, total_toks, 0, active_provider
1408
+ )
1409
+ captured_input_tokens = max(
1410
+ captured_input_tokens, total_toks
1411
+ )
1412
+
1413
+ # Check if this is an AIMessageChunk with content
1414
+ if not hasattr(message, "content_blocks"):
1415
+ logger.debug(
1416
+ "Message has no content_blocks: type=%s",
1417
+ type(message).__name__,
1418
+ )
1419
+ continue
1420
+
1421
+ # Process content blocks
1422
+ blocks = message.content_blocks
1423
+ if logger.isEnabledFor(logging.DEBUG):
1424
+ logger.debug(
1425
+ "content_blocks count=%d blocks=%s",
1426
+ len(blocks),
1427
+ repr(blocks)[:500],
1428
+ )
1429
+ for block in blocks:
1430
+ block_type = block.get("type")
1431
+
1432
+ if block_type == "text":
1433
+ text = block.get("text", "")
1434
+ if text:
1435
+ # Track accumulated text for reference
1436
+ pending_text = pending_text_by_namespace.get(ns_key, "")
1437
+ pending_text += text
1438
+ pending_text_by_namespace[ns_key] = pending_text
1439
+
1440
+ # Get or create assistant message for this namespace
1441
+ current_msg = assistant_message_by_namespace.get(ns_key)
1442
+ if current_msg is None:
1443
+ msg_id = f"asst-{uuid.uuid4().hex}"
1444
+ # Mark active BEFORE mounting so pruning
1445
+ # (triggered by mount) won't remove it
1446
+ # (_mount_message can trigger
1447
+ # _prune_old_messages if the window exceeds
1448
+ # WINDOW_SIZE.)
1449
+ if adapter._set_active_message:
1450
+ adapter._set_active_message(msg_id)
1451
+ current_msg = AssistantMessage(id=msg_id)
1452
+ await adapter._mount_message(current_msg)
1453
+ assistant_message_by_namespace[ns_key] = current_msg
1454
+ # Keep the Thinking spinner visible after
1455
+ # the streaming message so the user still
1456
+ # sees activity if the model pauses between
1457
+ # finishing text and emitting its next
1458
+ # action (e.g. a tool call). The mount
1459
+ # above placed the new message at the end
1460
+ # of the container; this re-anchors the
1461
+ # spinner after it.
1462
+ if (
1463
+ adapter._set_spinner
1464
+ and not adapter._current_tool_messages
1465
+ ):
1466
+ await adapter._set_spinner("Thinking")
1467
+
1468
+ # Append just the new text chunk for smoother
1469
+ # streaming (uses MarkdownStream internally for
1470
+ # better performance)
1471
+ await current_msg.append_content(text)
1472
+ _notify_user_visible_output_started()
1473
+
1474
+ elif block_type in {"tool_call_chunk", "tool_call"}:
1475
+ chunk_name = block.get("name")
1476
+ chunk_args = block.get("args")
1477
+ chunk_id = block.get("id")
1478
+ chunk_index = block.get("index")
1479
+
1480
+ buffer_key = tool_call_buffer_key(
1481
+ chunk_index, chunk_id, len(tool_call_buffers)
1482
+ )
1483
+ buffer = tool_call_buffers.setdefault(
1484
+ buffer_key, ToolCallBuffer()
1485
+ )
1486
+ buffer.ingest(
1487
+ name=chunk_name, tool_id=chunk_id, args=chunk_args
1488
+ )
1489
+
1490
+ buffer_name = buffer.name
1491
+ buffer_id = buffer.tool_id
1492
+ if buffer_name is None:
1493
+ continue
1494
+
1495
+ # `parse_args` reassembles streamed JSON string
1496
+ # fragments, deferring the parse until the value
1497
+ # looks complete — which avoids re-parsing the whole
1498
+ # prefix on every fragment (costly on the UI event
1499
+ # loop for large `edit_file` blobs) — and returns
1500
+ # None while still incomplete. Each `continue` leaves
1501
+ # the buffer in `tool_call_buffers` so the next
1502
+ # fragment keeps accumulating; it is popped only after
1503
+ # a successful parse + mount below.
1504
+ parsed_args = buffer.parse_args()
1505
+ if parsed_args is None:
1506
+ continue
1507
+
1508
+ # Flush pending text before tool call
1509
+ pending_text = pending_text_by_namespace.get(ns_key, "")
1510
+ if pending_text:
1511
+ await _flush_assistant_text_ns(
1512
+ adapter,
1513
+ pending_text,
1514
+ ns_key,
1515
+ assistant_message_by_namespace,
1516
+ )
1517
+ pending_text_by_namespace[ns_key] = ""
1518
+ assistant_message_by_namespace.pop(ns_key, None)
1519
+
1520
+ logger.debug(
1521
+ "Tool call buffer: name=%s id=%s args=%s",
1522
+ buffer_name,
1523
+ buffer_id,
1524
+ repr(parsed_args)[:200],
1525
+ )
1526
+ if (
1527
+ buffer_id is not None
1528
+ and buffer_id not in displayed_tool_ids
1529
+ ):
1530
+ displayed_tool_ids.add(buffer_id)
1531
+ file_op_tracker.start_operation(
1532
+ buffer_name, parsed_args, buffer_id
1533
+ )
1534
+
1535
+ # Keep the global "Thinking" spinner visible
1536
+ # across tool calls rather than hiding it per
1537
+ # tool: it's a stable turn-level indicator, and
1538
+ # the tool's own progress now shows in its
1539
+ # collapsed group row. Re-assert it so it stays
1540
+ # pinned at the bottom as the new row mounts
1541
+ # above it.
1542
+ if adapter._set_spinner:
1543
+ await adapter._set_spinner("Thinking")
1544
+
1545
+ # Mount tool call message
1546
+ logger.debug(
1547
+ "Mounting ToolCallMessage: %s(%s)",
1548
+ buffer_name,
1549
+ repr(parsed_args)[:200],
1550
+ )
1551
+ # Dispatch tool.use once the streamed call has a
1552
+ # resolved id and parsed args. The headless
1553
+ # surface dispatches from the stream loop
1554
+ # instead; see the "Gate tool.use" comment in
1555
+ # `non_interactive._process_ai_message`. Both
1556
+ # gate on a resolved tool-call id and fire at
1557
+ # most once per id — the parity contract is
1558
+ # documented in `_tool_stream`.
1559
+ _dispatch_tool_use_hook(
1560
+ buffer_name, buffer_id, parsed_args
1561
+ )
1562
+ tool_msg = ToolCallMessage(buffer_name, parsed_args)
1563
+ try:
1564
+ await adapter._mount_message(tool_msg)
1565
+ except Exception:
1566
+ # tool.use already fired. If the mount raises
1567
+ # (e.g. mounting into a torn-down DOM during
1568
+ # shutdown), still track the pending call so
1569
+ # the later real ToolMessage remains
1570
+ # authoritative for tool.result status/output.
1571
+ # If the stream ends first, the terminal
1572
+ # drains close this tool.use from the same
1573
+ # pending map.
1574
+ logger.exception(
1575
+ "Failed to mount tool widget for %s",
1576
+ buffer_id,
1577
+ )
1578
+ else:
1579
+ _notify_user_visible_output_started()
1580
+ # Mark running so the group row reflects live
1581
+ # progress; the row itself is hidden inside
1582
+ # the group, so this drives state, not a
1583
+ # visible per-tool spinner.
1584
+ tool_msg.set_running()
1585
+ adapter._sync_tool_widget(tool_msg)
1586
+ adapter._current_tool_messages[buffer_id] = tool_msg
1587
+
1588
+ if buffer_id is not None:
1589
+ tool_call_buffers.pop(buffer_key, None)
1590
+
1591
+ if getattr(message, "chunk_position", None) == "last":
1592
+ pending_text = pending_text_by_namespace.get(ns_key, "")
1593
+ if pending_text:
1594
+ await _flush_assistant_text_ns(
1595
+ adapter,
1596
+ pending_text,
1597
+ ns_key,
1598
+ assistant_message_by_namespace,
1599
+ )
1600
+ pending_text_by_namespace[ns_key] = ""
1601
+ assistant_message_by_namespace.pop(ns_key, None)
1602
+
1603
+ # Feed the finish reason from the main agent's model
1604
+ # call to the per-turn end-status tracker. `length` /
1605
+ # `max_tokens` etc. get classified there and win the
1606
+ # priority contest for the final marker.
1607
+ if is_main_agent:
1608
+ _meta = call_metadata_by_namespace.get(ns_key) or {}
1609
+ _fr = _meta.get("finish_reason") or _meta.get(
1610
+ "stop_reason"
1611
+ )
1612
+ if _fr:
1613
+ try:
1614
+ turn_end_summary.observe_finish_reason(_fr)
1615
+ except Exception: # never mask streaming
1616
+ logger.debug(
1617
+ "observe_finish_reason failed",
1618
+ exc_info=True,
1619
+ )
1620
+
1621
+ # Show a compact per-call info line so the user can
1622
+ # see the model response metadata (usage, finish
1623
+ # reason, model id, latency) inline after the AI
1624
+ # reply. Only the main agent surfaces here; subagent
1625
+ # calls stay quiet to avoid clutter.
1626
+ if is_main_agent:
1627
+ from deepagents_code.config import settings
1628
+
1629
+ info_line = _format_model_call_info(
1630
+ usage=call_usage_by_namespace.get(ns_key),
1631
+ response_metadata=call_metadata_by_namespace.get(
1632
+ ns_key
1633
+ ),
1634
+ elapsed=(
1635
+ time.monotonic() - call_start_by_namespace[ns_key]
1636
+ if ns_key in call_start_by_namespace
1637
+ else None
1638
+ ),
1639
+ requested_model=call_requested_model_by_namespace.get(
1640
+ ns_key
1641
+ ),
1642
+ context_limit=settings.model_context_limit,
1643
+ )
1644
+ if info_line:
1645
+ try:
1646
+ await adapter._mount_message(AppMessage(info_line))
1647
+ except Exception:
1648
+ logger.debug(
1649
+ "Failed to mount model-call info line",
1650
+ exc_info=True,
1651
+ )
1652
+ # Reset per-call trackers so the next model call in
1653
+ # the same turn starts fresh.
1654
+ call_start_by_namespace.pop(ns_key, None)
1655
+ call_usage_by_namespace.pop(ns_key, None)
1656
+ call_metadata_by_namespace.pop(ns_key, None)
1657
+ call_requested_model_by_namespace.pop(ns_key, None)
1658
+
1659
+ # Reset summarization state if stream ended mid-summarization
1660
+ # (e.g. middleware error, stream exhausted before regular chunks).
1661
+ if summarization_in_progress:
1662
+ summarization_in_progress = False
1663
+ try:
1664
+ await adapter._mount_message(SummarizationMessage())
1665
+ except Exception:
1666
+ logger.debug(
1667
+ "Failed to mount summarization notification",
1668
+ exc_info=True,
1669
+ )
1670
+ if adapter._set_spinner and not adapter._current_tool_messages:
1671
+ await adapter._set_spinner("Thinking")
1672
+
1673
+ # Flush any remaining text from all namespaces
1674
+ for ns_key, pending_text in list(pending_text_by_namespace.items()):
1675
+ if pending_text:
1676
+ await _flush_assistant_text_ns(
1677
+ adapter, pending_text, ns_key, assistant_message_by_namespace
1678
+ )
1679
+ pending_text_by_namespace.clear()
1680
+ assistant_message_by_namespace.clear()
1681
+
1682
+ # Handle HITL after stream completes
1683
+ if interrupt_occurred:
1684
+ any_rejected = False
1685
+ ask_user_cancelled = False
1686
+ resume_payload: dict[str, Any] = {}
1687
+
1688
+ # Tools mounted above start their spinner immediately, but a
1689
+ # tool blocked on HITL approval or `ask_user` input is not
1690
+ # actually running. Pause every in-flight row so none shows a
1691
+ # misleading "Running..."; the approve branches below call
1692
+ # `set_running` again to resume those that proceed. Guard each
1693
+ # row individually so a single bad widget can't abort the whole
1694
+ # interrupt handler (mirrors `clear_awaiting_approval` below).
1695
+ for tool_msg in adapter._current_tool_messages.values():
1696
+ try:
1697
+ tool_msg.pause_running()
1698
+ adapter._sync_tool_widget(tool_msg)
1699
+ except Exception:
1700
+ logger.exception(
1701
+ "Failed to pause running state on tool widget %s",
1702
+ tool_msg.tool_name,
1703
+ )
1704
+
1705
+ for interrupt_id, ask_req in list(pending_ask_user.items()):
1706
+ questions = ask_req["questions"]
1707
+ tool_args = {"questions": questions}
1708
+
1709
+ if adapter._request_ask_user:
1710
+ if adapter._set_spinner:
1711
+ await adapter._set_spinner(None)
1712
+ result: AskUserWidgetResult | dict[str, str] = {
1713
+ "type": "error",
1714
+ "error": "ask_user callback returned no response",
1715
+ }
1716
+ try:
1717
+ future = await adapter._request_ask_user(questions)
1718
+ except Exception:
1719
+ logger.exception("Failed to mount ask_user widget")
1720
+ result = {
1721
+ "type": "error",
1722
+ "error": "failed to display ask_user prompt",
1723
+ }
1724
+ future = None
1725
+
1726
+ if future is None:
1727
+ logger.error(
1728
+ "ask_user callback returned no Future; "
1729
+ "reporting as error"
1730
+ )
1731
+ else:
1732
+ try:
1733
+ future_result = await future
1734
+ if isinstance(future_result, dict):
1735
+ result = future_result
1736
+ else:
1737
+ logger.error(
1738
+ "ask_user future returned non-dict result: %s",
1739
+ type(future_result).__name__,
1740
+ )
1741
+ result = {
1742
+ "type": "error",
1743
+ "error": "invalid ask_user widget result",
1744
+ }
1745
+ except Exception:
1746
+ logger.exception(
1747
+ "ask_user future resolution failed; "
1748
+ "reporting as error"
1749
+ )
1750
+ result = {
1751
+ "type": "error",
1752
+ "error": "failed to receive ask_user response",
1753
+ }
1754
+
1755
+ result_type = result.get("type")
1756
+ tool_id = ask_req["tool_call_id"]
1757
+ if result_type == "answered":
1758
+ answers = result.get("answers", [])
1759
+ if isinstance(answers, list):
1760
+ resume_payload[interrupt_id] = {"answers": answers}
1761
+ output = "User answered"
1762
+ tool_msg = adapter._current_tool_messages.pop(
1763
+ tool_id, None
1764
+ )
1765
+ _dispatch_tool_result_hook(
1766
+ "ask_user", tool_id, tool_args, "success", output
1767
+ )
1768
+ completed_tool_result_ids.add(tool_id)
1769
+ if tool_msg is not None:
1770
+ try:
1771
+ tool_msg.set_success(output)
1772
+ adapter._sync_tool_widget(tool_msg)
1773
+ except Exception:
1774
+ logger.exception(
1775
+ "Failed to update ask_user row for %s",
1776
+ tool_id,
1777
+ )
1778
+ else:
1779
+ logger.warning(
1780
+ "ask_user tool_id %s missing from "
1781
+ "_current_tool_messages on answered",
1782
+ tool_id,
1783
+ )
1784
+ else:
1785
+ logger.error(
1786
+ "ask_user answered payload had non-list "
1787
+ "answers: %s",
1788
+ type(answers).__name__,
1789
+ )
1790
+ resume_payload[interrupt_id] = {
1791
+ "status": "error",
1792
+ "error": "invalid ask_user answers payload",
1793
+ "answers": ["" for _ in questions],
1794
+ }
1795
+ any_rejected = True
1796
+ output = "invalid ask_user answers payload"
1797
+ tool_msg = adapter._current_tool_messages.pop(
1798
+ tool_id, None
1799
+ )
1800
+ _dispatch_tool_error_hook("ask_user")
1801
+ _dispatch_tool_result_hook(
1802
+ "ask_user", tool_id, tool_args, "error", output
1803
+ )
1804
+ completed_tool_result_ids.add(tool_id)
1805
+ if tool_msg is not None:
1806
+ try:
1807
+ tool_msg.set_error(output)
1808
+ adapter._sync_tool_widget(tool_msg)
1809
+ except Exception:
1810
+ logger.exception(
1811
+ "Failed to update ask_user row for %s",
1812
+ tool_id,
1813
+ )
1814
+ elif result_type == "cancelled":
1815
+ resume_payload[interrupt_id] = {
1816
+ "status": "cancelled",
1817
+ "answers": ["" for _ in questions],
1818
+ }
1819
+ any_rejected = True
1820
+ # Halt the turn on cancel; error branches still
1821
+ # resume so the agent can react to the failure.
1822
+ ask_user_cancelled = True
1823
+ tool_msg = adapter._current_tool_messages.pop(tool_id, None)
1824
+ output = "Question cancelled"
1825
+ _dispatch_tool_error_hook("ask_user")
1826
+ _dispatch_tool_result_hook(
1827
+ "ask_user", tool_id, tool_args, "error", output
1828
+ )
1829
+ completed_tool_result_ids.add(tool_id)
1830
+ if tool_msg is not None:
1831
+ try:
1832
+ tool_msg.set_rejected()
1833
+ adapter._sync_tool_widget(tool_msg)
1834
+ except Exception:
1835
+ logger.exception(
1836
+ "Failed to update ask_user row for %s",
1837
+ tool_id,
1838
+ )
1839
+ else:
1840
+ logger.warning(
1841
+ "ask_user tool_id %s missing from "
1842
+ "_current_tool_messages on cancelled",
1843
+ tool_id,
1844
+ )
1845
+ else:
1846
+ error_text = result.get("error")
1847
+ if not isinstance(error_text, str) or not error_text:
1848
+ error_text = "ask_user interaction failed"
1849
+ resume_payload[interrupt_id] = {
1850
+ "status": "error",
1851
+ "error": error_text,
1852
+ "answers": ["" for _ in questions],
1853
+ }
1854
+ any_rejected = True
1855
+ tool_msg = adapter._current_tool_messages.pop(tool_id, None)
1856
+ _dispatch_tool_error_hook("ask_user")
1857
+ _dispatch_tool_result_hook(
1858
+ "ask_user", tool_id, tool_args, "error", error_text
1859
+ )
1860
+ completed_tool_result_ids.add(tool_id)
1861
+ if tool_msg is not None:
1862
+ try:
1863
+ tool_msg.set_error(error_text)
1864
+ adapter._sync_tool_widget(tool_msg)
1865
+ except Exception:
1866
+ logger.exception(
1867
+ "Failed to update ask_user row for %s",
1868
+ tool_id,
1869
+ )
1870
+ else:
1871
+ logger.warning(
1872
+ "ask_user interrupt received but no UI callback is "
1873
+ "registered; reporting as error"
1874
+ )
1875
+ resume_payload[interrupt_id] = {
1876
+ "status": "error",
1877
+ "error": _ASK_USER_UNSUPPORTED_ERROR,
1878
+ "answers": ["" for _ in questions],
1879
+ }
1880
+ tool_id = ask_req["tool_call_id"]
1881
+ tool_msg = adapter._current_tool_messages.pop(tool_id, None)
1882
+ _dispatch_tool_error_hook("ask_user")
1883
+ _dispatch_tool_result_hook(
1884
+ "ask_user",
1885
+ tool_id,
1886
+ tool_args,
1887
+ "error",
1888
+ _ASK_USER_UNSUPPORTED_ERROR,
1889
+ )
1890
+ completed_tool_result_ids.add(tool_id)
1891
+ if tool_msg is not None:
1892
+ try:
1893
+ tool_msg.set_error(_ASK_USER_UNSUPPORTED_ERROR)
1894
+ adapter._sync_tool_widget(tool_msg)
1895
+ except Exception:
1896
+ logger.exception(
1897
+ "Failed to update ask_user row for %s", tool_id
1898
+ )
1899
+
1900
+ for interrupt_id, hitl_request in list(pending_interrupts.items()):
1901
+ action_requests = hitl_request["action_requests"]
1902
+
1903
+ if session_state.auto_approve:
1904
+ decisions: list[HITLDecision] = [
1905
+ ApproveDecision(type="approve") for _ in action_requests
1906
+ ]
1907
+ resume_payload[interrupt_id] = {"decisions": decisions}
1908
+ for tool_msg in list(adapter._current_tool_messages.values()):
1909
+ tool_msg.set_running()
1910
+ adapter._sync_tool_widget(tool_msg)
1911
+ else:
1912
+ # Batch approval - one dialog for all parallel tool calls
1913
+ await dispatch_hook(
1914
+ "permission.request",
1915
+ {
1916
+ "tool_names": [
1917
+ r.get("name", "") for r in action_requests
1918
+ ]
1919
+ },
1920
+ )
1921
+ # Hide shell tool widgets while the approval renders
1922
+ # the same command; restore before processing the
1923
+ # decision so subsequent status updates render on the
1924
+ # visible widget. Only applies to single-tool
1925
+ # approvals — the batch dialog doesn't render
1926
+ # per-tool commands, so hiding the rows would leave
1927
+ # the user with no preview of what's being approved.
1928
+ suppressed_tool_msgs = (
1929
+ [
1930
+ tool_msg
1931
+ for tool_msg in adapter._current_tool_messages.values()
1932
+ if tool_msg.tool_name == "execute"
1933
+ ]
1934
+ if len(action_requests) == 1
1935
+ else []
1936
+ )
1937
+ for tool_msg in suppressed_tool_msgs:
1938
+ tool_msg.set_awaiting_approval()
1939
+ try:
1940
+ future = await adapter._request_approval(
1941
+ action_requests, assistant_id
1942
+ )
1943
+ decision = await future
1944
+ finally:
1945
+ for tool_msg in suppressed_tool_msgs:
1946
+ try:
1947
+ tool_msg.clear_awaiting_approval()
1948
+ except Exception:
1949
+ logger.exception(
1950
+ "Failed to clear awaiting-approval "
1951
+ "state on tool widget %s",
1952
+ tool_msg.tool_name,
1953
+ )
1954
+
1955
+ if isinstance(decision, dict):
1956
+ decision_type = decision.get("type")
1957
+
1958
+ if decision_type == "auto_approve_all":
1959
+ session_state.auto_approve = True
1960
+ # The resuming stream re-reads
1961
+ # `session_state.auto_approve` into run context
1962
+ # at the top of the loop, so the `interrupt_on`
1963
+ # `when` predicate suppresses interrupts on the
1964
+ # remaining tool calls in this turn — keeping it
1965
+ # a single run instead of resuming after each.
1966
+ if adapter._on_auto_approve_enabled:
1967
+ callback_result = adapter._on_auto_approve_enabled()
1968
+ if callback_result is not None:
1969
+ await callback_result
1970
+ decisions = [
1971
+ ApproveDecision(type="approve")
1972
+ for _ in action_requests
1973
+ ]
1974
+ tool_msgs = list(
1975
+ adapter._current_tool_messages.values()
1976
+ )
1977
+ for tool_msg in tool_msgs:
1978
+ tool_msg.set_running()
1979
+ adapter._sync_tool_widget(tool_msg)
1980
+ for action_request in action_requests:
1981
+ tool_name = action_request.get("name")
1982
+ if tool_name in {
1983
+ "write_file",
1984
+ "edit_file",
1985
+ "delete",
1986
+ }:
1987
+ args = action_request.get("args", {})
1988
+ if isinstance(args, dict):
1989
+ file_op_tracker.mark_hitl_approved(
1990
+ tool_name, args
1991
+ )
1992
+
1993
+ elif decision_type == "approve":
1994
+ decisions = [
1995
+ ApproveDecision(type="approve")
1996
+ for _ in action_requests
1997
+ ]
1998
+ tool_msgs = list(
1999
+ adapter._current_tool_messages.values()
2000
+ )
2001
+ for tool_msg in tool_msgs:
2002
+ tool_msg.set_running()
2003
+ adapter._sync_tool_widget(tool_msg)
2004
+ for action_request in action_requests:
2005
+ tool_name = action_request.get("name")
2006
+ if tool_name in {
2007
+ "write_file",
2008
+ "edit_file",
2009
+ "delete",
2010
+ }:
2011
+ args = action_request.get("args", {})
2012
+ if isinstance(args, dict):
2013
+ file_op_tracker.mark_hitl_approved(
2014
+ tool_name, args
2015
+ )
2016
+
2017
+ elif decision_type == "reject":
2018
+ reject_message = decision.get("message")
2019
+ reject_message = (
2020
+ reject_message
2021
+ if isinstance(reject_message, str)
2022
+ and reject_message.strip()
2023
+ else None
2024
+ )
2025
+ reject_decision: RejectDecision = (
2026
+ RejectDecision(
2027
+ type="reject", message=reject_message
2028
+ )
2029
+ if reject_message
2030
+ else RejectDecision(type="reject")
2031
+ )
2032
+ decisions = [reject_decision for _ in action_requests]
2033
+ tool_msgs = list(
2034
+ adapter._current_tool_messages.values()
2035
+ )
2036
+ for tool_msg in tool_msgs:
2037
+ tool_msg.set_rejected(reason=reject_message)
2038
+ adapter._sync_tool_widget(tool_msg)
2039
+ # Bare reject aborts the turn and shows the
2040
+ # canned "Command rejected" banner so the user
2041
+ # can redirect. When a reason is supplied, the
2042
+ # reason itself serves as feedback for the
2043
+ # agent: keep `any_rejected=False` so the
2044
+ # stream resumes and the banner is suppressed.
2045
+ if reject_message is None:
2046
+ completed_tool_result_ids.update(
2047
+ _dispatch_terminal_tool_result_hooks(
2048
+ adapter._current_tool_messages,
2049
+ "Tool approval rejected",
2050
+ )
2051
+ )
2052
+ adapter._current_tool_messages.clear()
2053
+ any_rejected = True
2054
+ else:
2055
+ logger.warning(
2056
+ "Unexpected HITL decision type: %s",
2057
+ decision_type,
2058
+ )
2059
+ decisions = [
2060
+ RejectDecision(type="reject")
2061
+ for _ in action_requests
2062
+ ]
2063
+ for tool_msg in list(
2064
+ adapter._current_tool_messages.values()
2065
+ ):
2066
+ tool_msg.set_rejected()
2067
+ adapter._sync_tool_widget(tool_msg)
2068
+ completed_tool_result_ids.update(
2069
+ _dispatch_terminal_tool_result_hooks(
2070
+ adapter._current_tool_messages,
2071
+ "Tool approval rejected",
2072
+ )
2073
+ )
2074
+ adapter._current_tool_messages.clear()
2075
+ any_rejected = True
2076
+ else:
2077
+ logger.warning(
2078
+ "HITL decision was not a dict: %s",
2079
+ type(decision).__name__,
2080
+ )
2081
+ decisions = [
2082
+ RejectDecision(type="reject") for _ in action_requests
2083
+ ]
2084
+ for tool_msg in list(
2085
+ adapter._current_tool_messages.values()
2086
+ ):
2087
+ tool_msg.set_rejected()
2088
+ adapter._sync_tool_widget(tool_msg)
2089
+ completed_tool_result_ids.update(
2090
+ _dispatch_terminal_tool_result_hooks(
2091
+ adapter._current_tool_messages,
2092
+ "Tool approval rejected",
2093
+ )
2094
+ )
2095
+ adapter._current_tool_messages.clear()
2096
+ any_rejected = True
2097
+
2098
+ resume_payload[interrupt_id] = {"decisions": decisions}
2099
+
2100
+ if any_rejected:
2101
+ break
2102
+
2103
+ suppress_resumed_output = any_rejected
2104
+
2105
+ if interrupt_occurred and resume_payload:
2106
+ if suppress_resumed_output and (
2107
+ ask_user_cancelled or not pending_ask_user
2108
+ ):
2109
+ message = (
2110
+ "Question cancelled. Tell the agent what you'd like instead."
2111
+ if ask_user_cancelled
2112
+ else "Command rejected. Tell the agent what you'd like instead."
2113
+ )
2114
+ await adapter._mount_message(AppMessage(message))
2115
+ turn_stats.wall_time_seconds = time.monotonic() - start_time
2116
+ # Model call already completed (HITL interrupt fires after
2117
+ # the model node); `ResumeStateMiddleware.after_model`
2118
+ # persisted the count, so only refresh UI here.
2119
+ _report_tokens(
2120
+ adapter,
2121
+ captured_input_tokens,
2122
+ captured_output_tokens,
2123
+ )
2124
+ return turn_stats
2125
+
2126
+ stream_input = Command(resume=resume_payload)
2127
+ else:
2128
+ # Clean stream end. Any tool still in `_current_tool_messages`
2129
+ # had its `tool.use` dispatched at mount but never received a
2130
+ # `ToolMessage` (e.g. a custom/remote graph that ends the turn
2131
+ # after emitting an unexecuted tool call). Close each one with a
2132
+ # terminal hook so the "every `tool.use` is terminated" guarantee
2133
+ # does not depend on the graph raising. This mirrors the headless
2134
+ # `_dispatch_orphaned_tool_result_hooks`, which likewise closes
2135
+ # orphans hooks-only (no widget mutation) on every loop exit —
2136
+ # the widget keeps its rendered state; only the audit stream and
2137
+ # the cross-turn `_current_tool_messages` tracking are settled.
2138
+ if adapter._current_tool_messages:
2139
+ logger.info(
2140
+ "Stream ended with %d un-resulted tool call(s); "
2141
+ "closing with terminal hooks",
2142
+ len(adapter._current_tool_messages),
2143
+ )
2144
+ _dispatch_terminal_tool_result_hooks(
2145
+ adapter._current_tool_messages,
2146
+ "Stream ended before tool result",
2147
+ )
2148
+ adapter._current_tool_messages.clear()
2149
+ # The end-of-stream diagnostic for buffered tool calls that never
2150
+ # fired a `tool.use` runs in the `finally` below, not here, so it
2151
+ # fires on cancel and mid-stream error too (not only this clean
2152
+ # end) — mirroring the headless surface, whose identical
2153
+ # diagnostic lives in `_run_agent_loop`'s `finally`.
2154
+ await dispatch_hook("task.complete", {"thread_id": thread_id})
2155
+ break
2156
+
2157
+ except (asyncio.CancelledError, KeyboardInterrupt) as _interrupt_exc:
2158
+ # Classify the interrupt for the per-turn end-status marker before
2159
+ # cleanup runs — the marker emit itself happens in the `finally`.
2160
+ try:
2161
+ turn_end_summary.mark_turn_reason(
2162
+ turn_end_summary.REASON_USER_INTERRUPTED,
2163
+ type(_interrupt_exc).__name__,
2164
+ )
2165
+ except Exception:
2166
+ logger.debug("mark_turn_reason (interrupt) failed", exc_info=True)
2167
+ await _handle_interrupt_cleanup(
2168
+ adapter=adapter,
2169
+ agent=agent,
2170
+ config=config,
2171
+ pending_text_by_namespace=pending_text_by_namespace,
2172
+ assistant_message_by_namespace=assistant_message_by_namespace,
2173
+ captured_input_tokens=captured_input_tokens,
2174
+ captured_output_tokens=captured_output_tokens,
2175
+ turn_stats=turn_stats,
2176
+ start_time=start_time,
2177
+ )
2178
+ return turn_stats
2179
+ finally:
2180
+ # Streamed text is coalesced in each AssistantMessage's `_pending_append`
2181
+ # buffer and flushed on a throttled timer, so up to one flush interval of
2182
+ # tokens can be in flight at any moment. Normal completion (the flush loop
2183
+ # above) and interrupt cleanup both clear the namespace dict, leaving this
2184
+ # a no-op there. The path that matters is a non-cancel mid-stream error
2185
+ # propagating to the caller: without this drain those buffered tokens are
2186
+ # never written and the user sees a silently truncated reply.
2187
+ try:
2188
+ await _stop_assistant_streams(adapter, assistant_message_by_namespace)
2189
+ except Exception: # drain must not mask the original error
2190
+ logger.exception("Failed to drain assistant streams on exit")
2191
+
2192
+ # Self-contained backstop for the "every `tool.use` is terminated" hook
2193
+ # guarantee. The clean-end branch, HITL-reject branches, and interrupt
2194
+ # cleanup each already drained `_current_tool_messages` and cleared it, so
2195
+ # this is a no-op on those paths. The one path it covers is a non-cancel
2196
+ # mid-stream error propagating to the caller: without it, the tools that
2197
+ # fired `tool.use` would be terminated only by the caller's
2198
+ # `finalize_pending_tools_with_error`, leaving the hook guarantee dependent
2199
+ # on the caller rather than owned here (a future second caller, or a
2200
+ # missing adapter, would leak an unterminated `tool.use`). Runs before the
2201
+ # exception reaches the caller, whose `finalize_pending_tools_with_error`
2202
+ # then finds an empty dict and no-ops, so no `tool.result` is dispatched
2203
+ # twice. Fail-loud and guarded so a dispatch problem can never mask the
2204
+ # error propagating from the stream.
2205
+ if adapter._current_tool_messages:
2206
+ logger.warning(
2207
+ "Turn exited with %d un-terminated tool call(s); closing with "
2208
+ "terminal hooks as a backstop",
2209
+ len(adapter._current_tool_messages),
2210
+ )
2211
+ try:
2212
+ adapter.finalize_pending_tools_with_error(
2213
+ "Agent error before tool result"
2214
+ )
2215
+ except Exception:
2216
+ logger.warning(
2217
+ "Backstop terminal tool close failed unexpectedly",
2218
+ exc_info=True,
2219
+ )
2220
+
2221
+ # Surface any buffered tool call that never mounted and never fired a
2222
+ # `tool.use`, so it would otherwise vanish with `tool_call_buffers` at turn
2223
+ # end with no trace. Two distinct cases (args that never parsed, and args
2224
+ # that parsed but carried no tool-call id) are classified by the shared
2225
+ # `count_unemitted_tool_calls`. In the `finally` so it fires on every exit
2226
+ # path — clean end, cancel, and mid-stream error — matching the headless
2227
+ # surface. Info, not warning: nothing executed for these and the
2228
+ # precondition (exiting mid-tool-call) is unusual; it only needs to be
2229
+ # greppable. Guarded so a logging failure can never mask a propagating
2230
+ # exception (`parse_args`, re-run inside the count, can raise on the
2231
+ # invariant-violating both-fields-set buffer).
2232
+ try:
2233
+ unemitted = count_unemitted_tool_calls(tool_call_buffers.values())
2234
+ if unemitted.unparsed:
2235
+ logger.info(
2236
+ "Stream ended with %d tool call(s) whose arguments never "
2237
+ "parsed; no tool.use was emitted for them",
2238
+ unemitted.unparsed,
2239
+ )
2240
+ if unemitted.idless_parsed:
2241
+ logger.info(
2242
+ "Stream ended with %d tool call(s) whose arguments parsed "
2243
+ "but carried no tool-call id; no tool.use was emitted for "
2244
+ "them",
2245
+ unemitted.idless_parsed,
2246
+ )
2247
+ except Exception:
2248
+ logger.warning(
2249
+ "Unparsed tool-call buffer check failed unexpectedly",
2250
+ exc_info=True,
2251
+ )
2252
+
2253
+ # Per-turn end-status marker. Always emitted, regardless of exit path
2254
+ # (clean end, user interrupt, mid-stream exception). If an unhandled
2255
+ # exception is in flight, classify it here so the marker reflects the
2256
+ # real cause instead of the default `unknown_truncation`. `finalize`
2257
+ # is idempotent, so double-invocation from nested `finally`s is safe.
2258
+ try:
2259
+ _exc = sys.exc_info()[1]
2260
+ if _exc is not None and not isinstance(
2261
+ _exc, (asyncio.CancelledError, KeyboardInterrupt)
2262
+ ):
2263
+ _reason, _detail = turn_end_summary.classify_exception(_exc)
2264
+ turn_end_summary.mark_turn_reason(_reason, _detail)
2265
+ _record = turn_end_summary.finalize_turn()
2266
+ if _record is not None:
2267
+ try:
2268
+ await adapter._mount_message(
2269
+ AppMessage(turn_end_summary.render_marker_text(_record))
2270
+ )
2271
+ except Exception:
2272
+ logger.debug(
2273
+ "Failed to mount turn-end marker", exc_info=True
2274
+ )
2275
+ except Exception:
2276
+ logger.debug("turn_end_summary finalize failed", exc_info=True)
2277
+
2278
+ # Update token count and return stats. Persistence is handled inside the
2279
+ # graph by `ResumeStateMiddleware.after_model`, so this only refreshes UI.
2280
+ turn_stats.wall_time_seconds = time.monotonic() - start_time
2281
+ _report_tokens(
2282
+ adapter,
2283
+ captured_input_tokens,
2284
+ captured_output_tokens,
2285
+ )
2286
+ return turn_stats
2287
+
2288
+
2289
+ async def _stop_assistant_streams(
2290
+ adapter: TextualUIAdapter,
2291
+ assistant_message_by_namespace: dict[tuple, Any] | None,
2292
+ ) -> None:
2293
+ """Finalize active assistant streams during interrupt cleanup."""
2294
+ if not assistant_message_by_namespace:
2295
+ return
2296
+
2297
+ for current_msg in list(assistant_message_by_namespace.values()):
2298
+ try:
2299
+ await current_msg.stop_stream()
2300
+ except Exception:
2301
+ logger.warning("Failed to stop interrupted assistant stream", exc_info=True)
2302
+ continue
2303
+
2304
+ if adapter._sync_message_content and current_msg.id:
2305
+ adapter._sync_message_content(current_msg.id, current_msg._content)
2306
+
2307
+ assistant_message_by_namespace.clear()
2308
+
2309
+
2310
+ async def _handle_interrupt_cleanup(
2311
+ *,
2312
+ adapter: TextualUIAdapter,
2313
+ agent: Any, # noqa: ANN401 # Dynamic agent graph type
2314
+ config: RunnableConfig,
2315
+ pending_text_by_namespace: dict[tuple, str],
2316
+ assistant_message_by_namespace: dict[tuple, Any] | None = None,
2317
+ captured_input_tokens: int,
2318
+ captured_output_tokens: int,
2319
+ turn_stats: SessionStats,
2320
+ start_time: float,
2321
+ ) -> None:
2322
+ """Shared cleanup for CancelledError and KeyboardInterrupt.
2323
+
2324
+ Args:
2325
+ adapter: UI adapter with display callbacks.
2326
+ agent: The LangGraph agent.
2327
+ config: Runnable config with `thread_id`.
2328
+ pending_text_by_namespace: Accumulated text per namespace.
2329
+ assistant_message_by_namespace: Active assistant message widgets per namespace.
2330
+ captured_input_tokens: Input tokens captured before interrupt.
2331
+ captured_output_tokens: Output tokens captured before interrupt.
2332
+ turn_stats: Stats for the current turn.
2333
+ start_time: Monotonic timestamp when the turn began.
2334
+
2335
+ Raises:
2336
+ ValueError: If proactive remote-run cancellation is attempted without a
2337
+ `thread_id` in `config` (a contract violation rather than a
2338
+ transient remote failure).
2339
+ """
2340
+ from langchain_core.messages import HumanMessage
2341
+
2342
+ # Clear active message immediately so it won't block pruning.
2343
+ # If we don't do this, the store still thinks it's active and protects
2344
+ # from pruning, which breaks get_messages_to_prune(), potentially
2345
+ # blocking all future pruning.
2346
+ if adapter._set_active_message:
2347
+ adapter._set_active_message(None)
2348
+
2349
+ # Hide spinner (may still show "Offloading" if interrupted mid-offload)
2350
+ if adapter._set_spinner:
2351
+ await adapter._set_spinner(None)
2352
+
2353
+ await _stop_assistant_streams(adapter, assistant_message_by_namespace)
2354
+
2355
+ await adapter._mount_message(AppMessage("Interrupted by user"))
2356
+
2357
+ # Proactively cancel server-side runs before persisting recovery state, so
2358
+ # the aupdate_state writes below don't 409 against a still-busy thread. This
2359
+ # is defense-in-depth layered on top of aupdate_state's own 409 -> cancel ->
2360
+ # retry path (see RemoteAgent.aupdate_state); a failure here is not fatal.
2361
+ # Absent on local agents, so this is a no-op for them.
2362
+ cancel_active_runs = getattr(agent, "acancel_active_runs", None)
2363
+ if cancel_active_runs is not None:
2364
+ try:
2365
+ await cancel_active_runs(config)
2366
+ except ValueError:
2367
+ # A missing thread_id is a contract violation (a bug), not a
2368
+ # transient remote failure — surface it rather than downgrading it
2369
+ # to a warning alongside the swallowed network errors below.
2370
+ raise
2371
+ except Exception:
2372
+ # Remote cancel is best-effort defense-in-depth; transient remote
2373
+ # failures here are recovered by aupdate_state's 409 retry below.
2374
+ logger.warning(
2375
+ "Failed to cancel active remote runs for thread %s",
2376
+ config.get("configurable", {}).get("thread_id"),
2377
+ exc_info=True,
2378
+ )
2379
+
2380
+ interrupted_msg = _build_interrupted_ai_message(
2381
+ pending_text_by_namespace,
2382
+ adapter._current_tool_messages,
2383
+ )
2384
+
2385
+ # Close out any tool whose `tool.use` fired but whose `ToolMessage` never
2386
+ # arrived because the turn was cancelled: emit terminal hooks before the
2387
+ # widgets are dropped, so a cancel path leaves no unterminated `tool.use`
2388
+ # (mirroring the HITL-reject branches). The turn does not resume from here,
2389
+ # so the returned ids need not be tracked for dedup.
2390
+ #
2391
+ # Dispatched *before* the `aupdate_state` writes below (not alongside the
2392
+ # `set_rejected` loop after them): those writes await a possibly-slow remote
2393
+ # checkpointer, and on an interactive quit the graceful-exit drain in
2394
+ # `app.py` snapshots the in-flight hook tasks right after cancelling this
2395
+ # worker. Scheduling the fire-and-forget hooks here — synchronously, as soon
2396
+ # as cancellation is observed — guarantees they are in that snapshot and get
2397
+ # drained, rather than being scheduled after a slow write and cancelled at
2398
+ # loop teardown (a silent audit gap). It reads `tool_msg.args`/`tool_name`,
2399
+ # both available regardless of the widget's rejected state.
2400
+ #
2401
+ # Guarded because this now sits *before* the recovery-state write below: the
2402
+ # dispatch never raises by construction today (pure payload builders, and
2403
+ # `dispatch_hook_fire_and_forget` swallows serialization inside its task), but
2404
+ # this function's whole contract is best-effort-must-not-propagate, so a
2405
+ # future change here must never skip the `aupdate_state` save or escape the
2406
+ # cancel handler.
2407
+ try:
2408
+ _dispatch_terminal_tool_result_hooks(
2409
+ adapter._current_tool_messages, "Turn cancelled"
2410
+ )
2411
+ except Exception:
2412
+ logger.warning("Terminal tool.result dispatch failed on cancel", exc_info=True)
2413
+
2414
+ # Save accumulated state before marking tools as rejected (best-effort).
2415
+ # State update failures shouldn't prevent cleanup.
2416
+ from langsmith import tracing_context
2417
+
2418
+ try:
2419
+ # tracing_context(enabled=False) suppresses only the UpdateState traced
2420
+ # run that each aupdate_state call would otherwise emit in LangSmith — it
2421
+ # does not affect any other tracing in the surrounding turn. These writes
2422
+ # are internal interrupt-recovery mechanics (partial AI message +
2423
+ # cancellation notice), not user-driven agent activity; surfacing them as
2424
+ # standalone peer runs alongside real agent turns clutters the trace view.
2425
+ with tracing_context(enabled=False):
2426
+ if interrupted_msg:
2427
+ await agent.aupdate_state(config, {"messages": [interrupted_msg]})
2428
+
2429
+ cancellation_msg = HumanMessage(
2430
+ content=f"{SYSTEM_MESSAGE_PREFIX} Task interrupted by user. "
2431
+ "Previous operation was cancelled."
2432
+ )
2433
+ cancellation_values: dict[str, Any] = {"messages": [cancellation_msg]}
2434
+ # Piggy-back the latest token count on this already-required write
2435
+ # instead of issuing a separate `aupdate_state`. `after_model` never
2436
+ # ran on the partial turn, so without this the count would be stale
2437
+ # on resume.
2438
+ captured_total = captured_input_tokens + captured_output_tokens
2439
+ if captured_total:
2440
+ cancellation_values["_context_tokens"] = captured_total
2441
+ await agent.aupdate_state(config, cancellation_values)
2442
+ except (httpx.TransportError, httpx.TimeoutException) as e:
2443
+ logger.warning("Could not save interrupted state (network): %s", e)
2444
+ except Exception as exc: # interrupt cleanup must not propagate
2445
+ logger.warning("Failed to save interrupted state", exc_info=True)
2446
+ # Surface via the chat surface — silent file-only warnings have
2447
+ # masked real state-write failures (validation, checkpointer
2448
+ # corruption) in past incidents. The mount is best-effort; the
2449
+ # adapter may already be tearing down.
2450
+ with contextlib.suppress(Exception):
2451
+ await adapter._mount_message(
2452
+ AppMessage(
2453
+ f"Could not save interrupted state ({type(exc).__name__}). "
2454
+ "Subsequent turns may see stale state."
2455
+ )
2456
+ )
2457
+
2458
+ # Mark tools as rejected AFTER saving state. Terminal hooks for these were
2459
+ # already dispatched before the state writes above (see the comment there).
2460
+ # Guard each `set_rejected` — it does DOM work that can raise during
2461
+ # app-exit teardown — so a failure can't skip the `clear()` below. If it
2462
+ # did, `_current_tool_messages` would stay populated and the caller's
2463
+ # `finally` backstop would re-dispatch a duplicate terminal hook for every
2464
+ # id already closed at the top of this function.
2465
+ for tool_msg in list(adapter._current_tool_messages.values()):
2466
+ try:
2467
+ tool_msg.set_rejected()
2468
+ adapter._sync_tool_widget(tool_msg)
2469
+ except Exception:
2470
+ logger.exception(
2471
+ "Failed to mark tool row rejected during interrupt cleanup"
2472
+ )
2473
+ adapter._current_tool_messages.clear()
2474
+
2475
+ # Keep the token count marked stale whenever interrupted state was captured,
2476
+ # including tool-only turns after assistant text was already flushed.
2477
+ approximate = interrupted_msg is not None
2478
+
2479
+ turn_stats.wall_time_seconds = time.monotonic() - start_time
2480
+ _report_tokens(
2481
+ adapter,
2482
+ captured_input_tokens,
2483
+ captured_output_tokens,
2484
+ approximate=approximate,
2485
+ )
2486
+
2487
+
2488
+ def _report_tokens(
2489
+ adapter: TextualUIAdapter,
2490
+ captured_input_tokens: int,
2491
+ captured_output_tokens: int,
2492
+ *,
2493
+ approximate: bool = False,
2494
+ ) -> None:
2495
+ """Refresh the token-count UI display.
2496
+
2497
+ Persistence into graph state is owned by `ResumeStateMiddleware.after_model`
2498
+ (normal turns), `_handle_offload` (offload turns), and the interrupt-cleanup
2499
+ `aupdate_state` write (partial turns) — never this helper.
2500
+
2501
+ Args:
2502
+ adapter: UI adapter with token callbacks.
2503
+ captured_input_tokens: Total input tokens captured during the turn.
2504
+ captured_output_tokens: Total output tokens captured during the turn.
2505
+ approximate: When `True`, signal to the UI that the count is stale
2506
+ (e.g. after an interrupted generation) by appending "+".
2507
+ """
2508
+ if captured_input_tokens or captured_output_tokens:
2509
+ if adapter._on_tokens_update:
2510
+ adapter._on_tokens_update(captured_input_tokens, approximate=approximate)
2511
+ elif adapter._on_tokens_show:
2512
+ adapter._on_tokens_show(approximate=approximate)
2513
+
2514
+
2515
+ async def _flush_assistant_text_ns(
2516
+ adapter: TextualUIAdapter,
2517
+ text: str,
2518
+ ns_key: tuple,
2519
+ assistant_message_by_namespace: dict[tuple, Any],
2520
+ ) -> None:
2521
+ """Flush accumulated assistant text for a specific namespace.
2522
+
2523
+ Finalizes the streaming by stopping the MarkdownStream.
2524
+ If no message exists yet, creates one with the full content.
2525
+ """
2526
+ if not text.strip():
2527
+ return
2528
+
2529
+ current_msg = assistant_message_by_namespace.get(ns_key)
2530
+ if current_msg is None:
2531
+ # No message was created during streaming - create one with full content
2532
+ msg_id = f"asst-{uuid.uuid4().hex}"
2533
+ current_msg = AssistantMessage(text, id=msg_id)
2534
+ await adapter._mount_message(current_msg)
2535
+ await current_msg.write_initial_content()
2536
+ assistant_message_by_namespace[ns_key] = current_msg
2537
+ else:
2538
+ # Stop the stream to finalize the content
2539
+ await current_msg.stop_stream()
2540
+
2541
+ # When the AssistantMessage was first mounted and recorded in the
2542
+ # MessageStore, it had empty content (streaming hadn't started yet).
2543
+ # Now that streaming is done, the widget holds the full text in
2544
+ # `_content`, but the store's MessageData still has `content=""`.
2545
+ # If the message is later pruned and re-hydrated, `to_widget()` would
2546
+ # recreate it from that stale empty string. This call copies the
2547
+ # widget's final content back into the store so re-hydration works.
2548
+ if adapter._sync_message_content and current_msg.id:
2549
+ adapter._sync_message_content(current_msg.id, current_msg._content)
2550
+
2551
+ # Clear active message since streaming is done
2552
+ if adapter._set_active_message:
2553
+ adapter._set_active_message(None)