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