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.
Files changed (50) hide show
  1. agent/__init__.py +42 -0
  2. agent/attachments.py +303 -0
  3. agent/bootstrap.py +44 -0
  4. agent/cancel.py +107 -0
  5. agent/cli/__init__.py +5 -0
  6. agent/cli/app.py +1768 -0
  7. agent/cli/clipboard.py +224 -0
  8. agent/cli/commands.py +94 -0
  9. agent/cli/gitinfo.py +84 -0
  10. agent/cli/input.py +65 -0
  11. agent/cli/interactions.py +187 -0
  12. agent/cli/main.py +124 -0
  13. agent/cli/previews.py +710 -0
  14. agent/cli/rendering.py +770 -0
  15. agent/cli/session_controller.py +221 -0
  16. agent/cli/state.py +326 -0
  17. agent/config.example.yaml +76 -0
  18. agent/config.py +528 -0
  19. agent/control.py +171 -0
  20. agent/factory.py +232 -0
  21. agent/file_mutation.py +5 -0
  22. agent/llm.py +339 -0
  23. agent/middleware/__init__.py +9 -0
  24. agent/middleware/attachments.py +31 -0
  25. agent/middleware/cancel_tools.py +39 -0
  26. agent/middleware/pause.py +18 -0
  27. agent/middleware/recovery.py +65 -0
  28. agent/middleware/steering.py +35 -0
  29. agent/middleware/tool_arg_hints.py +128 -0
  30. agent/middleware/workspace_filesystem.py +38 -0
  31. agent/middleware/write_operation.py +60 -0
  32. agent/network.py +30 -0
  33. agent/permission.py +80 -0
  34. agent/runner.py +1393 -0
  35. agent/sandbox.py +699 -0
  36. agent/session.py +431 -0
  37. agent/session_lock.py +223 -0
  38. agent/session_runtime.py +209 -0
  39. agent/stream.py +168 -0
  40. agent/tools/__init__.py +9 -0
  41. agent/tools/examples.py +30 -0
  42. agent/tools/execute.py +73 -0
  43. agent/tools/human_input.py +170 -0
  44. agent/tools/human_interaction.py +101 -0
  45. agent/tools/web_search.py +131 -0
  46. deep_agent_cli-0.1.0.dist-info/METADATA +408 -0
  47. deep_agent_cli-0.1.0.dist-info/RECORD +50 -0
  48. deep_agent_cli-0.1.0.dist-info/WHEEL +4 -0
  49. deep_agent_cli-0.1.0.dist-info/entry_points.txt +2 -0
  50. 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
+ ]}