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,1758 @@
|
|
|
1
|
+
"""Non-interactive execution mode.
|
|
2
|
+
|
|
3
|
+
Provides `run_non_interactive` which runs a single user task against the
|
|
4
|
+
agent graph, streams results to stdout, and exits with an appropriate code.
|
|
5
|
+
|
|
6
|
+
The agent runs inside a `langgraph dev` server subprocess, connected via
|
|
7
|
+
the `RemoteAgent` client (see `server_manager.server_session`).
|
|
8
|
+
|
|
9
|
+
Shell commands are gated by an optional allow-list (`--shell-allow-list`):
|
|
10
|
+
|
|
11
|
+
- Not set → shell disabled, all other tool calls auto-approved.
|
|
12
|
+
- `recommended` or explicit list → shell enabled, commands validated
|
|
13
|
+
against the list; non-shell tools approved unconditionally.
|
|
14
|
+
- `all` → shell enabled, any command allowed, all tools auto-approved.
|
|
15
|
+
|
|
16
|
+
An optional quiet mode (`--quiet` / `-q`) suppresses stream-time diagnostics
|
|
17
|
+
(the tool-call and file-operation notifications) so stdout carries only the
|
|
18
|
+
agent's response text.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
import logging
|
|
25
|
+
import sys
|
|
26
|
+
import threading
|
|
27
|
+
import time
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
30
|
+
|
|
31
|
+
from langchain.agents.middleware.human_in_the_loop import ActionRequest, HITLRequest
|
|
32
|
+
from langchain_core.messages import AIMessage, ToolMessage
|
|
33
|
+
from langgraph.types import Command, Interrupt
|
|
34
|
+
from pydantic import TypeAdapter, ValidationError
|
|
35
|
+
from rich.console import Console
|
|
36
|
+
from rich.live import Live
|
|
37
|
+
from rich.markup import escape as escape_markup
|
|
38
|
+
from rich.spinner import Spinner as RichSpinner
|
|
39
|
+
from rich.style import Style
|
|
40
|
+
from rich.text import Text
|
|
41
|
+
|
|
42
|
+
from deepagents_code._cli_context import CLIContext
|
|
43
|
+
from deepagents_code._session_stats import SessionStats, print_usage_table
|
|
44
|
+
from deepagents_code._tool_stream import (
|
|
45
|
+
UNRENDERABLE_TOOL_OUTPUT,
|
|
46
|
+
ToolCallBuffer,
|
|
47
|
+
ToolCallBufferKey,
|
|
48
|
+
ToolStatus,
|
|
49
|
+
build_tool_error_payload,
|
|
50
|
+
build_tool_result_payload,
|
|
51
|
+
build_tool_use_payload,
|
|
52
|
+
count_unemitted_tool_calls,
|
|
53
|
+
normalize_tool_status,
|
|
54
|
+
tool_call_buffer_key,
|
|
55
|
+
)
|
|
56
|
+
from deepagents_code._version import __version__
|
|
57
|
+
from deepagents_code.agent import DEFAULT_AGENT_NAME
|
|
58
|
+
from deepagents_code.config import (
|
|
59
|
+
SHELL_ALLOW_ALL,
|
|
60
|
+
build_langsmith_thread_url,
|
|
61
|
+
create_model,
|
|
62
|
+
is_shell_command_allowed,
|
|
63
|
+
settings,
|
|
64
|
+
)
|
|
65
|
+
from deepagents_code.file_ops import FileOpTracker
|
|
66
|
+
from deepagents_code.hooks import (
|
|
67
|
+
dispatch_hook,
|
|
68
|
+
dispatch_hook_fire_and_forget,
|
|
69
|
+
drain_pending_hooks,
|
|
70
|
+
)
|
|
71
|
+
from deepagents_code.model_config import ModelConfigError
|
|
72
|
+
from deepagents_code.sessions import generate_thread_id
|
|
73
|
+
from deepagents_code.tool_display import format_tool_message_content
|
|
74
|
+
from deepagents_code.unicode_security import (
|
|
75
|
+
check_url_safety,
|
|
76
|
+
detect_dangerous_unicode,
|
|
77
|
+
format_warning_detail,
|
|
78
|
+
iter_string_values,
|
|
79
|
+
looks_like_url_key,
|
|
80
|
+
summarize_issues,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
if TYPE_CHECKING:
|
|
84
|
+
from asyncio.subprocess import Process
|
|
85
|
+
|
|
86
|
+
from langchain_core.runnables import RunnableConfig
|
|
87
|
+
|
|
88
|
+
logger = logging.getLogger(__name__)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class HITLIterationLimitError(RuntimeError):
|
|
92
|
+
"""Raised when the HITL interrupt loop exceeds `_MAX_HITL_ITERATIONS` rounds."""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
_HITL_REQUEST_ADAPTER = TypeAdapter(HITLRequest)
|
|
96
|
+
|
|
97
|
+
_STREAM_CHUNK_LENGTH = 3
|
|
98
|
+
"""Expected element counts for the tuples emitted by agent.astream.
|
|
99
|
+
|
|
100
|
+
Stream chunks are 3-tuples: (namespace, stream_mode, data).
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
_MESSAGE_DATA_LENGTH = 2
|
|
104
|
+
"""Message-mode data is a 2-tuple: (message_obj, metadata)."""
|
|
105
|
+
|
|
106
|
+
_MAX_HITL_ITERATIONS = 50
|
|
107
|
+
"""Safety cap on the number of HITL interrupt round-trips to prevent infinite
|
|
108
|
+
loops (e.g. when the agent keeps retrying rejected commands)."""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _write_text(text: str) -> None:
|
|
112
|
+
"""Write agent response text to stdout (without a trailing newline).
|
|
113
|
+
|
|
114
|
+
Uses `sys.stdout` directly (rather than the Rich Console) so that agent
|
|
115
|
+
response text always appears on stdout, even in quiet mode where the
|
|
116
|
+
Console is redirected to stderr.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
text: The text string to write.
|
|
120
|
+
"""
|
|
121
|
+
sys.stdout.write(text)
|
|
122
|
+
sys.stdout.flush()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _write_newline() -> None:
|
|
126
|
+
"""Write a newline to stdout (and flush)."""
|
|
127
|
+
sys.stdout.write("\n")
|
|
128
|
+
sys.stdout.flush()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _make_stdio_encoding_safe() -> None:
|
|
132
|
+
"""Prevent `UnicodeEncodeError` from killing a non-interactive run.
|
|
133
|
+
|
|
134
|
+
Legacy Windows consoles default to a locale code page (e.g. cp1252) that
|
|
135
|
+
cannot encode glyphs like "✓"; the first `console.print()` containing one
|
|
136
|
+
then crashes the whole run. Reconfiguring the streams with
|
|
137
|
+
`errors="replace"` degrades unencodable characters to "?" instead. The
|
|
138
|
+
stream encoding itself is left untouched.
|
|
139
|
+
"""
|
|
140
|
+
for stream in (sys.stdout, sys.stderr):
|
|
141
|
+
# Streams replaced by non-reconfigurable objects (e.g. a plain
|
|
142
|
+
# StringIO or a captured buffer) are left as-is.
|
|
143
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
144
|
+
if reconfigure is None:
|
|
145
|
+
continue
|
|
146
|
+
try:
|
|
147
|
+
reconfigure(errors="replace")
|
|
148
|
+
except (ValueError, OSError, TypeError):
|
|
149
|
+
# Closed, detached, or otherwise non-reconfigurable stream (a
|
|
150
|
+
# duck-typed `reconfigure` with a different signature raises
|
|
151
|
+
# `TypeError`) — leave it as-is. This is a best-effort hardening
|
|
152
|
+
# step; it must never itself crash the run.
|
|
153
|
+
logger.debug(
|
|
154
|
+
"Could not reconfigure %s error handler",
|
|
155
|
+
getattr(stream, "name", stream),
|
|
156
|
+
exc_info=True,
|
|
157
|
+
)
|
|
158
|
+
continue
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class _ConsoleSpinner:
|
|
162
|
+
"""Animated spinner for non-interactive verbose output.
|
|
163
|
+
|
|
164
|
+
Uses Rich's `Live` display with a transient braille-dot spinner that
|
|
165
|
+
disappears when stopped, keeping terminal output clean.
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
def __init__(self, console: Console) -> None:
|
|
169
|
+
self._console = console
|
|
170
|
+
self._live: Live | None = None
|
|
171
|
+
|
|
172
|
+
def start(self, message: str = "Working...") -> None:
|
|
173
|
+
"""Start the spinner with the given message.
|
|
174
|
+
|
|
175
|
+
No-op if the spinner is already running. Fails silently if the console
|
|
176
|
+
cannot support live display.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
message: Status text to display next to the spinner.
|
|
180
|
+
"""
|
|
181
|
+
if self._live is not None:
|
|
182
|
+
return
|
|
183
|
+
renderable = RichSpinner(
|
|
184
|
+
"dots",
|
|
185
|
+
text=Text(f" {message}", style="dim"),
|
|
186
|
+
style="dim",
|
|
187
|
+
)
|
|
188
|
+
try:
|
|
189
|
+
self._live = Live(renderable, console=self._console, transient=True)
|
|
190
|
+
self._live.start()
|
|
191
|
+
except (AttributeError, TypeError, OSError) as exc:
|
|
192
|
+
logger.warning("Spinner start failed: %s", exc)
|
|
193
|
+
self._live = None
|
|
194
|
+
|
|
195
|
+
def stop(self) -> None:
|
|
196
|
+
"""Stop the spinner if running. Can be restarted with `start`."""
|
|
197
|
+
if self._live is not None:
|
|
198
|
+
try:
|
|
199
|
+
self._live.stop()
|
|
200
|
+
except (AttributeError, TypeError, OSError) as exc:
|
|
201
|
+
logger.warning("Spinner stop failed: %s", exc)
|
|
202
|
+
finally:
|
|
203
|
+
self._live = None
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
async def _terminate_startup_process(proc: Process) -> None:
|
|
207
|
+
"""Terminate and reap a startup command subprocess.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
proc: Process returned by `asyncio.create_subprocess_shell`.
|
|
211
|
+
"""
|
|
212
|
+
import sys
|
|
213
|
+
|
|
214
|
+
if proc.returncode is not None:
|
|
215
|
+
return
|
|
216
|
+
|
|
217
|
+
try:
|
|
218
|
+
if sys.platform != "win32":
|
|
219
|
+
import os
|
|
220
|
+
import signal
|
|
221
|
+
|
|
222
|
+
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
|
223
|
+
else:
|
|
224
|
+
proc.kill()
|
|
225
|
+
except ProcessLookupError:
|
|
226
|
+
return
|
|
227
|
+
except OSError:
|
|
228
|
+
logger.warning(
|
|
229
|
+
"Failed to terminate startup command (pid=%s)",
|
|
230
|
+
proc.pid,
|
|
231
|
+
exc_info=True,
|
|
232
|
+
)
|
|
233
|
+
return
|
|
234
|
+
|
|
235
|
+
try:
|
|
236
|
+
await asyncio.wait_for(proc.wait(), timeout=5)
|
|
237
|
+
except TimeoutError:
|
|
238
|
+
logger.warning(
|
|
239
|
+
"Startup command (pid=%s) did not exit after termination; sending SIGKILL",
|
|
240
|
+
proc.pid,
|
|
241
|
+
)
|
|
242
|
+
try:
|
|
243
|
+
if sys.platform != "win32":
|
|
244
|
+
import os
|
|
245
|
+
import signal
|
|
246
|
+
|
|
247
|
+
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
|
248
|
+
else:
|
|
249
|
+
proc.kill()
|
|
250
|
+
except ProcessLookupError:
|
|
251
|
+
return
|
|
252
|
+
except OSError:
|
|
253
|
+
logger.warning(
|
|
254
|
+
"Failed to SIGKILL startup command (pid=%s); process may leak",
|
|
255
|
+
proc.pid,
|
|
256
|
+
exc_info=True,
|
|
257
|
+
)
|
|
258
|
+
return
|
|
259
|
+
try:
|
|
260
|
+
await proc.wait()
|
|
261
|
+
except ProcessLookupError:
|
|
262
|
+
pass
|
|
263
|
+
except OSError:
|
|
264
|
+
logger.warning(
|
|
265
|
+
"Failed to reap startup command (pid=%s) after SIGKILL",
|
|
266
|
+
proc.pid,
|
|
267
|
+
exc_info=True,
|
|
268
|
+
)
|
|
269
|
+
except ProcessLookupError:
|
|
270
|
+
pass
|
|
271
|
+
except OSError:
|
|
272
|
+
logger.warning(
|
|
273
|
+
"Failed to wait on startup command (pid=%s) after SIGTERM; "
|
|
274
|
+
"process may leak",
|
|
275
|
+
proc.pid,
|
|
276
|
+
exc_info=True,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@dataclass(frozen=True)
|
|
281
|
+
class InFlightToolCall:
|
|
282
|
+
"""A tool call whose `tool.use` has fired but whose result has not arrived.
|
|
283
|
+
|
|
284
|
+
Bundling the name and args into one record keeps them structurally in
|
|
285
|
+
lock-step: a single `dict[str, InFlightToolCall]` cannot represent an id with
|
|
286
|
+
args but no name (or vice versa), which two parallel dicts could. That
|
|
287
|
+
removes the desync failure mode where an orphaned drain would emit a
|
|
288
|
+
`tool.error` with an empty `tool_name`.
|
|
289
|
+
"""
|
|
290
|
+
|
|
291
|
+
name: str
|
|
292
|
+
"""The tool name, carried so an orphaned call can be closed with a name."""
|
|
293
|
+
|
|
294
|
+
args: dict[str, Any]
|
|
295
|
+
"""The parsed tool-call arguments, for correlating the matching result."""
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
@dataclass
|
|
299
|
+
class StreamState:
|
|
300
|
+
"""Mutable state accumulated while iterating over the agent stream."""
|
|
301
|
+
|
|
302
|
+
quiet: bool = False
|
|
303
|
+
"""When `True`, stream-time diagnostics (the tool-call and file-operation
|
|
304
|
+
notifications, plus the stdout separator newline preceding a tool call) are
|
|
305
|
+
suppressed, so stdout carries only agent response text."""
|
|
306
|
+
|
|
307
|
+
stream: bool = True
|
|
308
|
+
"""When `True` (default), text chunks are written to stdout as they arrive.
|
|
309
|
+
|
|
310
|
+
When `False`, text is buffered in `full_response` and flushed after the
|
|
311
|
+
agent finishes.
|
|
312
|
+
"""
|
|
313
|
+
|
|
314
|
+
full_response: list[str] = field(default_factory=list)
|
|
315
|
+
"""Accumulated text fragments from the AI message stream."""
|
|
316
|
+
|
|
317
|
+
tool_call_buffers: dict[ToolCallBufferKey, ToolCallBuffer] = field(
|
|
318
|
+
default_factory=dict
|
|
319
|
+
)
|
|
320
|
+
"""Maps a tool-call index or ID to its in-progress buffer: name, ID,
|
|
321
|
+
accumulated argument fragments, and the display latch."""
|
|
322
|
+
|
|
323
|
+
in_flight_tool_calls: dict[str, InFlightToolCall] = field(default_factory=dict)
|
|
324
|
+
"""Maps in-flight tool-call IDs (those whose `tool.use` has fired) to their
|
|
325
|
+
name and parsed arguments, so the matching `tool.result` can be correlated.
|
|
326
|
+
Entries are removed when the result arrives; any still present when the
|
|
327
|
+
stream aborts are closed by `_dispatch_orphaned_tool_result_hooks`. One
|
|
328
|
+
record per id keeps name and args structurally in lock-step (see
|
|
329
|
+
`InFlightToolCall`)."""
|
|
330
|
+
|
|
331
|
+
displayed_tool_call_ids: set[str] = field(default_factory=set)
|
|
332
|
+
"""Tool-call IDs whose non-interactive call line has already been printed."""
|
|
333
|
+
|
|
334
|
+
emitted_tool_use_ids: set[str] = field(default_factory=set)
|
|
335
|
+
"""Tool-call IDs for which a `tool.use` has been dispatched.
|
|
336
|
+
|
|
337
|
+
Monotonic: never cleared within a run, so `tool.use` fires at most once per
|
|
338
|
+
id even if a call's arg chunks are redelivered after its result (mirrors the
|
|
339
|
+
TUI's `displayed_tool_ids`). Result correlation and the orphan drain use the
|
|
340
|
+
separate `in_flight_tool_calls`, which *is* cleared per result."""
|
|
341
|
+
|
|
342
|
+
pending_interrupts: dict[str, HITLRequest] = field(default_factory=dict)
|
|
343
|
+
"""Maps interrupt IDs to their validated HITL requests that are awaiting
|
|
344
|
+
decisions."""
|
|
345
|
+
|
|
346
|
+
hitl_response: dict[str, dict[str, list[dict[str, str]]]] = field(
|
|
347
|
+
default_factory=dict
|
|
348
|
+
)
|
|
349
|
+
"""Maps interrupt IDs to dicts containing a `'decisions'` key with a list of
|
|
350
|
+
decision dicts (each having a `'type'` key of `'approve'` or `'reject'`).
|
|
351
|
+
|
|
352
|
+
Used to resume the agent after HITL processing.
|
|
353
|
+
"""
|
|
354
|
+
|
|
355
|
+
interrupt_occurred: bool = False
|
|
356
|
+
"""Flag indicating whether any HITL interrupt was received during the
|
|
357
|
+
current stream pass."""
|
|
358
|
+
|
|
359
|
+
stats: SessionStats = field(default_factory=SessionStats)
|
|
360
|
+
"""Accumulated model usage stats for this stream."""
|
|
361
|
+
|
|
362
|
+
spinner: _ConsoleSpinner | None = None
|
|
363
|
+
"""Optional animated spinner shown during agent work in verbose mode."""
|
|
364
|
+
|
|
365
|
+
show_rubric_iterations: bool = False
|
|
366
|
+
"""Whether rubric lifecycle messages should include iteration numbers."""
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
@dataclass
|
|
370
|
+
class ThreadUrlLookupState:
|
|
371
|
+
"""Best-effort background LangSmith thread URL lookup state.
|
|
372
|
+
|
|
373
|
+
Thread safety: the background thread sets `url` then calls `done.set()`.
|
|
374
|
+
Consumers must check `done.is_set()` before reading `url`.
|
|
375
|
+
"""
|
|
376
|
+
|
|
377
|
+
done: threading.Event = field(default_factory=threading.Event)
|
|
378
|
+
url: str | None = None
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _start_langsmith_thread_url_lookup(thread_id: str) -> ThreadUrlLookupState:
|
|
382
|
+
"""Start background LangSmith URL resolution without blocking.
|
|
383
|
+
|
|
384
|
+
Args:
|
|
385
|
+
thread_id: Thread identifier to resolve.
|
|
386
|
+
|
|
387
|
+
Returns:
|
|
388
|
+
Mutable lookup state whose completion can be checked later.
|
|
389
|
+
"""
|
|
390
|
+
state = ThreadUrlLookupState()
|
|
391
|
+
|
|
392
|
+
def _resolve() -> None:
|
|
393
|
+
try:
|
|
394
|
+
state.url = build_langsmith_thread_url(thread_id)
|
|
395
|
+
except Exception: # build_langsmith_thread_url already handles known errors
|
|
396
|
+
logger.debug(
|
|
397
|
+
"Could not resolve LangSmith thread URL for '%s'",
|
|
398
|
+
thread_id,
|
|
399
|
+
exc_info=True,
|
|
400
|
+
)
|
|
401
|
+
finally:
|
|
402
|
+
state.done.set()
|
|
403
|
+
|
|
404
|
+
threading.Thread(target=_resolve, daemon=True).start()
|
|
405
|
+
return state
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _process_interrupts(
|
|
409
|
+
data: dict[str, list[Interrupt]],
|
|
410
|
+
state: StreamState,
|
|
411
|
+
console: Console,
|
|
412
|
+
) -> None:
|
|
413
|
+
"""Extract HITL interrupts from an `updates` chunk and record them.
|
|
414
|
+
|
|
415
|
+
Args:
|
|
416
|
+
data: The `updates` dict that contains an `__interrupt__` key.
|
|
417
|
+
state: Stream state to update with new pending interrupts.
|
|
418
|
+
console: Rich console for user-visible warnings.
|
|
419
|
+
"""
|
|
420
|
+
interrupts = data["__interrupt__"]
|
|
421
|
+
if interrupts:
|
|
422
|
+
for interrupt_obj in interrupts:
|
|
423
|
+
try:
|
|
424
|
+
validated_request = _HITL_REQUEST_ADAPTER.validate_python(
|
|
425
|
+
interrupt_obj.value
|
|
426
|
+
)
|
|
427
|
+
except ValidationError:
|
|
428
|
+
logger.warning(
|
|
429
|
+
"Rejecting malformed HITL interrupt %s (raw value: %r)",
|
|
430
|
+
interrupt_obj.id,
|
|
431
|
+
interrupt_obj.value,
|
|
432
|
+
)
|
|
433
|
+
console.print(
|
|
434
|
+
f"[yellow]Warning: Received malformed tool approval "
|
|
435
|
+
f"request (interrupt {interrupt_obj.id}). Rejecting.[/yellow]"
|
|
436
|
+
)
|
|
437
|
+
# Fail-closed: record a reject decision for malformed interrupts
|
|
438
|
+
|
|
439
|
+
state.hitl_response[interrupt_obj.id] = {
|
|
440
|
+
"decisions": [{"type": "reject", "message": "Malformed interrupt"}]
|
|
441
|
+
}
|
|
442
|
+
continue
|
|
443
|
+
state.pending_interrupts[interrupt_obj.id] = validated_request
|
|
444
|
+
state.interrupt_occurred = True
|
|
445
|
+
dispatch_hook_fire_and_forget("input.required", {})
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _process_ai_message(
|
|
449
|
+
message_obj: AIMessage,
|
|
450
|
+
state: StreamState,
|
|
451
|
+
console: Console,
|
|
452
|
+
) -> None:
|
|
453
|
+
"""Extract text and tool-call blocks from an AI message and render them.
|
|
454
|
+
|
|
455
|
+
When streaming is enabled, text blocks are written to stdout immediately;
|
|
456
|
+
otherwise they are accumulated in `state.full_response` for deferred
|
|
457
|
+
output. Tool-call blocks are buffered and their names are printed to the
|
|
458
|
+
console.
|
|
459
|
+
|
|
460
|
+
Args:
|
|
461
|
+
message_obj: The `AIMessage` received from the stream.
|
|
462
|
+
state: Stream state for accumulating response text and tool-call buffers.
|
|
463
|
+
console: Rich console for formatted output.
|
|
464
|
+
"""
|
|
465
|
+
# Extract token usage for stats accumulation
|
|
466
|
+
usage = getattr(message_obj, "usage_metadata", None)
|
|
467
|
+
if usage:
|
|
468
|
+
input_toks = usage.get("input_tokens", 0)
|
|
469
|
+
output_toks = usage.get("output_tokens", 0)
|
|
470
|
+
total_toks = usage.get("total_tokens", 0)
|
|
471
|
+
active_model = settings.model_name or ""
|
|
472
|
+
active_provider = settings.model_provider or ""
|
|
473
|
+
if input_toks or output_toks:
|
|
474
|
+
state.stats.record_request(
|
|
475
|
+
active_model, input_toks, output_toks, active_provider
|
|
476
|
+
)
|
|
477
|
+
elif total_toks:
|
|
478
|
+
state.stats.record_request(active_model, total_toks, 0, active_provider)
|
|
479
|
+
|
|
480
|
+
# Feed the finish reason to the per-turn end-status tracker so the marker
|
|
481
|
+
# printed at the bottom of the turn reflects `length` / `max_tokens` /
|
|
482
|
+
# `content_filter` etc. instead of defaulting to `unknown_truncation`.
|
|
483
|
+
_resp_meta = getattr(message_obj, "response_metadata", None) or {}
|
|
484
|
+
_fr = _resp_meta.get("finish_reason") or _resp_meta.get("stop_reason")
|
|
485
|
+
if _fr:
|
|
486
|
+
try:
|
|
487
|
+
from deepagents_code import turn_end_summary
|
|
488
|
+
|
|
489
|
+
turn_end_summary.observe_finish_reason(_fr)
|
|
490
|
+
except Exception:
|
|
491
|
+
logger.debug("observe_finish_reason (headless) failed", exc_info=True)
|
|
492
|
+
|
|
493
|
+
if not hasattr(message_obj, "content_blocks"):
|
|
494
|
+
logger.debug("AIMessage missing content_blocks attribute, skipping")
|
|
495
|
+
return
|
|
496
|
+
for block in message_obj.content_blocks:
|
|
497
|
+
if not isinstance(block, dict):
|
|
498
|
+
continue
|
|
499
|
+
block_type = block.get("type")
|
|
500
|
+
if block_type == "text":
|
|
501
|
+
text = block.get("text", "")
|
|
502
|
+
if text:
|
|
503
|
+
if state.stream:
|
|
504
|
+
if state.spinner:
|
|
505
|
+
state.spinner.stop()
|
|
506
|
+
_write_text(text)
|
|
507
|
+
state.full_response.append(text)
|
|
508
|
+
elif block_type in {"tool_call_chunk", "tool_call"}:
|
|
509
|
+
chunk_name = block.get("name")
|
|
510
|
+
chunk_id = block.get("id")
|
|
511
|
+
chunk_index = block.get("index")
|
|
512
|
+
chunk_args = block.get("args")
|
|
513
|
+
|
|
514
|
+
buffer_key = tool_call_buffer_key(
|
|
515
|
+
chunk_index, chunk_id, len(state.tool_call_buffers)
|
|
516
|
+
)
|
|
517
|
+
buffer = state.tool_call_buffers.setdefault(buffer_key, ToolCallBuffer())
|
|
518
|
+
buffer.ingest(name=chunk_name, tool_id=chunk_id, args=chunk_args)
|
|
519
|
+
|
|
520
|
+
buffer_name = buffer.name
|
|
521
|
+
buffer_id = buffer.tool_id
|
|
522
|
+
already_displayed = (
|
|
523
|
+
isinstance(buffer_id, str)
|
|
524
|
+
and buffer_id in state.displayed_tool_call_ids
|
|
525
|
+
)
|
|
526
|
+
if (
|
|
527
|
+
isinstance(buffer_name, str)
|
|
528
|
+
and not buffer.displayed
|
|
529
|
+
and not already_displayed
|
|
530
|
+
):
|
|
531
|
+
if state.spinner:
|
|
532
|
+
state.spinner.stop()
|
|
533
|
+
if not state.quiet:
|
|
534
|
+
if state.full_response:
|
|
535
|
+
_write_newline()
|
|
536
|
+
console.print(
|
|
537
|
+
f"[dim]🔧 Calling tool: {escape_markup(buffer_name)}[/dim]",
|
|
538
|
+
highlight=False,
|
|
539
|
+
)
|
|
540
|
+
buffer.displayed = True
|
|
541
|
+
if isinstance(buffer_id, str):
|
|
542
|
+
state.displayed_tool_call_ids.add(buffer_id)
|
|
543
|
+
elif isinstance(buffer_id, str) and buffer.displayed:
|
|
544
|
+
state.displayed_tool_call_ids.add(buffer_id)
|
|
545
|
+
|
|
546
|
+
# Gate tool.use on a resolved tool id so this surface matches the
|
|
547
|
+
# interactive one, which dispatches at widget-mount time in
|
|
548
|
+
# `textual_adapter.execute_task_textual`. Both gate on a resolved
|
|
549
|
+
# tool-call id and fire at most once per id via a monotonic id set
|
|
550
|
+
# (`emitted_tool_use_ids` here, `displayed_tool_ids` there) that is
|
|
551
|
+
# never cleared within the run — see the "fire-once-per-id" clause of
|
|
552
|
+
# the parity contract in `_tool_stream`. Gating on the monotonic set
|
|
553
|
+
# rather than `in_flight_tool_calls` (which is cleared per result)
|
|
554
|
+
# means a redelivery of a completed call's arg chunks does not
|
|
555
|
+
# re-fire `tool.use` and spawn a spurious orphan.
|
|
556
|
+
parsed_args = buffer.parse_args()
|
|
557
|
+
if (
|
|
558
|
+
isinstance(buffer_name, str)
|
|
559
|
+
and buffer_id is not None
|
|
560
|
+
and parsed_args is not None
|
|
561
|
+
and buffer_id not in state.emitted_tool_use_ids
|
|
562
|
+
):
|
|
563
|
+
dispatch_hook_fire_and_forget(
|
|
564
|
+
"tool.use",
|
|
565
|
+
build_tool_use_payload(buffer_name, buffer_id, parsed_args),
|
|
566
|
+
)
|
|
567
|
+
state.emitted_tool_use_ids.add(buffer_id)
|
|
568
|
+
state.in_flight_tool_calls[buffer_id] = InFlightToolCall(
|
|
569
|
+
buffer_name, parsed_args
|
|
570
|
+
)
|
|
571
|
+
if (
|
|
572
|
+
isinstance(buffer_id, str)
|
|
573
|
+
and parsed_args is not None
|
|
574
|
+
and buffer_id in state.emitted_tool_use_ids
|
|
575
|
+
):
|
|
576
|
+
# Drop the buffer so a later turn that reuses this streaming
|
|
577
|
+
# index (indices restart per message, per LangChain streaming
|
|
578
|
+
# semantics) starts fresh rather than reusing this call's state.
|
|
579
|
+
# This also clears a redelivered completed call's recreated
|
|
580
|
+
# buffer, whose `tool.use` is already suppressed by the
|
|
581
|
+
# monotonic `emitted_tool_use_ids`.
|
|
582
|
+
state.tool_call_buffers.pop(buffer_key, None)
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _process_message_chunk(
|
|
586
|
+
data: tuple[AIMessage | ToolMessage, dict[str, str]],
|
|
587
|
+
state: StreamState,
|
|
588
|
+
console: Console,
|
|
589
|
+
file_op_tracker: FileOpTracker,
|
|
590
|
+
) -> None:
|
|
591
|
+
"""Handle a `messages`-mode chunk from the stream.
|
|
592
|
+
|
|
593
|
+
Dispatches to AI-message or tool-message processing depending on the
|
|
594
|
+
message type.
|
|
595
|
+
|
|
596
|
+
Args:
|
|
597
|
+
data: A 2-tuple of `(message_obj, metadata)` from the messages
|
|
598
|
+
stream mode.
|
|
599
|
+
state: Shared stream state.
|
|
600
|
+
console: Rich console for formatted output.
|
|
601
|
+
file_op_tracker: Tracker for file-operation diffs.
|
|
602
|
+
"""
|
|
603
|
+
if not isinstance(data, tuple) or len(data) != _MESSAGE_DATA_LENGTH:
|
|
604
|
+
logger.debug(
|
|
605
|
+
"Unexpected message-mode data (type=%s), skipping", type(data).__name__
|
|
606
|
+
)
|
|
607
|
+
return
|
|
608
|
+
|
|
609
|
+
message_obj, metadata = data
|
|
610
|
+
|
|
611
|
+
# The summarization middleware injects synthetic messages to compress
|
|
612
|
+
# conversation history for the LLM. These are internal bookkeeping and
|
|
613
|
+
# should not be rendered to the user.
|
|
614
|
+
if metadata and metadata.get("lc_source") == "summarization":
|
|
615
|
+
return
|
|
616
|
+
|
|
617
|
+
if isinstance(message_obj, AIMessage):
|
|
618
|
+
_process_ai_message(message_obj, state, console)
|
|
619
|
+
elif isinstance(message_obj, ToolMessage):
|
|
620
|
+
tool_id = getattr(message_obj, "tool_call_id", None)
|
|
621
|
+
correlated_tool_name = ""
|
|
622
|
+
# Args come from the matching tool.use. They default to {} when the call
|
|
623
|
+
# had no id to correlate on, or no tool.use fired (e.g. its args never
|
|
624
|
+
# parsed). The seam is intentional — without a correlated id we cannot
|
|
625
|
+
# pair them — but log the correlation miss so a lost pairing is not
|
|
626
|
+
# completely silent.
|
|
627
|
+
if isinstance(tool_id, str):
|
|
628
|
+
in_flight = state.in_flight_tool_calls.pop(tool_id, None)
|
|
629
|
+
tool_args = in_flight.args if in_flight is not None else None
|
|
630
|
+
correlated_tool_name = in_flight.name if in_flight is not None else ""
|
|
631
|
+
state.displayed_tool_call_ids.discard(tool_id)
|
|
632
|
+
if tool_args is None:
|
|
633
|
+
# Warning, not info/debug: a real-id result with no matching
|
|
634
|
+
# tool.use means a hook consumer sees a `tool.result` with empty
|
|
635
|
+
# args for a tool that actually executed (its args never parsed) —
|
|
636
|
+
# degraded audit fidelity worth surfacing at default log levels,
|
|
637
|
+
# consistent with the rest of this feature's severity philosophy.
|
|
638
|
+
logger.warning(
|
|
639
|
+
"tool.result for %s has no correlated tool.use args; "
|
|
640
|
+
"sending empty tool_args",
|
|
641
|
+
tool_id,
|
|
642
|
+
)
|
|
643
|
+
tool_args = {}
|
|
644
|
+
else:
|
|
645
|
+
tool_args = {}
|
|
646
|
+
record = file_op_tracker.complete_with_message(message_obj)
|
|
647
|
+
if record and record.diff:
|
|
648
|
+
if state.spinner:
|
|
649
|
+
state.spinner.stop()
|
|
650
|
+
if not state.quiet:
|
|
651
|
+
console.print(
|
|
652
|
+
f"[dim]📝 {escape_markup(record.display_path)}[/dim]",
|
|
653
|
+
highlight=False,
|
|
654
|
+
)
|
|
655
|
+
tool_name = getattr(message_obj, "name", "")
|
|
656
|
+
if not tool_name:
|
|
657
|
+
tool_name = correlated_tool_name
|
|
658
|
+
# Normalize to the two-value hook domain, fail-closed: an unexpected
|
|
659
|
+
# provider status is logged and treated as an error (see
|
|
660
|
+
# `normalize_tool_status`) rather than silently reported as success.
|
|
661
|
+
tool_status: ToolStatus = normalize_tool_status(
|
|
662
|
+
getattr(message_obj, "status", "success"), tool_name
|
|
663
|
+
)
|
|
664
|
+
# Format the content the same way the interactive surface does so
|
|
665
|
+
# `tool_output` is identical across surfaces for list/structured content
|
|
666
|
+
# (e.g. multimodal or MCP tools returning content blocks) rather than a
|
|
667
|
+
# raw Python list repr here vs. extracted text there. Truncation to
|
|
668
|
+
# HOOK_TOOL_OUTPUT_LIMIT happens inside build_tool_result_payload.
|
|
669
|
+
# Guard formatting so a formatter error can't skip the tool.result
|
|
670
|
+
# dispatch below; on failure use a sentinel (see the except) rather than
|
|
671
|
+
# re-touching the offending content, so the dispatch stays unconditional.
|
|
672
|
+
try:
|
|
673
|
+
tool_content = format_tool_message_content(message_obj.content)
|
|
674
|
+
tool_output = str(tool_content) if tool_content else ""
|
|
675
|
+
except Exception:
|
|
676
|
+
# Guard formatting *and* the str() coercion together: a pathological
|
|
677
|
+
# __str__ must not re-raise past the fallback and skip the
|
|
678
|
+
# tool.result dispatch below. Use a sentinel rather than re-touching
|
|
679
|
+
# the offending content, so the dispatch stays unconditional.
|
|
680
|
+
logger.exception("Failed to format tool output")
|
|
681
|
+
tool_output = UNRENDERABLE_TOOL_OUTPUT
|
|
682
|
+
# Headless always dispatches tool.result for every ToolMessage — there
|
|
683
|
+
# are no widgets to skip. The TUI handles ToolMessages in three branches
|
|
684
|
+
# in `textual_adapter.execute_task_textual`: the widget-backed path and
|
|
685
|
+
# an `else` for unmounted tools both dispatch (mirroring this
|
|
686
|
+
# always-dispatch behavior), while the `completed_tool_result_ids` branch
|
|
687
|
+
# suppresses a duplicate rather than dispatching. See the parity contract
|
|
688
|
+
# in `_tool_stream` for the full guarantee.
|
|
689
|
+
if tool_status == "error":
|
|
690
|
+
dispatch_hook_fire_and_forget(
|
|
691
|
+
"tool.error",
|
|
692
|
+
build_tool_error_payload(tool_name),
|
|
693
|
+
)
|
|
694
|
+
dispatch_hook_fire_and_forget(
|
|
695
|
+
"tool.result",
|
|
696
|
+
build_tool_result_payload(
|
|
697
|
+
tool_name, tool_id, tool_args, tool_status, tool_output
|
|
698
|
+
),
|
|
699
|
+
)
|
|
700
|
+
if state.spinner:
|
|
701
|
+
state.spinner.start()
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def _process_rubric_event(
|
|
705
|
+
data: dict[str, Any],
|
|
706
|
+
state: StreamState,
|
|
707
|
+
console: Console,
|
|
708
|
+
) -> None:
|
|
709
|
+
"""Render a `RubricMiddleware` lifecycle event from the custom stream.
|
|
710
|
+
|
|
711
|
+
`RubricMiddleware` emits `rubric_evaluation_start` / `rubric_evaluation_end`
|
|
712
|
+
dicts via `runtime.stream_writer`. Non-rubric custom payloads are ignored.
|
|
713
|
+
|
|
714
|
+
Args:
|
|
715
|
+
data: The custom-stream payload dict.
|
|
716
|
+
state: Shared stream state (used to pause the spinner).
|
|
717
|
+
console: Rich console for status output (stderr in `--quiet` mode).
|
|
718
|
+
"""
|
|
719
|
+
event_type = data.get("type")
|
|
720
|
+
if event_type not in {"rubric_evaluation_start", "rubric_evaluation_end"}:
|
|
721
|
+
return
|
|
722
|
+
|
|
723
|
+
if state.spinner:
|
|
724
|
+
state.spinner.stop()
|
|
725
|
+
|
|
726
|
+
if event_type == "rubric_evaluation_start":
|
|
727
|
+
# `iteration` is untrusted streamed payload; only render the 1-based
|
|
728
|
+
# number when it is actually an int and the user explicitly requested an
|
|
729
|
+
# iteration cap. A non-int previously raised `TypeError` here and aborted
|
|
730
|
+
# the whole non-interactive run.
|
|
731
|
+
iteration = data.get("iteration", 0)
|
|
732
|
+
label = (
|
|
733
|
+
f" (iteration {iteration + 1})"
|
|
734
|
+
if state.show_rubric_iterations and isinstance(iteration, int)
|
|
735
|
+
else ""
|
|
736
|
+
)
|
|
737
|
+
console.print(
|
|
738
|
+
f"[dim]⏳ Checking acceptance criteria{label}…[/dim]",
|
|
739
|
+
highlight=False,
|
|
740
|
+
)
|
|
741
|
+
if state.spinner:
|
|
742
|
+
state.spinner.start()
|
|
743
|
+
return
|
|
744
|
+
|
|
745
|
+
result = data.get("result")
|
|
746
|
+
explanation = (data.get("explanation") or "").strip()
|
|
747
|
+
if result == "satisfied":
|
|
748
|
+
console.print("[green]✓ Acceptance criteria satisfied[/green]", highlight=False)
|
|
749
|
+
elif result == "needs_revision":
|
|
750
|
+
suffix = f": {escape_markup(explanation)}" if explanation else ""
|
|
751
|
+
console.print(
|
|
752
|
+
f"[yellow]↻ Acceptance criteria not yet satisfied{suffix}[/yellow]",
|
|
753
|
+
highlight=False,
|
|
754
|
+
)
|
|
755
|
+
for criterion in data.get("criteria", []):
|
|
756
|
+
if isinstance(criterion, dict) and not criterion.get("passed", True):
|
|
757
|
+
name = escape_markup(str(criterion.get("name", "criterion")))
|
|
758
|
+
gap = escape_markup(str(criterion.get("gap", "")).strip())
|
|
759
|
+
detail = f" — {gap}" if gap else ""
|
|
760
|
+
console.print(f"[yellow] ✗ {name}{detail}[/yellow]", highlight=False)
|
|
761
|
+
elif result == "max_iterations_reached":
|
|
762
|
+
suffix = f": {escape_markup(explanation)}" if explanation else ""
|
|
763
|
+
console.print(
|
|
764
|
+
"[yellow]⚠ Acceptance criteria not yet satisfied "
|
|
765
|
+
f"(iteration limit reached){suffix}[/yellow]",
|
|
766
|
+
highlight=False,
|
|
767
|
+
)
|
|
768
|
+
for criterion in data.get("criteria", []):
|
|
769
|
+
if isinstance(criterion, dict) and not criterion.get("passed", True):
|
|
770
|
+
name = escape_markup(str(criterion.get("name", "criterion")))
|
|
771
|
+
gap = escape_markup(str(criterion.get("gap", "")).strip())
|
|
772
|
+
detail = f" — {gap}" if gap else ""
|
|
773
|
+
console.print(f"[yellow] ✗ {name}{detail}[/yellow]", highlight=False)
|
|
774
|
+
elif result in {"failed", "grader_error"}:
|
|
775
|
+
label = (
|
|
776
|
+
"Rubric is invalid or cannot be evaluated"
|
|
777
|
+
if result == "failed"
|
|
778
|
+
else "Acceptance criteria check failed"
|
|
779
|
+
)
|
|
780
|
+
suffix = f": {escape_markup(explanation)}" if explanation else ""
|
|
781
|
+
console.print(f"[red]⚠ {label}{suffix}[/red]", highlight=False)
|
|
782
|
+
elif result is not None:
|
|
783
|
+
# A `rubric_evaluation_end` with an unrecognized result is still a
|
|
784
|
+
# terminal grading event; surface it rather than letting the run go
|
|
785
|
+
# quiet mid-task (e.g. if the SDK adds a new verdict). Mirrors the
|
|
786
|
+
# interactive fallback in `textual_adapter._format_rubric_event`.
|
|
787
|
+
suffix = f": {escape_markup(explanation)}" if explanation else ""
|
|
788
|
+
console.print(
|
|
789
|
+
f"[yellow]⚠ Acceptance criteria check ended{suffix}[/yellow]",
|
|
790
|
+
highlight=False,
|
|
791
|
+
)
|
|
792
|
+
|
|
793
|
+
if state.spinner:
|
|
794
|
+
state.spinner.start()
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def _process_stream_chunk(
|
|
798
|
+
chunk: object,
|
|
799
|
+
state: StreamState,
|
|
800
|
+
console: Console,
|
|
801
|
+
file_op_tracker: FileOpTracker,
|
|
802
|
+
) -> None:
|
|
803
|
+
"""Route a single raw stream chunk to the appropriate handler.
|
|
804
|
+
|
|
805
|
+
Only main-agent chunks are processed; sub-agent output is ignored so
|
|
806
|
+
that only top-level content is rendered.
|
|
807
|
+
|
|
808
|
+
Args:
|
|
809
|
+
chunk: A raw element yielded by `agent.astream`.
|
|
810
|
+
|
|
811
|
+
Expected to be a 3-tuple `(namespace, stream_mode, data)` for
|
|
812
|
+
main-agent output.
|
|
813
|
+
state: Shared stream state.
|
|
814
|
+
console: Rich console for formatted output.
|
|
815
|
+
file_op_tracker: Tracker for file-operation diffs.
|
|
816
|
+
"""
|
|
817
|
+
if not isinstance(chunk, tuple) or len(chunk) != _STREAM_CHUNK_LENGTH:
|
|
818
|
+
logger.debug(
|
|
819
|
+
"Unexpected stream chunk (type=%s), skipping", type(chunk).__name__
|
|
820
|
+
)
|
|
821
|
+
return
|
|
822
|
+
|
|
823
|
+
namespace, stream_mode, data = chunk
|
|
824
|
+
is_main_agent = not namespace
|
|
825
|
+
|
|
826
|
+
if not is_main_agent:
|
|
827
|
+
return
|
|
828
|
+
|
|
829
|
+
if stream_mode == "updates" and isinstance(data, dict) and "__interrupt__" in data:
|
|
830
|
+
_process_interrupts(cast("dict[str, list[Interrupt]]", data), state, console)
|
|
831
|
+
elif stream_mode == "custom" and isinstance(data, dict):
|
|
832
|
+
_process_rubric_event(cast("dict[str, Any]", data), state, console)
|
|
833
|
+
elif stream_mode == "messages":
|
|
834
|
+
_process_message_chunk(
|
|
835
|
+
cast("tuple[AIMessage | ToolMessage, dict[str, str]]", data),
|
|
836
|
+
state,
|
|
837
|
+
console,
|
|
838
|
+
file_op_tracker,
|
|
839
|
+
)
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
def _make_hitl_decision(
|
|
843
|
+
action_request: ActionRequest, console: Console
|
|
844
|
+
) -> dict[str, str]:
|
|
845
|
+
"""Decide whether to approve or reject a single action request.
|
|
846
|
+
|
|
847
|
+
This function is only invoked when a restrictive shell allow-list is
|
|
848
|
+
configured (not `all`). When shell is disabled or unrestricted,
|
|
849
|
+
`interrupt_on` is empty and this function is bypassed entirely.
|
|
850
|
+
|
|
851
|
+
Shell tools are always gated: if an allow-list is configured, the command
|
|
852
|
+
is validated against it; if no allow-list is configured, shell commands
|
|
853
|
+
are rejected outright (defense-in-depth — the caller should disable
|
|
854
|
+
shell tools when no allow-list is present, but this function fails
|
|
855
|
+
closed regardless). Non-shell tools are approved unconditionally.
|
|
856
|
+
|
|
857
|
+
Args:
|
|
858
|
+
action_request: The action-request dict emitted by the HITL middleware.
|
|
859
|
+
|
|
860
|
+
Must contain at least a `name` key.
|
|
861
|
+
console: Rich console for status output.
|
|
862
|
+
|
|
863
|
+
Returns:
|
|
864
|
+
Decision dict with a `type` key (`"approve"` or `"reject"`)
|
|
865
|
+
and an optional `message` key with a human-readable explanation.
|
|
866
|
+
"""
|
|
867
|
+
for warning in _collect_action_request_warnings(action_request):
|
|
868
|
+
console.print(f"[yellow]Warning:[/yellow] {warning}")
|
|
869
|
+
|
|
870
|
+
action_name = action_request.get("name", "")
|
|
871
|
+
|
|
872
|
+
if action_name == "execute":
|
|
873
|
+
if not settings.shell_allow_list:
|
|
874
|
+
command = action_request.get("args", {}).get("command", "")
|
|
875
|
+
console.print(
|
|
876
|
+
f"\n[red]Shell command rejected (no allow-list configured): "
|
|
877
|
+
f"{command}[/red]"
|
|
878
|
+
)
|
|
879
|
+
return {
|
|
880
|
+
"type": "reject",
|
|
881
|
+
"message": (
|
|
882
|
+
"Shell commands are not permitted in non-interactive mode "
|
|
883
|
+
"without a --shell-allow-list. Use --shell-allow-list to "
|
|
884
|
+
"specify allowed commands."
|
|
885
|
+
),
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
command = action_request.get("args", {}).get("command", "")
|
|
889
|
+
|
|
890
|
+
if is_shell_command_allowed(command, settings.shell_allow_list):
|
|
891
|
+
console.print(f"[dim]✓ Auto-approved: {escape_markup(command)}[/dim]")
|
|
892
|
+
return {"type": "approve"}
|
|
893
|
+
|
|
894
|
+
allowed_list_str = ", ".join(settings.shell_allow_list)
|
|
895
|
+
console.print(f"\n[red]Shell command rejected:[/red] {escape_markup(command)}")
|
|
896
|
+
console.print(
|
|
897
|
+
f"[yellow]Allowed commands:[/yellow] {escape_markup(allowed_list_str)}"
|
|
898
|
+
)
|
|
899
|
+
return {
|
|
900
|
+
"type": "reject",
|
|
901
|
+
"message": (
|
|
902
|
+
f"Command '{command}' is not in the allow-list. "
|
|
903
|
+
f"Allowed commands: {allowed_list_str}. "
|
|
904
|
+
f"Please use allowed commands or try another approach."
|
|
905
|
+
),
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
console.print(f"[dim]✓ Auto-approved action: {escape_markup(action_name)}[/dim]")
|
|
909
|
+
return {"type": "approve"}
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
def _collect_action_request_warnings(action_request: ActionRequest) -> list[str]:
|
|
913
|
+
"""Collect Unicode/URL safety warnings for one action request.
|
|
914
|
+
|
|
915
|
+
Recursively inspects all nested string values in action arguments.
|
|
916
|
+
|
|
917
|
+
Returns:
|
|
918
|
+
Warning messages for suspicious values in action arguments.
|
|
919
|
+
"""
|
|
920
|
+
warnings: list[str] = []
|
|
921
|
+
args = action_request.get("args", {})
|
|
922
|
+
if not isinstance(args, dict):
|
|
923
|
+
return warnings
|
|
924
|
+
|
|
925
|
+
tool_name = str(action_request.get("name", "unknown"))
|
|
926
|
+
|
|
927
|
+
for arg_path, text in iter_string_values(args):
|
|
928
|
+
issues = detect_dangerous_unicode(text)
|
|
929
|
+
if issues:
|
|
930
|
+
warnings.append(
|
|
931
|
+
f"{tool_name}.{arg_path} contains hidden Unicode "
|
|
932
|
+
f"({summarize_issues(issues)})"
|
|
933
|
+
)
|
|
934
|
+
|
|
935
|
+
if looks_like_url_key(arg_path):
|
|
936
|
+
safety = check_url_safety(text)
|
|
937
|
+
if safety.safe:
|
|
938
|
+
continue
|
|
939
|
+
detail = format_warning_detail(safety.warnings)
|
|
940
|
+
if safety.decoded_domain:
|
|
941
|
+
detail = f"{detail}; decoded host: {safety.decoded_domain}"
|
|
942
|
+
warnings.append(f"{tool_name}.{arg_path} URL warning: {detail}")
|
|
943
|
+
|
|
944
|
+
return warnings
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
def _process_hitl_interrupts(state: StreamState, console: Console) -> None:
|
|
948
|
+
"""Iterate over pending HITL interrupts and build approval/rejection responses.
|
|
949
|
+
|
|
950
|
+
After processing, `state.pending_interrupts` is cleared and decisions
|
|
951
|
+
are written into `state.hitl_response` so the agent can be resumed.
|
|
952
|
+
|
|
953
|
+
Args:
|
|
954
|
+
state: Stream state containing the pending interrupts to process.
|
|
955
|
+
console: Rich console for status output.
|
|
956
|
+
"""
|
|
957
|
+
current_interrupts = dict(state.pending_interrupts)
|
|
958
|
+
state.pending_interrupts.clear()
|
|
959
|
+
|
|
960
|
+
for interrupt_id, hitl_request in current_interrupts.items():
|
|
961
|
+
decisions = [
|
|
962
|
+
_make_hitl_decision(action_request, console)
|
|
963
|
+
for action_request in hitl_request["action_requests"]
|
|
964
|
+
]
|
|
965
|
+
state.hitl_response[interrupt_id] = {"decisions": decisions}
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
async def _stream_agent(
|
|
969
|
+
agent: Any, # noqa: ANN401
|
|
970
|
+
stream_input: dict[str, Any] | Command,
|
|
971
|
+
config: RunnableConfig,
|
|
972
|
+
state: StreamState,
|
|
973
|
+
console: Console,
|
|
974
|
+
file_op_tracker: FileOpTracker,
|
|
975
|
+
context: CLIContext,
|
|
976
|
+
) -> None:
|
|
977
|
+
"""Consume the full agent stream and update *state* with results.
|
|
978
|
+
|
|
979
|
+
Args:
|
|
980
|
+
agent: The agent (Pregel or RemoteAgent).
|
|
981
|
+
stream_input: Either the initial user message dict or a
|
|
982
|
+
`Command(resume=...)` for HITL continuation.
|
|
983
|
+
config: LangGraph runnable config (thread ID, metadata, etc.).
|
|
984
|
+
state: Shared stream state.
|
|
985
|
+
console: Rich console for formatted output.
|
|
986
|
+
file_op_tracker: Tracker for file-operation diffs.
|
|
987
|
+
context: Runtime context for model-call middleware.
|
|
988
|
+
"""
|
|
989
|
+
if state.spinner:
|
|
990
|
+
state.spinner.start()
|
|
991
|
+
try:
|
|
992
|
+
async for chunk in agent.astream(
|
|
993
|
+
stream_input,
|
|
994
|
+
stream_mode=["messages", "updates", "custom"],
|
|
995
|
+
subgraphs=True,
|
|
996
|
+
config=config,
|
|
997
|
+
context=context,
|
|
998
|
+
durability="exit",
|
|
999
|
+
):
|
|
1000
|
+
_process_stream_chunk(chunk, state, console, file_op_tracker)
|
|
1001
|
+
finally:
|
|
1002
|
+
if state.spinner:
|
|
1003
|
+
state.spinner.stop()
|
|
1004
|
+
|
|
1005
|
+
|
|
1006
|
+
def _dispatch_orphaned_tool_result_hooks(state: StreamState, tool_output: str) -> None:
|
|
1007
|
+
"""Close out `tool.use` events that never received a `ToolMessage`.
|
|
1008
|
+
|
|
1009
|
+
On a normally-completing run every `tool.use` is followed by a `ToolMessage`
|
|
1010
|
+
that drains `in_flight_tool_calls`, so this is a no-op. When the stream is
|
|
1011
|
+
aborted mid-flight (e.g. a provider error between the tool call and its
|
|
1012
|
+
result), any id still present had its `tool.use` dispatched with no terminal
|
|
1013
|
+
event; emit `tool.error` + a `tool_status="error"` `tool.result` for each so
|
|
1014
|
+
the headless surface upholds the same "every `tool.use` is closed" guarantee
|
|
1015
|
+
as the TUI's `_dispatch_terminal_tool_result_hooks`.
|
|
1016
|
+
|
|
1017
|
+
Args:
|
|
1018
|
+
state: The stream state whose in-flight tool maps are drained.
|
|
1019
|
+
tool_output: Terminal output recorded on each synthesized `tool.result`.
|
|
1020
|
+
"""
|
|
1021
|
+
if state.in_flight_tool_calls:
|
|
1022
|
+
# A non-empty in-flight map here means real tool results were lost to a
|
|
1023
|
+
# mid-stream abort (a clean run drains every id via its result). Surface
|
|
1024
|
+
# it at warning — matching the TUI's backstop for the same class — so an
|
|
1025
|
+
# operator can tell a clean run from one that synthesized error closes,
|
|
1026
|
+
# rather than the drain being silent (degraded audit fidelity).
|
|
1027
|
+
logger.warning(
|
|
1028
|
+
"Stream ended with %d in-flight tool call(s) that never received a "
|
|
1029
|
+
"result; closing each with a synthetic tool.error/tool.result",
|
|
1030
|
+
len(state.in_flight_tool_calls),
|
|
1031
|
+
)
|
|
1032
|
+
for tool_id, in_flight in list(state.in_flight_tool_calls.items()):
|
|
1033
|
+
dispatch_hook_fire_and_forget(
|
|
1034
|
+
"tool.error", build_tool_error_payload(in_flight.name)
|
|
1035
|
+
)
|
|
1036
|
+
dispatch_hook_fire_and_forget(
|
|
1037
|
+
"tool.result",
|
|
1038
|
+
build_tool_result_payload(
|
|
1039
|
+
in_flight.name, tool_id, in_flight.args, "error", tool_output
|
|
1040
|
+
),
|
|
1041
|
+
)
|
|
1042
|
+
state.in_flight_tool_calls.clear()
|
|
1043
|
+
|
|
1044
|
+
|
|
1045
|
+
async def _run_agent_loop(
|
|
1046
|
+
agent: Any, # noqa: ANN401
|
|
1047
|
+
message: str,
|
|
1048
|
+
config: RunnableConfig,
|
|
1049
|
+
console: Console,
|
|
1050
|
+
file_op_tracker: FileOpTracker,
|
|
1051
|
+
*,
|
|
1052
|
+
quiet: bool = False,
|
|
1053
|
+
stream: bool = True,
|
|
1054
|
+
message_kwargs: dict[str, Any] | None = None,
|
|
1055
|
+
thread_url_lookup: ThreadUrlLookupState | None = None,
|
|
1056
|
+
max_turns: int | None = None,
|
|
1057
|
+
rubric: str | None = None,
|
|
1058
|
+
show_rubric_iterations: bool = False,
|
|
1059
|
+
) -> None:
|
|
1060
|
+
"""Run the agent and handle HITL interrupts until the task completes.
|
|
1061
|
+
|
|
1062
|
+
The loop is capped at `max_turns` when set,
|
|
1063
|
+
otherwise `_MAX_HITL_ITERATIONS`, to prevent runaway retries
|
|
1064
|
+
(e.g. the agent repeatedly attempting rejected commands).
|
|
1065
|
+
|
|
1066
|
+
Args:
|
|
1067
|
+
agent: The agent (Pregel or RemoteAgent).
|
|
1068
|
+
message: The user's task message.
|
|
1069
|
+
config: LangGraph runnable config.
|
|
1070
|
+
console: Rich console for formatted output.
|
|
1071
|
+
file_op_tracker: Tracker for file-operation diffs.
|
|
1072
|
+
quiet: Suppress diagnostic formatting on stdout.
|
|
1073
|
+
stream: When `True`, text is written to stdout as it arrives.
|
|
1074
|
+
|
|
1075
|
+
When `False`, the full response is buffered and flushed at
|
|
1076
|
+
the end.
|
|
1077
|
+
message_kwargs: Extra fields merged into the initial HumanMessage
|
|
1078
|
+
dict (e.g., `additional_kwargs` for persisted skill metadata).
|
|
1079
|
+
thread_url_lookup: Optional non-blocking lookup state for rendering
|
|
1080
|
+
a fast-follow LangSmith thread link.
|
|
1081
|
+
max_turns: Optional cap on total agentic turns (initial response plus
|
|
1082
|
+
HITL resumes).
|
|
1083
|
+
|
|
1084
|
+
When `None`, falls back to `_MAX_HITL_ITERATIONS`.
|
|
1085
|
+
rubric: Acceptance criteria supplied to `RubricMiddleware` via the
|
|
1086
|
+
graph's `rubric` state field.
|
|
1087
|
+
|
|
1088
|
+
`None` leaves it unset (no grading).
|
|
1089
|
+
show_rubric_iterations: Whether rubric lifecycle messages should include
|
|
1090
|
+
iteration numbers.
|
|
1091
|
+
|
|
1092
|
+
Raises:
|
|
1093
|
+
HITLIterationLimitError: If the effective turn limit is exceeded.
|
|
1094
|
+
"""
|
|
1095
|
+
spinner = None if quiet else _ConsoleSpinner(console)
|
|
1096
|
+
state = StreamState(
|
|
1097
|
+
quiet=quiet,
|
|
1098
|
+
stream=stream,
|
|
1099
|
+
spinner=spinner,
|
|
1100
|
+
show_rubric_iterations=show_rubric_iterations,
|
|
1101
|
+
)
|
|
1102
|
+
user_msg: dict[str, Any] = {"role": "user", "content": message}
|
|
1103
|
+
if message_kwargs:
|
|
1104
|
+
user_msg.update(message_kwargs)
|
|
1105
|
+
stream_input: dict[str, Any] | Command = {"messages": [user_msg]}
|
|
1106
|
+
if rubric is not None:
|
|
1107
|
+
stream_input["rubric"] = rubric
|
|
1108
|
+
|
|
1109
|
+
thread_id = config.get("configurable", {}).get("thread_id", "")
|
|
1110
|
+
# An empty or missing thread ID carries no session identity, so leave it
|
|
1111
|
+
# unset in context rather than passing a blank string to model middleware.
|
|
1112
|
+
context_thread_id = thread_id if isinstance(thread_id, str) and thread_id else None
|
|
1113
|
+
context = CLIContext(thread_id=context_thread_id)
|
|
1114
|
+
await dispatch_hook("session.start", {"thread_id": thread_id})
|
|
1115
|
+
|
|
1116
|
+
start_time = time.monotonic()
|
|
1117
|
+
|
|
1118
|
+
# Begin per-turn end-status tracking; the marker prints from the finally
|
|
1119
|
+
# block below regardless of exit path (clean end, HITL cap, interrupt,
|
|
1120
|
+
# stream error). Import lazily so a bad module never blocks the loop.
|
|
1121
|
+
from deepagents_code import turn_end_summary
|
|
1122
|
+
|
|
1123
|
+
turn_end_summary.mark_turn_start(
|
|
1124
|
+
thread_id=thread_id if isinstance(thread_id, str) else "",
|
|
1125
|
+
)
|
|
1126
|
+
|
|
1127
|
+
try:
|
|
1128
|
+
# Initial stream
|
|
1129
|
+
await _stream_agent(
|
|
1130
|
+
agent, stream_input, config, state, console, file_op_tracker, context
|
|
1131
|
+
)
|
|
1132
|
+
|
|
1133
|
+
# The internal default applies when --max-turns is omitted, guarding
|
|
1134
|
+
# against unbounded runaway loops in scripts that forgot to set one.
|
|
1135
|
+
effective_limit = max_turns if max_turns is not None else _MAX_HITL_ITERATIONS
|
|
1136
|
+
|
|
1137
|
+
# The initial stream above counts as turn 1; each HITL resume is a
|
|
1138
|
+
# further turn. Raise before starting a resume that would exceed the
|
|
1139
|
+
# budget so the user-facing count matches the flag's semantics.
|
|
1140
|
+
turns = 1
|
|
1141
|
+
while state.interrupt_occurred:
|
|
1142
|
+
if turns >= effective_limit:
|
|
1143
|
+
limit_source = (
|
|
1144
|
+
f"--max-turns {max_turns}"
|
|
1145
|
+
if max_turns is not None
|
|
1146
|
+
else f"the internal safety default of {_MAX_HITL_ITERATIONS}"
|
|
1147
|
+
)
|
|
1148
|
+
msg = (
|
|
1149
|
+
f"Exceeded {effective_limit} agentic turns ({limit_source}). "
|
|
1150
|
+
"The agent may be stuck retrying rejected commands. "
|
|
1151
|
+
"Increase --max-turns or break the task into smaller steps."
|
|
1152
|
+
)
|
|
1153
|
+
raise HITLIterationLimitError(msg)
|
|
1154
|
+
turns += 1
|
|
1155
|
+
state.interrupt_occurred = False
|
|
1156
|
+
state.hitl_response.clear()
|
|
1157
|
+
_process_hitl_interrupts(state, console)
|
|
1158
|
+
stream_input = Command(resume=state.hitl_response)
|
|
1159
|
+
await _stream_agent(
|
|
1160
|
+
agent, stream_input, config, state, console, file_op_tracker, context
|
|
1161
|
+
)
|
|
1162
|
+
finally:
|
|
1163
|
+
# Close out any `tool.use` with no matching `ToolMessage` — e.g. a stream
|
|
1164
|
+
# aborted by a provider error mid-tool. On a clean run every id was
|
|
1165
|
+
# already drained by its result, so this is a no-op. Guarded so a
|
|
1166
|
+
# dispatch problem can never mask the exception propagating from the
|
|
1167
|
+
# stream (this runs on the error path too).
|
|
1168
|
+
try:
|
|
1169
|
+
_dispatch_orphaned_tool_result_hooks(
|
|
1170
|
+
state, "Stream ended before tool result"
|
|
1171
|
+
)
|
|
1172
|
+
except Exception:
|
|
1173
|
+
logger.warning(
|
|
1174
|
+
"Orphaned tool.result drain failed unexpectedly", exc_info=True
|
|
1175
|
+
)
|
|
1176
|
+
# Surface any buffered tool call whose args never parsed: it never
|
|
1177
|
+
# entered `in_flight_tool_calls` (so the orphan drain above skips it) and
|
|
1178
|
+
# would otherwise be dropped with `state` at scope exit with no trace.
|
|
1179
|
+
# Info, not warning — some of these may still have executed (their
|
|
1180
|
+
# `tool.result` fired with `{}` args and logged a correlation miss); this
|
|
1181
|
+
# only asserts the args never parsed. Guarded so a logging failure can
|
|
1182
|
+
# never mask an exception propagating from the stream.
|
|
1183
|
+
try:
|
|
1184
|
+
# Two distinct reasons a buffered call never fired tool.use — args
|
|
1185
|
+
# that never parsed, and args that parsed but carried no id (so
|
|
1186
|
+
# tool.use was gated out). The classification is shared with the TUI
|
|
1187
|
+
# via `count_unemitted_tool_calls`; each surface logs its own lines.
|
|
1188
|
+
unemitted = count_unemitted_tool_calls(state.tool_call_buffers.values())
|
|
1189
|
+
if unemitted.unparsed:
|
|
1190
|
+
logger.info(
|
|
1191
|
+
"Stream ended with %d tool call(s) whose arguments never "
|
|
1192
|
+
"parsed; no tool.use was emitted for them",
|
|
1193
|
+
unemitted.unparsed,
|
|
1194
|
+
)
|
|
1195
|
+
if unemitted.idless_parsed:
|
|
1196
|
+
logger.info(
|
|
1197
|
+
"Stream ended with %d tool call(s) whose arguments parsed but "
|
|
1198
|
+
"carried no tool-call id; no tool.use was emitted for them",
|
|
1199
|
+
unemitted.idless_parsed,
|
|
1200
|
+
)
|
|
1201
|
+
except Exception:
|
|
1202
|
+
logger.warning(
|
|
1203
|
+
"Unparsed tool-call buffer check failed unexpectedly",
|
|
1204
|
+
exc_info=True,
|
|
1205
|
+
)
|
|
1206
|
+
|
|
1207
|
+
# Per-turn end-status marker. Always emitted, regardless of exit
|
|
1208
|
+
# path. If an unhandled exception is in flight, classify it here so
|
|
1209
|
+
# the marker reflects the real cause; interrupts are attributed to
|
|
1210
|
+
# `user_interrupted`. Guarded so a marker failure can never mask the
|
|
1211
|
+
# propagating exception (this runs on the error path too).
|
|
1212
|
+
try:
|
|
1213
|
+
_exc = sys.exc_info()[1]
|
|
1214
|
+
if isinstance(_exc, (asyncio.CancelledError, KeyboardInterrupt)):
|
|
1215
|
+
turn_end_summary.mark_turn_reason(
|
|
1216
|
+
turn_end_summary.REASON_USER_INTERRUPTED,
|
|
1217
|
+
type(_exc).__name__,
|
|
1218
|
+
)
|
|
1219
|
+
elif _exc is not None:
|
|
1220
|
+
_reason, _detail = turn_end_summary.classify_exception(_exc)
|
|
1221
|
+
turn_end_summary.mark_turn_reason(_reason, _detail)
|
|
1222
|
+
_record = turn_end_summary.finalize_turn()
|
|
1223
|
+
if _record is not None and not quiet:
|
|
1224
|
+
try:
|
|
1225
|
+
console.print(turn_end_summary.render_marker_text(_record))
|
|
1226
|
+
except Exception:
|
|
1227
|
+
logger.debug(
|
|
1228
|
+
"Failed to print turn-end marker", exc_info=True
|
|
1229
|
+
)
|
|
1230
|
+
except Exception:
|
|
1231
|
+
logger.debug("turn_end_summary finalize failed", exc_info=True)
|
|
1232
|
+
|
|
1233
|
+
wall_time = time.monotonic() - start_time
|
|
1234
|
+
|
|
1235
|
+
if state.full_response:
|
|
1236
|
+
if not state.stream:
|
|
1237
|
+
_write_text("".join(state.full_response))
|
|
1238
|
+
_write_newline()
|
|
1239
|
+
|
|
1240
|
+
if not quiet:
|
|
1241
|
+
console.print()
|
|
1242
|
+
if (
|
|
1243
|
+
thread_url_lookup is not None
|
|
1244
|
+
and thread_url_lookup.done.is_set()
|
|
1245
|
+
and thread_url_lookup.url
|
|
1246
|
+
):
|
|
1247
|
+
link_text = Text("View in LangSmith: ", style="dim")
|
|
1248
|
+
link_text.append(
|
|
1249
|
+
thread_url_lookup.url,
|
|
1250
|
+
style=Style(dim=True, link=thread_url_lookup.url),
|
|
1251
|
+
)
|
|
1252
|
+
console.print(link_text)
|
|
1253
|
+
console.print("[green]✓ Task completed[/green]")
|
|
1254
|
+
print_usage_table(state.stats, wall_time, console)
|
|
1255
|
+
|
|
1256
|
+
await dispatch_hook("task.complete", {"thread_id": thread_id})
|
|
1257
|
+
await dispatch_hook("session.end", {"thread_id": thread_id})
|
|
1258
|
+
|
|
1259
|
+
# Attach the thread id to the session_end_summary so the atexit-driven
|
|
1260
|
+
# exit panel names this session. Best-effort; must not affect exit code.
|
|
1261
|
+
try:
|
|
1262
|
+
from deepagents_code import session_end_summary
|
|
1263
|
+
|
|
1264
|
+
session_end_summary.set_thread_id(thread_id)
|
|
1265
|
+
except Exception:
|
|
1266
|
+
logger.debug("session_end_summary.set_thread_id failed", exc_info=True)
|
|
1267
|
+
|
|
1268
|
+
|
|
1269
|
+
def _build_non_interactive_header(
|
|
1270
|
+
assistant_id: str,
|
|
1271
|
+
thread_id: str,
|
|
1272
|
+
*,
|
|
1273
|
+
include_thread_link: bool = False,
|
|
1274
|
+
rubric_active: bool = False,
|
|
1275
|
+
) -> Text:
|
|
1276
|
+
"""Build the non-interactive mode header with model, agent, and thread info.
|
|
1277
|
+
|
|
1278
|
+
By default, this function avoids LangSmith network lookups and renders the
|
|
1279
|
+
thread ID as plain text. Callers can opt in to hyperlink resolution.
|
|
1280
|
+
|
|
1281
|
+
Args:
|
|
1282
|
+
assistant_id: Agent identifier.
|
|
1283
|
+
thread_id: Thread identifier.
|
|
1284
|
+
include_thread_link: Whether to resolve and render a LangSmith link for
|
|
1285
|
+
the thread ID.
|
|
1286
|
+
rubric_active: Whether a rubric is active for this run; when `True`,
|
|
1287
|
+
appends a `Rubric: active` marker so the behavior change is visible.
|
|
1288
|
+
|
|
1289
|
+
Returns:
|
|
1290
|
+
Rich Text object with the formatted header line.
|
|
1291
|
+
"""
|
|
1292
|
+
default_label = " (default)" if assistant_id == DEFAULT_AGENT_NAME else ""
|
|
1293
|
+
parts: list[tuple[str, str | Style]] = [
|
|
1294
|
+
(f"App: v{__version__}", "dim"),
|
|
1295
|
+
(" | ", "dim"),
|
|
1296
|
+
(f"Agent: {assistant_id}{default_label}", "dim"),
|
|
1297
|
+
]
|
|
1298
|
+
|
|
1299
|
+
if settings.model_name:
|
|
1300
|
+
parts.extend([(" | ", "dim"), (f"Model: {settings.model_name}", "dim")])
|
|
1301
|
+
|
|
1302
|
+
parts.append((" | ", "dim"))
|
|
1303
|
+
|
|
1304
|
+
thread_url = build_langsmith_thread_url(thread_id) if include_thread_link else None
|
|
1305
|
+
if thread_url:
|
|
1306
|
+
parts.extend(
|
|
1307
|
+
[
|
|
1308
|
+
("Thread: ", "dim"),
|
|
1309
|
+
(thread_id, Style(dim=True, link=thread_url)),
|
|
1310
|
+
]
|
|
1311
|
+
)
|
|
1312
|
+
else:
|
|
1313
|
+
parts.append((f"Thread: {thread_id}", "dim"))
|
|
1314
|
+
|
|
1315
|
+
if rubric_active:
|
|
1316
|
+
parts.extend([(" | ", "dim"), ("Rubric: active", "dim")])
|
|
1317
|
+
|
|
1318
|
+
return Text.assemble(*parts)
|
|
1319
|
+
|
|
1320
|
+
|
|
1321
|
+
async def _run_startup_command(
|
|
1322
|
+
command: str,
|
|
1323
|
+
console: Console,
|
|
1324
|
+
*,
|
|
1325
|
+
quiet: bool,
|
|
1326
|
+
) -> None:
|
|
1327
|
+
"""Run the `--startup-cmd` shell command before the agent loop.
|
|
1328
|
+
|
|
1329
|
+
Stdout and stderr are routed through `console`. In `--quiet` mode the
|
|
1330
|
+
caller wires `console` to stderr so agent output on stdout stays clean;
|
|
1331
|
+
otherwise both streams land on stdout alongside the agent's response.
|
|
1332
|
+
Non-zero exits and timeouts emit a yellow warning but do not abort the
|
|
1333
|
+
session — the non-zero exit code is logged, not propagated.
|
|
1334
|
+
|
|
1335
|
+
Args:
|
|
1336
|
+
command: Shell command to execute (subject to shell expansion).
|
|
1337
|
+
console: Rich console for status messages. Respects `quiet`.
|
|
1338
|
+
quiet: When `True`, suppresses the "Running startup command" header
|
|
1339
|
+
so piped output stays minimal; warnings still appear (on stderr
|
|
1340
|
+
when the caller wired the console there).
|
|
1341
|
+
|
|
1342
|
+
Raises:
|
|
1343
|
+
asyncio.CancelledError: If the caller cancels while the startup command
|
|
1344
|
+
is running.
|
|
1345
|
+
"""
|
|
1346
|
+
import sys
|
|
1347
|
+
|
|
1348
|
+
if not quiet:
|
|
1349
|
+
console.print(Text(f"Running startup command: {command}", style="dim"))
|
|
1350
|
+
|
|
1351
|
+
try:
|
|
1352
|
+
proc = await asyncio.create_subprocess_shell(
|
|
1353
|
+
command,
|
|
1354
|
+
stdout=asyncio.subprocess.PIPE,
|
|
1355
|
+
stderr=asyncio.subprocess.PIPE,
|
|
1356
|
+
start_new_session=(sys.platform != "win32"),
|
|
1357
|
+
)
|
|
1358
|
+
except OSError as e:
|
|
1359
|
+
console.print(
|
|
1360
|
+
"[yellow]Warning:[/yellow] startup command failed to launch: "
|
|
1361
|
+
f"{escape_markup(str(e))}"
|
|
1362
|
+
)
|
|
1363
|
+
return
|
|
1364
|
+
|
|
1365
|
+
try:
|
|
1366
|
+
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
|
1367
|
+
proc.communicate(), timeout=60
|
|
1368
|
+
)
|
|
1369
|
+
except asyncio.CancelledError:
|
|
1370
|
+
await _terminate_startup_process(proc)
|
|
1371
|
+
raise
|
|
1372
|
+
except TimeoutError:
|
|
1373
|
+
await _terminate_startup_process(proc)
|
|
1374
|
+
console.print("[yellow]Warning:[/yellow] startup command timed out (60s limit)")
|
|
1375
|
+
return
|
|
1376
|
+
|
|
1377
|
+
stdout_text = (stdout_bytes or b"").decode(errors="replace").rstrip("\n")
|
|
1378
|
+
stderr_text = (stderr_bytes or b"").decode(errors="replace").rstrip("\n")
|
|
1379
|
+
if stdout_text:
|
|
1380
|
+
# Wrap in `Text` so Rich treats the shell output as literal — otherwise
|
|
1381
|
+
# brackets in tool output (e.g. `[INFO]`, `[1/3]`) would be parsed as
|
|
1382
|
+
# markup and either silently stripped or raise `MarkupError`.
|
|
1383
|
+
console.print(Text(stdout_text), highlight=False)
|
|
1384
|
+
if stderr_text:
|
|
1385
|
+
console.print(Text(stderr_text, style="dim"), highlight=False)
|
|
1386
|
+
|
|
1387
|
+
if proc.returncode:
|
|
1388
|
+
console.print(
|
|
1389
|
+
"[yellow]Warning:[/yellow] startup command exited with code "
|
|
1390
|
+
f"{proc.returncode} — continuing anyway"
|
|
1391
|
+
)
|
|
1392
|
+
|
|
1393
|
+
|
|
1394
|
+
async def run_non_interactive(
|
|
1395
|
+
message: str,
|
|
1396
|
+
assistant_id: str = DEFAULT_AGENT_NAME,
|
|
1397
|
+
model_name: str | None = None,
|
|
1398
|
+
model_params: dict[str, Any] | None = None,
|
|
1399
|
+
sandbox_type: str = "none", # str (not None) to match argparse choices
|
|
1400
|
+
sandbox_id: str | None = None,
|
|
1401
|
+
sandbox_snapshot_name: str | None = None,
|
|
1402
|
+
sandbox_setup: str | None = None,
|
|
1403
|
+
*,
|
|
1404
|
+
initial_skill: str | None = None,
|
|
1405
|
+
startup_cmd: str | None = None,
|
|
1406
|
+
profile_override: dict[str, Any] | None = None,
|
|
1407
|
+
quiet: bool = False,
|
|
1408
|
+
stream: bool = True,
|
|
1409
|
+
mcp_config_path: str | None = None,
|
|
1410
|
+
no_mcp: bool = False,
|
|
1411
|
+
trust_project_mcp: bool = False,
|
|
1412
|
+
enable_interpreter: bool | None = None,
|
|
1413
|
+
interpreter_ptc: str | list[str] | None = None,
|
|
1414
|
+
interpreter_ptc_acknowledge_unsafe: bool = False,
|
|
1415
|
+
max_turns: int | None = None,
|
|
1416
|
+
rubric: str | None = None,
|
|
1417
|
+
rubric_model: str | None = None,
|
|
1418
|
+
rubric_max_iterations: int | None = None,
|
|
1419
|
+
) -> int:
|
|
1420
|
+
"""Run a single task non-interactively and exit.
|
|
1421
|
+
|
|
1422
|
+
The agent is created with `interactive=False`, which tailors the system
|
|
1423
|
+
prompt for autonomous headless execution (no clarification questions,
|
|
1424
|
+
reasonable assumptions).
|
|
1425
|
+
|
|
1426
|
+
Shell access and auto-approval are controlled by `--shell-allow-list`:
|
|
1427
|
+
|
|
1428
|
+
- Not set → shell disabled, all other tools auto-approved.
|
|
1429
|
+
- `recommended` or explicit list → shell enabled, commands gated by
|
|
1430
|
+
allow-list; non-shell tools approved unconditionally.
|
|
1431
|
+
- `all` → shell enabled, any command allowed, all tools auto-approved.
|
|
1432
|
+
|
|
1433
|
+
Note: startup header rendering avoids synchronous LangSmith URL lookups.
|
|
1434
|
+
A background thread resolves the thread URL concurrently and the result is
|
|
1435
|
+
displayed after task completion if available.
|
|
1436
|
+
|
|
1437
|
+
Args:
|
|
1438
|
+
message: The task/message to execute.
|
|
1439
|
+
assistant_id: Agent identifier for memory storage.
|
|
1440
|
+
model_name: Optional model name to use.
|
|
1441
|
+
model_params: Extra kwargs from `--model-params` to pass to the model.
|
|
1442
|
+
|
|
1443
|
+
These override config file values.
|
|
1444
|
+
sandbox_type: Type of sandbox (`'none'`, `'agentcore'`,
|
|
1445
|
+
`'daytona'`, `'langsmith'`, `'modal'`, `'runloop'`).
|
|
1446
|
+
sandbox_id: Optional existing sandbox ID to reuse.
|
|
1447
|
+
sandbox_snapshot_name: Snapshot (langsmith) or blueprint (runloop) name.
|
|
1448
|
+
sandbox_setup: Optional path to setup script to run in the sandbox
|
|
1449
|
+
after creation.
|
|
1450
|
+
initial_skill: Optional skill name whose `SKILL.md` instructions wrap
|
|
1451
|
+
the user message before sending it to the agent.
|
|
1452
|
+
startup_cmd: Shell command to run at startup, before the agent runs.
|
|
1453
|
+
|
|
1454
|
+
Output follows the same console routing as other app messages:
|
|
1455
|
+
stdout by default, stderr when `-q` is set. Non-zero exits and
|
|
1456
|
+
timeouts warn but do not abort the task.
|
|
1457
|
+
profile_override: Extra profile fields from `--profile-override`.
|
|
1458
|
+
|
|
1459
|
+
Merged on top of config file profile overrides.
|
|
1460
|
+
quiet: When `True`, all console output (headers, status messages,
|
|
1461
|
+
tool notifications, HITL decisions, errors) is redirected to
|
|
1462
|
+
stderr so that only the agent's response text appears on stdout.
|
|
1463
|
+
stream: When `True` (default), text chunks are written to stdout
|
|
1464
|
+
as they arrive.
|
|
1465
|
+
|
|
1466
|
+
When `False`, the full response is buffered and written to stdout in
|
|
1467
|
+
one shot after the agent finishes.
|
|
1468
|
+
mcp_config_path: Optional path to MCP servers JSON configuration file.
|
|
1469
|
+
Merged on top of auto-discovered configs (highest precedence).
|
|
1470
|
+
no_mcp: Disable all MCP tool loading.
|
|
1471
|
+
trust_project_mcp: When `True`, allow project-level stdio MCP
|
|
1472
|
+
servers. When `False` (default), project stdio servers are
|
|
1473
|
+
silently skipped.
|
|
1474
|
+
enable_interpreter: Enable the JS interpreter (`js_eval`) middleware
|
|
1475
|
+
on the main agent. `None` uses the sandbox-aware default.
|
|
1476
|
+
interpreter_ptc: Override for `settings.interpreter_ptc` (PTC
|
|
1477
|
+
allowlist for `js_eval`).
|
|
1478
|
+
interpreter_ptc_acknowledge_unsafe: Explicit acknowledgement for
|
|
1479
|
+
`interpreter_ptc="all"` outside of `auto_approve`.
|
|
1480
|
+
max_turns: Optional cap on total agentic turns. When `None`, the
|
|
1481
|
+
internal safety default applies.
|
|
1482
|
+
rubric: Acceptance criteria for `RubricMiddleware`. When provided, the
|
|
1483
|
+
agent self-evaluates against it and loops until satisfied.
|
|
1484
|
+
|
|
1485
|
+
`None` disables rubric grading.
|
|
1486
|
+
rubric_model: Grader model spec; `None` reuses the main model.
|
|
1487
|
+
rubric_max_iterations: Grader iterations per rubric attempt; `None`
|
|
1488
|
+
uses the middleware default.
|
|
1489
|
+
|
|
1490
|
+
Returns:
|
|
1491
|
+
Exit code: 0 for success, 1 for error, 124 when the `--max-turns`
|
|
1492
|
+
budget was exceeded (matching GNU `timeout`), 130 for keyboard
|
|
1493
|
+
interrupt.
|
|
1494
|
+
"""
|
|
1495
|
+
_make_stdio_encoding_safe()
|
|
1496
|
+
|
|
1497
|
+
# stderr=True routes all console.print() to stderr; agent response text
|
|
1498
|
+
# uses _write_text() -> sys.stdout directly.
|
|
1499
|
+
console = Console(stderr=True) if quiet else Console()
|
|
1500
|
+
|
|
1501
|
+
if startup_cmd and startup_cmd.strip():
|
|
1502
|
+
await _run_startup_command(startup_cmd.strip(), console, quiet=quiet)
|
|
1503
|
+
|
|
1504
|
+
message_kwargs: dict[str, Any] | None = None
|
|
1505
|
+
if initial_skill and initial_skill.strip():
|
|
1506
|
+
from deepagents_code.skills.invocation import (
|
|
1507
|
+
build_skill_invocation_envelope,
|
|
1508
|
+
discover_skills_and_roots,
|
|
1509
|
+
)
|
|
1510
|
+
from deepagents_code.skills.load import load_skill_content
|
|
1511
|
+
|
|
1512
|
+
normalized_skill = initial_skill.strip().lower()
|
|
1513
|
+
try:
|
|
1514
|
+
# Offloaded to a thread: discovery does blocking filesystem I/O
|
|
1515
|
+
# (a JSON trust-store read plus `Path.resolve()` calls) that must
|
|
1516
|
+
# not block the event loop.
|
|
1517
|
+
skills, allowed_roots = await asyncio.to_thread(
|
|
1518
|
+
discover_skills_and_roots, assistant_id
|
|
1519
|
+
)
|
|
1520
|
+
skill = next((s for s in skills if s["name"] == normalized_skill), None)
|
|
1521
|
+
except OSError as e:
|
|
1522
|
+
console.print(
|
|
1523
|
+
"[bold red]Error:[/bold red] "
|
|
1524
|
+
f"Could not load skill: {escape_markup(normalized_skill)}. "
|
|
1525
|
+
f"Filesystem error: {escape_markup(str(e))}"
|
|
1526
|
+
)
|
|
1527
|
+
return 1
|
|
1528
|
+
except Exception as e: # noqa: BLE001
|
|
1529
|
+
console.print(
|
|
1530
|
+
"[bold red]Error:[/bold red] "
|
|
1531
|
+
f"Error loading skill: {escape_markup(normalized_skill)}. "
|
|
1532
|
+
f"Unexpected error: {type(e).__name__}: {escape_markup(str(e))}"
|
|
1533
|
+
)
|
|
1534
|
+
return 1
|
|
1535
|
+
|
|
1536
|
+
if skill is None:
|
|
1537
|
+
console.print(
|
|
1538
|
+
"[bold red]Error:[/bold red] "
|
|
1539
|
+
f"Skill not found: {escape_markup(normalized_skill)}"
|
|
1540
|
+
)
|
|
1541
|
+
return 1
|
|
1542
|
+
|
|
1543
|
+
try:
|
|
1544
|
+
content = load_skill_content(
|
|
1545
|
+
str(skill["path"]),
|
|
1546
|
+
allowed_roots=allowed_roots,
|
|
1547
|
+
)
|
|
1548
|
+
except PermissionError as e:
|
|
1549
|
+
console.print(f"[bold red]Error:[/bold red] {escape_markup(str(e))}")
|
|
1550
|
+
return 1
|
|
1551
|
+
except OSError as e:
|
|
1552
|
+
console.print(
|
|
1553
|
+
"[bold red]Error:[/bold red] "
|
|
1554
|
+
f"Could not load skill: {escape_markup(normalized_skill)}. "
|
|
1555
|
+
f"Filesystem error: {escape_markup(str(e))}"
|
|
1556
|
+
)
|
|
1557
|
+
return 1
|
|
1558
|
+
except Exception as e: # noqa: BLE001
|
|
1559
|
+
console.print(
|
|
1560
|
+
"[bold red]Error:[/bold red] "
|
|
1561
|
+
f"Error loading skill: {escape_markup(normalized_skill)}. "
|
|
1562
|
+
f"Unexpected error: {type(e).__name__}: {escape_markup(str(e))}"
|
|
1563
|
+
)
|
|
1564
|
+
return 1
|
|
1565
|
+
if content is None:
|
|
1566
|
+
console.print(
|
|
1567
|
+
"[bold red]Error:[/bold red] "
|
|
1568
|
+
f"Could not read content for skill: {escape_markup(normalized_skill)}. "
|
|
1569
|
+
"Check that the SKILL.md file exists, is readable, "
|
|
1570
|
+
"and is saved as UTF-8."
|
|
1571
|
+
)
|
|
1572
|
+
return 1
|
|
1573
|
+
|
|
1574
|
+
if not content.strip():
|
|
1575
|
+
console.print(
|
|
1576
|
+
"[bold red]Error:[/bold red] "
|
|
1577
|
+
f"Skill '{escape_markup(normalized_skill)}' has an empty "
|
|
1578
|
+
"SKILL.md file. "
|
|
1579
|
+
"Add instructions to the file before invoking."
|
|
1580
|
+
)
|
|
1581
|
+
return 1
|
|
1582
|
+
|
|
1583
|
+
envelope = build_skill_invocation_envelope(skill, content, message)
|
|
1584
|
+
message = envelope.prompt
|
|
1585
|
+
message_kwargs = envelope.message_kwargs
|
|
1586
|
+
|
|
1587
|
+
try:
|
|
1588
|
+
result = create_model(
|
|
1589
|
+
model_name,
|
|
1590
|
+
extra_kwargs=model_params,
|
|
1591
|
+
profile_overrides=profile_override,
|
|
1592
|
+
)
|
|
1593
|
+
except ModelConfigError as e:
|
|
1594
|
+
console.print(f"[bold red]Error:[/bold red] {e}")
|
|
1595
|
+
return 1
|
|
1596
|
+
|
|
1597
|
+
result.apply_to_settings()
|
|
1598
|
+
|
|
1599
|
+
thread_id = generate_thread_id()
|
|
1600
|
+
|
|
1601
|
+
# One user turn per process: fresh turn id, turn_number 1.
|
|
1602
|
+
from uuid import uuid4
|
|
1603
|
+
|
|
1604
|
+
from deepagents_code.config import build_stream_config
|
|
1605
|
+
|
|
1606
|
+
config: RunnableConfig = build_stream_config(
|
|
1607
|
+
thread_id,
|
|
1608
|
+
assistant_id,
|
|
1609
|
+
sandbox_type=sandbox_type,
|
|
1610
|
+
turn_id=str(uuid4()),
|
|
1611
|
+
turn_number=1,
|
|
1612
|
+
)
|
|
1613
|
+
|
|
1614
|
+
thread_url_lookup: ThreadUrlLookupState | None = None
|
|
1615
|
+
if not quiet:
|
|
1616
|
+
thread_url_lookup = _start_langsmith_thread_url_lookup(thread_id)
|
|
1617
|
+
console.print(Text("Running task non-interactively...", style="dim"))
|
|
1618
|
+
header = _build_non_interactive_header(
|
|
1619
|
+
assistant_id,
|
|
1620
|
+
thread_id,
|
|
1621
|
+
rubric_active=rubric is not None,
|
|
1622
|
+
)
|
|
1623
|
+
console.print(header)
|
|
1624
|
+
|
|
1625
|
+
from deepagents_code.client.launch.server_manager import server_session
|
|
1626
|
+
|
|
1627
|
+
# Launch MCP preload concurrently with server startup
|
|
1628
|
+
mcp_task: asyncio.Task[Any] | None = None
|
|
1629
|
+
if not no_mcp and not quiet:
|
|
1630
|
+
try:
|
|
1631
|
+
from deepagents_code.main import _preload_session_mcp_server_info
|
|
1632
|
+
|
|
1633
|
+
mcp_task = asyncio.create_task(
|
|
1634
|
+
_preload_session_mcp_server_info(
|
|
1635
|
+
mcp_config_path=mcp_config_path,
|
|
1636
|
+
no_mcp=no_mcp,
|
|
1637
|
+
trust_project_mcp=trust_project_mcp,
|
|
1638
|
+
)
|
|
1639
|
+
)
|
|
1640
|
+
except Exception:
|
|
1641
|
+
logger.warning("MCP metadata preload task creation failed", exc_info=True)
|
|
1642
|
+
|
|
1643
|
+
try:
|
|
1644
|
+
enable_shell = bool(settings.shell_allow_list)
|
|
1645
|
+
shell_is_unrestricted = isinstance(
|
|
1646
|
+
settings.shell_allow_list, type(SHELL_ALLOW_ALL)
|
|
1647
|
+
)
|
|
1648
|
+
# Currently, non-shell tools have no HITL handler in non-interactive
|
|
1649
|
+
# mode, so interrupting on them just fragments LangSmith traces
|
|
1650
|
+
# without adding value. Gate only shell execution via middleware.
|
|
1651
|
+
use_auto_approve = not enable_shell or shell_is_unrestricted
|
|
1652
|
+
use_interrupt_shell_only = enable_shell and not shell_is_unrestricted
|
|
1653
|
+
# Extract the concrete allow-list to forward to the server subprocess.
|
|
1654
|
+
# settings.shell_allow_list is already validated at this point.
|
|
1655
|
+
restrictive_allow_list: list[str] | None = (
|
|
1656
|
+
list(settings.shell_allow_list)
|
|
1657
|
+
if use_interrupt_shell_only and settings.shell_allow_list
|
|
1658
|
+
else None
|
|
1659
|
+
)
|
|
1660
|
+
|
|
1661
|
+
if not quiet:
|
|
1662
|
+
console.print(Text("Starting LangGraph server...", style="dim"))
|
|
1663
|
+
|
|
1664
|
+
async with server_session(
|
|
1665
|
+
assistant_id=assistant_id,
|
|
1666
|
+
model_name=model_name,
|
|
1667
|
+
model_params=model_params,
|
|
1668
|
+
auto_approve=use_auto_approve,
|
|
1669
|
+
interrupt_shell_only=use_interrupt_shell_only,
|
|
1670
|
+
shell_allow_list=restrictive_allow_list,
|
|
1671
|
+
sandbox_type=sandbox_type,
|
|
1672
|
+
sandbox_id=sandbox_id,
|
|
1673
|
+
sandbox_snapshot_name=sandbox_snapshot_name,
|
|
1674
|
+
sandbox_setup=sandbox_setup,
|
|
1675
|
+
enable_shell=enable_shell,
|
|
1676
|
+
enable_ask_user=False,
|
|
1677
|
+
enable_interpreter=enable_interpreter,
|
|
1678
|
+
interpreter_ptc=interpreter_ptc,
|
|
1679
|
+
interpreter_ptc_acknowledge_unsafe=interpreter_ptc_acknowledge_unsafe,
|
|
1680
|
+
rubric_model=rubric_model,
|
|
1681
|
+
rubric_max_iterations=rubric_max_iterations,
|
|
1682
|
+
mcp_config_path=mcp_config_path,
|
|
1683
|
+
no_mcp=no_mcp,
|
|
1684
|
+
trust_project_mcp=trust_project_mcp,
|
|
1685
|
+
interactive=False,
|
|
1686
|
+
) as (agent, _server_proc):
|
|
1687
|
+
# Collect MCP preload result (ran concurrently with server startup)
|
|
1688
|
+
if mcp_task is not None:
|
|
1689
|
+
try:
|
|
1690
|
+
mcp_info = await mcp_task
|
|
1691
|
+
if mcp_info:
|
|
1692
|
+
tool_count = sum(len(s.tools) for s in mcp_info)
|
|
1693
|
+
if tool_count:
|
|
1694
|
+
label = "MCP tool" if tool_count == 1 else "MCP tools"
|
|
1695
|
+
console.print(
|
|
1696
|
+
f"[green]✓ Loaded {tool_count} {label}[/green]"
|
|
1697
|
+
)
|
|
1698
|
+
except Exception:
|
|
1699
|
+
logger.warning("MCP metadata preload failed", exc_info=True)
|
|
1700
|
+
|
|
1701
|
+
if not quiet:
|
|
1702
|
+
console.print("[green]✓ Server ready[/green]")
|
|
1703
|
+
|
|
1704
|
+
file_op_tracker = FileOpTracker(assistant_id=assistant_id, backend=None)
|
|
1705
|
+
|
|
1706
|
+
await _run_agent_loop(
|
|
1707
|
+
agent,
|
|
1708
|
+
message,
|
|
1709
|
+
config,
|
|
1710
|
+
console,
|
|
1711
|
+
file_op_tracker,
|
|
1712
|
+
quiet=quiet,
|
|
1713
|
+
stream=stream,
|
|
1714
|
+
message_kwargs=message_kwargs,
|
|
1715
|
+
thread_url_lookup=thread_url_lookup,
|
|
1716
|
+
max_turns=max_turns,
|
|
1717
|
+
rubric=rubric,
|
|
1718
|
+
show_rubric_iterations=rubric_max_iterations is not None,
|
|
1719
|
+
)
|
|
1720
|
+
|
|
1721
|
+
except KeyboardInterrupt:
|
|
1722
|
+
console.print("\n[yellow]Interrupted[/yellow]")
|
|
1723
|
+
return 130
|
|
1724
|
+
except HITLIterationLimitError as e:
|
|
1725
|
+
console.print(f"\n[red]{escape_markup(str(e))}[/red]")
|
|
1726
|
+
console.print(
|
|
1727
|
+
"[yellow]Hint: The agent may be repeatedly attempting commands "
|
|
1728
|
+
"that are not in the allow-list. Consider expanding the "
|
|
1729
|
+
"--shell-allow-list or adjusting the task.[/yellow]"
|
|
1730
|
+
)
|
|
1731
|
+
# Dedicated exit code (matches GNU `timeout`) so CI can distinguish
|
|
1732
|
+
# a turn-budget hit from a generic failure that also returns 1.
|
|
1733
|
+
return 124
|
|
1734
|
+
except (ValueError, OSError) as e:
|
|
1735
|
+
logger.exception("Error during non-interactive execution")
|
|
1736
|
+
console.print(f"\n[red]Error: {escape_markup(str(e))}[/red]")
|
|
1737
|
+
return 1
|
|
1738
|
+
except Exception as e:
|
|
1739
|
+
logger.exception("Unexpected error during non-interactive execution")
|
|
1740
|
+
console.print(
|
|
1741
|
+
f"\n[red]Unexpected error ({type(e).__name__}): "
|
|
1742
|
+
f"{escape_markup(str(e))}[/red]"
|
|
1743
|
+
)
|
|
1744
|
+
return 1
|
|
1745
|
+
else:
|
|
1746
|
+
return 0
|
|
1747
|
+
finally:
|
|
1748
|
+
# Fire-and-forget hooks (tool.use/tool.result) run as background tasks;
|
|
1749
|
+
# await them here so the final tool.result is not cancelled when
|
|
1750
|
+
# asyncio.run tears the loop down. Never return from this block — that
|
|
1751
|
+
# would swallow the exit code determined above. drain_pending_hooks is
|
|
1752
|
+
# documented never to raise, but guard it anyway (mirroring app.py's
|
|
1753
|
+
# shutdown drain) so a future contract break can't replace the exit code
|
|
1754
|
+
# with an exception escaping the finally.
|
|
1755
|
+
try:
|
|
1756
|
+
await drain_pending_hooks()
|
|
1757
|
+
except Exception:
|
|
1758
|
+
logger.warning("Hook drain raised unexpectedly before exit", exc_info=True)
|