zjcode 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. deepagents_code/__init__.py +42 -0
  2. deepagents_code/__main__.py +6 -0
  3. deepagents_code/_ask_user_types.py +90 -0
  4. deepagents_code/_cli_context.py +96 -0
  5. deepagents_code/_constants.py +41 -0
  6. deepagents_code/_debug.py +148 -0
  7. deepagents_code/_debug_buffer.py +204 -0
  8. deepagents_code/_env_vars.py +411 -0
  9. deepagents_code/_fake_models.py +66 -0
  10. deepagents_code/_git.py +521 -0
  11. deepagents_code/_paths.py +69 -0
  12. deepagents_code/_server_config.py +576 -0
  13. deepagents_code/_session_stats.py +235 -0
  14. deepagents_code/_startup_error.py +45 -0
  15. deepagents_code/_testing_models.py +305 -0
  16. deepagents_code/_textual_patches.py +420 -0
  17. deepagents_code/_tool_stream.py +694 -0
  18. deepagents_code/_version.py +46 -0
  19. deepagents_code/agent.py +1976 -0
  20. deepagents_code/app.py +19239 -0
  21. deepagents_code/app.tcss +438 -0
  22. deepagents_code/approval_mode.py +131 -0
  23. deepagents_code/ask_user.py +306 -0
  24. deepagents_code/auth_display.py +185 -0
  25. deepagents_code/auth_store.py +545 -0
  26. deepagents_code/built_in_skills/__init__.py +5 -0
  27. deepagents_code/built_in_skills/remember/SKILL.md +118 -0
  28. deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
  29. deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
  30. deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
  31. deepagents_code/client/__init__.py +1 -0
  32. deepagents_code/client/commands/__init__.py +1 -0
  33. deepagents_code/client/commands/auth.py +541 -0
  34. deepagents_code/client/commands/config.py +714 -0
  35. deepagents_code/client/commands/mcp.py +250 -0
  36. deepagents_code/client/commands/tools.py +416 -0
  37. deepagents_code/client/launch/__init__.py +1 -0
  38. deepagents_code/client/launch/server.py +978 -0
  39. deepagents_code/client/launch/server_manager.py +540 -0
  40. deepagents_code/client/non_interactive.py +1758 -0
  41. deepagents_code/client/remote_client.py +794 -0
  42. deepagents_code/clipboard.py +217 -0
  43. deepagents_code/command_registry.py +450 -0
  44. deepagents_code/config.py +4829 -0
  45. deepagents_code/config_manifest.py +1451 -0
  46. deepagents_code/configurable_model.py +577 -0
  47. deepagents_code/default_agent_prompt.md +12 -0
  48. deepagents_code/doctor.py +559 -0
  49. deepagents_code/editor.py +142 -0
  50. deepagents_code/event_bus.py +411 -0
  51. deepagents_code/extras_info.py +661 -0
  52. deepagents_code/file_ops.py +576 -0
  53. deepagents_code/formatting.py +135 -0
  54. deepagents_code/goal_rubric.py +497 -0
  55. deepagents_code/goal_tools.py +459 -0
  56. deepagents_code/hooks.py +348 -0
  57. deepagents_code/input.py +1041 -0
  58. deepagents_code/integrations/__init__.py +1 -0
  59. deepagents_code/integrations/openai_codex.py +551 -0
  60. deepagents_code/integrations/sandbox_config.py +198 -0
  61. deepagents_code/integrations/sandbox_factory.py +1124 -0
  62. deepagents_code/integrations/sandbox_provider.py +137 -0
  63. deepagents_code/integrations/sandbox_registry.py +350 -0
  64. deepagents_code/iterm_cursor_guide.py +176 -0
  65. deepagents_code/local_context.py +926 -0
  66. deepagents_code/main.py +3985 -0
  67. deepagents_code/managed_tools.py +642 -0
  68. deepagents_code/mcp_auth.py +1950 -0
  69. deepagents_code/mcp_config.py +176 -0
  70. deepagents_code/mcp_disabled.py +212 -0
  71. deepagents_code/mcp_login_service.py +281 -0
  72. deepagents_code/mcp_oauth_ui.py +199 -0
  73. deepagents_code/mcp_providers/__init__.py +23 -0
  74. deepagents_code/mcp_providers/_registry.py +39 -0
  75. deepagents_code/mcp_providers/base.py +133 -0
  76. deepagents_code/mcp_providers/github.py +102 -0
  77. deepagents_code/mcp_providers/slack.py +175 -0
  78. deepagents_code/mcp_tools.py +2427 -0
  79. deepagents_code/mcp_trust.py +207 -0
  80. deepagents_code/media_utils.py +826 -0
  81. deepagents_code/memory_guard.py +474 -0
  82. deepagents_code/model_config.py +4156 -0
  83. deepagents_code/notifications.py +247 -0
  84. deepagents_code/offload.py +402 -0
  85. deepagents_code/onboarding.py +223 -0
  86. deepagents_code/output.py +69 -0
  87. deepagents_code/paste_collapse.py +103 -0
  88. deepagents_code/project_utils.py +231 -0
  89. deepagents_code/py.typed +0 -0
  90. deepagents_code/reasoning_effort.py +641 -0
  91. deepagents_code/reliable_rubric.py +97 -0
  92. deepagents_code/resume_state.py +237 -0
  93. deepagents_code/server_graph.py +310 -0
  94. deepagents_code/session_end_summary.py +329 -0
  95. deepagents_code/sessions.py +1716 -0
  96. deepagents_code/skills/__init__.py +18 -0
  97. deepagents_code/skills/commands.py +1252 -0
  98. deepagents_code/skills/invocation.py +112 -0
  99. deepagents_code/skills/load.py +196 -0
  100. deepagents_code/skills/trust.py +547 -0
  101. deepagents_code/state_migration.py +136 -0
  102. deepagents_code/subagents.py +278 -0
  103. deepagents_code/system_prompt.md +204 -0
  104. deepagents_code/terminal_capabilities.py +115 -0
  105. deepagents_code/terminal_escape.py +287 -0
  106. deepagents_code/theme.py +891 -0
  107. deepagents_code/todo_list_prompt.md +12 -0
  108. deepagents_code/tool_catalog.py +509 -0
  109. deepagents_code/tool_display.py +367 -0
  110. deepagents_code/tools.py +516 -0
  111. deepagents_code/tui/__init__.py +1 -0
  112. deepagents_code/tui/textual_adapter.py +2553 -0
  113. deepagents_code/tui/widgets/__init__.py +9 -0
  114. deepagents_code/tui/widgets/_js_eval_display.py +139 -0
  115. deepagents_code/tui/widgets/_links.py +261 -0
  116. deepagents_code/tui/widgets/agent_selector.py +420 -0
  117. deepagents_code/tui/widgets/approval.py +602 -0
  118. deepagents_code/tui/widgets/ask_user.py +515 -0
  119. deepagents_code/tui/widgets/auth.py +1997 -0
  120. deepagents_code/tui/widgets/autocomplete.py +890 -0
  121. deepagents_code/tui/widgets/chat_input.py +3181 -0
  122. deepagents_code/tui/widgets/codex_auth.py +452 -0
  123. deepagents_code/tui/widgets/cwd_switch.py +242 -0
  124. deepagents_code/tui/widgets/debug_console.py +868 -0
  125. deepagents_code/tui/widgets/diff.py +252 -0
  126. deepagents_code/tui/widgets/effort_selector.py +189 -0
  127. deepagents_code/tui/widgets/goal_review.py +390 -0
  128. deepagents_code/tui/widgets/goal_status.py +50 -0
  129. deepagents_code/tui/widgets/history.py +194 -0
  130. deepagents_code/tui/widgets/install_confirm.py +248 -0
  131. deepagents_code/tui/widgets/launch_init.py +482 -0
  132. deepagents_code/tui/widgets/loading.py +227 -0
  133. deepagents_code/tui/widgets/mcp_login.py +539 -0
  134. deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
  135. deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
  136. deepagents_code/tui/widgets/message_store.py +999 -0
  137. deepagents_code/tui/widgets/messages.py +3846 -0
  138. deepagents_code/tui/widgets/model_selector.py +2343 -0
  139. deepagents_code/tui/widgets/notification_center.py +456 -0
  140. deepagents_code/tui/widgets/notification_detail.py +257 -0
  141. deepagents_code/tui/widgets/notification_settings.py +170 -0
  142. deepagents_code/tui/widgets/restart_prompt.py +158 -0
  143. deepagents_code/tui/widgets/skill_trust.py +131 -0
  144. deepagents_code/tui/widgets/startup_tip.py +91 -0
  145. deepagents_code/tui/widgets/status.py +781 -0
  146. deepagents_code/tui/widgets/subagent_panel.py +969 -0
  147. deepagents_code/tui/widgets/theme_selector.py +401 -0
  148. deepagents_code/tui/widgets/thread_selector.py +2564 -0
  149. deepagents_code/tui/widgets/tool_renderers.py +190 -0
  150. deepagents_code/tui/widgets/tool_widgets.py +304 -0
  151. deepagents_code/tui/widgets/update_available.py +311 -0
  152. deepagents_code/tui/widgets/update_confirm.py +184 -0
  153. deepagents_code/tui/widgets/update_progress.py +327 -0
  154. deepagents_code/tui/widgets/welcome.py +508 -0
  155. deepagents_code/turn_end_summary.py +522 -0
  156. deepagents_code/ui.py +858 -0
  157. deepagents_code/unicode_security.py +563 -0
  158. deepagents_code/update_check.py +3124 -0
  159. zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
  160. zjcode-0.0.1.dist-info/METADATA +220 -0
  161. zjcode-0.0.1.dist-info/RECORD +163 -0
  162. zjcode-0.0.1.dist-info/WHEEL +4 -0
  163. zjcode-0.0.1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,794 @@
1
+ """Remote agent client — thin wrapper around LangGraph's `RemoteGraph`.
2
+
3
+ Delegates streaming, state management, and SSE handling to
4
+ `langgraph.pregel.remote.RemoteGraph`. This wrapper converts streamed message
5
+ dicts into LangChain message objects for the app's Textual adapter, but leaves
6
+ state snapshots in the server's serialized form.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import logging
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ if TYPE_CHECKING:
16
+ from collections.abc import AsyncIterator, Callable, Mapping
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ _RUN_CANCEL_WAIT_SECONDS = 10.0
21
+ """Per-run cancel wait. Picked so a stuck server-side run can't hang the UI on
22
+ Esc for more than ~10s, while leaving room for an actually-cancelling run to
23
+ finish its in-flight tool call.
24
+
25
+ Concurrent cancels keep aggregate wall time bounded by this value regardless of
26
+ how many runs are active.
27
+ """
28
+
29
+
30
+ def _require_thread_id(config: Mapping[str, Any] | None) -> str:
31
+ """Extract and validate that `thread_id` is present in config.
32
+
33
+ Args:
34
+ config: Config dict with `configurable.thread_id`.
35
+
36
+ Returns:
37
+ The thread ID string.
38
+
39
+ Raises:
40
+ ValueError: If `thread_id` is missing.
41
+ """
42
+ thread_id = (config or {}).get("configurable", {}).get("thread_id")
43
+ if not thread_id:
44
+ msg = "thread_id is required in config.configurable"
45
+ raise ValueError(msg)
46
+ return thread_id
47
+
48
+
49
+ def agent_error_type(exc: BaseException) -> str:
50
+ """Best-effort error-type name for an exception from `RemoteAgent.astream`.
51
+
52
+ The LangGraph server serializes non-allowlisted exceptions as
53
+ `{"error": <ExceptionType>, "message": ...}` wrapped in
54
+ `RemoteException(payload)` (see `langgraph_api.serde`). The server-reported
55
+ `"error"` type is the authoritative name when present; otherwise the
56
+ exception's own class name is used. This is the single source of truth for
57
+ "what error did the stream report" — both `format_agent_exception` (display
58
+ string) and the UI's error-enrichment path (error-type dispatch) read it.
59
+
60
+ Args:
61
+ exc: The exception caught from the agent stream.
62
+
63
+ Returns:
64
+ The serialized error type from a `RemoteException` dict payload, else
65
+ the exception's class name.
66
+ """
67
+ payload = exc.args[0] if exc.args else None
68
+ if isinstance(payload, dict):
69
+ err_type = payload.get("error")
70
+ if isinstance(err_type, str) and err_type:
71
+ return err_type
72
+ return type(exc).__name__
73
+
74
+
75
+ def format_agent_exception(exc: BaseException) -> str:
76
+ """Render an exception from `RemoteAgent.astream` for the UI.
77
+
78
+ The LangGraph server serializes non-allowlisted exceptions as
79
+ `{"error": <ExceptionType>, "message": <text or "An internal error occurred">}`
80
+ (see `langgraph_api.serde`). `RemoteGraph` wraps that dict in
81
+ `RemoteException(payload)`, so the default `str(exc)` renders as an ugly
82
+ Python dict repr in the UI.
83
+
84
+ Args:
85
+ exc: The exception caught from the agent stream.
86
+
87
+ Returns:
88
+ `"<ErrorType>: <message>"` for `RemoteException` dict payloads,
89
+ otherwise `str(exc)`, falling back to the exception's class name when
90
+ the string form is empty.
91
+ """
92
+ payload = exc.args[0] if exc.args else None
93
+ if isinstance(payload, dict):
94
+ err_type = agent_error_type(exc)
95
+ message = payload.get("message")
96
+ if isinstance(message, str) and message:
97
+ return f"{err_type}: {message}"
98
+ return err_type
99
+ text = str(exc)
100
+ return text or type(exc).__name__
101
+
102
+
103
+ class RemoteAgent:
104
+ """Client that talks to a LangGraph server over HTTP+SSE.
105
+
106
+ Wraps `langgraph.pregel.remote.RemoteGraph` which handles SSE parsing,
107
+ stream-mode negotiation (`messages-tuple`), namespace extraction, and
108
+ interrupt detection. This class adds streamed message-object conversion for
109
+ the Textual adapter and thread-ID normalization. State snapshots are
110
+ returned as provided by the server.
111
+ """
112
+
113
+ def __init__(
114
+ self,
115
+ url: str,
116
+ *,
117
+ graph_name: str = "agent",
118
+ api_key: str | None = None,
119
+ headers: dict[str, str] | None = None,
120
+ ) -> None:
121
+ """Initialize the remote agent client.
122
+
123
+ Args:
124
+ url: Base URL of the LangGraph server.
125
+ graph_name: Name of the graph on the server.
126
+ api_key: API key for authenticated deployments.
127
+
128
+ When `None`, `RemoteGraph` auto-reads `LANGGRAPH_API_KEY`,
129
+ `LANGSMITH_API_KEY`, or `LANGCHAIN_API_KEY` from
130
+ the environment.
131
+ headers: Extra HTTP headers to include in every request
132
+ (e.g. bearer tokens, proxy headers).
133
+ """
134
+ self._url = url
135
+ self._graph_name = graph_name
136
+ self._api_key = api_key
137
+ self._headers = headers
138
+ self._graph: Any = None
139
+
140
+ def _get_graph(self) -> Any: # noqa: ANN401
141
+ """Lazily create the `RemoteGraph` instance.
142
+
143
+ Returns:
144
+ A `RemoteGraph` connected to the server.
145
+ """
146
+ if self._graph is None:
147
+ from langgraph.pregel.remote import RemoteGraph
148
+
149
+ self._graph = RemoteGraph(
150
+ self._graph_name,
151
+ url=self._url,
152
+ api_key=self._api_key,
153
+ headers=self._headers,
154
+ )
155
+ return self._graph
156
+
157
+ async def astream(
158
+ self,
159
+ input: dict | Any, # noqa: A002, ANN401
160
+ *,
161
+ stream_mode: list[str] | None = None,
162
+ subgraphs: bool = False,
163
+ config: dict[str, Any] | None = None,
164
+ context: Any | None = None, # noqa: ANN401
165
+ durability: str | None = None, # noqa: ARG002
166
+ ) -> AsyncIterator[tuple[tuple[str, ...], str, Any]]:
167
+ """Stream agent execution, yielding tuples matching Pregel's format.
168
+
169
+ Delegates to `RemoteGraph.astream` (which handles `messages-tuple`
170
+ negotiation, SSE routing, and namespace parsing) and converts the raw
171
+ message dicts into LangChain message objects for the adapter.
172
+
173
+ Args:
174
+ input: The input to send (messages dict or Command).
175
+ stream_mode: Stream modes to request.
176
+ subgraphs: Whether to stream subgraph events.
177
+ config: LangGraph config with `configurable.thread_id`, etc.
178
+ context: Runtime context (e.g. `CLIContext`) forwarded to the
179
+ server via the SDK's `context=` parameter.
180
+ durability: Ignored (server manages durability).
181
+
182
+ Yields:
183
+ 3-tuples of `(namespace, stream_mode, data)`.
184
+
185
+ Raises:
186
+ ValueError: If `thread_id` is not present in `config`.
187
+ """ # noqa: DOC502 — raised by _require_thread_id
188
+ from langchain_core.messages import BaseMessage
189
+
190
+ _require_thread_id(config)
191
+
192
+ graph = self._get_graph()
193
+ config = _prepare_config(config)
194
+ dropped_count = 0
195
+
196
+ # Mirror this server-side run to an extra LangSmith project when
197
+ # configured. The server (`langgraph_api`) reads the SDK's
198
+ # `langsmith_tracing` field and replicates the run to that project plus
199
+ # its primary project. This is the only channel that reaches the run:
200
+ # it executes in the server process, and reserved `configurable` keys
201
+ # like `__langsmith_project__` are stripped from client input
202
+ # server-side, so they cannot be set directly here. (Server internals
203
+ # verified against `langgraph-api` 0.10.0 and subject to change.)
204
+ from deepagents_code.config import get_langsmith_replica_project
205
+
206
+ extra_stream_kwargs: dict[str, Any] = {}
207
+ replica_project = get_langsmith_replica_project()
208
+ if replica_project:
209
+ extra_stream_kwargs["langsmith_tracing"] = {"project_name": replica_project}
210
+
211
+ async for ns, mode, data in graph.astream(
212
+ input,
213
+ stream_mode=stream_mode or ["messages", "updates"],
214
+ subgraphs=subgraphs,
215
+ config=config,
216
+ context=context,
217
+ **extra_stream_kwargs,
218
+ ):
219
+ logger.debug("RemoteGraph event mode=%s ns=%s", mode, ns)
220
+
221
+ if mode == "messages":
222
+ msg_dict, meta = data
223
+ if isinstance(msg_dict, dict):
224
+ msg_obj = _convert_message_data(msg_dict)
225
+ if msg_obj is not None:
226
+ yield (ns, "messages", (msg_obj, meta or {}))
227
+ else:
228
+ dropped_count += 1
229
+ elif isinstance(msg_dict, BaseMessage):
230
+ # Already a LangChain message object (pre-deserialized)
231
+ yield (ns, "messages", (msg_dict, meta or {}))
232
+ else:
233
+ logger.warning(
234
+ "Unexpected message data type in stream: %s",
235
+ type(msg_dict).__name__,
236
+ )
237
+ continue
238
+
239
+ if mode == "updates" and isinstance(data, dict):
240
+ update_data = data
241
+ if "__interrupt__" in data:
242
+ update_data = {
243
+ **data,
244
+ "__interrupt__": _convert_interrupts(data["__interrupt__"]),
245
+ }
246
+ yield (ns, "updates", update_data)
247
+ continue
248
+
249
+ yield (ns, mode, data)
250
+
251
+ if dropped_count:
252
+ logger.warning(
253
+ "Dropped %d message(s) during stream due to conversion failures",
254
+ dropped_count,
255
+ )
256
+
257
+ async def aget_state(
258
+ self,
259
+ config: dict[str, Any],
260
+ ) -> Any: # noqa: ANN401
261
+ """Get the current state of a thread.
262
+
263
+ Returns `None` when the thread does not exist on the server (404) or
264
+ when the thread exists but has no checkpoint yet (new/empty thread).
265
+ All other errors (network, auth, 500) are logged at WARNING and
266
+ re-raised so callers can handle them.
267
+
268
+ Unlike `astream`, message values are not deserialized; callers may
269
+ receive serialized message dicts in `values["messages"]` from the
270
+ server.
271
+
272
+ Args:
273
+ config: Config with `configurable.thread_id`.
274
+
275
+ Returns:
276
+ Thread state object with `values` and `next` attributes, or `None`
277
+ if the thread is not found or has no checkpoint.
278
+
279
+ Raises:
280
+ ValueError: If `thread_id` is not present in `config`.
281
+ TypeError: If the server returns an unexpected state shape.
282
+ """ # noqa: DOC502 — raised by _require_thread_id
283
+ from langgraph_sdk.errors import NotFoundError
284
+
285
+ thread_id = _require_thread_id(config)
286
+
287
+ graph = self._get_graph()
288
+ try:
289
+ return await graph.aget_state(_prepare_config(config))
290
+ except NotFoundError:
291
+ logger.debug("Thread %s not found on server", thread_id)
292
+ return None
293
+ except TypeError as e:
294
+ # langgraph SDK bug: _create_state_snapshot does
295
+ # state["checkpoint"]["thread_id"], but the server returns
296
+ # checkpoint=null for threads with no checkpoint yet (new threads,
297
+ # or threads registered via aensure_thread before any run).
298
+ if "subscriptable" in str(e).lower():
299
+ logger.debug(
300
+ "Thread %s has no checkpoint yet; treating as empty", thread_id
301
+ )
302
+ return None
303
+ logger.warning(
304
+ "Failed to get state for thread %s", thread_id, exc_info=True
305
+ )
306
+ raise
307
+ except Exception:
308
+ logger.warning(
309
+ "Failed to get state for thread %s", thread_id, exc_info=True
310
+ )
311
+ raise
312
+
313
+ async def acancel_active_runs(self, config: dict[str, Any]) -> None:
314
+ """Cancel pending/running runs on the configured thread.
315
+
316
+ Best-effort: per-run cancellation failures are swallowed by
317
+ `_cancel_active_runs`. Intended for proactive cancellation on
318
+ interrupt, before recovery-state writes.
319
+
320
+ Args:
321
+ config: Config with `configurable.thread_id`.
322
+
323
+ Raises:
324
+ ValueError: If `thread_id` is not present in `config`.
325
+ """ # noqa: DOC502 — raised by _require_thread_id
326
+ thread_id = _require_thread_id(config)
327
+ await _cancel_active_runs(self._get_graph(), thread_id)
328
+
329
+ async def aupdate_state(
330
+ self,
331
+ config: Mapping[str, Any],
332
+ values: dict[str, Any],
333
+ *,
334
+ as_node: str | None = None,
335
+ ) -> None:
336
+ """Update the state of a thread.
337
+
338
+ On HTTP 409 (`ConflictError`) the server still considers the thread
339
+ busy — typically because the client cancelled the SSE stream before
340
+ the server finished the run. In that case, cancel any pending/running
341
+ runs with `wait=True` and retry the state update once. Per-run cancel
342
+ waits are bounded by `_RUN_CANCEL_WAIT_SECONDS` and run concurrently,
343
+ so callers cannot block indefinitely regardless of how many runs were
344
+ active.
345
+
346
+ Other exceptions from the underlying graph (server/network errors) are
347
+ logged at DEBUG level and re-raised so callers can decide how to
348
+ surface them (callers typically log at WARNING with a friendlier
349
+ message).
350
+
351
+ Args:
352
+ config: Config with `configurable.thread_id`.
353
+ values: State values to update.
354
+ as_node: Optional graph node to attribute the state update to.
355
+
356
+ Raises:
357
+ ValueError: If `thread_id` is not present in `config`.
358
+ """ # noqa: DOC502 — raised by _require_thread_id
359
+ from langgraph_sdk.errors import ConflictError
360
+
361
+ thread_id = _require_thread_id(config)
362
+ prepared = _prepare_config(config)
363
+ graph = self._get_graph()
364
+
365
+ try:
366
+ await graph.aupdate_state(prepared, values, as_node=as_node)
367
+ except ConflictError:
368
+ logger.debug(
369
+ "update_state conflict for thread %s; cancelling active runs "
370
+ "and retrying",
371
+ thread_id,
372
+ )
373
+ except Exception:
374
+ logger.debug(
375
+ "Failed to update state for thread %s", thread_id, exc_info=True
376
+ )
377
+ raise
378
+ else:
379
+ return
380
+
381
+ await _cancel_active_runs(graph, thread_id)
382
+
383
+ try:
384
+ await graph.aupdate_state(prepared, values, as_node=as_node)
385
+ except Exception:
386
+ logger.debug(
387
+ "Retry of update_state still failed for thread %s",
388
+ thread_id,
389
+ exc_info=True,
390
+ )
391
+ raise
392
+
393
+ async def aput_store_item(
394
+ self,
395
+ namespace: tuple[str, ...],
396
+ key: str,
397
+ value: dict[str, Any],
398
+ ) -> None:
399
+ """Write an item to the server-side LangGraph Store.
400
+
401
+ Args:
402
+ namespace: Store namespace.
403
+ key: Item key within `namespace`.
404
+ value: JSON-serializable item value.
405
+
406
+ Notes:
407
+ A failed write is logged at debug and re-raised. The re-raise is
408
+ load-bearing: callers (`awrite_approval_mode` and its callers)
409
+ depend on the failure propagating so they can fail closed — drop
410
+ the live approval-mode key and interrupt rather than keep
411
+ auto-approving. Removing the `raise` would turn the debug log into
412
+ a silent-failure hole, so the higher-severity logging is left to
413
+ those callers, which re-log at warning with `exc_info`.
414
+ """
415
+ graph = self._get_graph()
416
+ try:
417
+ client = graph._validate_client()
418
+ await client.store.put_item(namespace, key, value, index=False)
419
+ except Exception:
420
+ logger.debug(
421
+ "Failed to write store item %s/%s",
422
+ ".".join(namespace),
423
+ key,
424
+ exc_info=True,
425
+ )
426
+ # Load-bearing: see Notes. Callers fail closed on this propagation.
427
+ raise
428
+
429
+ async def aensure_thread(self, config: dict[str, Any]) -> None:
430
+ """Ensure the remote thread record exists before mutating state.
431
+
432
+ In the LangGraph dev server, checkpoint persistence and HTTP thread
433
+ registration are separate. After a server restart, a thread may still
434
+ have checkpointed state on disk while `POST /threads/{id}/state`
435
+ returns 404 because the server has not yet materialized that thread in
436
+ its live store.
437
+
438
+ This method performs the idempotent HTTP-side registration with
439
+ `if_exists='do_nothing'` so callers that recovered state from
440
+ persistence can safely follow up with `aupdate_state`.
441
+
442
+ Args:
443
+ config: Config with `configurable.thread_id` and optional metadata.
444
+
445
+ Raises:
446
+ ValueError: If `thread_id` is not present in `config`.
447
+ """ # noqa: DOC502 — raised by _require_thread_id
448
+ _require_thread_id(config)
449
+
450
+ graph = self._get_graph()
451
+ prepared = _prepare_config(config)
452
+ thread_id = prepared["configurable"]["thread_id"]
453
+ metadata = prepared.get("metadata")
454
+ thread_metadata = metadata if isinstance(metadata, dict) else None
455
+
456
+ try:
457
+ client = graph._validate_client()
458
+ await client.threads.create(
459
+ thread_id=thread_id,
460
+ if_exists="do_nothing",
461
+ metadata=thread_metadata,
462
+ graph_id=self._graph_name,
463
+ )
464
+ except Exception:
465
+ logger.warning(
466
+ "Failed to ensure thread %s exists on remote server",
467
+ thread_id,
468
+ exc_info=True,
469
+ )
470
+ raise
471
+
472
+ def with_config(self, config: dict[str, Any]) -> RemoteAgent: # noqa: ARG002
473
+ """Return self (config is passed per-call, not stored).
474
+
475
+ Args:
476
+ config: Ignored.
477
+
478
+ Returns:
479
+ Self.
480
+ """
481
+ return self
482
+
483
+
484
+ async def _cancel_active_runs(graph: Any, thread_id: str) -> None: # noqa: ANN401
485
+ """Cancel pending/running runs on a thread and wait for them to settle.
486
+
487
+ Best-effort: per-run cancellation failures are logged at DEBUG and
488
+ swallowed. Conditions that imply the retry will likely still 409 — failing
489
+ to obtain the SDK client, or failing to list runs in every status — are
490
+ logged at WARNING so they show up in default logs.
491
+
492
+ The SDK client is reached via `graph._validate_client()`, a private
493
+ attribute on `langgraph.pregel.remote.RemoteGraph`. If upstream renames
494
+ or removes it, this helper degrades to no-op and the caller's retry will
495
+ re-raise the original `ConflictError`.
496
+
497
+ Per-run cancels run concurrently and are bounded by
498
+ `_RUN_CANCEL_WAIT_SECONDS`, so aggregate wall time stays near that bound
499
+ regardless of how many runs are active.
500
+
501
+ Args:
502
+ graph: Underlying `RemoteGraph` instance.
503
+ thread_id: Server-side thread identifier.
504
+ """
505
+ try:
506
+ client = graph._validate_client()
507
+ except Exception:
508
+ logger.warning(
509
+ "Could not obtain SDK client for thread %s; retry will likely "
510
+ "still see the conflict",
511
+ thread_id,
512
+ exc_info=True,
513
+ )
514
+ return
515
+
516
+ run_ids: list[str] = []
517
+ listed_any = False
518
+ for status in ("running", "pending"):
519
+ try:
520
+ runs = await client.runs.list(thread_id, status=status, limit=10)
521
+ except Exception:
522
+ logger.debug(
523
+ "Failed to list %s runs for thread %s",
524
+ status,
525
+ thread_id,
526
+ exc_info=True,
527
+ )
528
+ continue
529
+ listed_any = True
530
+ for run in runs:
531
+ run_id = run.get("run_id") if isinstance(run, dict) else None
532
+ if run_id:
533
+ run_ids.append(run_id)
534
+
535
+ if not listed_any:
536
+ logger.warning(
537
+ "Could not list active runs for thread %s; retry will likely "
538
+ "still see the conflict",
539
+ thread_id,
540
+ )
541
+ return
542
+
543
+ if not run_ids:
544
+ return
545
+
546
+ async def _cancel_one(run_id: str) -> None:
547
+ try:
548
+ await asyncio.wait_for(
549
+ client.runs.cancel(thread_id, run_id, wait=True, action="interrupt"),
550
+ timeout=_RUN_CANCEL_WAIT_SECONDS,
551
+ )
552
+ except TimeoutError:
553
+ logger.warning(
554
+ "Timed out after %.1fs waiting for run %s on thread %s to "
555
+ "cancel; retry may still see the conflict",
556
+ _RUN_CANCEL_WAIT_SECONDS,
557
+ run_id,
558
+ thread_id,
559
+ )
560
+ except Exception:
561
+ logger.debug(
562
+ "Failed to cancel run %s on thread %s",
563
+ run_id,
564
+ thread_id,
565
+ exc_info=True,
566
+ )
567
+
568
+ await asyncio.gather(*(_cancel_one(rid) for rid in run_ids))
569
+
570
+
571
+ # ---------------------------------------------------------------------------
572
+ # Config helpers
573
+ # ---------------------------------------------------------------------------
574
+
575
+
576
+ def _prepare_config(config: Mapping[str, Any] | None) -> dict[str, Any]:
577
+ """Shallow-copy config so callers' dicts are not mutated.
578
+
579
+ Args:
580
+ config: Raw config dict.
581
+
582
+ Returns:
583
+ A shallow copy of the config.
584
+ """
585
+ config = dict(config or {})
586
+ configurable = dict(config.get("configurable", {}))
587
+ config["configurable"] = configurable
588
+ return config
589
+
590
+
591
+ def _convert_interrupts(raw: Any) -> list[Any]: # noqa: ANN401
592
+ """Convert interrupt dicts from the server into Interrupt objects.
593
+
594
+ Args:
595
+ raw: List of interrupt dicts or Interrupt objects from the server.
596
+
597
+ Returns:
598
+ List of Interrupt objects.
599
+ """
600
+ from langgraph.types import Interrupt
601
+
602
+ if not isinstance(raw, list):
603
+ logger.warning(
604
+ "Expected list for __interrupt__ data, got %s",
605
+ type(raw).__name__,
606
+ )
607
+ return [raw] if raw is not None else []
608
+ results = []
609
+ for item in raw:
610
+ if isinstance(item, Interrupt):
611
+ results.append(item)
612
+ elif isinstance(item, dict) and "value" in item:
613
+ results.append(Interrupt(value=item["value"], id=item.get("id", "")))
614
+ else:
615
+ results.append(item)
616
+ return results
617
+
618
+
619
+ # ---------------------------------------------------------------------------
620
+ # Message conversion — per-type converters with a dispatch table
621
+ # ---------------------------------------------------------------------------
622
+ #
623
+ # Each converter handles one LangChain message type. The dispatch table
624
+ # maps type strings (both short and class-name forms) to the appropriate
625
+ # converter. This keeps each converter focused and makes adding new
626
+ # message types a one-line addition to the table.
627
+ # ---------------------------------------------------------------------------
628
+
629
+
630
+ def _convert_ai_message(data: dict[str, Any]) -> Any: # noqa: ANN401
631
+ """Convert a server AI message dict to an `AIMessageChunk`.
632
+
633
+ Handles the three tool-call representations the server may emit:
634
+
635
+ - `tool_call_chunks`: streaming partial args (string `args`).
636
+ - `tool_calls` with string `args`: legacy streaming format,
637
+ normalized to `tool_call_chunks`.
638
+ - `tool_calls` with dict `args`: fully parsed calls.
639
+
640
+ Args:
641
+ data: Raw message dict from the server.
642
+
643
+ Returns:
644
+ An `AIMessageChunk`, or `None` on construction failure.
645
+ """
646
+ from langchain_core.messages import AIMessageChunk
647
+
648
+ content = data.get("content", "")
649
+ tool_call_chunks = data.get("tool_call_chunks", [])
650
+ tool_calls = data.get("tool_calls", [])
651
+ usage_metadata = data.get("usage_metadata")
652
+ response_metadata = data.get("response_metadata", {})
653
+ chunk_position = data.get("chunk_position")
654
+
655
+ kwargs: dict[str, Any] = {
656
+ "content": content,
657
+ "id": data.get("id"),
658
+ "response_metadata": response_metadata,
659
+ }
660
+ # `chunk_position="last"` marks the terminal chunk of a model call.
661
+ # The Textual adapter uses it to flush per-call model info; drop the
662
+ # signal here and the info line would never render in remote mode.
663
+ if chunk_position:
664
+ kwargs["chunk_position"] = chunk_position
665
+
666
+ if tool_call_chunks:
667
+ kwargs["tool_call_chunks"] = [
668
+ {
669
+ "name": tc.get("name"),
670
+ "args": tc.get("args", ""),
671
+ "id": tc.get("id"),
672
+ "index": tc.get("index", i),
673
+ }
674
+ for i, tc in enumerate(tool_call_chunks)
675
+ ]
676
+ elif tool_calls:
677
+ has_str_args = any(isinstance(tc.get("args"), str) for tc in tool_calls)
678
+ if has_str_args:
679
+ kwargs["tool_call_chunks"] = [
680
+ {
681
+ "name": tc.get("name"),
682
+ "args": tc.get("args", ""),
683
+ "id": tc.get("id"),
684
+ "index": i,
685
+ }
686
+ for i, tc in enumerate(tool_calls)
687
+ ]
688
+ else:
689
+ kwargs["tool_calls"] = tool_calls
690
+
691
+ try:
692
+ chunk = AIMessageChunk(**kwargs)
693
+ except (TypeError, ValueError, KeyError):
694
+ logger.warning(
695
+ "Failed to construct AIMessageChunk from server data (id=%s)",
696
+ data.get("id"),
697
+ exc_info=True,
698
+ )
699
+ return None
700
+
701
+ if usage_metadata:
702
+ chunk.usage_metadata = usage_metadata
703
+ return chunk
704
+
705
+
706
+ def _convert_human_message(data: dict[str, Any]) -> Any: # noqa: ANN401
707
+ """Convert a server human message dict to a `HumanMessage`.
708
+
709
+ Args:
710
+ data: Raw message dict from the server.
711
+
712
+ Returns:
713
+ A `HumanMessage`, or `None` on construction failure.
714
+ """
715
+ from langchain_core.messages import HumanMessage
716
+
717
+ try:
718
+ return HumanMessage(
719
+ content=data.get("content", ""),
720
+ id=data.get("id"),
721
+ )
722
+ except (TypeError, ValueError, KeyError):
723
+ logger.warning(
724
+ "Failed to construct HumanMessage from server data (id=%s)",
725
+ data.get("id"),
726
+ exc_info=True,
727
+ )
728
+ return None
729
+
730
+
731
+ def _convert_tool_message(data: dict[str, Any]) -> Any: # noqa: ANN401
732
+ """Convert a server tool message dict to a `ToolMessage`.
733
+
734
+ Args:
735
+ data: Raw message dict from the server.
736
+
737
+ Returns:
738
+ A `ToolMessage`, or `None` on construction failure.
739
+ """
740
+ from langchain_core.messages import ToolMessage
741
+
742
+ try:
743
+ return ToolMessage(
744
+ content=data.get("content", ""),
745
+ tool_call_id=data.get("tool_call_id", ""),
746
+ name=data.get("name", ""),
747
+ id=data.get("id"),
748
+ status=data.get("status", "success"),
749
+ )
750
+ except (TypeError, ValueError, KeyError):
751
+ logger.warning(
752
+ "Failed to construct ToolMessage from server data (id=%s)",
753
+ data.get("id"),
754
+ exc_info=True,
755
+ )
756
+ return None
757
+
758
+
759
+ _MESSAGE_CONVERTERS: dict[str, Callable[[dict[str, Any]], Any]] = {
760
+ "ai": _convert_ai_message,
761
+ "AIMessage": _convert_ai_message,
762
+ "AIMessageChunk": _convert_ai_message,
763
+ "human": _convert_human_message,
764
+ "HumanMessage": _convert_human_message,
765
+ "tool": _convert_tool_message,
766
+ "ToolMessage": _convert_tool_message,
767
+ }
768
+ """Maps server message `type` strings to their converter functions.
769
+
770
+ Both short forms (`'ai'`, `'human'`, `'tool'`) and class-name forms
771
+ (`'AIMessage'`, `'HumanMessage'`, `'ToolMessage'`) are supported so
772
+ the converter works regardless of how the server serializes the type field.
773
+ """
774
+
775
+
776
+ def _convert_message_data(data: dict[str, Any]) -> Any: # noqa: ANN401
777
+ """Convert a server message dict into a LangChain message object.
778
+
779
+ Dispatches to a per-type converter via `_MESSAGE_CONVERTERS`. New message
780
+ types can be supported by adding a converter function and a table entry —
781
+ no changes to this dispatcher are needed.
782
+
783
+ Args:
784
+ data: Message dict from the server.
785
+
786
+ Returns:
787
+ A LangChain message object, or `None` if conversion fails.
788
+ """
789
+ msg_type = data.get("type", "")
790
+ converter = _MESSAGE_CONVERTERS.get(msg_type)
791
+ if converter is not None:
792
+ return converter(data)
793
+ logger.warning("Unknown message type in stream: %s", msg_type)
794
+ return None