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.
- deepagents_code/__init__.py +42 -0
- deepagents_code/__main__.py +6 -0
- deepagents_code/_ask_user_types.py +90 -0
- deepagents_code/_cli_context.py +96 -0
- deepagents_code/_constants.py +41 -0
- deepagents_code/_debug.py +148 -0
- deepagents_code/_debug_buffer.py +204 -0
- deepagents_code/_env_vars.py +411 -0
- deepagents_code/_fake_models.py +66 -0
- deepagents_code/_git.py +521 -0
- deepagents_code/_paths.py +69 -0
- deepagents_code/_server_config.py +576 -0
- deepagents_code/_session_stats.py +235 -0
- deepagents_code/_startup_error.py +45 -0
- deepagents_code/_testing_models.py +305 -0
- deepagents_code/_textual_patches.py +420 -0
- deepagents_code/_tool_stream.py +694 -0
- deepagents_code/_version.py +46 -0
- deepagents_code/agent.py +1976 -0
- deepagents_code/app.py +19239 -0
- deepagents_code/app.tcss +438 -0
- deepagents_code/approval_mode.py +131 -0
- deepagents_code/ask_user.py +306 -0
- deepagents_code/auth_display.py +185 -0
- deepagents_code/auth_store.py +545 -0
- deepagents_code/built_in_skills/__init__.py +5 -0
- deepagents_code/built_in_skills/remember/SKILL.md +118 -0
- deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
- deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
- deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
- deepagents_code/client/__init__.py +1 -0
- deepagents_code/client/commands/__init__.py +1 -0
- deepagents_code/client/commands/auth.py +541 -0
- deepagents_code/client/commands/config.py +714 -0
- deepagents_code/client/commands/mcp.py +250 -0
- deepagents_code/client/commands/tools.py +416 -0
- deepagents_code/client/launch/__init__.py +1 -0
- deepagents_code/client/launch/server.py +978 -0
- deepagents_code/client/launch/server_manager.py +540 -0
- deepagents_code/client/non_interactive.py +1758 -0
- deepagents_code/client/remote_client.py +794 -0
- deepagents_code/clipboard.py +217 -0
- deepagents_code/command_registry.py +450 -0
- deepagents_code/config.py +4829 -0
- deepagents_code/config_manifest.py +1451 -0
- deepagents_code/configurable_model.py +577 -0
- deepagents_code/default_agent_prompt.md +12 -0
- deepagents_code/doctor.py +559 -0
- deepagents_code/editor.py +142 -0
- deepagents_code/event_bus.py +411 -0
- deepagents_code/extras_info.py +661 -0
- deepagents_code/file_ops.py +576 -0
- deepagents_code/formatting.py +135 -0
- deepagents_code/goal_rubric.py +497 -0
- deepagents_code/goal_tools.py +459 -0
- deepagents_code/hooks.py +348 -0
- deepagents_code/input.py +1041 -0
- deepagents_code/integrations/__init__.py +1 -0
- deepagents_code/integrations/openai_codex.py +551 -0
- deepagents_code/integrations/sandbox_config.py +198 -0
- deepagents_code/integrations/sandbox_factory.py +1124 -0
- deepagents_code/integrations/sandbox_provider.py +137 -0
- deepagents_code/integrations/sandbox_registry.py +350 -0
- deepagents_code/iterm_cursor_guide.py +176 -0
- deepagents_code/local_context.py +926 -0
- deepagents_code/main.py +3985 -0
- deepagents_code/managed_tools.py +642 -0
- deepagents_code/mcp_auth.py +1950 -0
- deepagents_code/mcp_config.py +176 -0
- deepagents_code/mcp_disabled.py +212 -0
- deepagents_code/mcp_login_service.py +281 -0
- deepagents_code/mcp_oauth_ui.py +199 -0
- deepagents_code/mcp_providers/__init__.py +23 -0
- deepagents_code/mcp_providers/_registry.py +39 -0
- deepagents_code/mcp_providers/base.py +133 -0
- deepagents_code/mcp_providers/github.py +102 -0
- deepagents_code/mcp_providers/slack.py +175 -0
- deepagents_code/mcp_tools.py +2427 -0
- deepagents_code/mcp_trust.py +207 -0
- deepagents_code/media_utils.py +826 -0
- deepagents_code/memory_guard.py +474 -0
- deepagents_code/model_config.py +4156 -0
- deepagents_code/notifications.py +247 -0
- deepagents_code/offload.py +402 -0
- deepagents_code/onboarding.py +223 -0
- deepagents_code/output.py +69 -0
- deepagents_code/paste_collapse.py +103 -0
- deepagents_code/project_utils.py +231 -0
- deepagents_code/py.typed +0 -0
- deepagents_code/reasoning_effort.py +641 -0
- deepagents_code/reliable_rubric.py +97 -0
- deepagents_code/resume_state.py +237 -0
- deepagents_code/server_graph.py +310 -0
- deepagents_code/session_end_summary.py +329 -0
- deepagents_code/sessions.py +1716 -0
- deepagents_code/skills/__init__.py +18 -0
- deepagents_code/skills/commands.py +1252 -0
- deepagents_code/skills/invocation.py +112 -0
- deepagents_code/skills/load.py +196 -0
- deepagents_code/skills/trust.py +547 -0
- deepagents_code/state_migration.py +136 -0
- deepagents_code/subagents.py +278 -0
- deepagents_code/system_prompt.md +204 -0
- deepagents_code/terminal_capabilities.py +115 -0
- deepagents_code/terminal_escape.py +287 -0
- deepagents_code/theme.py +891 -0
- deepagents_code/todo_list_prompt.md +12 -0
- deepagents_code/tool_catalog.py +509 -0
- deepagents_code/tool_display.py +367 -0
- deepagents_code/tools.py +516 -0
- deepagents_code/tui/__init__.py +1 -0
- deepagents_code/tui/textual_adapter.py +2553 -0
- deepagents_code/tui/widgets/__init__.py +9 -0
- deepagents_code/tui/widgets/_js_eval_display.py +139 -0
- deepagents_code/tui/widgets/_links.py +261 -0
- deepagents_code/tui/widgets/agent_selector.py +420 -0
- deepagents_code/tui/widgets/approval.py +602 -0
- deepagents_code/tui/widgets/ask_user.py +515 -0
- deepagents_code/tui/widgets/auth.py +1997 -0
- deepagents_code/tui/widgets/autocomplete.py +890 -0
- deepagents_code/tui/widgets/chat_input.py +3181 -0
- deepagents_code/tui/widgets/codex_auth.py +452 -0
- deepagents_code/tui/widgets/cwd_switch.py +242 -0
- deepagents_code/tui/widgets/debug_console.py +868 -0
- deepagents_code/tui/widgets/diff.py +252 -0
- deepagents_code/tui/widgets/effort_selector.py +189 -0
- deepagents_code/tui/widgets/goal_review.py +390 -0
- deepagents_code/tui/widgets/goal_status.py +50 -0
- deepagents_code/tui/widgets/history.py +194 -0
- deepagents_code/tui/widgets/install_confirm.py +248 -0
- deepagents_code/tui/widgets/launch_init.py +482 -0
- deepagents_code/tui/widgets/loading.py +227 -0
- deepagents_code/tui/widgets/mcp_login.py +539 -0
- deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
- deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
- deepagents_code/tui/widgets/message_store.py +999 -0
- deepagents_code/tui/widgets/messages.py +3846 -0
- deepagents_code/tui/widgets/model_selector.py +2343 -0
- deepagents_code/tui/widgets/notification_center.py +456 -0
- deepagents_code/tui/widgets/notification_detail.py +257 -0
- deepagents_code/tui/widgets/notification_settings.py +170 -0
- deepagents_code/tui/widgets/restart_prompt.py +158 -0
- deepagents_code/tui/widgets/skill_trust.py +131 -0
- deepagents_code/tui/widgets/startup_tip.py +91 -0
- deepagents_code/tui/widgets/status.py +781 -0
- deepagents_code/tui/widgets/subagent_panel.py +969 -0
- deepagents_code/tui/widgets/theme_selector.py +401 -0
- deepagents_code/tui/widgets/thread_selector.py +2564 -0
- deepagents_code/tui/widgets/tool_renderers.py +190 -0
- deepagents_code/tui/widgets/tool_widgets.py +304 -0
- deepagents_code/tui/widgets/update_available.py +311 -0
- deepagents_code/tui/widgets/update_confirm.py +184 -0
- deepagents_code/tui/widgets/update_progress.py +327 -0
- deepagents_code/tui/widgets/welcome.py +508 -0
- deepagents_code/turn_end_summary.py +522 -0
- deepagents_code/ui.py +858 -0
- deepagents_code/unicode_security.py +563 -0
- deepagents_code/update_check.py +3124 -0
- zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
- zjcode-0.0.1.dist-info/METADATA +220 -0
- zjcode-0.0.1.dist-info/RECORD +163 -0
- zjcode-0.0.1.dist-info/WHEEL +4 -0
- 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)
|