langgraph-api 0.16.0.dev10__py3-none-any.whl → 0.16.0.dev12__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.
- langgraph_api/__init__.py +1 -1
- langgraph_api/api/a2a.py +214 -163
- langgraph_api/config/__init__.py +6 -2
- langgraph_api/serde.py +3 -0
- {langgraph_api-0.16.0.dev10.dist-info → langgraph_api-0.16.0.dev12.dist-info}/METADATA +1 -1
- {langgraph_api-0.16.0.dev10.dist-info → langgraph_api-0.16.0.dev12.dist-info}/RECORD +9 -9
- {langgraph_api-0.16.0.dev10.dist-info → langgraph_api-0.16.0.dev12.dist-info}/WHEEL +0 -0
- {langgraph_api-0.16.0.dev10.dist-info → langgraph_api-0.16.0.dev12.dist-info}/entry_points.txt +0 -0
- {langgraph_api-0.16.0.dev10.dist-info → langgraph_api-0.16.0.dev12.dist-info}/licenses/LICENSE +0 -0
langgraph_api/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "0.16.0.
|
|
1
|
+
__version__ = "0.16.0.dev12"
|
langgraph_api/api/a2a.py
CHANGED
|
@@ -18,7 +18,7 @@ import os
|
|
|
18
18
|
import uuid
|
|
19
19
|
from dataclasses import dataclass, replace
|
|
20
20
|
from datetime import UTC, datetime
|
|
21
|
-
from typing import Any, Literal, NotRequired, cast
|
|
21
|
+
from typing import Any, Literal, NotRequired, Self, cast
|
|
22
22
|
|
|
23
23
|
import orjson
|
|
24
24
|
import structlog
|
|
@@ -37,7 +37,11 @@ from langgraph_api.api.a2ui import (
|
|
|
37
37
|
validate_client_metadata,
|
|
38
38
|
validate_payload,
|
|
39
39
|
)
|
|
40
|
-
from langgraph_api.config import
|
|
40
|
+
from langgraph_api.config import (
|
|
41
|
+
A2A_A2UI_ENABLED,
|
|
42
|
+
A2A_A2UI_TOOL_CALL_RESULTS,
|
|
43
|
+
A2A_ALLOWED_TOOL_CALL_RESULTS,
|
|
44
|
+
)
|
|
41
45
|
from langgraph_api.metadata import USER_API_URL
|
|
42
46
|
from langgraph_api.route import ApiRequest, ApiRoute
|
|
43
47
|
from langgraph_api.schema import RunCommand
|
|
@@ -52,7 +56,6 @@ logger = structlog.stdlib.get_logger(__name__)
|
|
|
52
56
|
_assistant_schemas_cache = LRUCache[dict[str, Any]](max_size=1000, ttl=60)
|
|
53
57
|
|
|
54
58
|
MAX_HISTORY_LENGTH_REQUESTED = 10
|
|
55
|
-
LANGGRAPH_HISTORY_QUERY_LIMIT = 500
|
|
56
59
|
HISTORY_SCOPE_EXTENSION_URI = "https://langchain.com/a2a/extensions/history-scope/v1"
|
|
57
60
|
HISTORY_SCOPE_HEADER = "LangGraph-A2A-History-Scope"
|
|
58
61
|
A2A_EXTENSIONS_HEADER = "A2A-Extensions"
|
|
@@ -127,6 +130,45 @@ class A2UIContext:
|
|
|
127
130
|
drop_invalid: bool = False
|
|
128
131
|
mime_type: str = A2UI_MIME_TYPE
|
|
129
132
|
|
|
133
|
+
@property
|
|
134
|
+
def negotiated(self) -> bool:
|
|
135
|
+
return self.enabled and self.active
|
|
136
|
+
|
|
137
|
+
def part_for(
|
|
138
|
+
self,
|
|
139
|
+
data: object,
|
|
140
|
+
metadata: dict[str, Any],
|
|
141
|
+
*,
|
|
142
|
+
direction: Literal["client", "server"],
|
|
143
|
+
) -> dict[str, Any] | None:
|
|
144
|
+
if not self.negotiated:
|
|
145
|
+
return None
|
|
146
|
+
try:
|
|
147
|
+
validate_payload(data, direction=direction)
|
|
148
|
+
except A2UIValidationError:
|
|
149
|
+
if not self.drop_invalid:
|
|
150
|
+
raise
|
|
151
|
+
logger.warning("Dropping invalid A2UI payload", direction=direction)
|
|
152
|
+
return None
|
|
153
|
+
return {
|
|
154
|
+
"kind": "data",
|
|
155
|
+
"data": data,
|
|
156
|
+
"metadata": normalize_metadata(metadata, mime_type=self.mime_type),
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
def parts_per_message(
|
|
160
|
+
self,
|
|
161
|
+
data: object,
|
|
162
|
+
metadata: dict[str, Any],
|
|
163
|
+
*,
|
|
164
|
+
direction: Literal["client", "server"],
|
|
165
|
+
) -> list[dict[str, Any]]:
|
|
166
|
+
part = self.part_for(data, metadata, direction=direction)
|
|
167
|
+
if part is None:
|
|
168
|
+
return []
|
|
169
|
+
messages = part["data"] if isinstance(part["data"], list) else [part["data"]]
|
|
170
|
+
return [{**part, "data": message} for message in messages]
|
|
171
|
+
|
|
130
172
|
|
|
131
173
|
_A2UI_DISABLED_CONTEXT = A2UIContext(False, False, {}, [])
|
|
132
174
|
|
|
@@ -141,6 +183,59 @@ class A2ATask:
|
|
|
141
183
|
task_ids_by_start_id: dict[str, str]
|
|
142
184
|
|
|
143
185
|
|
|
186
|
+
@dataclass(frozen=True)
|
|
187
|
+
class RunMessages:
|
|
188
|
+
messages: list[Any]
|
|
189
|
+
added_from: int | None
|
|
190
|
+
|
|
191
|
+
@classmethod
|
|
192
|
+
def between(cls, before: list[Any], messages: list[Any]) -> Self:
|
|
193
|
+
if messages[: len(before)] == before:
|
|
194
|
+
return cls(messages, len(before))
|
|
195
|
+
before_ids = {
|
|
196
|
+
message.get("id") for message in before if isinstance(message, dict)
|
|
197
|
+
}
|
|
198
|
+
if len(before_ids) != len(before) or None in before_ids:
|
|
199
|
+
return cls(messages, None)
|
|
200
|
+
last_kept = max(
|
|
201
|
+
(
|
|
202
|
+
index
|
|
203
|
+
for index, message in enumerate(messages)
|
|
204
|
+
if isinstance(message, dict) and message.get("id") in before_ids
|
|
205
|
+
),
|
|
206
|
+
default=-1,
|
|
207
|
+
)
|
|
208
|
+
return cls(messages, last_kept + 1)
|
|
209
|
+
|
|
210
|
+
@property
|
|
211
|
+
def added(self) -> list[Any] | None:
|
|
212
|
+
return None if self.added_from is None else self.messages[self.added_from :]
|
|
213
|
+
|
|
214
|
+
def stamped(self, task_id: str) -> list[Any]:
|
|
215
|
+
if self.added_from is None or self.added_from >= len(self.messages):
|
|
216
|
+
return self.messages
|
|
217
|
+
message = self.messages[self.added_from]
|
|
218
|
+
if not isinstance(message, dict):
|
|
219
|
+
return self.messages
|
|
220
|
+
additional_kwargs = message.get("additional_kwargs")
|
|
221
|
+
additional_kwargs = (
|
|
222
|
+
dict(additional_kwargs) if isinstance(additional_kwargs, dict) else {}
|
|
223
|
+
)
|
|
224
|
+
stored_a2a = additional_kwargs.get("a2a")
|
|
225
|
+
stored_a2a = dict(stored_a2a) if isinstance(stored_a2a, dict) else {}
|
|
226
|
+
if stored_a2a.get("task_id") or stored_a2a.get("task_start_id"):
|
|
227
|
+
return self.messages
|
|
228
|
+
messages = list(self.messages)
|
|
229
|
+
messages[self.added_from] = {
|
|
230
|
+
**message,
|
|
231
|
+
"additional_kwargs": {
|
|
232
|
+
**additional_kwargs,
|
|
233
|
+
"a2a": {**stored_a2a, "task_id": task_id},
|
|
234
|
+
},
|
|
235
|
+
}
|
|
236
|
+
return messages
|
|
237
|
+
|
|
238
|
+
|
|
144
239
|
# Keep each core search rate-limit charge bounded.
|
|
145
240
|
_A2A_CONTEXT_SEARCH_LIMIT = 1000
|
|
146
241
|
_A2A_RUN_SCAN_LIMIT = 1000
|
|
@@ -158,18 +253,17 @@ _TERMINAL_TASK_STATES = frozenset(
|
|
|
158
253
|
def _a2ui_enabled(assistant: dict[str, Any]) -> bool:
|
|
159
254
|
metadata = assistant.get("metadata")
|
|
160
255
|
a2a_metadata = metadata.get("a2a") if isinstance(metadata, dict) else None
|
|
161
|
-
|
|
256
|
+
override = a2a_metadata.get("a2ui") if isinstance(a2a_metadata, dict) else None
|
|
257
|
+
return override if isinstance(override, bool) else A2A_A2UI_ENABLED
|
|
162
258
|
|
|
163
259
|
|
|
164
|
-
def _a2ui_context_for_run(
|
|
165
|
-
run: dict[str, Any], *, enabled: bool | None = None
|
|
166
|
-
) -> A2UIContext:
|
|
260
|
+
def _a2ui_context_for_run(run: dict[str, Any]) -> A2UIContext:
|
|
167
261
|
metadata = run.get("metadata")
|
|
168
262
|
a2a_metadata = metadata.get("a2a") if isinstance(metadata, dict) else None
|
|
169
263
|
if not isinstance(a2a_metadata, dict):
|
|
170
264
|
a2a_metadata = {}
|
|
171
265
|
return A2UIContext(
|
|
172
|
-
enabled=a2a_metadata.get("a2ui") is True
|
|
266
|
+
enabled=a2a_metadata.get("a2ui") is True,
|
|
173
267
|
active=a2a_metadata.get("a2ui_active") is True,
|
|
174
268
|
metadata={},
|
|
175
269
|
extensions=[],
|
|
@@ -204,12 +298,10 @@ def _a2a_run_metadata(
|
|
|
204
298
|
**inherited,
|
|
205
299
|
**({"task_id": task_id} if task_id else {}),
|
|
206
300
|
**({"task_start_id": task_start_id} if task_start_id else {}),
|
|
207
|
-
**({"a2ui_active": context.active} if context.enabled else {}),
|
|
208
301
|
**(
|
|
209
|
-
{"
|
|
210
|
-
if context.enabled and context.active
|
|
211
|
-
else {}
|
|
302
|
+
{"a2ui": True, "a2ui_active": context.active} if context.enabled else {}
|
|
212
303
|
),
|
|
304
|
+
**({"a2ui_mime_type": context.mime_type} if context.negotiated else {}),
|
|
213
305
|
}
|
|
214
306
|
}
|
|
215
307
|
|
|
@@ -1090,8 +1182,6 @@ def _content_to_a2a_parts(
|
|
|
1090
1182
|
a2ui: A2UIContext,
|
|
1091
1183
|
a2ui_direction: Literal["client", "server"] = "server",
|
|
1092
1184
|
) -> list[dict[str, Any]]:
|
|
1093
|
-
"""Convert public LangChain message content to ordered A2A parts."""
|
|
1094
|
-
|
|
1095
1185
|
if isinstance(content, str):
|
|
1096
1186
|
return [{"kind": "text", "text": content}]
|
|
1097
1187
|
if content is None:
|
|
@@ -1120,33 +1210,10 @@ def _content_to_a2a_parts(
|
|
|
1120
1210
|
)
|
|
1121
1211
|
if not is_a2ui_mime_type(metadata.get("mimeType")):
|
|
1122
1212
|
continue
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
"Agent emitted A2UI while the extension was disabled"
|
|
1128
|
-
)
|
|
1129
|
-
if not a2ui.active:
|
|
1130
|
-
continue
|
|
1131
|
-
data = block.get("data")
|
|
1132
|
-
try:
|
|
1133
|
-
validate_payload(data, direction=a2ui_direction)
|
|
1134
|
-
except A2UIValidationError:
|
|
1135
|
-
if not a2ui.drop_invalid:
|
|
1136
|
-
raise
|
|
1137
|
-
logger.warning(
|
|
1138
|
-
"Dropping invalid A2UI block from task history",
|
|
1139
|
-
direction=a2ui_direction,
|
|
1140
|
-
)
|
|
1141
|
-
continue
|
|
1142
|
-
flush_text()
|
|
1143
|
-
parts.append(
|
|
1144
|
-
{
|
|
1145
|
-
"kind": "data",
|
|
1146
|
-
"data": data,
|
|
1147
|
-
"metadata": normalize_metadata(metadata, mime_type=a2ui.mime_type),
|
|
1148
|
-
}
|
|
1149
|
-
)
|
|
1213
|
+
part = a2ui.part_for(block.get("data"), metadata, direction=a2ui_direction)
|
|
1214
|
+
if part is not None:
|
|
1215
|
+
flush_text()
|
|
1216
|
+
parts.append(part)
|
|
1150
1217
|
elif (
|
|
1151
1218
|
isinstance(block, dict) and block.get("type") in _MEDIA_CONTENT_BLOCK_TYPES
|
|
1152
1219
|
):
|
|
@@ -1187,46 +1254,45 @@ def _content_to_text(content: Any) -> str:
|
|
|
1187
1254
|
return str(content)
|
|
1188
1255
|
|
|
1189
1256
|
|
|
1190
|
-
def
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1257
|
+
def _current_turn(messages: list[object]) -> list[object]:
|
|
1258
|
+
for index in range(len(messages) - 1, -1, -1):
|
|
1259
|
+
message = messages[index]
|
|
1260
|
+
if isinstance(message, dict) and _a2a_role_for_message(message) == "ROLE_USER":
|
|
1261
|
+
return messages[index + 1 :]
|
|
1262
|
+
return messages
|
|
1194
1263
|
|
|
1195
|
-
Args:
|
|
1196
|
-
result: Graph execution result
|
|
1197
|
-
|
|
1198
|
-
Returns:
|
|
1199
|
-
Ordered public parts from the last assistant message
|
|
1200
1264
|
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1265
|
+
def _extract_a2a_response(
|
|
1266
|
+
result: dict[str, Any],
|
|
1267
|
+
*,
|
|
1268
|
+
a2ui: A2UIContext,
|
|
1269
|
+
turn: list[object] | None = None,
|
|
1270
|
+
) -> list[dict[str, Any]]:
|
|
1204
1271
|
if "__error__" in result:
|
|
1205
|
-
# Let the caller handle errors
|
|
1206
1272
|
return [{"kind": "text", "text": str(result)}]
|
|
1207
1273
|
|
|
1208
1274
|
if "messages" not in result:
|
|
1209
|
-
# Fallback to the full result if no messages schema. It is not optimal to do A2A on assistants without
|
|
1210
|
-
# a messages key, but it is not a hard requirement.
|
|
1211
1275
|
return [{"kind": "text", "text": str(result)}]
|
|
1212
1276
|
|
|
1213
1277
|
messages = result["messages"]
|
|
1214
1278
|
if not isinstance(messages, list) or not messages:
|
|
1215
1279
|
return [{"kind": "text", "text": str(result)}]
|
|
1216
1280
|
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1281
|
+
turn = _current_turn(messages) if turn is None else turn
|
|
1282
|
+
tool_ui = [
|
|
1283
|
+
part for message in turn for part in _tool_result_a2ui_parts(message, a2ui)
|
|
1284
|
+
]
|
|
1285
|
+
for message in reversed(turn):
|
|
1286
|
+
if (
|
|
1287
|
+
isinstance(message, dict)
|
|
1288
|
+
and _a2a_role_for_message(message) == "ROLE_AGENT"
|
|
1289
|
+
and "content" in message
|
|
1290
|
+
):
|
|
1291
|
+
reply = _content_to_a2a_parts(message["content"], a2ui=a2ui)
|
|
1292
|
+
if _a2a_parts_have_content(reply):
|
|
1293
|
+
return [*reply, *tool_ui]
|
|
1228
1294
|
|
|
1229
|
-
return [{"kind": "text", "text": ""}]
|
|
1295
|
+
return tool_ui or [{"kind": "text", "text": ""}]
|
|
1230
1296
|
|
|
1231
1297
|
|
|
1232
1298
|
def _create_interrupt_artifact(interrupts: list[dict[str, Any]]) -> dict[str, Any]:
|
|
@@ -1296,19 +1362,14 @@ def _create_response_artifact_update(
|
|
|
1296
1362
|
}
|
|
1297
1363
|
|
|
1298
1364
|
|
|
1299
|
-
def
|
|
1300
|
-
|
|
1365
|
+
def _tool_call_id(message: dict[str, Any]) -> str | None:
|
|
1366
|
+
tool_call_id = message.get("tool_call_id")
|
|
1367
|
+
return tool_call_id if isinstance(tool_call_id, str) and tool_call_id else None
|
|
1301
1368
|
|
|
1302
|
-
A tool result is only published when it is correlated to the call that
|
|
1303
|
-
produced it: ``tool_call_id`` is what lets a client match the payload back
|
|
1304
|
-
to its request. Uncorrelated tool output stays internal.
|
|
1305
1369
|
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
"""
|
|
1310
|
-
tool_call_id = message.get("tool_call_id")
|
|
1311
|
-
if not isinstance(tool_call_id, str) or not tool_call_id:
|
|
1370
|
+
def _tool_result_data(message: dict[str, Any]) -> dict[str, Any] | None:
|
|
1371
|
+
tool_call_id = _tool_call_id(message)
|
|
1372
|
+
if tool_call_id is None:
|
|
1312
1373
|
return None
|
|
1313
1374
|
|
|
1314
1375
|
if (
|
|
@@ -1334,6 +1395,26 @@ def _tool_results_data_part(tool_results: list[dict[str, Any]]) -> dict[str, Any
|
|
|
1334
1395
|
return {"kind": "data", "data": {"tool_results": tool_results}}
|
|
1335
1396
|
|
|
1336
1397
|
|
|
1398
|
+
def _tool_result_a2ui_parts(message: object, a2ui: A2UIContext) -> list[dict[str, Any]]:
|
|
1399
|
+
if (
|
|
1400
|
+
not a2ui.negotiated
|
|
1401
|
+
or not isinstance(message, dict)
|
|
1402
|
+
or message.get("name") not in A2A_A2UI_TOOL_CALL_RESULTS
|
|
1403
|
+
or message.get("status") == "error"
|
|
1404
|
+
or _tool_call_id(message) is None
|
|
1405
|
+
):
|
|
1406
|
+
return []
|
|
1407
|
+
content = message.get("content")
|
|
1408
|
+
try:
|
|
1409
|
+
data = orjson.loads(content) if isinstance(content, str) else content
|
|
1410
|
+
except orjson.JSONDecodeError:
|
|
1411
|
+
logger.warning("Dropping non-JSON A2UI tool result", tool=message.get("name"))
|
|
1412
|
+
return []
|
|
1413
|
+
return replace(a2ui, drop_invalid=True).parts_per_message(
|
|
1414
|
+
data, {"mimeType": a2ui.mime_type}, direction="server"
|
|
1415
|
+
)
|
|
1416
|
+
|
|
1417
|
+
|
|
1337
1418
|
def _lc_stream_items_to_a2a_message(
|
|
1338
1419
|
items: list[dict[str, Any]],
|
|
1339
1420
|
*,
|
|
@@ -1566,13 +1647,13 @@ def _convert_messages_to_a2a_format(
|
|
|
1566
1647
|
a2a_role = _a2a_role_for_message(msg)
|
|
1567
1648
|
|
|
1568
1649
|
if a2a_role is None:
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1650
|
+
parts = []
|
|
1651
|
+
if (tool_result := _tool_result_data(msg)) is not None:
|
|
1652
|
+
parts.append(_tool_results_data_part([tool_result]))
|
|
1653
|
+
parts.extend(_tool_result_a2ui_parts(msg, history_a2ui))
|
|
1654
|
+
if not parts:
|
|
1573
1655
|
continue
|
|
1574
1656
|
a2a_role = "ROLE_AGENT"
|
|
1575
|
-
parts = [_tool_results_data_part([tool_result])]
|
|
1576
1657
|
else:
|
|
1577
1658
|
parts = _content_to_a2a_parts(
|
|
1578
1659
|
msg.get("content", ""),
|
|
@@ -1614,21 +1695,8 @@ async def _create_task_response(
|
|
|
1614
1695
|
history_length: int | None = None,
|
|
1615
1696
|
history_scope: HistoryScope = "context",
|
|
1616
1697
|
task_ids_by_start_id: dict[str, str] | None = None,
|
|
1698
|
+
turn: list[object] | None = None,
|
|
1617
1699
|
) -> dict[str, Any]:
|
|
1618
|
-
"""Create A2A Task response structure for both success and failure cases.
|
|
1619
|
-
|
|
1620
|
-
Args:
|
|
1621
|
-
task_id: The task/run ID
|
|
1622
|
-
context_id: The context/thread ID
|
|
1623
|
-
result: LangGraph execution result
|
|
1624
|
-
assistant_id: The assistant ID used
|
|
1625
|
-
history_length: Maximum public history messages to include.
|
|
1626
|
-
history_scope: Whether to return the whole context or only this task.
|
|
1627
|
-
|
|
1628
|
-
Returns:
|
|
1629
|
-
A2A Task response dictionary
|
|
1630
|
-
"""
|
|
1631
|
-
# Convert result messages to A2A message format
|
|
1632
1700
|
messages = result.get("messages", []) or []
|
|
1633
1701
|
thread_history = _convert_messages_to_a2a_format(
|
|
1634
1702
|
messages,
|
|
@@ -1678,10 +1746,7 @@ async def _create_task_response(
|
|
|
1678
1746
|
}
|
|
1679
1747
|
base_task["artifacts"] = [
|
|
1680
1748
|
_create_response_artifact(
|
|
1681
|
-
_extract_a2a_response(
|
|
1682
|
-
result,
|
|
1683
|
-
a2ui=a2ui,
|
|
1684
|
-
),
|
|
1749
|
+
_extract_a2a_response(result, a2ui=a2ui, turn=turn),
|
|
1685
1750
|
assistant_id,
|
|
1686
1751
|
)
|
|
1687
1752
|
]
|
|
@@ -2264,18 +2329,19 @@ async def handle_message_send(
|
|
|
2264
2329
|
run_id=run["run_id"],
|
|
2265
2330
|
headers=request.headers,
|
|
2266
2331
|
)
|
|
2267
|
-
|
|
2332
|
+
data_only_input = command is None and "messages" not in input_content
|
|
2333
|
+
turn = None
|
|
2334
|
+
if data_only_input or (
|
|
2268
2335
|
history_scope == "task"
|
|
2269
2336
|
and history_length != 0
|
|
2270
2337
|
and "messages" not in input_content
|
|
2271
2338
|
):
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
context_id,
|
|
2275
|
-
str(run["run_id"]),
|
|
2276
|
-
request.headers,
|
|
2277
|
-
task_id=task_id,
|
|
2339
|
+
run_messages = await _load_run_messages(
|
|
2340
|
+
context_id, str(run["run_id"]), request.headers
|
|
2278
2341
|
)
|
|
2342
|
+
result = {**result, "messages": run_messages.stamped(task_id)}
|
|
2343
|
+
if data_only_input:
|
|
2344
|
+
turn = run_messages.added
|
|
2279
2345
|
|
|
2280
2346
|
return await _create_task_response(
|
|
2281
2347
|
task_id=task_id,
|
|
@@ -2286,6 +2352,7 @@ async def handle_message_send(
|
|
|
2286
2352
|
history_scope=history_scope,
|
|
2287
2353
|
a2ui=a2ui,
|
|
2288
2354
|
task_ids_by_start_id=task_ids_by_start_id,
|
|
2355
|
+
turn=turn,
|
|
2289
2356
|
)
|
|
2290
2357
|
|
|
2291
2358
|
except InvalidAgentResponseError as e:
|
|
@@ -2305,6 +2372,29 @@ async def handle_message_send(
|
|
|
2305
2372
|
}
|
|
2306
2373
|
|
|
2307
2374
|
|
|
2375
|
+
async def _latest_messages(
|
|
2376
|
+
context_id: str, metadata: dict[str, str], headers: Headers
|
|
2377
|
+
) -> list[Any] | None:
|
|
2378
|
+
history = await get_client().threads.get_history(
|
|
2379
|
+
context_id, limit=1, metadata=metadata, headers=headers
|
|
2380
|
+
)
|
|
2381
|
+
return (history[0]["values"].get("messages") or []) if history else None
|
|
2382
|
+
|
|
2383
|
+
|
|
2384
|
+
async def _load_run_messages(
|
|
2385
|
+
context_id: str, run_id: str, headers: Headers
|
|
2386
|
+
) -> RunMessages:
|
|
2387
|
+
messages, before = await asyncio.gather(
|
|
2388
|
+
_latest_messages(context_id, {"run_id": run_id}, headers),
|
|
2389
|
+
_latest_messages(context_id, {"run_id": run_id, "source": "input"}, headers),
|
|
2390
|
+
)
|
|
2391
|
+
if messages is None:
|
|
2392
|
+
return RunMessages([], None)
|
|
2393
|
+
if before is None:
|
|
2394
|
+
return RunMessages(messages, None)
|
|
2395
|
+
return RunMessages.between(before, messages)
|
|
2396
|
+
|
|
2397
|
+
|
|
2308
2398
|
async def _get_historical_messages_for_task(
|
|
2309
2399
|
context_id: str,
|
|
2310
2400
|
task_run_id: str,
|
|
@@ -2312,49 +2402,8 @@ async def _get_historical_messages_for_task(
|
|
|
2312
2402
|
*,
|
|
2313
2403
|
task_id: str,
|
|
2314
2404
|
) -> list[Any]:
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
context_id,
|
|
2318
|
-
limit=LANGGRAPH_HISTORY_QUERY_LIMIT,
|
|
2319
|
-
metadata={"run_id": task_run_id},
|
|
2320
|
-
headers=request_headers,
|
|
2321
|
-
)
|
|
2322
|
-
|
|
2323
|
-
if history:
|
|
2324
|
-
# Find the checkpoint with the highest step number (final state for this task)
|
|
2325
|
-
target_checkpoint = max(
|
|
2326
|
-
history, key=lambda c: c.get("metadata", {}).get("step", 0)
|
|
2327
|
-
)
|
|
2328
|
-
messages = target_checkpoint["values"].get("messages", []) or []
|
|
2329
|
-
input_checkpoint = min(
|
|
2330
|
-
history, key=lambda c: c.get("metadata", {}).get("step", 0)
|
|
2331
|
-
)
|
|
2332
|
-
input_messages = input_checkpoint["values"].get("messages", []) or []
|
|
2333
|
-
new_message_index = len(input_messages)
|
|
2334
|
-
if (
|
|
2335
|
-
new_message_index < len(messages)
|
|
2336
|
-
and messages[:new_message_index] == input_messages
|
|
2337
|
-
and isinstance(messages[new_message_index], dict)
|
|
2338
|
-
):
|
|
2339
|
-
message = messages[new_message_index]
|
|
2340
|
-
additional_kwargs = message.get("additional_kwargs")
|
|
2341
|
-
additional_kwargs = (
|
|
2342
|
-
dict(additional_kwargs) if isinstance(additional_kwargs, dict) else {}
|
|
2343
|
-
)
|
|
2344
|
-
stored_a2a = additional_kwargs.get("a2a")
|
|
2345
|
-
stored_a2a = dict(stored_a2a) if isinstance(stored_a2a, dict) else {}
|
|
2346
|
-
if not stored_a2a.get("task_id") and not stored_a2a.get("task_start_id"):
|
|
2347
|
-
messages = list(messages)
|
|
2348
|
-
messages[new_message_index] = {
|
|
2349
|
-
**message,
|
|
2350
|
-
"additional_kwargs": {
|
|
2351
|
-
**additional_kwargs,
|
|
2352
|
-
"a2a": {**stored_a2a, "task_id": task_id},
|
|
2353
|
-
},
|
|
2354
|
-
}
|
|
2355
|
-
return messages
|
|
2356
|
-
else:
|
|
2357
|
-
return []
|
|
2405
|
+
run_messages = await _load_run_messages(context_id, task_run_id, request_headers)
|
|
2406
|
+
return run_messages.stamped(task_id)
|
|
2358
2407
|
|
|
2359
2408
|
|
|
2360
2409
|
async def handle_tasks_get(
|
|
@@ -2437,8 +2486,6 @@ async def handle_tasks_get(
|
|
|
2437
2486
|
}
|
|
2438
2487
|
}
|
|
2439
2488
|
|
|
2440
|
-
assistant = await _get_assistant(assistant_id, request.headers)
|
|
2441
|
-
|
|
2442
2489
|
lg_status = run_info.get("status", "unknown")
|
|
2443
2490
|
a2a_state = task.state
|
|
2444
2491
|
|
|
@@ -2455,9 +2502,7 @@ async def handle_tasks_get(
|
|
|
2455
2502
|
context_id,
|
|
2456
2503
|
history_length=history_length,
|
|
2457
2504
|
history_scope=history_scope,
|
|
2458
|
-
a2ui=_a2ui_context_for_run(
|
|
2459
|
-
run_info, enabled=_a2ui_enabled(assistant)
|
|
2460
|
-
),
|
|
2505
|
+
a2ui=_a2ui_context_for_run(run_info),
|
|
2461
2506
|
task_ids_by_start_id=task.task_ids_by_start_id,
|
|
2462
2507
|
)
|
|
2463
2508
|
except A2UIValidationError:
|
|
@@ -3587,6 +3632,14 @@ async def handle_message_stream(
|
|
|
3587
3632
|
{"jsonrpc": "2.0", "id": rpc_id, "result": {"task": initial_task}},
|
|
3588
3633
|
)
|
|
3589
3634
|
|
|
3635
|
+
async def data_only_turn() -> list[Any] | None:
|
|
3636
|
+
if command is not None or "messages" in input_content:
|
|
3637
|
+
return None
|
|
3638
|
+
run_messages = await _load_run_messages(
|
|
3639
|
+
context_id, str(run_id), request.headers
|
|
3640
|
+
)
|
|
3641
|
+
return run_messages.added
|
|
3642
|
+
|
|
3590
3643
|
result = None
|
|
3591
3644
|
err = None
|
|
3592
3645
|
notified_is_working = False
|
|
@@ -3602,10 +3655,10 @@ async def handle_message_stream(
|
|
|
3602
3655
|
# the completed and interrupt paths).
|
|
3603
3656
|
final_parts: list[dict[str, Any]] | None = None
|
|
3604
3657
|
if isinstance(result, dict):
|
|
3658
|
+
turn = await data_only_turn()
|
|
3605
3659
|
try:
|
|
3606
3660
|
final_parts = _extract_a2a_response(
|
|
3607
|
-
result,
|
|
3608
|
-
a2ui=a2ui,
|
|
3661
|
+
result, a2ui=a2ui, turn=turn
|
|
3609
3662
|
)
|
|
3610
3663
|
except A2UIValidationError:
|
|
3611
3664
|
await logger.aexception(
|
|
@@ -3827,11 +3880,9 @@ async def handle_message_stream(
|
|
|
3827
3880
|
# and we actually captured a result; a degenerate empty exit keeps
|
|
3828
3881
|
# its plain terminal status-update.
|
|
3829
3882
|
if fallback_state == "TASK_STATE_COMPLETED" and isinstance(result, dict):
|
|
3883
|
+
turn = await data_only_turn()
|
|
3830
3884
|
try:
|
|
3831
|
-
fallback_parts = _extract_a2a_response(
|
|
3832
|
-
result,
|
|
3833
|
-
a2ui=a2ui,
|
|
3834
|
-
)
|
|
3885
|
+
fallback_parts = _extract_a2a_response(result, a2ui=a2ui, turn=turn)
|
|
3835
3886
|
except A2UIValidationError:
|
|
3836
3887
|
await logger.aexception("Agent emitted an invalid A2UI payload")
|
|
3837
3888
|
yield (
|
langgraph_api/config/__init__.py
CHANGED
|
@@ -257,15 +257,19 @@ MCP_ENABLED = HTTP_CONFIG is None or not HTTP_CONFIG.get("disable_mcp")
|
|
|
257
257
|
A2A_ENABLED = HTTP_CONFIG is None or not HTTP_CONFIG.get("disable_a2a")
|
|
258
258
|
|
|
259
259
|
|
|
260
|
-
def
|
|
260
|
+
def _parse_tool_names(value: str) -> frozenset[str]:
|
|
261
261
|
return frozenset(name.strip() for name in value.split(",") if name.strip())
|
|
262
262
|
|
|
263
263
|
|
|
264
264
|
A2A_ALLOWED_TOOL_CALL_RESULTS = env(
|
|
265
265
|
"A2A_ALLOWED_TOOL_CALL_RESULTS",
|
|
266
|
-
cast=
|
|
266
|
+
cast=_parse_tool_names,
|
|
267
267
|
default=None,
|
|
268
268
|
)
|
|
269
|
+
A2A_A2UI_ENABLED = env("A2A_A2UI_ENABLED", cast=bool, default=False)
|
|
270
|
+
A2A_A2UI_TOOL_CALL_RESULTS = env(
|
|
271
|
+
"A2A_A2UI_TOOL_CALL_RESULTS", cast=_parse_tool_names, default=""
|
|
272
|
+
)
|
|
269
273
|
WEBHOOKS_ENABLED = HTTP_CONFIG and HTTP_CONFIG.get("disable_webhooks")
|
|
270
274
|
# Not in public docs: populated by langgraph.json config, not set as env var directly
|
|
271
275
|
STORE_CONFIG = env(
|
langgraph_api/serde.py
CHANGED
|
@@ -29,6 +29,7 @@ from langgraph.errors import (
|
|
|
29
29
|
InvalidUpdateError,
|
|
30
30
|
TaskNotFound,
|
|
31
31
|
)
|
|
32
|
+
from langgraph.types import Send
|
|
32
33
|
from starlette.exceptions import HTTPException
|
|
33
34
|
|
|
34
35
|
from langgraph_api.errors import UserInterrupt
|
|
@@ -74,6 +75,8 @@ def default(obj):
|
|
|
74
75
|
# https://github.com/ijl/orjson#serialize
|
|
75
76
|
if isinstance(obj, Fragment):
|
|
76
77
|
return orjson.Fragment(obj.buf)
|
|
78
|
+
if isinstance(obj, Send):
|
|
79
|
+
return {"node": obj.node, "args": obj.arg}
|
|
77
80
|
if (
|
|
78
81
|
hasattr(obj, "model_dump")
|
|
79
82
|
and callable(obj.model_dump)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
langgraph_api/__init__.py,sha256=
|
|
1
|
+
langgraph_api/__init__.py,sha256=2lKXoSHF6sBtjdWyMKs386Y_GD2v5ruqOtEBpbLZAjU,29
|
|
2
2
|
langgraph_api/_factory_utils.py,sha256=_FqW0olMu2PGLsnRq0ylt3a4jDCe12YIFHExMXI6Nyo,5976
|
|
3
3
|
langgraph_api/asgi_transport.py,sha256=XApY3lIWBZTMbbsl8dDJzl0cLGirmAGE0SifqZUnXvs,11896
|
|
4
4
|
langgraph_api/asyncio.py,sha256=smqyAoO9nIyPPQRw1IVSCh85fwoZP4PEo-5dDhxGruc,10676
|
|
@@ -25,7 +25,7 @@ langgraph_api/route.py,sha256=_KE8A8Q-J-QfqjGlyM2Kc6n5cirmgt8xmI5-pI8kVEE,8837
|
|
|
25
25
|
langgraph_api/schema.py,sha256=c1N7wXLNLH2P19twDaZNC9Bq6HOo3xsNaI3Cqx8cM04,15528
|
|
26
26
|
langgraph_api/self_hosted_logs.py,sha256=l35fIWKGj5bMf8uwfqlcH2Z0GnS9eVWNn03cXGkVCIk,5992
|
|
27
27
|
langgraph_api/self_hosted_metrics.py,sha256=pWsQQ-2ukoFIbmVfzNOSkwCqZ5Cnts6pRSWTII44Ll4,16844
|
|
28
|
-
langgraph_api/serde.py,sha256=
|
|
28
|
+
langgraph_api/serde.py,sha256=sMjrORY2WCNmZEXDaE3lydi1un686qR1E_Wxw4mFgjU,9144
|
|
29
29
|
langgraph_api/server.py,sha256=L1jGWkvK0fDewyus0xYvYmwQ6k775uNn_mXSKUnIOK0,11245
|
|
30
30
|
langgraph_api/sse.py,sha256=cChZ7raQUHp8p5BreE_5wMBR8lFO0n7746sV8_HQOrc,4822
|
|
31
31
|
langgraph_api/state.py,sha256=SC6kOmKXVV2puU37rc3yYveZFgnqP5uI_WidDyE5oXQ,4763
|
|
@@ -42,7 +42,7 @@ langgraph_api/_checkpointer/__init__.py,sha256=ofJTJLGy7Hsuzhj-2dpfDvrDloM0BzlhT
|
|
|
42
42
|
langgraph_api/_checkpointer/_adapter.py,sha256=idGYL-FwyVtUXcmhvfYboARfQAkoj0aFD40L5h9N7F8,22289
|
|
43
43
|
langgraph_api/_checkpointer/protocol.py,sha256=udgYKMNtKWG_eLDwYkHXV3b2bZLZg8Rsfm3fjkhU-rU,3635
|
|
44
44
|
langgraph_api/api/__init__.py,sha256=HdHkqmA1pi49djy1Kfpqy96Gaxy1N9lBH_kwW0SEO2I,11134
|
|
45
|
-
langgraph_api/api/a2a.py,sha256=
|
|
45
|
+
langgraph_api/api/a2a.py,sha256=uI4VHJe2KnObUJxfoCwdRW7WrqqJEz5JxV8_i1-YA20,145136
|
|
46
46
|
langgraph_api/api/a2ui.py,sha256=Dx9qwwIOmQ0MfgOwL-UZabBI0F7DEvz3qG1cFkjvnjE,3108
|
|
47
47
|
langgraph_api/api/assistants.py,sha256=rwaS3_Sy2nqf_2aIuG5XDvhw0GNWmT1-WIsESMuz1x0,21719
|
|
48
48
|
langgraph_api/api/event_streaming.py,sha256=jvoBp5qHI92JJHsx4f9LEpb0i0VE31G_qFhS5JQWtlA,17809
|
|
@@ -73,7 +73,7 @@ langgraph_api/auth/studio_user.py,sha256=Lx3eMsRA8gxZ3--ApL09hDW84A6qIncaZoBZjSg
|
|
|
73
73
|
langgraph_api/auth/langsmith/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
74
74
|
langgraph_api/auth/langsmith/backend.py,sha256=gaSuZC8JO32XHe8IdqttNpBGO6PCVrtXaq1Negc7vX0,3857
|
|
75
75
|
langgraph_api/auth/langsmith/client.py,sha256=79kwCVeHU64nsHsxWipfZhf44lM6vfs2nlfTxlJF6LU,4142
|
|
76
|
-
langgraph_api/config/__init__.py,sha256=
|
|
76
|
+
langgraph_api/config/__init__.py,sha256=irVLC0SYb9s0M5u9yKGCZkUBbLEz2uprfhQ_-AQjHQ8,28807
|
|
77
77
|
langgraph_api/config/_parse.py,sha256=RpfZgFAPJlRMv13AzGr3kAYbIrqHcgjzO8IgeboTw4A,3922
|
|
78
78
|
langgraph_api/config/schemas.py,sha256=Yi3SZ7uNLSncRe0SyFb0eVqWljDpap8csm9B_xH9IWE,21487
|
|
79
79
|
langgraph_api/encryption/__init__.py,sha256=gaCZ00CocSbqSqrDn6XJHaSp2CZCnC8qnrD9G4fbzyI,363
|
|
@@ -252,8 +252,8 @@ langgraph_grpc_common/proto/store_pb2.py,sha256=JKcuJK0YUC1BSwLUZJWHCQ7AWkzVS9Kf
|
|
|
252
252
|
langgraph_grpc_common/proto/store_pb2.pyi,sha256=ItSuXbA2X9bQlHtuEjDGi0fT_gJj2b0sr7XuQkybe3s,28983
|
|
253
253
|
langgraph_grpc_common/proto/store_pb2_grpc.py,sha256=Kyt8eV2NXkjh3be_BYytGdD19kNf74961cruonMvCrw,3300
|
|
254
254
|
langgraph_grpc_common/proto/store_pb2_grpc.pyi,sha256=oS1h2cDoq2OjyM8xazcmc8LwRDC5oCAD0zto2QUmPQw,2027
|
|
255
|
-
langgraph_api-0.16.0.
|
|
256
|
-
langgraph_api-0.16.0.
|
|
257
|
-
langgraph_api-0.16.0.
|
|
258
|
-
langgraph_api-0.16.0.
|
|
259
|
-
langgraph_api-0.16.0.
|
|
255
|
+
langgraph_api-0.16.0.dev12.dist-info/METADATA,sha256=ecK57tTJQr6B4reHreWXSFoTCtJiThhl_ueqO3NkBxU,5880
|
|
256
|
+
langgraph_api-0.16.0.dev12.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
257
|
+
langgraph_api-0.16.0.dev12.dist-info/entry_points.txt,sha256=hGedv8n7cgi41PypMfinwS_HfCwA7xJIfS0jAp8htV8,78
|
|
258
|
+
langgraph_api-0.16.0.dev12.dist-info/licenses/LICENSE,sha256=ZPwVR73Biwm3sK6vR54djCrhaRiM4cAD2zvOQZV8Xis,3859
|
|
259
|
+
langgraph_api-0.16.0.dev12.dist-info/RECORD,,
|
|
File without changes
|
{langgraph_api-0.16.0.dev10.dist-info → langgraph_api-0.16.0.dev12.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{langgraph_api-0.16.0.dev10.dist-info → langgraph_api-0.16.0.dev12.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|