langgraph-api 0.15.0.dev1__py3-none-any.whl → 0.15.0.dev2__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.15.0.dev1"
1
+ __version__ = "0.15.0.dev2"
langgraph_api/api/a2a.py CHANGED
@@ -16,6 +16,7 @@ import binascii
16
16
  import functools
17
17
  import os
18
18
  import uuid
19
+ from dataclasses import dataclass, replace
19
20
  from datetime import UTC, datetime
20
21
  from typing import Any, Literal, NotRequired, cast
21
22
 
@@ -27,6 +28,15 @@ from starlette.responses import JSONResponse, Response
27
28
  from typing_extensions import TypedDict
28
29
 
29
30
  from langgraph_api import __version__
31
+ from langgraph_api.api.a2ui import (
32
+ A2UI_EXTENSION_URI,
33
+ A2UI_MIME_TYPE,
34
+ A2UIValidationError,
35
+ is_a2ui_mime_type,
36
+ normalize_metadata,
37
+ validate_client_metadata,
38
+ validate_payload,
39
+ )
30
40
  from langgraph_api.config import A2A_ALLOWED_TOOL_CALL_RESULTS
31
41
  from langgraph_api.metadata import USER_API_URL
32
42
  from langgraph_api.route import ApiRequest, ApiRoute
@@ -103,6 +113,73 @@ class InvalidAgentResponseError(ValueError):
103
113
  """Raised when public agent output cannot be represented as an A2A part."""
104
114
 
105
115
 
116
+ @dataclass(frozen=True)
117
+ class A2UIContext:
118
+ enabled: bool
119
+ active: bool
120
+ metadata: dict[str, Any]
121
+ extensions: list[str]
122
+ drop_invalid: bool = False
123
+
124
+
125
+ _A2UI_DISABLED_CONTEXT = A2UIContext(False, False, {}, [])
126
+
127
+
128
+ def _a2ui_enabled(assistant: dict[str, Any]) -> bool:
129
+ metadata = assistant.get("metadata")
130
+ a2a_metadata = metadata.get("a2a") if isinstance(metadata, dict) else None
131
+ return isinstance(a2a_metadata, dict) and a2a_metadata.get("a2ui") is True
132
+
133
+
134
+ def _a2ui_context_for_run(
135
+ run: dict[str, Any], *, enabled: bool | None = None
136
+ ) -> A2UIContext:
137
+ metadata = run.get("metadata")
138
+ a2a_metadata = metadata.get("a2a") if isinstance(metadata, dict) else None
139
+ if not isinstance(a2a_metadata, dict):
140
+ a2a_metadata = {}
141
+ return A2UIContext(
142
+ enabled=a2a_metadata.get("a2ui") is True if enabled is None else enabled,
143
+ active=a2a_metadata.get("a2ui_active") is True,
144
+ metadata={},
145
+ extensions=[],
146
+ )
147
+
148
+
149
+ def _a2ui_run_metadata(
150
+ assistant: dict[str, Any], context: A2UIContext
151
+ ) -> dict[str, Any] | None:
152
+ if not context.enabled:
153
+ return None
154
+ metadata = assistant.get("metadata")
155
+ a2a_metadata = metadata.get("a2a") if isinstance(metadata, dict) else None
156
+ return {
157
+ "a2a": {
158
+ **(a2a_metadata if isinstance(a2a_metadata, dict) else {}),
159
+ "a2ui_active": context.active,
160
+ }
161
+ }
162
+
163
+
164
+ def _a2ui_message_context(
165
+ message: dict[str, Any], assistant: dict[str, Any]
166
+ ) -> A2UIContext:
167
+ metadata = message.get("metadata") or {}
168
+ extensions = message.get("extensions") or []
169
+ if (
170
+ not isinstance(metadata, dict)
171
+ or not isinstance(extensions, list)
172
+ or not all(isinstance(extension, str) for extension in extensions)
173
+ ):
174
+ raise A2UIValidationError("Invalid A2A message metadata or extensions")
175
+ return A2UIContext(
176
+ enabled=_a2ui_enabled(assistant),
177
+ active=A2UI_EXTENSION_URI in extensions,
178
+ metadata=metadata,
179
+ extensions=extensions,
180
+ )
181
+
182
+
106
183
  # ============================================================================
107
184
  # Legacy (v0.x) format helpers
108
185
  # ============================================================================
@@ -284,7 +361,15 @@ async def _validate_supports_messages(
284
361
 
285
362
  # Validate messages field only if there are text or file parts
286
363
  has_message_parts = any(
287
- ("text" in part) or ("file" in part) for part in parts if isinstance(part, dict)
364
+ ("text" in part)
365
+ or ("file" in part)
366
+ or (
367
+ "data" in part
368
+ and isinstance(part.get("metadata"), dict)
369
+ and is_a2ui_mime_type(part["metadata"].get("mimeType"))
370
+ )
371
+ for part in parts
372
+ if isinstance(part, dict)
288
373
  )
289
374
  if has_message_parts:
290
375
  input_schema = schemas.get("input_schema") or schemas.get("state_schema")
@@ -506,12 +591,14 @@ def _process_a2a_message_parts(
506
591
  parts: list[dict[str, Any]],
507
592
  message_role: str,
508
593
  message_id: str,
594
+ *,
595
+ a2ui: A2UIContext,
509
596
  ) -> dict[str, Any]:
510
597
  """Convert A2A message parts to LangChain messages format.
511
598
 
512
- Text-only messages keep today's per-part string content. When any file part
513
- is present, emit one consolidated message whose ``content`` is an ordered
514
- list of LangChain content blocks (text + file/image/audio/video).
599
+ Text-only messages keep today's per-part string content. When a file or
600
+ A2UI part is present, emit one consolidated message whose ``content`` is an
601
+ ordered list of LangChain content blocks.
515
602
 
516
603
  Args:
517
604
  parts: List of A2A message parts
@@ -526,7 +613,19 @@ def _process_a2a_message_parts(
526
613
  messages = []
527
614
  additional_data = {}
528
615
  content_blocks: list[dict[str, Any]] = []
529
- has_file_parts = any(isinstance(part, dict) and "file" in part for part in parts)
616
+ validate_client_metadata(a2ui.metadata)
617
+ has_content_blocks = any(
618
+ isinstance(part, dict)
619
+ and (
620
+ "file" in part
621
+ or (
622
+ "data" in part
623
+ and isinstance(part.get("metadata"), dict)
624
+ and is_a2ui_mime_type(part["metadata"].get("mimeType"))
625
+ )
626
+ )
627
+ for part in parts
628
+ )
530
629
  langgraph_role = "human" if message_role == "ROLE_USER" else "assistant"
531
630
 
532
631
  for part in parts:
@@ -544,7 +643,7 @@ def _process_a2a_message_parts(
544
643
  )
545
644
 
546
645
  if "text" in part:
547
- if has_file_parts:
646
+ if has_content_blocks:
548
647
  content_blocks.append({"type": "text", "text": part["text"]})
549
648
  else:
550
649
  messages.append(
@@ -559,8 +658,26 @@ def _process_a2a_message_parts(
559
658
  content_blocks.append(_a2a_file_part_to_content_block(part))
560
659
 
561
660
  elif "data" in part:
562
- # Data parts become structured input parameters
563
661
  part_data = part.get("data", {})
662
+ part_metadata = (
663
+ part.get("metadata") if isinstance(part.get("metadata"), dict) else {}
664
+ )
665
+ if is_a2ui_mime_type(part_metadata.get("mimeType")):
666
+ if not a2ui.enabled:
667
+ raise A2UIValidationError("A2UI is not enabled for this agent")
668
+ if not a2ui.active:
669
+ raise A2UIValidationError("The A2UI extension must be activated")
670
+ validate_payload(part_data, direction="client")
671
+ content_blocks.append(
672
+ {
673
+ "type": "data",
674
+ "data": part_data,
675
+ "metadata": normalize_metadata(part_metadata),
676
+ }
677
+ )
678
+ continue
679
+
680
+ # Non-A2UI data parts remain structured top-level input parameters.
564
681
  if not isinstance(part_data, dict):
565
682
  raise ValueError(
566
683
  "DataPart must contain a JSON object in the 'data' field"
@@ -573,7 +690,7 @@ def _process_a2a_message_parts(
573
690
  "A2A agents support 'text', 'data', and 'file' parts only."
574
691
  )
575
692
 
576
- if has_file_parts:
693
+ if has_content_blocks:
577
694
  messages.append(
578
695
  {
579
696
  "role": langgraph_role,
@@ -582,6 +699,14 @@ def _process_a2a_message_parts(
582
699
  }
583
700
  )
584
701
 
702
+ if a2ui.metadata or a2ui.extensions:
703
+ a2a_data = {
704
+ **({"metadata": a2ui.metadata} if a2ui.metadata else {}),
705
+ **({"extensions": a2ui.extensions} if a2ui.extensions else {}),
706
+ }
707
+ for graph_message in messages:
708
+ graph_message["additional_kwargs"] = {"a2a": a2a_data}
709
+
585
710
  if not messages and not additional_data:
586
711
  raise ValueError(
587
712
  "Message must contain at least one valid text, data, or file part"
@@ -707,7 +832,12 @@ def _content_block_to_a2a_file_part(block: dict[str, Any]) -> dict[str, Any]:
707
832
  return {"kind": "file", "file": file_obj}
708
833
 
709
834
 
710
- def _content_to_a2a_parts(content: Any) -> list[dict[str, Any]]:
835
+ def _content_to_a2a_parts(
836
+ content: Any,
837
+ *,
838
+ a2ui: A2UIContext,
839
+ a2ui_direction: Literal["client", "server"] = "server",
840
+ ) -> list[dict[str, Any]]:
711
841
  """Convert public LangChain message content to ordered A2A parts."""
712
842
 
713
843
  if isinstance(content, str):
@@ -732,6 +862,39 @@ def _content_to_a2a_parts(content: Any) -> list[dict[str, Any]]:
732
862
  text = block.get("text")
733
863
  if isinstance(text, str):
734
864
  text_parts.append(text)
865
+ elif isinstance(block, dict) and block.get("type") == "data":
866
+ metadata = (
867
+ block.get("metadata") if isinstance(block.get("metadata"), dict) else {}
868
+ )
869
+ if not is_a2ui_mime_type(metadata.get("mimeType")):
870
+ continue
871
+ if not a2ui.enabled:
872
+ if a2ui.drop_invalid:
873
+ continue
874
+ raise A2UIValidationError(
875
+ "Agent emitted A2UI while the extension was disabled"
876
+ )
877
+ if not a2ui.active:
878
+ continue
879
+ data = block.get("data")
880
+ try:
881
+ validate_payload(data, direction=a2ui_direction)
882
+ except A2UIValidationError:
883
+ if not a2ui.drop_invalid:
884
+ raise
885
+ logger.warning(
886
+ "Dropping invalid A2UI block from task history",
887
+ direction=a2ui_direction,
888
+ )
889
+ continue
890
+ flush_text()
891
+ parts.append(
892
+ {
893
+ "kind": "data",
894
+ "data": data,
895
+ "metadata": normalize_metadata(metadata),
896
+ }
897
+ )
735
898
  elif (
736
899
  isinstance(block, dict) and block.get("type") in _MEDIA_CONTENT_BLOCK_TYPES
737
900
  ):
@@ -746,7 +909,7 @@ def _a2a_parts_have_content(parts: list[dict[str, Any]]) -> bool:
746
909
  """Return whether converted parts contain public text or file content."""
747
910
 
748
911
  return any(
749
- part.get("kind") == "file"
912
+ part.get("kind") in {"data", "file"}
750
913
  or (part.get("kind") == "text" and bool(part.get("text")))
751
914
  for part in parts
752
915
  )
@@ -772,7 +935,9 @@ def _content_to_text(content: Any) -> str:
772
935
  return str(content)
773
936
 
774
937
 
775
- def _extract_a2a_response(result: dict[str, Any]) -> list[dict[str, Any]]:
938
+ def _extract_a2a_response(
939
+ result: dict[str, Any], *, a2ui: A2UIContext
940
+ ) -> list[dict[str, Any]]:
776
941
  """Extract public A2A parts from the last assistant response.
777
942
 
778
943
  Args:
@@ -802,7 +967,10 @@ def _extract_a2a_response(result: dict[str, Any]) -> list[dict[str, Any]]:
802
967
  if not isinstance(message, dict):
803
968
  continue
804
969
  if _a2a_role_for_message(message) == "ROLE_AGENT" and "content" in message:
805
- parts = _content_to_a2a_parts(message["content"])
970
+ parts = _content_to_a2a_parts(
971
+ message["content"],
972
+ a2ui=a2ui,
973
+ )
806
974
  if _a2a_parts_have_content(parts):
807
975
  return parts
808
976
 
@@ -1135,6 +1303,7 @@ def _convert_messages_to_a2a_format(
1135
1303
  context_id: str,
1136
1304
  *,
1137
1305
  history_length: int | None = None,
1306
+ a2ui: A2UIContext,
1138
1307
  ) -> list[dict[str, Any]]:
1139
1308
  """Convert LangChain messages to A2A message format.
1140
1309
 
@@ -1154,6 +1323,8 @@ def _convert_messages_to_a2a_format(
1154
1323
  List of A2A messages
1155
1324
  """
1156
1325
 
1326
+ history_a2ui = replace(a2ui, drop_invalid=True)
1327
+
1157
1328
  # Convert each LangChain message to A2A format
1158
1329
  a2a_messages = []
1159
1330
  for msg in messages:
@@ -1169,7 +1340,11 @@ def _convert_messages_to_a2a_format(
1169
1340
  a2a_role = "ROLE_AGENT"
1170
1341
  parts = [_tool_results_data_part([tool_result])]
1171
1342
  else:
1172
- parts = _content_to_a2a_parts(msg.get("content", ""))
1343
+ parts = _content_to_a2a_parts(
1344
+ msg.get("content", ""),
1345
+ a2ui=history_a2ui,
1346
+ a2ui_direction=("client" if a2a_role == "ROLE_USER" else "server"),
1347
+ )
1173
1348
  if a2a_role == "ROLE_AGENT" and not _a2a_parts_have_content(parts):
1174
1349
  continue
1175
1350
  if not parts:
@@ -1197,6 +1372,7 @@ async def _create_task_response(
1197
1372
  context_id: str,
1198
1373
  result: dict[str, Any],
1199
1374
  assistant_id: str,
1375
+ a2ui: A2UIContext,
1200
1376
  history_length: int | None = None,
1201
1377
  ) -> dict[str, Any]:
1202
1378
  """Create A2A Task response structure for both success and failure cases.
@@ -1218,6 +1394,7 @@ async def _create_task_response(
1218
1394
  task_id,
1219
1395
  context_id,
1220
1396
  history_length=history_length,
1397
+ a2ui=a2ui,
1221
1398
  )
1222
1399
 
1223
1400
  base_task: dict[str, Any] = {
@@ -1257,7 +1434,13 @@ async def _create_task_response(
1257
1434
  "timestamp": datetime.now(UTC).isoformat(),
1258
1435
  }
1259
1436
  base_task["artifacts"] = [
1260
- _create_response_artifact(_extract_a2a_response(result), assistant_id)
1437
+ _create_response_artifact(
1438
+ _extract_a2a_response(
1439
+ result,
1440
+ a2ui=a2ui,
1441
+ ),
1442
+ assistant_id,
1443
+ )
1261
1444
  ]
1262
1445
 
1263
1446
  return {"result": {"task": base_task}}
@@ -1510,7 +1693,7 @@ async def handle_jsonrpc_request(
1510
1693
  elif method == "SendMessage":
1511
1694
  result_or_error = await handle_message_send(request, params, assistant_id)
1512
1695
  elif method == "GetTask":
1513
- result_or_error = await handle_tasks_get(request, params)
1696
+ result_or_error = await handle_tasks_get(request, params, assistant_id)
1514
1697
  elif method == "CancelTask":
1515
1698
  result_or_error = await handle_tasks_cancel(request, params)
1516
1699
  elif method == "ListTasks":
@@ -1656,8 +1839,21 @@ async def handle_message_send(
1656
1839
 
1657
1840
  # Process A2A message parts into LangChain messages format
1658
1841
  try:
1842
+ a2ui = _a2ui_message_context(message, assistant)
1659
1843
  message_role = _normalize_input_role(message.get("role", "ROLE_USER"))
1660
- input_content = _process_a2a_message_parts(parts, message_role, message_id)
1844
+ input_content = _process_a2a_message_parts(
1845
+ parts,
1846
+ message_role,
1847
+ message_id,
1848
+ a2ui=a2ui,
1849
+ )
1850
+ except A2UIValidationError as e:
1851
+ return {
1852
+ "error": {
1853
+ "code": ERROR_CODE_INVALID_PARAMS,
1854
+ "message": str(e),
1855
+ }
1856
+ }
1661
1857
  except ValueError as e:
1662
1858
  return {
1663
1859
  "error": {
@@ -1714,6 +1910,7 @@ async def handle_message_send(
1714
1910
  input=input_content,
1715
1911
  command=command,
1716
1912
  context=run_context,
1913
+ metadata=_a2ui_run_metadata(assistant, a2ui),
1717
1914
  if_not_exists="create",
1718
1915
  headers=request.headers,
1719
1916
  )
@@ -1740,6 +1937,7 @@ async def handle_message_send(
1740
1937
  result=result,
1741
1938
  assistant_id=assistant_id,
1742
1939
  history_length=history_length,
1940
+ a2ui=a2ui,
1743
1941
  )
1744
1942
 
1745
1943
  except InvalidAgentResponseError as e:
@@ -1784,7 +1982,7 @@ async def _get_historical_messages_for_task(
1784
1982
 
1785
1983
 
1786
1984
  async def handle_tasks_get(
1787
- request: ApiRequest, params: dict[str, Any]
1985
+ request: ApiRequest, params: dict[str, Any], assistant_id: str
1788
1986
  ) -> dict[str, Any]:
1789
1987
  """Handle tasks/get requests to retrieve task status.
1790
1988
 
@@ -1843,7 +2041,8 @@ async def handle_tasks_get(
1843
2041
 
1844
2042
  try:
1845
2043
  # TODO: fix the N+1 query issue
1846
- run_info, thread_info = await asyncio.gather(
2044
+ assistant, run_info, thread_info = await asyncio.gather(
2045
+ _get_assistant(assistant_id, request.headers),
1847
2046
  client.runs.get(
1848
2047
  thread_id=context_id,
1849
2048
  run_id=run_id,
@@ -1896,7 +2095,12 @@ async def handle_tasks_get(
1896
2095
  task_id,
1897
2096
  context_id,
1898
2097
  history_length=history_length,
2098
+ a2ui=_a2ui_context_for_run(
2099
+ run_info, enabled=_a2ui_enabled(assistant)
2100
+ ),
1899
2101
  )
2102
+ except A2UIValidationError:
2103
+ raise
1900
2104
  except InvalidAgentResponseError as e:
1901
2105
  return {
1902
2106
  "error": {
@@ -2225,6 +2429,7 @@ async def handle_list_tasks(
2225
2429
 
2226
2430
  # Collect all runs from matching threads
2227
2431
  all_tasks: list[dict[str, Any]] = []
2432
+ a2ui_by_task_id: dict[str, A2UIContext] = {}
2228
2433
  for tid in thread_ids:
2229
2434
  try:
2230
2435
  runs = await client.runs.list(
@@ -2267,6 +2472,7 @@ async def handle_list_tasks(
2267
2472
  task["artifacts"] = []
2268
2473
 
2269
2474
  all_tasks.append(task)
2475
+ a2ui_by_task_id[task_id] = _a2ui_context_for_run(run)
2270
2476
 
2271
2477
  # Sort by timestamp descending (newest first)
2272
2478
  all_tasks.sort(
@@ -2290,6 +2496,7 @@ async def handle_list_tasks(
2290
2496
  task["id"],
2291
2497
  task["contextId"],
2292
2498
  history_length=history_length,
2499
+ a2ui=a2ui_by_task_id[task["id"]],
2293
2500
  )
2294
2501
 
2295
2502
  await asyncio.gather(*(populate_history(task) for task in page_tasks))
@@ -2445,6 +2652,25 @@ async def generate_agent_card(request: ApiRequest, assistant_id: str) -> dict[st
2445
2652
  assistant.get("description") or f"{assistant_name} assistant"
2446
2653
  )
2447
2654
  input_modes, output_modes = _resolve_agent_card_modes(assistant)
2655
+ extensions: list[dict[str, Any]] = []
2656
+ if _a2ui_enabled(assistant):
2657
+ if A2UI_MIME_TYPE not in input_modes:
2658
+ input_modes.append(A2UI_MIME_TYPE)
2659
+ if A2UI_MIME_TYPE not in output_modes:
2660
+ output_modes.append(A2UI_MIME_TYPE)
2661
+ extensions.append(
2662
+ {
2663
+ "uri": A2UI_EXTENSION_URI,
2664
+ "description": "Ability to render A2UI v0.9",
2665
+ "required": False,
2666
+ "params": {
2667
+ "v0.9": {
2668
+ "supportedCatalogIds": [],
2669
+ "acceptsInlineCatalogs": False,
2670
+ }
2671
+ },
2672
+ }
2673
+ )
2448
2674
 
2449
2675
  # For now, each assistant has one main skill - itself
2450
2676
  skills = [
@@ -2488,6 +2714,14 @@ async def generate_agent_card(request: ApiRequest, assistant_id: str) -> dict[st
2488
2714
 
2489
2715
  agent_url = f"{base_url}{agent_path}"
2490
2716
 
2717
+ capabilities: dict[str, Any] = {
2718
+ "streaming": True,
2719
+ "pushNotifications": False, # Not implemented yet
2720
+ "stateTransitionHistory": False,
2721
+ }
2722
+ if extensions:
2723
+ capabilities["extensions"] = extensions
2724
+
2491
2725
  return {
2492
2726
  "name": assistant_name,
2493
2727
  "description": assistant_description,
@@ -2495,15 +2729,11 @@ async def generate_agent_card(request: ApiRequest, assistant_id: str) -> dict[st
2495
2729
  "supportedInterfaces": [
2496
2730
  {
2497
2731
  "url": agent_url,
2498
- "protocolBinding": "jsonrpc",
2732
+ "protocolBinding": "JSONRPC",
2499
2733
  "protocolVersion": "1.0",
2500
2734
  },
2501
2735
  ],
2502
- "capabilities": {
2503
- "streaming": True,
2504
- "pushNotifications": False, # Not implemented yet
2505
- "stateTransitionHistory": False,
2506
- },
2736
+ "capabilities": capabilities,
2507
2737
  "defaultInputModes": input_modes,
2508
2738
  "defaultOutputModes": output_modes,
2509
2739
  "skills": skills,
@@ -2680,10 +2910,27 @@ async def handle_message_stream(
2680
2910
 
2681
2911
  # Process A2A message parts into LangChain messages format
2682
2912
  try:
2913
+ a2ui = _a2ui_message_context(message, assistant)
2683
2914
  message_role = _normalize_input_role(message.get("role", "ROLE_USER"))
2684
2915
  input_content = _process_a2a_message_parts(
2685
- parts, message_role, message_id
2916
+ parts,
2917
+ message_role,
2918
+ message_id,
2919
+ a2ui=a2ui,
2920
+ )
2921
+ except A2UIValidationError as e:
2922
+ yield (
2923
+ b"message",
2924
+ {
2925
+ "jsonrpc": "2.0",
2926
+ "id": rpc_id,
2927
+ "error": {
2928
+ "code": ERROR_CODE_INVALID_PARAMS,
2929
+ "message": str(e),
2930
+ },
2931
+ },
2686
2932
  )
2933
+ return
2687
2934
  except ValueError as e:
2688
2935
  yield (
2689
2936
  b"message",
@@ -2775,6 +3022,7 @@ async def handle_message_stream(
2775
3022
  input=input_content,
2776
3023
  command=command,
2777
3024
  context=run_context,
3025
+ metadata=_a2ui_run_metadata(assistant, a2ui),
2778
3026
  headers=request.headers,
2779
3027
  )
2780
3028
 
@@ -2825,7 +3073,26 @@ async def handle_message_stream(
2825
3073
  final_parts: list[dict[str, Any]] | None = None
2826
3074
  if isinstance(result, dict):
2827
3075
  try:
2828
- final_parts = _extract_a2a_response(result)
3076
+ final_parts = _extract_a2a_response(
3077
+ result,
3078
+ a2ui=a2ui,
3079
+ )
3080
+ except A2UIValidationError:
3081
+ await logger.aexception(
3082
+ "Agent emitted an invalid A2UI payload"
3083
+ )
3084
+ yield (
3085
+ b"message",
3086
+ {
3087
+ "jsonrpc": "2.0",
3088
+ "id": rpc_id,
3089
+ "error": {
3090
+ "code": ERROR_CODE_INTERNAL_ERROR,
3091
+ "message": "Internal server error",
3092
+ },
3093
+ },
3094
+ )
3095
+ return
2829
3096
  except InvalidAgentResponseError as e:
2830
3097
  yield (
2831
3098
  b"message",
@@ -3031,7 +3298,24 @@ async def handle_message_stream(
3031
3298
  # its plain terminal status-update.
3032
3299
  if fallback_state == "TASK_STATE_COMPLETED" and isinstance(result, dict):
3033
3300
  try:
3034
- fallback_parts = _extract_a2a_response(result)
3301
+ fallback_parts = _extract_a2a_response(
3302
+ result,
3303
+ a2ui=a2ui,
3304
+ )
3305
+ except A2UIValidationError:
3306
+ await logger.aexception("Agent emitted an invalid A2UI payload")
3307
+ yield (
3308
+ b"message",
3309
+ {
3310
+ "jsonrpc": "2.0",
3311
+ "id": rpc_id,
3312
+ "error": {
3313
+ "code": ERROR_CODE_INTERNAL_ERROR,
3314
+ "message": "Internal server error",
3315
+ },
3316
+ },
3317
+ )
3318
+ return
3035
3319
  except InvalidAgentResponseError as e:
3036
3320
  yield (
3037
3321
  b"message",
@@ -3206,6 +3490,12 @@ a2a_routes = [
3206
3490
  handle_a2a_assistant_endpoint,
3207
3491
  methods=["GET", "POST", "DELETE"],
3208
3492
  ),
3493
+ ApiRoute(
3494
+ "/a2a/{assistant_id}/",
3495
+ handle_a2a_assistant_endpoint,
3496
+ methods=["GET", "POST", "DELETE"],
3497
+ include_in_schema=False,
3498
+ ),
3209
3499
  # Per-assistant agent card (multi-tenant pattern)
3210
3500
  ApiRoute(
3211
3501
  "/a2a/{assistant_id}/.well-known/agent-card.json",