deep-agent-cli 0.1.0__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.
- agent/__init__.py +42 -0
- agent/attachments.py +303 -0
- agent/bootstrap.py +44 -0
- agent/cancel.py +107 -0
- agent/cli/__init__.py +5 -0
- agent/cli/app.py +1768 -0
- agent/cli/clipboard.py +224 -0
- agent/cli/commands.py +94 -0
- agent/cli/gitinfo.py +84 -0
- agent/cli/input.py +65 -0
- agent/cli/interactions.py +187 -0
- agent/cli/main.py +124 -0
- agent/cli/previews.py +710 -0
- agent/cli/rendering.py +770 -0
- agent/cli/session_controller.py +221 -0
- agent/cli/state.py +326 -0
- agent/config.example.yaml +76 -0
- agent/config.py +528 -0
- agent/control.py +171 -0
- agent/factory.py +232 -0
- agent/file_mutation.py +5 -0
- agent/llm.py +339 -0
- agent/middleware/__init__.py +9 -0
- agent/middleware/attachments.py +31 -0
- agent/middleware/cancel_tools.py +39 -0
- agent/middleware/pause.py +18 -0
- agent/middleware/recovery.py +65 -0
- agent/middleware/steering.py +35 -0
- agent/middleware/tool_arg_hints.py +128 -0
- agent/middleware/workspace_filesystem.py +38 -0
- agent/middleware/write_operation.py +60 -0
- agent/network.py +30 -0
- agent/permission.py +80 -0
- agent/runner.py +1393 -0
- agent/sandbox.py +699 -0
- agent/session.py +431 -0
- agent/session_lock.py +223 -0
- agent/session_runtime.py +209 -0
- agent/stream.py +168 -0
- agent/tools/__init__.py +9 -0
- agent/tools/examples.py +30 -0
- agent/tools/execute.py +73 -0
- agent/tools/human_input.py +170 -0
- agent/tools/human_interaction.py +101 -0
- agent/tools/web_search.py +131 -0
- deep_agent_cli-0.1.0.dist-info/METADATA +408 -0
- deep_agent_cli-0.1.0.dist-info/RECORD +50 -0
- deep_agent_cli-0.1.0.dist-info/WHEEL +4 -0
- deep_agent_cli-0.1.0.dist-info/entry_points.txt +2 -0
- deep_agent_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
agent/runner.py
ADDED
|
@@ -0,0 +1,1393 @@
|
|
|
1
|
+
# Stream the compiled graph, classify LangGraph interrupts, resume with Command.
|
|
2
|
+
# Token-level reasoning/assistant deltas use LangChain callbacks (ChatOpenAI streaming=True).
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
from functools import wraps
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import os
|
|
11
|
+
import tempfile
|
|
12
|
+
import time
|
|
13
|
+
from threading import Lock
|
|
14
|
+
from typing import Any, Callable, Sequence
|
|
15
|
+
from uuid import uuid4
|
|
16
|
+
|
|
17
|
+
from langchain_core.language_models import BaseChatModel
|
|
18
|
+
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, ToolMessage
|
|
19
|
+
from langchain_openai import ChatOpenAI
|
|
20
|
+
from langgraph.errors import GraphDrained
|
|
21
|
+
from langgraph.types import Command
|
|
22
|
+
|
|
23
|
+
from deepagents.backends.protocol import BackendProtocol
|
|
24
|
+
from agent.cancel import set_output_emitter
|
|
25
|
+
from agent.attachments import (
|
|
26
|
+
ATTACHMENT_META_KEY,
|
|
27
|
+
MAX_IMAGES_PER_MESSAGE,
|
|
28
|
+
AttachmentCleanupResult,
|
|
29
|
+
AttachmentStore,
|
|
30
|
+
ImageAttachment,
|
|
31
|
+
ImageAttachmentRef,
|
|
32
|
+
refs_to_dicts,
|
|
33
|
+
)
|
|
34
|
+
from agent.config import InputKind, ModelProfile, SandboxConfig, Settings
|
|
35
|
+
from agent.control import RunController
|
|
36
|
+
from agent.factory import AgentSpec, PreparedAgent, build_agent
|
|
37
|
+
from agent.llm import build_chat_model
|
|
38
|
+
from agent.middleware.attachments import reset_attachment_store, set_attachment_store
|
|
39
|
+
from agent.middleware.recovery import RecoveryContext, reset_recovery_context, set_recovery_context
|
|
40
|
+
from agent.permission import (
|
|
41
|
+
PermissionMode,
|
|
42
|
+
allow_mode_available,
|
|
43
|
+
allow_mode_unavailable_reason,
|
|
44
|
+
interrupt_on_for_mode,
|
|
45
|
+
parse_permission_mode,
|
|
46
|
+
permission_mode_from_interrupt_on,
|
|
47
|
+
)
|
|
48
|
+
from agent.session import (
|
|
49
|
+
SessionInfo,
|
|
50
|
+
SessionStore,
|
|
51
|
+
StopReason,
|
|
52
|
+
TranscriptBlock,
|
|
53
|
+
messages_to_transcript,
|
|
54
|
+
settle_restored_tools,
|
|
55
|
+
tool_message_is_error,
|
|
56
|
+
workspace_state_path,
|
|
57
|
+
)
|
|
58
|
+
from agent.session_lock import SessionLockBusyError
|
|
59
|
+
from agent.session_runtime import RestorePlan, SessionLeaseLike, SessionRuntime
|
|
60
|
+
from agent.stream import DeltaHandler, StreamDeltaCallback, merge_stream_callbacks, visible_text
|
|
61
|
+
|
|
62
|
+
HUMAN_TOOLS = frozenset({"request_human_input"})
|
|
63
|
+
|
|
64
|
+
# Maximum time close() waits for an active operation to drain. Timing out never
|
|
65
|
+
# releases the session lease early: close() raises, keeps the lease, and can be
|
|
66
|
+
# retried once the run finally drained.
|
|
67
|
+
CLOSE_DRAIN_SECONDS = 30.0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _exclusive_operation(method: Callable[..., Any]) -> Callable[..., Any]:
|
|
71
|
+
@wraps(method)
|
|
72
|
+
def guarded(self: AgentRunner, *args: Any, **kwargs: Any) -> Any:
|
|
73
|
+
if self._closed:
|
|
74
|
+
raise RuntimeError("Runner is closed")
|
|
75
|
+
if not self._operation_lock.acquire(blocking=False):
|
|
76
|
+
raise RuntimeError("Runner already has an active operation")
|
|
77
|
+
try:
|
|
78
|
+
return method(self, *args, **kwargs)
|
|
79
|
+
finally:
|
|
80
|
+
self._operation_lock.release()
|
|
81
|
+
return guarded
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True)
|
|
85
|
+
class RunEvent:
|
|
86
|
+
"""Presentation-neutral event emitted while a graph run is progressing."""
|
|
87
|
+
|
|
88
|
+
type: str
|
|
89
|
+
content: str = ""
|
|
90
|
+
message_id: str = ""
|
|
91
|
+
tool_call_id: str = ""
|
|
92
|
+
name: str = ""
|
|
93
|
+
arguments: dict[str, Any] = field(default_factory=dict)
|
|
94
|
+
result: Any = None
|
|
95
|
+
is_error: bool = False
|
|
96
|
+
stream: str = ""
|
|
97
|
+
# Structured tool result payload (ToolMessage.artifact), forwarded as-is
|
|
98
|
+
# for every tool, not just execute.
|
|
99
|
+
artifact: Any = None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass(frozen=True)
|
|
103
|
+
class TurnTiming:
|
|
104
|
+
elapsed_seconds: float
|
|
105
|
+
finished_at: datetime
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
RunEventHandler = Callable[[RunEvent], None]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass
|
|
112
|
+
class RunResult:
|
|
113
|
+
status: str
|
|
114
|
+
output: str = ""
|
|
115
|
+
pending_tool_calls: list[dict[str, Any]] = field(default_factory=list)
|
|
116
|
+
human_input: dict[str, Any] = field(default_factory=dict)
|
|
117
|
+
error: str = ""
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(frozen=True)
|
|
121
|
+
class CompactResult:
|
|
122
|
+
status: str
|
|
123
|
+
used_tokens: int | None = None
|
|
124
|
+
window_tokens: int = 0
|
|
125
|
+
message: str = ""
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def percent(self) -> float | None:
|
|
129
|
+
return self.used_tokens / self.window_tokens * 100 if self.window_tokens > 0 and self.used_tokens is not None else None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class InterruptKind(StrEnum):
|
|
133
|
+
PAUSED = "paused"
|
|
134
|
+
WAITING_HUMAN = "waiting_human"
|
|
135
|
+
WAITING_CONFIRMATION = "waiting_confirmation"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class UnknownInterruptError(ValueError):
|
|
139
|
+
"""Checkpoint or stream has an interrupt that is not a known protocol."""
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass(frozen=True)
|
|
143
|
+
class InterruptState:
|
|
144
|
+
kind: InterruptKind
|
|
145
|
+
payload: dict[str, Any]
|
|
146
|
+
pending_tools: tuple[dict[str, Any], ...] = ()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass
|
|
150
|
+
class SessionSnapshot:
|
|
151
|
+
info: SessionInfo
|
|
152
|
+
transcript: list[TranscriptBlock]
|
|
153
|
+
todos: list[dict[str, str]] = field(default_factory=list)
|
|
154
|
+
interrupt_kind: InterruptKind | None = None
|
|
155
|
+
human_input: dict[str, Any] = field(default_factory=dict)
|
|
156
|
+
pending_tool_calls: list[dict[str, Any]] = field(default_factory=list)
|
|
157
|
+
notices: list[str] = field(default_factory=list)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@dataclass(frozen=True)
|
|
161
|
+
class SessionSwitchResult:
|
|
162
|
+
"""Phase one of a session switch.
|
|
163
|
+
|
|
164
|
+
``busy`` means the target thread is owned elsewhere: the runner has already
|
|
165
|
+
released its own lease and refuses operations until the wait completes or
|
|
166
|
+
another session is selected.
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
status: str # "switched" | "busy"
|
|
170
|
+
target_id: str = ""
|
|
171
|
+
snapshot: SessionSnapshot | None = None
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def busy(self) -> bool:
|
|
175
|
+
return self.status == "busy"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class AgentRunner:
|
|
179
|
+
def __init__(
|
|
180
|
+
self,
|
|
181
|
+
*,
|
|
182
|
+
prepared: PreparedAgent | None = None,
|
|
183
|
+
model: BaseChatModel | None = None,
|
|
184
|
+
checkpointer: Any | None = None,
|
|
185
|
+
backend: BackendProtocol | None = None,
|
|
186
|
+
sandbox_config: SandboxConfig | None = None,
|
|
187
|
+
thread_id: str | None = None,
|
|
188
|
+
on_delta: DeltaHandler | None = None,
|
|
189
|
+
on_event: RunEventHandler | None = None,
|
|
190
|
+
session_store: SessionStore | None = None,
|
|
191
|
+
enable_sessions: bool = False,
|
|
192
|
+
workspace: Path | None = None,
|
|
193
|
+
settings: Settings | None = None,
|
|
194
|
+
model_id: str | None = None,
|
|
195
|
+
) -> None:
|
|
196
|
+
if prepared is not None and (backend is not None or sandbox_config is not None):
|
|
197
|
+
raise ValueError("prepared already defines the backend and sandbox configuration")
|
|
198
|
+
self.control = prepared.run_controller if prepared is not None and prepared.run_controller is not None else RunController()
|
|
199
|
+
self.control.set_event_handler(self._on_control_event)
|
|
200
|
+
self.on_delta = on_delta
|
|
201
|
+
self.on_event = on_event
|
|
202
|
+
self._event_handler: RunEventHandler | None = None
|
|
203
|
+
self._seen_tool_calls: set[str] = set()
|
|
204
|
+
self._todo_call_ids: set[str] = set()
|
|
205
|
+
self.settings = settings
|
|
206
|
+
self._sandbox_config = sandbox_config or (settings.sandbox if settings else None)
|
|
207
|
+
self._busy = False
|
|
208
|
+
self._operation_lock = Lock()
|
|
209
|
+
self._permission_mode = PermissionMode.ASK
|
|
210
|
+
self._resume_context: RecoveryContext | None = None
|
|
211
|
+
self._closed = False
|
|
212
|
+
self._close_completed = False
|
|
213
|
+
self._runtime: SessionRuntime | None = None
|
|
214
|
+
|
|
215
|
+
self.session_store = session_store
|
|
216
|
+
if self.session_store is None and enable_sessions:
|
|
217
|
+
root = workspace or (
|
|
218
|
+
self._sandbox_config.workspace if self._sandbox_config else Path.cwd()
|
|
219
|
+
)
|
|
220
|
+
self.session_store = SessionStore.for_workspace(root)
|
|
221
|
+
|
|
222
|
+
self._attachment_tempdir: tempfile.TemporaryDirectory[str] | None = None
|
|
223
|
+
if self.session_store is not None:
|
|
224
|
+
self.attachment_store = self.session_store.attachment_store
|
|
225
|
+
else:
|
|
226
|
+
self._attachment_tempdir = tempfile.TemporaryDirectory(prefix="deep-agent-attachments-")
|
|
227
|
+
self.attachment_store = AttachmentStore(Path(self._attachment_tempdir.name))
|
|
228
|
+
|
|
229
|
+
saver = checkpointer
|
|
230
|
+
if saver is not None and self.session_store is not None and saver is not self.session_store.checkpointer:
|
|
231
|
+
raise ValueError("checkpointer must be the session store checkpointer")
|
|
232
|
+
if saver is None and self.session_store is not None:
|
|
233
|
+
saver = self.session_store.checkpointer
|
|
234
|
+
if saver is None and prepared is not None:
|
|
235
|
+
saver = prepared.checkpointer
|
|
236
|
+
|
|
237
|
+
cfg = settings or Settings()
|
|
238
|
+
profile = cfg.get_profile(model_id) if model_id else cfg.active_profile
|
|
239
|
+
self._current_model_id = profile.id
|
|
240
|
+
initial_model = model or (prepared.model if prepared is not None else build_chat_model(
|
|
241
|
+
profile, attachment_store=self.attachment_store,
|
|
242
|
+
))
|
|
243
|
+
self._spec = prepared.spec if prepared is not None else AgentSpec(
|
|
244
|
+
instructions=cfg.agent_instructions,
|
|
245
|
+
backend=backend, sandbox=self._sandbox_config or cfg.sandbox,
|
|
246
|
+
web_search_api_key=os.environ.get("TAVILY_API_KEY") or cfg.tavily_api_key,
|
|
247
|
+
)
|
|
248
|
+
self._pause_condition = prepared.pause_condition if prepared is not None else (lambda: False)
|
|
249
|
+
self._custom_interrupt_on = None
|
|
250
|
+
if prepared is not None and prepared.interrupt_on and prepared.interrupt_on != interrupt_on_for_mode(PermissionMode.ASK):
|
|
251
|
+
self._custom_interrupt_on = dict(prepared.interrupt_on)
|
|
252
|
+
if prepared is not None:
|
|
253
|
+
inferred = permission_mode_from_interrupt_on(prepared.interrupt_on)
|
|
254
|
+
if inferred is PermissionMode.ALLOW and not allow_mode_available(prepared.execution_mode):
|
|
255
|
+
raise ValueError(allow_mode_unavailable_reason(prepared.execution_mode))
|
|
256
|
+
self._permission_mode = inferred
|
|
257
|
+
self.prepared = prepared if prepared is not None and saver is prepared.checkpointer and initial_model is prepared.model else build_agent(
|
|
258
|
+
self._spec, initial_model, self._permission_mode, saver,
|
|
259
|
+
self.control, self._pause_condition,
|
|
260
|
+
interrupt_on_override=prepared.interrupt_on if prepared is not None else None,
|
|
261
|
+
)
|
|
262
|
+
self._checkpointer = self.prepared.checkpointer
|
|
263
|
+
self._chat_model = initial_model
|
|
264
|
+
|
|
265
|
+
# Every runner owns exactly one lease for the thread it may write.
|
|
266
|
+
self._runtime = SessionRuntime(
|
|
267
|
+
session_store=self.session_store, checkpointer=self._checkpointer,
|
|
268
|
+
settings=self.settings,
|
|
269
|
+
)
|
|
270
|
+
self._runtime.acquire_initial(
|
|
271
|
+
thread_id, model_id=self._current_model_id,
|
|
272
|
+
permission_mode=self._permission_mode.value,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
self.state_path = (
|
|
276
|
+
self.session_store.path if self.session_store is not None
|
|
277
|
+
else workspace_state_path(workspace or Path.cwd())
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
@property
|
|
281
|
+
def thread_id(self) -> str:
|
|
282
|
+
"""The thread this runner currently points at (lease may be absent)."""
|
|
283
|
+
assert self._runtime is not None and self._runtime.thread_id is not None
|
|
284
|
+
return self._runtime.thread_id
|
|
285
|
+
|
|
286
|
+
def _require_active_session(self) -> None:
|
|
287
|
+
"""Invariant guard: writes to a persisted thread need its lease held."""
|
|
288
|
+
assert self._runtime is not None
|
|
289
|
+
if self._runtime.lease is None:
|
|
290
|
+
raise RuntimeError("This runner owns no session; start or switch to one first")
|
|
291
|
+
if self._runtime.lease.thread_id != self.thread_id:
|
|
292
|
+
raise RuntimeError("The session lease does not cover the active thread")
|
|
293
|
+
if self._runtime.pending_switch is not None:
|
|
294
|
+
raise RuntimeError("A session switch is in progress")
|
|
295
|
+
|
|
296
|
+
def list_models(self) -> list[ModelProfile]:
|
|
297
|
+
if self.settings is None:
|
|
298
|
+
return []
|
|
299
|
+
return list(self.settings.list_profiles())
|
|
300
|
+
|
|
301
|
+
def current_model(self) -> ModelProfile | None:
|
|
302
|
+
if self.settings is None:
|
|
303
|
+
return None
|
|
304
|
+
try:
|
|
305
|
+
return self.settings.get_profile(self._current_model_id)
|
|
306
|
+
except KeyError:
|
|
307
|
+
return self.settings.active_profile
|
|
308
|
+
|
|
309
|
+
def supports_input(self, kind: InputKind) -> bool:
|
|
310
|
+
profile = self.current_model()
|
|
311
|
+
return kind == "text" if profile is None else profile.supports_input(kind)
|
|
312
|
+
|
|
313
|
+
def context_window(self) -> int:
|
|
314
|
+
"""Configured context window in tokens; 0 when the profile does not declare one."""
|
|
315
|
+
profile = self.current_model()
|
|
316
|
+
return 0 if profile is None else profile.context_window
|
|
317
|
+
|
|
318
|
+
def latest_usage(self) -> dict[str, int]:
|
|
319
|
+
"""Token usage of the most recent model call still held in the checkpoint."""
|
|
320
|
+
try:
|
|
321
|
+
state = self.prepared.graph.get_state(self._thread_config())
|
|
322
|
+
except Exception: # noqa: BLE001
|
|
323
|
+
return {}
|
|
324
|
+
return _usage_from_messages((state.values or {}).get("messages", []) or [])
|
|
325
|
+
|
|
326
|
+
@_exclusive_operation
|
|
327
|
+
def compact_context(self) -> CompactResult:
|
|
328
|
+
"""Run Deep Agents' compact tool in the graph, then close its tool turn."""
|
|
329
|
+
self._require_active_session()
|
|
330
|
+
self._require_empty_input_queue()
|
|
331
|
+
middleware = self.prepared.compact_middleware
|
|
332
|
+
if middleware is None:
|
|
333
|
+
raise RuntimeError("Manual compaction middleware is unavailable")
|
|
334
|
+
config = self._thread_config()
|
|
335
|
+
state = self.prepared.graph.get_state(config)
|
|
336
|
+
if getattr(state, "next", ()) or self.current_interrupt() is not None:
|
|
337
|
+
raise RuntimeError("Finish the pending interaction before compacting")
|
|
338
|
+
values = state.values or {}
|
|
339
|
+
messages = list(values.get("messages", []) or [])
|
|
340
|
+
effective = middleware._summarization._apply_event_to_messages(
|
|
341
|
+
messages, values.get("_summarization_event"),
|
|
342
|
+
)
|
|
343
|
+
usage = _usage_from_messages(effective)
|
|
344
|
+
used = usage.get("total_tokens") or usage.get("input_tokens")
|
|
345
|
+
window = self.context_window()
|
|
346
|
+
if not window and self.prepared.model is not None:
|
|
347
|
+
profile = self.prepared.model.profile
|
|
348
|
+
if isinstance(profile, dict) and isinstance(profile.get("max_input_tokens"), int):
|
|
349
|
+
window = profile["max_input_tokens"]
|
|
350
|
+
if not middleware._is_eligible_for_compaction(effective):
|
|
351
|
+
return CompactResult("ineligible", used, window)
|
|
352
|
+
if not middleware._summarization._determine_cutoff_index(effective):
|
|
353
|
+
return CompactResult("nothing_to_compact", used, window)
|
|
354
|
+
|
|
355
|
+
# The tool must execute inside LangGraph so StateBackend can archive old
|
|
356
|
+
# messages. A synthetic tool call enters the tools node; interrupting
|
|
357
|
+
# after that node avoids an unnecessary ordinary model response.
|
|
358
|
+
call_id = f"manual-compact-{uuid4()}"
|
|
359
|
+
last_ai = next((item for item in reversed(effective) if isinstance(item, AIMessage)), None)
|
|
360
|
+
metadata = dict(last_ai.response_metadata) if last_ai is not None else {}
|
|
361
|
+
self.prepared.graph.update_state(config, {"messages": [AIMessage(
|
|
362
|
+
content="",
|
|
363
|
+
tool_calls=[{"name": "compact_conversation", "args": {}, "id": call_id}],
|
|
364
|
+
usage_metadata=usage or None,
|
|
365
|
+
response_metadata=metadata,
|
|
366
|
+
)]}, as_node="model")
|
|
367
|
+
self.prepared.graph.invoke(None, config, interrupt_after=["tools"])
|
|
368
|
+
after = self.prepared.graph.get_state(config)
|
|
369
|
+
tool_result = next((
|
|
370
|
+
item for item in reversed((after.values or {}).get("messages", []) or [])
|
|
371
|
+
if isinstance(item, ToolMessage) and item.tool_call_id == call_id
|
|
372
|
+
), None)
|
|
373
|
+
message = str(tool_result.content) if tool_result is not None else ""
|
|
374
|
+
status = "compacted" if message.startswith("Conversation compacted.") else "failed"
|
|
375
|
+
# Supply a terminal assistant turn, then let after-model hooks finish
|
|
376
|
+
# without entering the model node again.
|
|
377
|
+
self.prepared.graph.update_state(
|
|
378
|
+
config, {"messages": [AIMessage(
|
|
379
|
+
content="", additional_kwargs={"manual_compact_completed": True} if status == "compacted" else {},
|
|
380
|
+
)]}, as_node="model",
|
|
381
|
+
)
|
|
382
|
+
self.prepared.graph.invoke(None, config, interrupt_before=["model"])
|
|
383
|
+
if self.prepared.graph.get_state(config).next:
|
|
384
|
+
raise RuntimeError("Compaction left the agent with a pending graph step")
|
|
385
|
+
if tool_result is None:
|
|
386
|
+
raise RuntimeError("Compaction tool did not return a result")
|
|
387
|
+
if status == "compacted" and self.session_store is not None:
|
|
388
|
+
self.session_store.touch(self.thread_id, last_run_status=StopReason.STOP)
|
|
389
|
+
return CompactResult(status, used, window, message)
|
|
390
|
+
|
|
391
|
+
@_exclusive_operation
|
|
392
|
+
def switch_model(self, id_or_prefix: str) -> ModelProfile:
|
|
393
|
+
if self.settings is None:
|
|
394
|
+
raise RuntimeError("Model switching requires Settings with llm.models")
|
|
395
|
+
self._require_active_session()
|
|
396
|
+
profile = self.settings.get_profile(id_or_prefix)
|
|
397
|
+
if profile.id == self._current_model_id:
|
|
398
|
+
return profile
|
|
399
|
+
self._rebuild_prepared(model=build_chat_model(
|
|
400
|
+
profile, attachment_store=self.attachment_store,
|
|
401
|
+
))
|
|
402
|
+
self._current_model_id = profile.id
|
|
403
|
+
if self.session_store is not None:
|
|
404
|
+
self.session_store.touch(self.thread_id, model_id=profile.id)
|
|
405
|
+
return profile
|
|
406
|
+
|
|
407
|
+
def permission_mode(self) -> PermissionMode:
|
|
408
|
+
return self._permission_mode
|
|
409
|
+
|
|
410
|
+
@_exclusive_operation
|
|
411
|
+
def set_permission_mode(self, mode: PermissionMode | str) -> PermissionMode:
|
|
412
|
+
self._require_active_session()
|
|
413
|
+
if isinstance(mode, str):
|
|
414
|
+
parsed = parse_permission_mode(mode)
|
|
415
|
+
if parsed is None:
|
|
416
|
+
raise ValueError(f"Unknown permission mode: {mode}")
|
|
417
|
+
mode = parsed
|
|
418
|
+
if mode is self._permission_mode:
|
|
419
|
+
return mode
|
|
420
|
+
if mode is PermissionMode.ALLOW and not allow_mode_available(self.prepared.execution_mode):
|
|
421
|
+
raise ValueError(allow_mode_unavailable_reason(self.prepared.execution_mode))
|
|
422
|
+
model = self._chat_model
|
|
423
|
+
if self.settings is not None:
|
|
424
|
+
model = build_chat_model(
|
|
425
|
+
self.settings.get_profile(self._current_model_id),
|
|
426
|
+
attachment_store=self.attachment_store,
|
|
427
|
+
)
|
|
428
|
+
self._rebuild_prepared(model=model, permission_mode=mode)
|
|
429
|
+
self._permission_mode = mode
|
|
430
|
+
if self.session_store is not None:
|
|
431
|
+
self.session_store.touch(self.thread_id, permission_mode=mode.value)
|
|
432
|
+
return mode
|
|
433
|
+
|
|
434
|
+
def _rebuild_prepared(
|
|
435
|
+
self,
|
|
436
|
+
*,
|
|
437
|
+
model: BaseChatModel | None = None,
|
|
438
|
+
permission_mode: PermissionMode | None = None,
|
|
439
|
+
) -> None:
|
|
440
|
+
mode = permission_mode or self._permission_mode
|
|
441
|
+
chat = model if model is not None else self._chat_model
|
|
442
|
+
if chat is None and self.settings is not None:
|
|
443
|
+
chat = build_chat_model(
|
|
444
|
+
self.settings.get_profile(self._current_model_id),
|
|
445
|
+
attachment_store=self.attachment_store,
|
|
446
|
+
)
|
|
447
|
+
self.prepared = build_agent(
|
|
448
|
+
self._spec, chat, mode, self._checkpointer,
|
|
449
|
+
self.control, self._pause_condition,
|
|
450
|
+
interrupt_on_override=self._custom_interrupt_on if mode is PermissionMode.ASK else None,
|
|
451
|
+
)
|
|
452
|
+
self._chat_model = chat
|
|
453
|
+
|
|
454
|
+
def request_pause(self) -> None:
|
|
455
|
+
self.control.request_pause()
|
|
456
|
+
|
|
457
|
+
def request_cancel(self) -> None:
|
|
458
|
+
"""Request hard cancel for execute / cancellable tools and drain the graph."""
|
|
459
|
+
self.control.cancel()
|
|
460
|
+
if self._busy:
|
|
461
|
+
self._touch_status(StopReason.ABORTED)
|
|
462
|
+
|
|
463
|
+
def steer(self, text: str) -> None:
|
|
464
|
+
# No _exclusive_operation: steering must enter while a run holds the
|
|
465
|
+
# operation lock, but never while the runner is detached or closed.
|
|
466
|
+
if self._closed:
|
|
467
|
+
raise RuntimeError("Runner is closed")
|
|
468
|
+
self._require_active_session()
|
|
469
|
+
self.control.steer(text)
|
|
470
|
+
|
|
471
|
+
def follow_up(self, text: str) -> None:
|
|
472
|
+
if self._closed:
|
|
473
|
+
raise RuntimeError("Runner is closed")
|
|
474
|
+
self._require_active_session()
|
|
475
|
+
self.control.follow_up(text)
|
|
476
|
+
|
|
477
|
+
def take_unapplied_messages(self) -> list[str]:
|
|
478
|
+
return [item.text for item in self.control.take_unapplied()]
|
|
479
|
+
|
|
480
|
+
def list_sessions(self, *, limit: int = 50) -> list[SessionInfo]:
|
|
481
|
+
assert self._runtime is not None
|
|
482
|
+
return self._runtime.list_sessions(limit=limit)
|
|
483
|
+
|
|
484
|
+
def thread_has_content(self, thread_id: str | None = None) -> bool:
|
|
485
|
+
tid = thread_id or self.thread_id
|
|
486
|
+
if self.session_store is not None:
|
|
487
|
+
assert self._runtime is not None
|
|
488
|
+
return self._runtime.thread_has_checkpoint(tid)
|
|
489
|
+
try:
|
|
490
|
+
state = self.prepared.graph.get_state({"configurable": {"thread_id": tid}})
|
|
491
|
+
except Exception: # noqa: BLE001
|
|
492
|
+
return False
|
|
493
|
+
messages = list((state.values or {}).get("messages", []) or [])
|
|
494
|
+
return any(bool(getattr(message, "content", None) or getattr(message, "tool_calls", None)) for message in messages)
|
|
495
|
+
|
|
496
|
+
def load_session(self, session_id: str) -> SessionSnapshot | None:
|
|
497
|
+
assert self._runtime is not None
|
|
498
|
+
info = self._runtime.resolve(session_id)
|
|
499
|
+
if info is None:
|
|
500
|
+
return None
|
|
501
|
+
config = {"configurable": {"thread_id": info.id}}
|
|
502
|
+
try:
|
|
503
|
+
state = self.prepared.graph.get_state(config)
|
|
504
|
+
except Exception as exc: # noqa: BLE001
|
|
505
|
+
raise RuntimeError(f"Failed to load session {info.id}: {exc}") from exc
|
|
506
|
+
messages = list((state.values or {}).get("messages", []) or [])
|
|
507
|
+
interrupt = interrupt_kind_from_state(self.prepared.graph, config)
|
|
508
|
+
transcript = messages_to_transcript(messages)
|
|
509
|
+
settle_restored_tools(
|
|
510
|
+
transcript,
|
|
511
|
+
waiting_ids={
|
|
512
|
+
str(call.get("toolCallId") or "")
|
|
513
|
+
for call in interrupt.pending_tools
|
|
514
|
+
} if interrupt is not None else set(),
|
|
515
|
+
waiting_human=interrupt is not None and interrupt.kind is InterruptKind.WAITING_HUMAN,
|
|
516
|
+
)
|
|
517
|
+
return SessionSnapshot(
|
|
518
|
+
info=info,
|
|
519
|
+
transcript=transcript,
|
|
520
|
+
todos=list((state.values or {}).get("todos", []) or []),
|
|
521
|
+
interrupt_kind=interrupt.kind if interrupt is not None else None,
|
|
522
|
+
human_input=interrupt.payload if interrupt is not None and interrupt.kind is InterruptKind.WAITING_HUMAN else {},
|
|
523
|
+
pending_tool_calls=list(interrupt.pending_tools) if interrupt is not None else [],
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
@_exclusive_operation
|
|
527
|
+
def switch_session(self, session_id: str) -> SessionSnapshot:
|
|
528
|
+
"""Switch synchronously; a busy target raises instead of detaching."""
|
|
529
|
+
result = self._begin_session_switch(session_id, detach_on_busy=False)
|
|
530
|
+
if result.status == "busy":
|
|
531
|
+
raise SessionLockBusyError(
|
|
532
|
+
f"Session {result.target_id} is already open in another window"
|
|
533
|
+
)
|
|
534
|
+
assert result.snapshot is not None
|
|
535
|
+
return result.snapshot
|
|
536
|
+
|
|
537
|
+
@_exclusive_operation
|
|
538
|
+
def begin_session_switch(self, session_id: str) -> SessionSwitchResult:
|
|
539
|
+
"""Phase one of a switch: either finish immediately or detach to wait."""
|
|
540
|
+
return self._begin_session_switch(session_id, detach_on_busy=True)
|
|
541
|
+
|
|
542
|
+
@_exclusive_operation
|
|
543
|
+
def complete_session_switch(
|
|
544
|
+
self,
|
|
545
|
+
session_id: str,
|
|
546
|
+
*,
|
|
547
|
+
cancelled: Callable[[], bool] | None = None,
|
|
548
|
+
) -> SessionSnapshot | None:
|
|
549
|
+
"""Phase two: wait for a busy target, then load it and take ownership.
|
|
550
|
+
|
|
551
|
+
Returns None when the wait was cancelled or superseded; the runner stays
|
|
552
|
+
detached (no lease) in that case.
|
|
553
|
+
"""
|
|
554
|
+
assert self._runtime is not None
|
|
555
|
+
if self._runtime.pending_switch != session_id or self._runtime.lease is not None:
|
|
556
|
+
return None
|
|
557
|
+
lease = self._runtime.wait_acquire(session_id, cancelled=cancelled)
|
|
558
|
+
if lease is None:
|
|
559
|
+
return None
|
|
560
|
+
if (
|
|
561
|
+
self._closed
|
|
562
|
+
or self._runtime.pending_switch != session_id
|
|
563
|
+
or self._runtime.lease is not None
|
|
564
|
+
or (cancelled is not None and cancelled())
|
|
565
|
+
):
|
|
566
|
+
lease.release()
|
|
567
|
+
return None
|
|
568
|
+
info = self._runtime.get(session_id)
|
|
569
|
+
if info is None:
|
|
570
|
+
self._runtime.clear_pending()
|
|
571
|
+
lease.release()
|
|
572
|
+
raise KeyError(f"Unknown session: {session_id}")
|
|
573
|
+
return self._load_and_commit_switch(info, lease)
|
|
574
|
+
|
|
575
|
+
def _begin_session_switch(self, session_id: str, *, detach_on_busy: bool) -> SessionSwitchResult:
|
|
576
|
+
assert self._runtime is not None
|
|
577
|
+
info = self._runtime.resolve(session_id)
|
|
578
|
+
if info is None:
|
|
579
|
+
raise KeyError(f"Unknown session: {session_id}")
|
|
580
|
+
if info.id != self.thread_id:
|
|
581
|
+
self._require_empty_input_queue()
|
|
582
|
+
self._runtime.clear_pending()
|
|
583
|
+
# A→A with the lease still held only reloads the snapshot; every other
|
|
584
|
+
# target is acquired first so the old thread never sees a lock gap.
|
|
585
|
+
already_owned = self._runtime.owns(info.id)
|
|
586
|
+
lease = None if already_owned else self._runtime.try_acquire(info.id)
|
|
587
|
+
if lease is None and not already_owned:
|
|
588
|
+
if not detach_on_busy:
|
|
589
|
+
return SessionSwitchResult(status="busy", target_id=info.id)
|
|
590
|
+
# The old thread must accept no further operations while waiting.
|
|
591
|
+
self._runtime.detach_for_wait(info.id)
|
|
592
|
+
return SessionSwitchResult(status="busy", target_id=info.id)
|
|
593
|
+
snapshot = self._load_and_commit_switch(info, lease)
|
|
594
|
+
return SessionSwitchResult(status="switched", target_id=info.id, snapshot=snapshot)
|
|
595
|
+
|
|
596
|
+
def _load_and_commit_switch(
|
|
597
|
+
self, info: SessionInfo, lease: SessionLeaseLike | None,
|
|
598
|
+
) -> SessionSnapshot:
|
|
599
|
+
"""Prepare everything, then commit the switch as the final step.
|
|
600
|
+
|
|
601
|
+
All fallible graph / catalog / checkpointer reads happen while the old
|
|
602
|
+
session is still held and before the target row is written, so a
|
|
603
|
+
failure rolls back cleanly and never dirties the target's catalog row.
|
|
604
|
+
``touch`` is the only fallible operation left, and ``adopt`` — followed
|
|
605
|
+
by in-memory updates only — is the commit point.
|
|
606
|
+
"""
|
|
607
|
+
assert self._runtime is not None
|
|
608
|
+
previous = (self._chat_model, self._current_model_id, self._permission_mode)
|
|
609
|
+
plan = self._runtime.restore_plan(info, execution_mode=self.prepared.execution_mode)
|
|
610
|
+
try:
|
|
611
|
+
snapshot = self.load_session(info.id)
|
|
612
|
+
if snapshot is None:
|
|
613
|
+
raise KeyError(f"Unknown session: {info.id}")
|
|
614
|
+
self._apply_restore_plan(plan)
|
|
615
|
+
has_checkpoint = self._runtime.thread_has_checkpoint(info.id)
|
|
616
|
+
recovery = self._runtime.recovery_context(
|
|
617
|
+
info,
|
|
618
|
+
has_checkpoint=has_checkpoint,
|
|
619
|
+
interrupt_active=snapshot.interrupt_kind is not None,
|
|
620
|
+
)
|
|
621
|
+
refreshed = self._runtime.get(info.id)
|
|
622
|
+
self.session_store.touch(
|
|
623
|
+
info.id, model_id=self._current_model_id,
|
|
624
|
+
permission_mode=self._permission_mode.value,
|
|
625
|
+
)
|
|
626
|
+
except Exception:
|
|
627
|
+
old_model, old_id, old_mode = previous
|
|
628
|
+
self._rebuild_prepared(model=old_model, permission_mode=old_mode)
|
|
629
|
+
self._current_model_id = old_id
|
|
630
|
+
self._permission_mode = old_mode
|
|
631
|
+
if lease is not None:
|
|
632
|
+
lease.release()
|
|
633
|
+
raise
|
|
634
|
+
if refreshed is not None:
|
|
635
|
+
snapshot.info = refreshed
|
|
636
|
+
if lease is not None:
|
|
637
|
+
self._runtime.adopt(snapshot.info.id, lease)
|
|
638
|
+
else:
|
|
639
|
+
self._runtime.bind(snapshot.info.id)
|
|
640
|
+
self.control.clear_pause()
|
|
641
|
+
self.control.set_defer_steering(bool(snapshot.interrupt_kind))
|
|
642
|
+
snapshot.notices.extend(plan.notices)
|
|
643
|
+
self._resume_context = recovery
|
|
644
|
+
return snapshot
|
|
645
|
+
|
|
646
|
+
def _apply_restore_plan(self, plan: RestorePlan) -> None:
|
|
647
|
+
if (
|
|
648
|
+
self.settings is not None
|
|
649
|
+
and plan.model_id
|
|
650
|
+
and plan.model_id != self._current_model_id
|
|
651
|
+
):
|
|
652
|
+
profile = self.settings.get_profile(plan.model_id)
|
|
653
|
+
self._rebuild_prepared(
|
|
654
|
+
model=build_chat_model(profile, attachment_store=self.attachment_store),
|
|
655
|
+
permission_mode=plan.permission_mode,
|
|
656
|
+
)
|
|
657
|
+
self._current_model_id = profile.id
|
|
658
|
+
elif plan.permission_mode is not self._permission_mode:
|
|
659
|
+
self._rebuild_prepared(permission_mode=plan.permission_mode)
|
|
660
|
+
self._permission_mode = plan.permission_mode
|
|
661
|
+
|
|
662
|
+
@_exclusive_operation
|
|
663
|
+
def new_session(self, *, title: str = "") -> SessionInfo:
|
|
664
|
+
assert self._runtime is not None
|
|
665
|
+
self._require_empty_input_queue()
|
|
666
|
+
self._resume_context = None
|
|
667
|
+
self.control.clear_pause()
|
|
668
|
+
self.control.set_defer_steering(False)
|
|
669
|
+
self._runtime.clear_pending()
|
|
670
|
+
if self.session_store is None:
|
|
671
|
+
thread_id = f"cli-{uuid4()}"
|
|
672
|
+
self._runtime.adopt_new(thread_id)
|
|
673
|
+
now = datetime.now(timezone.utc)
|
|
674
|
+
return SessionInfo(
|
|
675
|
+
id=self.thread_id,
|
|
676
|
+
title=title or "New session",
|
|
677
|
+
created_at=now,
|
|
678
|
+
updated_at=now,
|
|
679
|
+
status="running",
|
|
680
|
+
model_id=self._current_model_id,
|
|
681
|
+
permission_mode=self._permission_mode.value,
|
|
682
|
+
)
|
|
683
|
+
info = self.session_store.create_session(
|
|
684
|
+
title=title, model_id=self._current_model_id,
|
|
685
|
+
permission_mode=self._permission_mode.value,
|
|
686
|
+
)
|
|
687
|
+
self._runtime.adopt_new(info.id)
|
|
688
|
+
return info
|
|
689
|
+
|
|
690
|
+
def close(self) -> None:
|
|
691
|
+
"""Forbid new operations, drain an active run, then drop the lease.
|
|
692
|
+
|
|
693
|
+
The lease is never released while a run can still write the thread: a
|
|
694
|
+
close that times out keeps the lease (and the temporary attachment
|
|
695
|
+
store) and raises, so no other window can take the thread mid-write.
|
|
696
|
+
The lock then either drops when this process exits or when close() is
|
|
697
|
+
retried after the run finally drained.
|
|
698
|
+
"""
|
|
699
|
+
assert self._runtime is not None
|
|
700
|
+
if self._closed and self._close_completed:
|
|
701
|
+
return
|
|
702
|
+
self._closed = True
|
|
703
|
+
self._runtime.abort_wait()
|
|
704
|
+
if self._busy:
|
|
705
|
+
self.request_cancel()
|
|
706
|
+
drained = self._operation_lock.acquire(timeout=CLOSE_DRAIN_SECONDS)
|
|
707
|
+
if drained:
|
|
708
|
+
self._operation_lock.release()
|
|
709
|
+
else:
|
|
710
|
+
raise RuntimeError(
|
|
711
|
+
"Runner close timed out with a run still active; the session "
|
|
712
|
+
"lease is kept until the run drains and close is retried"
|
|
713
|
+
)
|
|
714
|
+
self._close_completed = True
|
|
715
|
+
self._runtime.close()
|
|
716
|
+
if self._attachment_tempdir is not None:
|
|
717
|
+
self._attachment_tempdir.cleanup()
|
|
718
|
+
self._attachment_tempdir = None
|
|
719
|
+
|
|
720
|
+
def _require_empty_input_queue(self) -> None:
|
|
721
|
+
if self.control.pending_steering_count() or self.control.pending_follow_up_count():
|
|
722
|
+
raise RuntimeError("Unapplied input belongs to the current session; reclaim it before switching")
|
|
723
|
+
|
|
724
|
+
@_exclusive_operation
|
|
725
|
+
def invoke(
|
|
726
|
+
self,
|
|
727
|
+
text: str,
|
|
728
|
+
*,
|
|
729
|
+
images: Sequence[ImageAttachment] = (),
|
|
730
|
+
on_delta: DeltaHandler | None = None,
|
|
731
|
+
on_event: RunEventHandler | None = None,
|
|
732
|
+
) -> RunResult:
|
|
733
|
+
if images and not self.supports_input("image"):
|
|
734
|
+
raise ValueError("The current model does not declare image input support")
|
|
735
|
+
if len(images) > MAX_IMAGES_PER_MESSAGE:
|
|
736
|
+
raise ValueError(f"A message can contain at most {MAX_IMAGES_PER_MESSAGE} images")
|
|
737
|
+
if images and not self._can_materialize_attachments():
|
|
738
|
+
raise RuntimeError(
|
|
739
|
+
"The current custom model does not implement Deep-Agent image attachment resolution"
|
|
740
|
+
)
|
|
741
|
+
refs = tuple(self.attachment_store.put(image) for image in images)
|
|
742
|
+
return self._invoke_with_attachment_refs(
|
|
743
|
+
text,
|
|
744
|
+
image_refs=refs,
|
|
745
|
+
on_delta=on_delta,
|
|
746
|
+
on_event=on_event,
|
|
747
|
+
)
|
|
748
|
+
|
|
749
|
+
@_exclusive_operation
|
|
750
|
+
def invoke_with_attachment_refs(
|
|
751
|
+
self,
|
|
752
|
+
text: str,
|
|
753
|
+
*,
|
|
754
|
+
image_refs: Sequence[ImageAttachmentRef] = (),
|
|
755
|
+
on_delta: DeltaHandler | None = None,
|
|
756
|
+
on_event: RunEventHandler | None = None,
|
|
757
|
+
) -> RunResult:
|
|
758
|
+
return self._invoke_with_attachment_refs(
|
|
759
|
+
text, image_refs=image_refs, on_delta=on_delta, on_event=on_event,
|
|
760
|
+
)
|
|
761
|
+
|
|
762
|
+
def _invoke_with_attachment_refs(
|
|
763
|
+
self,
|
|
764
|
+
text: str,
|
|
765
|
+
*,
|
|
766
|
+
image_refs: Sequence[ImageAttachmentRef] = (),
|
|
767
|
+
on_delta: DeltaHandler | None = None,
|
|
768
|
+
on_event: RunEventHandler | None = None,
|
|
769
|
+
) -> RunResult:
|
|
770
|
+
self._require_active_session()
|
|
771
|
+
refs = tuple(image_refs)
|
|
772
|
+
if refs and not self.supports_input("image"):
|
|
773
|
+
raise ValueError("The current model does not declare image input support")
|
|
774
|
+
if len(refs) > MAX_IMAGES_PER_MESSAGE:
|
|
775
|
+
raise ValueError(f"A message can contain at most {MAX_IMAGES_PER_MESSAGE} images")
|
|
776
|
+
if refs and not self._can_materialize_attachments():
|
|
777
|
+
raise RuntimeError(
|
|
778
|
+
"The current custom model does not implement Deep-Agent image attachment resolution"
|
|
779
|
+
)
|
|
780
|
+
additional = {ATTACHMENT_META_KEY: refs_to_dicts(refs)} if refs else {}
|
|
781
|
+
graph_input: dict[str, Any] = {
|
|
782
|
+
"messages": [HumanMessage(content=text, additional_kwargs=additional)],
|
|
783
|
+
}
|
|
784
|
+
if self._resume_context is not None:
|
|
785
|
+
self._resume_context.armed = True
|
|
786
|
+
if self.session_store is not None:
|
|
787
|
+
title = text.strip().splitlines()[0][:80] if text.strip() else None
|
|
788
|
+
self.session_store.touch(self.thread_id, title=title,
|
|
789
|
+
last_run_status=StopReason.PENDING)
|
|
790
|
+
return self._stream(graph_input, on_delta=on_delta, on_event=on_event)
|
|
791
|
+
|
|
792
|
+
def _can_materialize_attachments(self) -> bool:
|
|
793
|
+
return isinstance(self._chat_model, ChatOpenAI) or bool(
|
|
794
|
+
getattr(self._chat_model, "materializes_attachment_refs", False)
|
|
795
|
+
)
|
|
796
|
+
|
|
797
|
+
def store_image(self, image: ImageAttachment) -> ImageAttachmentRef:
|
|
798
|
+
if not self.supports_input("image"):
|
|
799
|
+
raise ValueError("The current model does not declare image input support")
|
|
800
|
+
return self.attachment_store.put(image)
|
|
801
|
+
|
|
802
|
+
@_exclusive_operation
|
|
803
|
+
def cleanup_attachments(
|
|
804
|
+
self,
|
|
805
|
+
*,
|
|
806
|
+
protected: Sequence[ImageAttachmentRef] = (),
|
|
807
|
+
) -> AttachmentCleanupResult:
|
|
808
|
+
if self.session_store is None:
|
|
809
|
+
return self.attachment_store.cleanup(
|
|
810
|
+
set(), protected_storage_keys=(ref.storage_key for ref in protected),
|
|
811
|
+
)
|
|
812
|
+
return self.session_store.cleanup_attachments(
|
|
813
|
+
protected=tuple(protected), release_runtime_lease=True,
|
|
814
|
+
)
|
|
815
|
+
|
|
816
|
+
@_exclusive_operation
|
|
817
|
+
def continue_run(
|
|
818
|
+
self,
|
|
819
|
+
*,
|
|
820
|
+
on_delta: DeltaHandler | None = None,
|
|
821
|
+
on_event: RunEventHandler | None = None,
|
|
822
|
+
) -> RunResult:
|
|
823
|
+
self._require_active_session()
|
|
824
|
+
self._require_interrupt(InterruptKind.PAUSED, "continue_run")
|
|
825
|
+
self.control.clear_pause()
|
|
826
|
+
return self._resume(True, on_delta=on_delta, on_event=on_event)
|
|
827
|
+
|
|
828
|
+
@_exclusive_operation
|
|
829
|
+
def approve_tool(
|
|
830
|
+
self,
|
|
831
|
+
tool_call_id: str,
|
|
832
|
+
*,
|
|
833
|
+
on_delta: DeltaHandler | None = None,
|
|
834
|
+
on_event: RunEventHandler | None = None,
|
|
835
|
+
) -> RunResult:
|
|
836
|
+
self._require_active_session()
|
|
837
|
+
state = self._require_interrupt(InterruptKind.WAITING_CONFIRMATION, "approve_tool")
|
|
838
|
+
return self._resume(
|
|
839
|
+
_tool_decisions(list(state.pending_tools), decision_type="approve", tool_call_ids=[tool_call_id]),
|
|
840
|
+
on_delta=on_delta,
|
|
841
|
+
on_event=on_event,
|
|
842
|
+
)
|
|
843
|
+
|
|
844
|
+
@_exclusive_operation
|
|
845
|
+
def reject_tool(
|
|
846
|
+
self,
|
|
847
|
+
tool_call_id: str,
|
|
848
|
+
message: str | None = None,
|
|
849
|
+
*,
|
|
850
|
+
on_delta: DeltaHandler | None = None,
|
|
851
|
+
on_event: RunEventHandler | None = None,
|
|
852
|
+
) -> RunResult:
|
|
853
|
+
self._require_active_session()
|
|
854
|
+
state = self._require_interrupt(InterruptKind.WAITING_CONFIRMATION, "reject_tool")
|
|
855
|
+
return self._resume(
|
|
856
|
+
_tool_decisions(
|
|
857
|
+
list(state.pending_tools), decision_type="reject", tool_call_ids=[tool_call_id], message=message,
|
|
858
|
+
),
|
|
859
|
+
on_delta=on_delta,
|
|
860
|
+
on_event=on_event,
|
|
861
|
+
)
|
|
862
|
+
|
|
863
|
+
@_exclusive_operation
|
|
864
|
+
def submit_human_input(
|
|
865
|
+
self,
|
|
866
|
+
values: dict[str, Any],
|
|
867
|
+
*,
|
|
868
|
+
on_delta: DeltaHandler | None = None,
|
|
869
|
+
on_event: RunEventHandler | None = None,
|
|
870
|
+
) -> RunResult:
|
|
871
|
+
self._require_active_session()
|
|
872
|
+
state = self._require_interrupt(InterruptKind.WAITING_HUMAN, "submit_human_input")
|
|
873
|
+
if _has_pending_legacy_handoff(self.prepared.graph, self._thread_config()):
|
|
874
|
+
raise RuntimeError(
|
|
875
|
+
"This session uses the removed handoff_to_human tool. "
|
|
876
|
+
"Start a new session and ask again with request_human_input."
|
|
877
|
+
)
|
|
878
|
+
if not isinstance(values, dict):
|
|
879
|
+
raise ValueError("Human input response must contain a values object")
|
|
880
|
+
envelope: dict[str, Any] = {"type": "human_input", "values": dict(values)}
|
|
881
|
+
if state.payload.get("interactionId"):
|
|
882
|
+
envelope["interactionId"] = state.payload["interactionId"]
|
|
883
|
+
return self._resume(envelope, on_delta=on_delta, on_event=on_event)
|
|
884
|
+
|
|
885
|
+
@_exclusive_operation
|
|
886
|
+
def _decide_listed_tools( # HITL adapter: one Command must carry the full decisions list.
|
|
887
|
+
self,
|
|
888
|
+
tool_call_ids: Sequence[str],
|
|
889
|
+
*,
|
|
890
|
+
approved: bool,
|
|
891
|
+
message: str | None = None,
|
|
892
|
+
on_delta: DeltaHandler | None = None,
|
|
893
|
+
on_event: RunEventHandler | None = None,
|
|
894
|
+
) -> RunResult:
|
|
895
|
+
action = "approve_tool" if approved else "reject_tool"
|
|
896
|
+
state = self._require_interrupt(InterruptKind.WAITING_CONFIRMATION, action)
|
|
897
|
+
return self._resume(
|
|
898
|
+
_tool_decisions(
|
|
899
|
+
list(state.pending_tools),
|
|
900
|
+
decision_type="approve" if approved else "reject",
|
|
901
|
+
tool_call_ids=tool_call_ids,
|
|
902
|
+
message=message,
|
|
903
|
+
others="same",
|
|
904
|
+
),
|
|
905
|
+
on_delta=on_delta,
|
|
906
|
+
on_event=on_event,
|
|
907
|
+
)
|
|
908
|
+
|
|
909
|
+
def _require_interrupt(self, expected: InterruptKind, action: str) -> InterruptState:
|
|
910
|
+
state = self.current_interrupt()
|
|
911
|
+
if state is None or state.kind is not expected:
|
|
912
|
+
current = state.kind.value if state is not None else "none"
|
|
913
|
+
raise ValueError(f"{action} requires a {expected.value} interrupt, current is {current}")
|
|
914
|
+
return state
|
|
915
|
+
|
|
916
|
+
def _resume(
|
|
917
|
+
self,
|
|
918
|
+
value: Any,
|
|
919
|
+
*,
|
|
920
|
+
on_delta: DeltaHandler | None = None,
|
|
921
|
+
on_event: RunEventHandler | None = None,
|
|
922
|
+
) -> RunResult:
|
|
923
|
+
self._require_active_session()
|
|
924
|
+
self.control.set_defer_steering(False)
|
|
925
|
+
if self.session_store is not None:
|
|
926
|
+
self.session_store.touch(self.thread_id, last_run_status=StopReason.PENDING)
|
|
927
|
+
return self._stream(Command(resume=value), on_delta=on_delta, on_event=on_event)
|
|
928
|
+
|
|
929
|
+
def current_interrupt(self) -> InterruptState | None:
|
|
930
|
+
return interrupt_kind_from_state(self.prepared.graph, self._thread_config())
|
|
931
|
+
|
|
932
|
+
def _thread_config(self) -> dict[str, Any]:
|
|
933
|
+
return {"configurable": {"thread_id": self.thread_id}}
|
|
934
|
+
|
|
935
|
+
def _run_config(
|
|
936
|
+
self,
|
|
937
|
+
on_delta: DeltaHandler | None,
|
|
938
|
+
on_event: RunEventHandler | None,
|
|
939
|
+
) -> dict[str, Any]:
|
|
940
|
+
handler = on_delta or self.on_delta
|
|
941
|
+
event_handler = on_event or self.on_event
|
|
942
|
+
config = self._thread_config()
|
|
943
|
+
if handler is None and event_handler is None:
|
|
944
|
+
return config
|
|
945
|
+
callback = StreamDeltaCallback(
|
|
946
|
+
handler or (lambda _kind, _text: None),
|
|
947
|
+
on_reasoning=(
|
|
948
|
+
(lambda text: _emit(event_handler, RunEvent(type="thinking_delta", content=text)))
|
|
949
|
+
if event_handler else None
|
|
950
|
+
),
|
|
951
|
+
on_assistant=(
|
|
952
|
+
(lambda text: _emit(event_handler, RunEvent(type="assistant_delta", content=text)))
|
|
953
|
+
if event_handler else None
|
|
954
|
+
),
|
|
955
|
+
on_start=(
|
|
956
|
+
(lambda: _emit(event_handler, RunEvent(type="assistant_started")))
|
|
957
|
+
if event_handler else None
|
|
958
|
+
),
|
|
959
|
+
on_end=(
|
|
960
|
+
(lambda assistant, reasoning: _emit(event_handler, RunEvent(
|
|
961
|
+
type="assistant_completed", content=assistant, result={"thinking": reasoning},
|
|
962
|
+
)))
|
|
963
|
+
if event_handler else None
|
|
964
|
+
),
|
|
965
|
+
)
|
|
966
|
+
return merge_stream_callbacks(config, callback)
|
|
967
|
+
|
|
968
|
+
def _stream(
|
|
969
|
+
self,
|
|
970
|
+
graph_input: Any,
|
|
971
|
+
*,
|
|
972
|
+
on_delta: DeltaHandler | None = None,
|
|
973
|
+
on_event: RunEventHandler | None = None,
|
|
974
|
+
) -> RunResult:
|
|
975
|
+
event_handler = on_event or self.on_event
|
|
976
|
+
self._event_handler = event_handler
|
|
977
|
+
config = self._run_config(on_delta, on_event)
|
|
978
|
+
self._seen_tool_calls.clear()
|
|
979
|
+
self._todo_call_ids.clear()
|
|
980
|
+
run_control = self.control.begin_run()
|
|
981
|
+
set_output_emitter(self._emit_tool_output)
|
|
982
|
+
attachment_token = set_attachment_store(self.attachment_store)
|
|
983
|
+
recovery_token = set_recovery_context(self._resume_context)
|
|
984
|
+
self._busy = True
|
|
985
|
+
_emit(event_handler, RunEvent(type="run_started"))
|
|
986
|
+
|
|
987
|
+
result: RunResult | None = None
|
|
988
|
+
turn_started = time.monotonic()
|
|
989
|
+
|
|
990
|
+
def emit_turn_completed(output: str) -> None:
|
|
991
|
+
_emit(event_handler, RunEvent(
|
|
992
|
+
type="turn_completed",
|
|
993
|
+
content=output,
|
|
994
|
+
result=TurnTiming(
|
|
995
|
+
elapsed_seconds=max(0.0, time.monotonic() - turn_started),
|
|
996
|
+
finished_at=datetime.now(timezone.utc),
|
|
997
|
+
),
|
|
998
|
+
))
|
|
999
|
+
|
|
1000
|
+
try:
|
|
1001
|
+
result = self._stream_once(graph_input, config, run_control, event_handler)
|
|
1002
|
+
# Consume queued input at the same run boundary for every client.
|
|
1003
|
+
while result.status == "completed" and not self.control.cancel_requested:
|
|
1004
|
+
text = self.control.pop_steering()
|
|
1005
|
+
if text is None:
|
|
1006
|
+
text = self.control.pop_follow_up()
|
|
1007
|
+
if text is None:
|
|
1008
|
+
break
|
|
1009
|
+
emit_turn_completed(result.output)
|
|
1010
|
+
run_control = self.control.begin_run()
|
|
1011
|
+
self._seen_tool_calls.clear()
|
|
1012
|
+
self._todo_call_ids.clear()
|
|
1013
|
+
turn_started = time.monotonic()
|
|
1014
|
+
result = self._stream_once(
|
|
1015
|
+
{"messages": [HumanMessage(content=text)]},
|
|
1016
|
+
config,
|
|
1017
|
+
run_control,
|
|
1018
|
+
event_handler,
|
|
1019
|
+
)
|
|
1020
|
+
if result.status == "completed":
|
|
1021
|
+
_emit(event_handler, RunEvent(type="run_completed", content=result.output))
|
|
1022
|
+
emit_turn_completed(result.output)
|
|
1023
|
+
self._touch_status(StopReason.STOP)
|
|
1024
|
+
return result
|
|
1025
|
+
finally:
|
|
1026
|
+
self._busy = False
|
|
1027
|
+
self.control.end_run()
|
|
1028
|
+
set_output_emitter(None)
|
|
1029
|
+
reset_attachment_store(attachment_token)
|
|
1030
|
+
reset_recovery_context(recovery_token)
|
|
1031
|
+
if (result is not None and result.status == "completed") or (
|
|
1032
|
+
self._resume_context is not None and self._resume_context.text is None
|
|
1033
|
+
):
|
|
1034
|
+
self._resume_context = None
|
|
1035
|
+
self._event_handler = None
|
|
1036
|
+
|
|
1037
|
+
def _stream_once(
|
|
1038
|
+
self,
|
|
1039
|
+
graph_input: Any,
|
|
1040
|
+
config: dict[str, Any],
|
|
1041
|
+
run_control: Any,
|
|
1042
|
+
event_handler: RunEventHandler | None,
|
|
1043
|
+
) -> RunResult:
|
|
1044
|
+
interrupted = ""
|
|
1045
|
+
pending: list[dict[str, Any]] = []
|
|
1046
|
+
human_input: dict[str, Any] = {}
|
|
1047
|
+
previous_todos = _todos_in_state(
|
|
1048
|
+
self.prepared.graph.get_state(self._thread_config()).values or {}
|
|
1049
|
+
)
|
|
1050
|
+
try:
|
|
1051
|
+
for chunk in self.prepared.graph.stream(
|
|
1052
|
+
graph_input, config, stream_mode=["updates", "values"], control=run_control,
|
|
1053
|
+
durability="sync",
|
|
1054
|
+
):
|
|
1055
|
+
mode, payload = chunk
|
|
1056
|
+
if mode == "values":
|
|
1057
|
+
todos = _todos_in_state(payload)
|
|
1058
|
+
if todos is not None and todos != previous_todos:
|
|
1059
|
+
_emit(event_handler, RunEvent(type="todos_updated", result=todos))
|
|
1060
|
+
previous_todos = todos
|
|
1061
|
+
continue
|
|
1062
|
+
chunk = payload
|
|
1063
|
+
self._emit_update_events(chunk, event_handler)
|
|
1064
|
+
if not isinstance(chunk, dict):
|
|
1065
|
+
continue
|
|
1066
|
+
for node, update in chunk.items():
|
|
1067
|
+
if node != "__interrupt__":
|
|
1068
|
+
continue
|
|
1069
|
+
interrupt = classify_interrupt(
|
|
1070
|
+
update, self.prepared.graph, self._thread_config(),
|
|
1071
|
+
)
|
|
1072
|
+
interrupted = interrupt.kind.value
|
|
1073
|
+
pending = list(interrupt.pending_tools)
|
|
1074
|
+
if interrupt.kind is InterruptKind.WAITING_HUMAN:
|
|
1075
|
+
human_input = interrupt.payload
|
|
1076
|
+
except GraphDrained:
|
|
1077
|
+
_emit(event_handler, RunEvent(type="run_cancelled"))
|
|
1078
|
+
self._touch_status(StopReason.ABORTED)
|
|
1079
|
+
return RunResult(status="cancelled")
|
|
1080
|
+
except Exception as exc: # noqa: BLE001
|
|
1081
|
+
if self.control.cancel_requested:
|
|
1082
|
+
_emit(event_handler, RunEvent(type="run_cancelled"))
|
|
1083
|
+
self._touch_status(StopReason.ABORTED)
|
|
1084
|
+
return RunResult(status="cancelled")
|
|
1085
|
+
_emit(event_handler, RunEvent(type="run_failed", content=str(exc), is_error=True))
|
|
1086
|
+
self._touch_status(StopReason.ERROR)
|
|
1087
|
+
return RunResult(status="failed", error=str(exc))
|
|
1088
|
+
|
|
1089
|
+
if self.control.cancel_requested and not interrupted:
|
|
1090
|
+
_emit(event_handler, RunEvent(type="run_cancelled"))
|
|
1091
|
+
self._touch_status(StopReason.ABORTED)
|
|
1092
|
+
return RunResult(status="cancelled")
|
|
1093
|
+
|
|
1094
|
+
if interrupted:
|
|
1095
|
+
self.control.set_defer_steering(True)
|
|
1096
|
+
self._touch_status(StopReason.DEFERRED)
|
|
1097
|
+
result = RunResult(
|
|
1098
|
+
status=interrupted,
|
|
1099
|
+
pending_tool_calls=pending,
|
|
1100
|
+
human_input=human_input,
|
|
1101
|
+
)
|
|
1102
|
+
_emit(event_handler, RunEvent(type="interaction_requested", result=result))
|
|
1103
|
+
return result
|
|
1104
|
+
|
|
1105
|
+
output = _final_output(self.prepared.graph, self._thread_config())
|
|
1106
|
+
return RunResult(status="completed", output=output)
|
|
1107
|
+
|
|
1108
|
+
def _emit_tool_output(self, tool_call_id: str, content: str, stream: str) -> None:
|
|
1109
|
+
_emit(self._event_handler or self.on_event, RunEvent(
|
|
1110
|
+
type="tool_output_delta",
|
|
1111
|
+
tool_call_id=tool_call_id,
|
|
1112
|
+
content=content,
|
|
1113
|
+
stream=stream,
|
|
1114
|
+
))
|
|
1115
|
+
|
|
1116
|
+
def _on_control_event(self, event_type: str, payload: dict[str, Any]) -> None:
|
|
1117
|
+
_emit(self._event_handler or self.on_event, RunEvent(
|
|
1118
|
+
type=event_type,
|
|
1119
|
+
content=str(payload.get("content") or ""),
|
|
1120
|
+
result=payload,
|
|
1121
|
+
))
|
|
1122
|
+
|
|
1123
|
+
def _touch_status(self, reason: StopReason) -> None:
|
|
1124
|
+
if self.session_store is not None:
|
|
1125
|
+
self.session_store.touch(self.thread_id, last_run_status=reason)
|
|
1126
|
+
if reason in {StopReason.ABORTED, StopReason.ERROR}:
|
|
1127
|
+
self._resume_context = RecoveryContext.for_stop_reason(reason)
|
|
1128
|
+
|
|
1129
|
+
def _emit_update_events(self, chunk: Any, handler: RunEventHandler | None) -> None:
|
|
1130
|
+
if handler is None:
|
|
1131
|
+
return
|
|
1132
|
+
for message in _messages_in_update(chunk):
|
|
1133
|
+
if isinstance(message, AIMessage):
|
|
1134
|
+
message_id = str(getattr(message, "id", "") or "")
|
|
1135
|
+
usage = _usage_metadata_dict(message)
|
|
1136
|
+
if usage:
|
|
1137
|
+
_emit(handler, RunEvent(type="usage", result=usage))
|
|
1138
|
+
for call in message.tool_calls or []:
|
|
1139
|
+
tool_call_id = str(call.get("id") or "")
|
|
1140
|
+
if call.get("name") == "write_todos":
|
|
1141
|
+
self._todo_call_ids.add(tool_call_id)
|
|
1142
|
+
continue
|
|
1143
|
+
identity = tool_call_id or f"{call.get('name')}:{id(call)}"
|
|
1144
|
+
if identity in self._seen_tool_calls:
|
|
1145
|
+
continue
|
|
1146
|
+
self._seen_tool_calls.add(identity)
|
|
1147
|
+
_emit(handler, RunEvent(
|
|
1148
|
+
type="tool_started",
|
|
1149
|
+
message_id=message_id,
|
|
1150
|
+
tool_call_id=tool_call_id,
|
|
1151
|
+
name=str(call.get("name") or "tool"),
|
|
1152
|
+
arguments=call.get("args") if isinstance(call.get("args"), dict) else {},
|
|
1153
|
+
))
|
|
1154
|
+
elif isinstance(message, ToolMessage):
|
|
1155
|
+
tool_name = str(getattr(message, "name", "") or "tool")
|
|
1156
|
+
if tool_name == "write_todos" or str(
|
|
1157
|
+
getattr(message, "tool_call_id", "") or ""
|
|
1158
|
+
) in self._todo_call_ids:
|
|
1159
|
+
continue
|
|
1160
|
+
content = _message_text(message)
|
|
1161
|
+
artifact = getattr(message, "artifact", None)
|
|
1162
|
+
is_error = tool_message_is_error(message)
|
|
1163
|
+
_emit(handler, RunEvent(
|
|
1164
|
+
type="tool_completed",
|
|
1165
|
+
tool_call_id=str(getattr(message, "tool_call_id", "") or ""),
|
|
1166
|
+
name=tool_name,
|
|
1167
|
+
content=content,
|
|
1168
|
+
result=getattr(message, "content", content),
|
|
1169
|
+
is_error=is_error,
|
|
1170
|
+
artifact=artifact,
|
|
1171
|
+
))
|
|
1172
|
+
|
|
1173
|
+
def interrupt_payloads(interrupts: Any) -> list[dict[str, Any]]:
|
|
1174
|
+
payloads: list[dict[str, Any]] = []
|
|
1175
|
+
items = interrupts if isinstance(interrupts, (list, tuple)) else [interrupts]
|
|
1176
|
+
for item in items:
|
|
1177
|
+
value = getattr(item, "value", item)
|
|
1178
|
+
if isinstance(value, dict):
|
|
1179
|
+
payloads.append(value)
|
|
1180
|
+
elif isinstance(value, (list, tuple)):
|
|
1181
|
+
for inner in value:
|
|
1182
|
+
inner_value = getattr(inner, "value", inner)
|
|
1183
|
+
if isinstance(inner_value, dict):
|
|
1184
|
+
payloads.append(inner_value)
|
|
1185
|
+
return payloads
|
|
1186
|
+
|
|
1187
|
+
|
|
1188
|
+
def resolve_pending_tool_calls(interrupts: Any, graph: Any, config: dict[str, Any]) -> list[dict[str, Any]]:
|
|
1189
|
+
requested: list[dict[str, Any]] = []
|
|
1190
|
+
for interrupt in interrupts if isinstance(interrupts, (list, tuple)) else [interrupts]:
|
|
1191
|
+
value = getattr(interrupt, "value", interrupt)
|
|
1192
|
+
if isinstance(value, dict):
|
|
1193
|
+
for action in value.get("action_requests", []) or []:
|
|
1194
|
+
requested.append({
|
|
1195
|
+
"name": action.get("name"),
|
|
1196
|
+
"args": action.get("args", {}),
|
|
1197
|
+
"description": action.get("description", ""),
|
|
1198
|
+
})
|
|
1199
|
+
tool_call_ids: dict[str, list[str]] = {}
|
|
1200
|
+
try:
|
|
1201
|
+
state = graph.get_state(config)
|
|
1202
|
+
messages = state.values.get("messages", [])
|
|
1203
|
+
for message in reversed(messages):
|
|
1204
|
+
if isinstance(message, AIMessage) and message.tool_calls:
|
|
1205
|
+
for call in message.tool_calls:
|
|
1206
|
+
tool_call_ids.setdefault(str(call.get("name")), []).append(str(call.get("id")))
|
|
1207
|
+
break
|
|
1208
|
+
except Exception: # noqa: BLE001
|
|
1209
|
+
pass
|
|
1210
|
+
pending: list[dict[str, Any]] = []
|
|
1211
|
+
for action in requested:
|
|
1212
|
+
name = str(action["name"])
|
|
1213
|
+
ids = tool_call_ids.get(name, [])
|
|
1214
|
+
pending.append({
|
|
1215
|
+
"toolCallId": ids.pop(0) if ids else "",
|
|
1216
|
+
"name": name,
|
|
1217
|
+
"args": action["args"],
|
|
1218
|
+
})
|
|
1219
|
+
return pending
|
|
1220
|
+
|
|
1221
|
+
|
|
1222
|
+
def is_valid_hitl_interrupt(payload: Any) -> bool:
|
|
1223
|
+
"""True only for the locked LangChain HITLRequest schema (no type field)."""
|
|
1224
|
+
if not isinstance(payload, dict):
|
|
1225
|
+
return False
|
|
1226
|
+
if payload.get("type") not in (None, ""):
|
|
1227
|
+
return False
|
|
1228
|
+
requests = payload.get("action_requests")
|
|
1229
|
+
configs = payload.get("review_configs")
|
|
1230
|
+
if not isinstance(requests, list) or not requests:
|
|
1231
|
+
return False
|
|
1232
|
+
if not isinstance(configs, list) or not configs:
|
|
1233
|
+
return False
|
|
1234
|
+
for item in requests:
|
|
1235
|
+
if not isinstance(item, dict) or not isinstance(item.get("name"), str) or not item["name"]:
|
|
1236
|
+
return False
|
|
1237
|
+
if not isinstance(item.get("args"), dict):
|
|
1238
|
+
return False
|
|
1239
|
+
for item in configs:
|
|
1240
|
+
if not isinstance(item, dict) or not isinstance(item.get("action_name"), str):
|
|
1241
|
+
return False
|
|
1242
|
+
if not isinstance(item.get("allowed_decisions"), list):
|
|
1243
|
+
return False
|
|
1244
|
+
return True
|
|
1245
|
+
|
|
1246
|
+
|
|
1247
|
+
def classify_interrupt(
|
|
1248
|
+
interrupts: Any, graph: Any, config: dict[str, Any],
|
|
1249
|
+
) -> InterruptState:
|
|
1250
|
+
payloads = interrupt_payloads(interrupts)
|
|
1251
|
+
if not payloads:
|
|
1252
|
+
raise UnknownInterruptError("Interrupt has no recognizable payload")
|
|
1253
|
+
for payload in payloads:
|
|
1254
|
+
kind = str(payload.get("type") or "").strip().lower()
|
|
1255
|
+
if kind == "human_input":
|
|
1256
|
+
return InterruptState(InterruptKind.WAITING_HUMAN, payload)
|
|
1257
|
+
if kind == "pause":
|
|
1258
|
+
return InterruptState(InterruptKind.PAUSED, payload)
|
|
1259
|
+
if is_valid_hitl_interrupt(payload):
|
|
1260
|
+
pending = resolve_pending_tool_calls(interrupts, graph, config)
|
|
1261
|
+
return InterruptState(InterruptKind.WAITING_CONFIRMATION, payload, tuple(pending))
|
|
1262
|
+
if kind:
|
|
1263
|
+
raise UnknownInterruptError(f"Unsupported interrupt type: {kind!r}")
|
|
1264
|
+
raise UnknownInterruptError(
|
|
1265
|
+
f"Unsupported interrupt payload: {sorted(payloads[0].keys()) if payloads[0] else 'empty dict'}"
|
|
1266
|
+
)
|
|
1267
|
+
|
|
1268
|
+
|
|
1269
|
+
def interrupt_kind_from_state(graph: Any, config: dict[str, Any]) -> InterruptState | None:
|
|
1270
|
+
state = graph.get_state(config)
|
|
1271
|
+
interrupts = getattr(state, "interrupts", ()) or ()
|
|
1272
|
+
if not interrupts:
|
|
1273
|
+
return None
|
|
1274
|
+
return classify_interrupt(interrupts, graph, config)
|
|
1275
|
+
|
|
1276
|
+
|
|
1277
|
+
def _final_output(graph: Any, config: dict[str, Any]) -> str:
|
|
1278
|
+
state = graph.get_state(config)
|
|
1279
|
+
messages = state.values.get("messages", [])
|
|
1280
|
+
for message in reversed(messages):
|
|
1281
|
+
if isinstance(message, AIMessage) and not message.tool_calls:
|
|
1282
|
+
return visible_text(message) or _message_text(message)
|
|
1283
|
+
return ""
|
|
1284
|
+
|
|
1285
|
+
|
|
1286
|
+
def _message_text(message: Any) -> str:
|
|
1287
|
+
text = getattr(message, "text", None)
|
|
1288
|
+
if isinstance(text, str) and text:
|
|
1289
|
+
return text
|
|
1290
|
+
content = getattr(message, "content", "")
|
|
1291
|
+
if isinstance(content, str):
|
|
1292
|
+
return content
|
|
1293
|
+
parts: list[str] = []
|
|
1294
|
+
for block in content if isinstance(content, list) else []:
|
|
1295
|
+
if isinstance(block, dict) and block.get("type") == "text":
|
|
1296
|
+
parts.append(str(block.get("text", "")))
|
|
1297
|
+
elif isinstance(block, str):
|
|
1298
|
+
parts.append(block)
|
|
1299
|
+
return "".join(parts)
|
|
1300
|
+
|
|
1301
|
+
|
|
1302
|
+
def _messages_in_update(value: Any) -> list[BaseMessage]:
|
|
1303
|
+
"""Find messages in LangGraph update envelopes without depending on node names."""
|
|
1304
|
+
found: list[BaseMessage] = []
|
|
1305
|
+
if isinstance(value, BaseMessage):
|
|
1306
|
+
return [value]
|
|
1307
|
+
if isinstance(value, dict):
|
|
1308
|
+
for key, item in value.items():
|
|
1309
|
+
if key == "__interrupt__":
|
|
1310
|
+
continue
|
|
1311
|
+
found.extend(_messages_in_update(item))
|
|
1312
|
+
elif isinstance(value, (list, tuple)):
|
|
1313
|
+
for item in value:
|
|
1314
|
+
found.extend(_messages_in_update(item))
|
|
1315
|
+
return found
|
|
1316
|
+
|
|
1317
|
+
|
|
1318
|
+
def _todos_in_state(value: Any) -> list[dict[str, str]] | None:
|
|
1319
|
+
"""Project middleware-owned todos from a LangGraph values snapshot."""
|
|
1320
|
+
if isinstance(value, dict) and isinstance(value.get("todos"), list):
|
|
1321
|
+
return [dict(item) for item in value["todos"] if isinstance(item, dict)]
|
|
1322
|
+
return None
|
|
1323
|
+
|
|
1324
|
+
|
|
1325
|
+
_USAGE_KEYS = ("input_tokens", "output_tokens", "total_tokens")
|
|
1326
|
+
|
|
1327
|
+
|
|
1328
|
+
def _usage_metadata_dict(message: BaseMessage) -> dict[str, int]:
|
|
1329
|
+
usage = getattr(message, "usage_metadata", None)
|
|
1330
|
+
if not isinstance(usage, dict):
|
|
1331
|
+
return {}
|
|
1332
|
+
return {key: int(usage[key]) for key in _USAGE_KEYS if isinstance(usage.get(key), int)}
|
|
1333
|
+
|
|
1334
|
+
|
|
1335
|
+
def _usage_from_messages(messages: Any) -> dict[str, int]:
|
|
1336
|
+
"""Most recent reported usage, which is the size of the context the model saw."""
|
|
1337
|
+
for message in reversed(list(messages or [])):
|
|
1338
|
+
if isinstance(message, AIMessage):
|
|
1339
|
+
if message.additional_kwargs.get("manual_compact_completed"):
|
|
1340
|
+
return {}
|
|
1341
|
+
usage = _usage_metadata_dict(message)
|
|
1342
|
+
if usage:
|
|
1343
|
+
return usage
|
|
1344
|
+
return {}
|
|
1345
|
+
|
|
1346
|
+
|
|
1347
|
+
def _emit(handler: RunEventHandler | None, event: RunEvent) -> None:
|
|
1348
|
+
if handler is not None:
|
|
1349
|
+
try:
|
|
1350
|
+
handler(event)
|
|
1351
|
+
except Exception: # noqa: BLE001
|
|
1352
|
+
# Presentation observers must never change Agent/Tool execution semantics.
|
|
1353
|
+
pass
|
|
1354
|
+
|
|
1355
|
+
|
|
1356
|
+
def _has_pending_legacy_handoff(graph: Any, config: dict[str, Any]) -> bool:
|
|
1357
|
+
state = graph.get_state(config)
|
|
1358
|
+
if not getattr(state, "interrupts", ()):
|
|
1359
|
+
return False
|
|
1360
|
+
for message in reversed((state.values or {}).get("messages", []) or []):
|
|
1361
|
+
if isinstance(message, AIMessage):
|
|
1362
|
+
return any(call.get("name") == "handoff_to_human" for call in message.tool_calls or [])
|
|
1363
|
+
return False
|
|
1364
|
+
|
|
1365
|
+
|
|
1366
|
+
def _tool_decisions(
|
|
1367
|
+
pending: list[dict[str, Any]],
|
|
1368
|
+
*,
|
|
1369
|
+
decision_type: str,
|
|
1370
|
+
tool_call_ids: Sequence[str],
|
|
1371
|
+
message: str | None = None,
|
|
1372
|
+
others: str = "reject",
|
|
1373
|
+
) -> dict[str, Any]:
|
|
1374
|
+
if decision_type not in ("approve", "reject"):
|
|
1375
|
+
raise ValueError("Tool decision must be approve or reject")
|
|
1376
|
+
targets = [str(item) for item in tool_call_ids if str(item)]
|
|
1377
|
+
if not targets:
|
|
1378
|
+
raise ValueError("Tool call id is required")
|
|
1379
|
+
pending_ids = [str(call.get("toolCallId", "")) for call in pending]
|
|
1380
|
+
for target in targets:
|
|
1381
|
+
if target not in pending_ids:
|
|
1382
|
+
raise ValueError(f"Tool call is no longer pending approval: {target}")
|
|
1383
|
+
item: dict[str, Any] = {"type": decision_type}
|
|
1384
|
+
if decision_type == "reject" and message:
|
|
1385
|
+
item["message"] = str(message)
|
|
1386
|
+
chosen = set(targets) if others == "same" else {targets[0]}
|
|
1387
|
+
if len(pending_ids) <= 1:
|
|
1388
|
+
return {"decisions": [dict(item) for _ in range(max(len(pending_ids), 1))]}
|
|
1389
|
+
return {"decisions": [
|
|
1390
|
+
dict(item) if tool_call_id in chosen
|
|
1391
|
+
else {"type": "reject", "message": "另一个并发的待确认调用未包含在本次人工决策中,按拒绝处理"}
|
|
1392
|
+
for tool_call_id in pending_ids
|
|
1393
|
+
]}
|