langgraph-api 0.16.0.dev11__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 CHANGED
@@ -1 +1 @@
1
- __version__ = "0.16.0.dev11"
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 A2A_ALLOWED_TOOL_CALL_RESULTS
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
- return isinstance(a2a_metadata, dict) and a2a_metadata.get("a2ui") is True
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 if enabled is None else enabled,
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
- {"a2ui_mime_type": context.mime_type}
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
- if not a2ui.enabled:
1124
- if a2ui.drop_invalid:
1125
- continue
1126
- raise A2UIValidationError(
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 _extract_a2a_response(
1191
- result: dict[str, Any], *, a2ui: A2UIContext
1192
- ) -> list[dict[str, Any]]:
1193
- """Extract public A2A parts from the last assistant response.
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
- Raises:
1202
- ValueError: If result doesn't contain messages or is invalid
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
- # Find the last assistant message
1218
- for message in reversed(messages):
1219
- if not isinstance(message, dict):
1220
- continue
1221
- if _a2a_role_for_message(message) == "ROLE_AGENT" and "content" in message:
1222
- parts = _content_to_a2a_parts(
1223
- message["content"],
1224
- a2ui=a2ui,
1225
- )
1226
- if _a2a_parts_have_content(parts):
1227
- return parts
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 _tool_result_data(message: dict[str, Any]) -> dict[str, Any] | None:
1300
- """Build the published form of one tool result, or None if not publishable.
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
- Only the correlation id, content, tool name, and status are copied. Raw
1307
- LangChain internals (``response_metadata``, ``additional_kwargs``,
1308
- ``artifact``) are deliberately excluded.
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
- # Internal message: publish a correlated tool result as data,
1570
- # drop system prompts and uncorrelated output entirely.
1571
- tool_result = _tool_result_data(msg)
1572
- if tool_result is None:
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
- if (
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
- result = dict(result)
2273
- result["messages"] = await _get_historical_messages_for_task(
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
- """Get historical messages for a specific task by matching run_id."""
2316
- history = await get_client().threads.get_history(
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 (
@@ -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 _parse_allowed_tool_call_results(value: str) -> frozenset[str]:
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=_parse_allowed_tool_call_results,
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(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: langgraph-api
3
- Version: 0.16.0.dev11
3
+ Version: 0.16.0.dev12
4
4
  Author-email: Will Fu-Hinthorn <will@langchain.dev>, Josh Rogers <josh@langchain.dev>, Parker Rule <parker@langchain.dev>
5
5
  License: Elastic-2.0
6
6
  License-File: LICENSE
@@ -1,4 +1,4 @@
1
- langgraph_api/__init__.py,sha256=sQBkLkeFGX-kqfM2r9VRqEQnIrwyH9Qbgsi2fL1VmDA,29
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
@@ -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=0jCMN_BXXRYXJJIZfBkS4es9ilARbW5OIzM3rLvNvd4,143588
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=g5gRFH3gmEYxEAsK9cp-5ltjcHwWramT-IGF6WRoC0A,28663
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.dev11.dist-info/METADATA,sha256=XMP4zFlG6KnYUwQ6wVojA_t7gEwhWMUXpJ0jqcRP4Bk,5880
256
- langgraph_api-0.16.0.dev11.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
257
- langgraph_api-0.16.0.dev11.dist-info/entry_points.txt,sha256=hGedv8n7cgi41PypMfinwS_HfCwA7xJIfS0jAp8htV8,78
258
- langgraph_api-0.16.0.dev11.dist-info/licenses/LICENSE,sha256=ZPwVR73Biwm3sK6vR54djCrhaRiM4cAD2zvOQZV8Xis,3859
259
- langgraph_api-0.16.0.dev11.dist-info/RECORD,,
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,,