langgraph-api 0.4.1__py3-none-any.whl → 0.7.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. langgraph_api/__init__.py +1 -1
  2. langgraph_api/api/__init__.py +111 -51
  3. langgraph_api/api/a2a.py +1610 -0
  4. langgraph_api/api/assistants.py +212 -89
  5. langgraph_api/api/mcp.py +3 -3
  6. langgraph_api/api/meta.py +52 -28
  7. langgraph_api/api/openapi.py +27 -17
  8. langgraph_api/api/profile.py +108 -0
  9. langgraph_api/api/runs.py +342 -195
  10. langgraph_api/api/store.py +19 -2
  11. langgraph_api/api/threads.py +209 -27
  12. langgraph_api/asgi_transport.py +14 -9
  13. langgraph_api/asyncio.py +14 -4
  14. langgraph_api/auth/custom.py +52 -37
  15. langgraph_api/auth/langsmith/backend.py +4 -3
  16. langgraph_api/auth/langsmith/client.py +13 -8
  17. langgraph_api/cli.py +230 -133
  18. langgraph_api/command.py +5 -3
  19. langgraph_api/config/__init__.py +532 -0
  20. langgraph_api/config/_parse.py +58 -0
  21. langgraph_api/config/schemas.py +431 -0
  22. langgraph_api/cron_scheduler.py +17 -1
  23. langgraph_api/encryption/__init__.py +15 -0
  24. langgraph_api/encryption/aes_json.py +158 -0
  25. langgraph_api/encryption/context.py +35 -0
  26. langgraph_api/encryption/custom.py +280 -0
  27. langgraph_api/encryption/middleware.py +632 -0
  28. langgraph_api/encryption/shared.py +63 -0
  29. langgraph_api/errors.py +12 -1
  30. langgraph_api/executor_entrypoint.py +11 -6
  31. langgraph_api/feature_flags.py +29 -0
  32. langgraph_api/graph.py +176 -76
  33. langgraph_api/grpc/client.py +313 -0
  34. langgraph_api/grpc/config_conversion.py +231 -0
  35. langgraph_api/grpc/generated/__init__.py +29 -0
  36. langgraph_api/grpc/generated/checkpointer_pb2.py +63 -0
  37. langgraph_api/grpc/generated/checkpointer_pb2.pyi +99 -0
  38. langgraph_api/grpc/generated/checkpointer_pb2_grpc.py +329 -0
  39. langgraph_api/grpc/generated/core_api_pb2.py +216 -0
  40. langgraph_api/grpc/generated/core_api_pb2.pyi +905 -0
  41. langgraph_api/grpc/generated/core_api_pb2_grpc.py +1621 -0
  42. langgraph_api/grpc/generated/engine_common_pb2.py +219 -0
  43. langgraph_api/grpc/generated/engine_common_pb2.pyi +722 -0
  44. langgraph_api/grpc/generated/engine_common_pb2_grpc.py +24 -0
  45. langgraph_api/grpc/generated/enum_cancel_run_action_pb2.py +37 -0
  46. langgraph_api/grpc/generated/enum_cancel_run_action_pb2.pyi +12 -0
  47. langgraph_api/grpc/generated/enum_cancel_run_action_pb2_grpc.py +24 -0
  48. langgraph_api/grpc/generated/enum_control_signal_pb2.py +37 -0
  49. langgraph_api/grpc/generated/enum_control_signal_pb2.pyi +16 -0
  50. langgraph_api/grpc/generated/enum_control_signal_pb2_grpc.py +24 -0
  51. langgraph_api/grpc/generated/enum_durability_pb2.py +37 -0
  52. langgraph_api/grpc/generated/enum_durability_pb2.pyi +16 -0
  53. langgraph_api/grpc/generated/enum_durability_pb2_grpc.py +24 -0
  54. langgraph_api/grpc/generated/enum_multitask_strategy_pb2.py +37 -0
  55. langgraph_api/grpc/generated/enum_multitask_strategy_pb2.pyi +16 -0
  56. langgraph_api/grpc/generated/enum_multitask_strategy_pb2_grpc.py +24 -0
  57. langgraph_api/grpc/generated/enum_run_status_pb2.py +37 -0
  58. langgraph_api/grpc/generated/enum_run_status_pb2.pyi +22 -0
  59. langgraph_api/grpc/generated/enum_run_status_pb2_grpc.py +24 -0
  60. langgraph_api/grpc/generated/enum_stream_mode_pb2.py +37 -0
  61. langgraph_api/grpc/generated/enum_stream_mode_pb2.pyi +28 -0
  62. langgraph_api/grpc/generated/enum_stream_mode_pb2_grpc.py +24 -0
  63. langgraph_api/grpc/generated/enum_thread_status_pb2.py +37 -0
  64. langgraph_api/grpc/generated/enum_thread_status_pb2.pyi +16 -0
  65. langgraph_api/grpc/generated/enum_thread_status_pb2_grpc.py +24 -0
  66. langgraph_api/grpc/generated/enum_thread_stream_mode_pb2.py +37 -0
  67. langgraph_api/grpc/generated/enum_thread_stream_mode_pb2.pyi +16 -0
  68. langgraph_api/grpc/generated/enum_thread_stream_mode_pb2_grpc.py +24 -0
  69. langgraph_api/grpc/generated/errors_pb2.py +39 -0
  70. langgraph_api/grpc/generated/errors_pb2.pyi +21 -0
  71. langgraph_api/grpc/generated/errors_pb2_grpc.py +24 -0
  72. langgraph_api/grpc/ops/__init__.py +370 -0
  73. langgraph_api/grpc/ops/assistants.py +424 -0
  74. langgraph_api/grpc/ops/runs.py +792 -0
  75. langgraph_api/grpc/ops/threads.py +1013 -0
  76. langgraph_api/http.py +16 -5
  77. langgraph_api/http_metrics.py +15 -35
  78. langgraph_api/http_metrics_utils.py +38 -0
  79. langgraph_api/js/build.mts +1 -1
  80. langgraph_api/js/client.http.mts +13 -7
  81. langgraph_api/js/client.mts +2 -5
  82. langgraph_api/js/package.json +29 -28
  83. langgraph_api/js/remote.py +56 -30
  84. langgraph_api/js/src/graph.mts +20 -0
  85. langgraph_api/js/sse.py +2 -2
  86. langgraph_api/js/ui.py +1 -1
  87. langgraph_api/js/yarn.lock +1204 -1006
  88. langgraph_api/logging.py +29 -2
  89. langgraph_api/metadata.py +99 -28
  90. langgraph_api/middleware/http_logger.py +7 -2
  91. langgraph_api/middleware/private_network.py +7 -7
  92. langgraph_api/models/run.py +54 -93
  93. langgraph_api/otel_context.py +205 -0
  94. langgraph_api/patch.py +5 -3
  95. langgraph_api/queue_entrypoint.py +154 -65
  96. langgraph_api/route.py +47 -5
  97. langgraph_api/schema.py +88 -10
  98. langgraph_api/self_hosted_logs.py +124 -0
  99. langgraph_api/self_hosted_metrics.py +450 -0
  100. langgraph_api/serde.py +79 -37
  101. langgraph_api/server.py +138 -60
  102. langgraph_api/state.py +4 -3
  103. langgraph_api/store.py +25 -16
  104. langgraph_api/stream.py +80 -29
  105. langgraph_api/thread_ttl.py +31 -13
  106. langgraph_api/timing/__init__.py +25 -0
  107. langgraph_api/timing/profiler.py +200 -0
  108. langgraph_api/timing/timer.py +318 -0
  109. langgraph_api/utils/__init__.py +53 -8
  110. langgraph_api/utils/cache.py +47 -10
  111. langgraph_api/utils/config.py +2 -1
  112. langgraph_api/utils/errors.py +77 -0
  113. langgraph_api/utils/future.py +10 -6
  114. langgraph_api/utils/headers.py +76 -2
  115. langgraph_api/utils/retriable_client.py +74 -0
  116. langgraph_api/utils/stream_codec.py +315 -0
  117. langgraph_api/utils/uuids.py +29 -62
  118. langgraph_api/validation.py +9 -0
  119. langgraph_api/webhook.py +120 -6
  120. langgraph_api/worker.py +55 -24
  121. {langgraph_api-0.4.1.dist-info → langgraph_api-0.7.3.dist-info}/METADATA +16 -8
  122. langgraph_api-0.7.3.dist-info/RECORD +168 -0
  123. {langgraph_api-0.4.1.dist-info → langgraph_api-0.7.3.dist-info}/WHEEL +1 -1
  124. langgraph_runtime/__init__.py +1 -0
  125. langgraph_runtime/routes.py +11 -0
  126. logging.json +1 -3
  127. openapi.json +839 -478
  128. langgraph_api/config.py +0 -387
  129. langgraph_api/js/isolate-0x130008000-46649-46649-v8.log +0 -4430
  130. langgraph_api/js/isolate-0x138008000-44681-44681-v8.log +0 -4430
  131. langgraph_api/js/package-lock.json +0 -3308
  132. langgraph_api-0.4.1.dist-info/RECORD +0 -107
  133. /langgraph_api/{utils.py → grpc/__init__.py} +0 -0
  134. {langgraph_api-0.4.1.dist-info → langgraph_api-0.7.3.dist-info}/entry_points.txt +0 -0
  135. {langgraph_api-0.4.1.dist-info → langgraph_api-0.7.3.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,1610 @@
1
+ """Implement A2A (Agent2Agent) endpoint for JSON-RPC 2.0 protocol.
2
+
3
+ The Agent2Agent (A2A) Protocol is an open standard designed to facilitate
4
+ communication and interoperability between independent AI agent systems.
5
+
6
+ A2A Protocol specification:
7
+ https://a2a-protocol.org/dev/specification/
8
+
9
+ The implementation currently supports JSON-RPC 2.0 transport only.
10
+ Push notifications are not implemented.
11
+ """
12
+
13
+ import asyncio
14
+ import functools
15
+ import uuid
16
+ from datetime import UTC, datetime
17
+ from typing import Any, Literal, NotRequired, cast
18
+
19
+ import orjson
20
+ import structlog
21
+ from langgraph_sdk.client import LangGraphClient, get_client
22
+ from starlette.datastructures import Headers
23
+ from starlette.responses import JSONResponse, Response
24
+ from typing_extensions import TypedDict
25
+
26
+ from langgraph_api import __version__
27
+ from langgraph_api.metadata import USER_API_URL
28
+ from langgraph_api.route import ApiRequest, ApiRoute
29
+ from langgraph_api.sse import EventSourceResponse
30
+ from langgraph_api.utils.cache import LRUCache
31
+
32
+ logger = structlog.stdlib.get_logger(__name__)
33
+
34
+ # Cache for assistant schemas (assistant_id -> schemas dict)
35
+ _assistant_schemas_cache = LRUCache[dict[str, Any]](max_size=1000, ttl=60)
36
+
37
+ MAX_HISTORY_LENGTH_REQUESTED = 10
38
+ LANGGRAPH_HISTORY_QUERY_LIMIT = 500
39
+
40
+
41
+ # ============================================================================
42
+ # JSON-RPC 2.0 Base Types (shared with MCP)
43
+ # ============================================================================
44
+
45
+
46
+ class JsonRpcErrorObject(TypedDict):
47
+ code: int
48
+ message: str
49
+ data: NotRequired[Any]
50
+
51
+
52
+ class JsonRpcRequest(TypedDict):
53
+ jsonrpc: Literal["2.0"]
54
+ id: str | int
55
+ method: str
56
+ params: NotRequired[dict[str, Any]]
57
+
58
+
59
+ class JsonRpcResponse(TypedDict):
60
+ jsonrpc: Literal["2.0"]
61
+ id: str | int
62
+ result: NotRequired[dict[str, Any]]
63
+ error: NotRequired[JsonRpcErrorObject]
64
+
65
+
66
+ class JsonRpcNotification(TypedDict):
67
+ jsonrpc: Literal["2.0"]
68
+ method: str
69
+ params: NotRequired[dict[str, Any]]
70
+
71
+
72
+ # ============================================================================
73
+ # A2A Specific Error Codes
74
+ # ============================================================================
75
+
76
+ # Standard JSON-RPC error codes
77
+ ERROR_CODE_PARSE_ERROR = -32700
78
+ ERROR_CODE_INVALID_REQUEST = -32600
79
+ ERROR_CODE_METHOD_NOT_FOUND = -32601
80
+ ERROR_CODE_INVALID_PARAMS = -32602
81
+ ERROR_CODE_INTERNAL_ERROR = -32603
82
+
83
+ # A2A-specific error codes (in server error range -32000 to -32099)
84
+ ERROR_CODE_TASK_NOT_FOUND = -32001
85
+ ERROR_CODE_TASK_NOT_CANCELABLE = -32002
86
+ ERROR_CODE_PUSH_NOTIFICATION_NOT_SUPPORTED = -32003
87
+ ERROR_CODE_UNSUPPORTED_OPERATION = -32004
88
+ ERROR_CODE_CONTENT_TYPE_NOT_SUPPORTED = -32005
89
+ ERROR_CODE_INVALID_AGENT_RESPONSE = -32006
90
+
91
+
92
+ # ============================================================================
93
+ # Constants and Configuration
94
+ # ============================================================================
95
+
96
+ A2A_PROTOCOL_VERSION = "0.3.0"
97
+
98
+
99
+ @functools.lru_cache(maxsize=1)
100
+ def _client() -> LangGraphClient:
101
+ """Get a client for local operations."""
102
+ return get_client(url=None)
103
+
104
+
105
+ async def _get_assistant(
106
+ assistant_id: str, headers: Headers | dict[str, Any] | None
107
+ ) -> dict[str, Any]:
108
+ """Get assistant with proper 404 error handling.
109
+
110
+ Args:
111
+ assistant_id: The assistant ID to get
112
+ headers: Request headers
113
+
114
+ Returns:
115
+ The assistant dictionary
116
+
117
+ Raises:
118
+ ValueError: If assistant not found or other errors
119
+ """
120
+ try:
121
+ return await get_client().assistants.get(assistant_id, headers=headers)
122
+ except Exception as e:
123
+ if (
124
+ hasattr(e, "response")
125
+ and hasattr(e.response, "status_code")
126
+ and e.response.status_code == 404
127
+ ):
128
+ raise ValueError(f"Assistant '{assistant_id}' not found") from e
129
+ raise ValueError(f"Failed to get assistant '{assistant_id}': {e}") from e
130
+
131
+
132
+ async def _validate_supports_messages(
133
+ assistant: dict[str, Any],
134
+ headers: Headers | dict[str, Any] | None,
135
+ parts: list[dict[str, Any]],
136
+ ) -> dict[str, Any]:
137
+ """Validate that assistant supports messages if text parts are present.
138
+
139
+ If the parts contain text parts, the agent must support the 'messages' field.
140
+ If the parts only contain data parts, no validation is performed.
141
+
142
+ Args:
143
+ assistant: The assistant dictionary
144
+ headers: Request headers
145
+ parts: The original A2A message parts
146
+
147
+ Returns:
148
+ The schemas dictionary from the assistant
149
+
150
+ Raises:
151
+ ValueError: If assistant doesn't support messages when text parts are present
152
+ """
153
+ assistant_id = assistant["assistant_id"]
154
+
155
+ cached_schemas = await _assistant_schemas_cache.get(assistant_id)
156
+ if cached_schemas is not None:
157
+ schemas = cached_schemas
158
+ else:
159
+ try:
160
+ schemas = await get_client().assistants.get_schemas(
161
+ assistant_id, headers=headers
162
+ )
163
+ _assistant_schemas_cache.set(assistant_id, schemas)
164
+ except Exception as e:
165
+ raise ValueError(
166
+ f"Failed to get schemas for assistant '{assistant_id}': {e}"
167
+ ) from e
168
+
169
+ # Validate messages field only if there are text parts
170
+ has_text_parts = any(part.get("kind") == "text" for part in parts)
171
+ if has_text_parts:
172
+ input_schema = schemas.get("input_schema")
173
+ if not input_schema:
174
+ raise ValueError(
175
+ f"Assistant '{assistant_id}' has no input schema defined. "
176
+ f"A2A conversational agents using text parts must have an input schema with a 'messages' field."
177
+ )
178
+
179
+ properties = input_schema.get("properties", {})
180
+ if "messages" not in properties:
181
+ graph_id = assistant["graph_id"]
182
+ raise ValueError(
183
+ f"Assistant '{assistant_id}' (graph '{graph_id}') does not support A2A conversational messages. "
184
+ f"Graph input schema must include a 'messages' field to accept text parts. "
185
+ f"Available input fields: {list(properties.keys())}"
186
+ )
187
+
188
+ return schemas
189
+
190
+
191
+ def _process_a2a_message_parts(
192
+ parts: list[dict[str, Any]], message_role: str
193
+ ) -> dict[str, Any]:
194
+ """Convert A2A message parts to LangChain messages format.
195
+
196
+ Args:
197
+ parts: List of A2A message parts
198
+ message_role: A2A message role ("user" or "agent")
199
+
200
+ Returns:
201
+ Input content with messages in LangChain format
202
+
203
+ Raises:
204
+ ValueError: If message parts are invalid
205
+ """
206
+ messages = []
207
+ additional_data = {}
208
+
209
+ for part in parts:
210
+ part_kind = part.get("kind")
211
+
212
+ if part_kind == "text":
213
+ # Text parts become messages with role based on A2A message role
214
+ if "text" not in part:
215
+ raise ValueError("TextPart must contain a 'text' field")
216
+
217
+ # Map A2A role to LangGraph role
218
+ langgraph_role = "human" if message_role == "user" else "assistant"
219
+ messages.append({"role": langgraph_role, "content": part["text"]})
220
+
221
+ elif part_kind == "data":
222
+ # Data parts become structured input parameters
223
+ part_data = part.get("data", {})
224
+ if not isinstance(part_data, dict):
225
+ raise ValueError(
226
+ "DataPart must contain a JSON object in the 'data' field"
227
+ )
228
+ additional_data.update(part_data)
229
+
230
+ else:
231
+ raise ValueError(
232
+ f"Unsupported part kind '{part_kind}'. "
233
+ f"A2A agents support 'text' and 'data' parts only."
234
+ )
235
+
236
+ if not messages and not additional_data:
237
+ raise ValueError("Message must contain at least one valid text or data part")
238
+
239
+ # Create input with messages in LangChain format
240
+ input_content = {}
241
+ if messages:
242
+ input_content["messages"] = messages
243
+ if additional_data:
244
+ input_content.update(additional_data)
245
+
246
+ return input_content
247
+
248
+
249
+ def _extract_a2a_response(result: dict[str, Any]) -> str:
250
+ """Extract the last assistant message from graph execution result.
251
+
252
+ Args:
253
+ result: Graph execution result
254
+
255
+ Returns:
256
+ Content of the last assistant message
257
+
258
+ Raises:
259
+ ValueError: If result doesn't contain messages or is invalid
260
+ """
261
+ if "__error__" in result:
262
+ # Let the caller handle errors
263
+ return str(result)
264
+
265
+ if "messages" not in result:
266
+ # Fallback to the full result if no messages schema. It is not optimal to do A2A on assistants without
267
+ # a messages key, but it is not a hard requirement.
268
+ return str(result)
269
+
270
+ messages = result["messages"]
271
+ if not isinstance(messages, list) or not messages:
272
+ return str(result)
273
+
274
+ # Find the last assistant message
275
+ for message in reversed(messages):
276
+ if (
277
+ isinstance(message, dict)
278
+ and message.get("role") == "assistant"
279
+ and "content" in message
280
+ ) or (message.get("type") == "ai" and "content" in message):
281
+ return message["content"]
282
+
283
+ # If no assistant message found, return the last message content
284
+ last_message = messages[-1]
285
+ if isinstance(last_message, dict):
286
+ return last_message.get("content", str(last_message))
287
+
288
+ return str(last_message)
289
+
290
+
291
+ def _lc_stream_items_to_a2a_message(
292
+ items: list[dict[str, Any]],
293
+ *,
294
+ task_id: str,
295
+ context_id: str,
296
+ role: Literal["agent", "user"] = "agent",
297
+ ) -> dict[str, Any]:
298
+ """Convert LangChain stream "messages/*" items into a valid A2A Message.
299
+
300
+ This takes the list found in a messages/* StreamPart's data field and
301
+ constructs a single A2A Message object, concatenating textual content and
302
+ preserving select structured metadata into a DataPart.
303
+
304
+ Args:
305
+ items: List of LangChain message dicts from stream (e.g., with keys like
306
+ "content", "type", "response_metadata", "tool_calls", etc.)
307
+ task_id: The A2A task ID this message belongs to
308
+ context_id: The A2A context ID (thread) for grouping
309
+ role: A2A role; defaults to "agent" for streamed assistant output
310
+
311
+ Returns:
312
+ A2A Message dict with required fields and minimally valid parts.
313
+ """
314
+ # Aggregate any text content across items
315
+ text_parts: list[str] = []
316
+ # Collect a small amount of structured data for debugging/traceability
317
+ extra_data: dict[str, Any] = {}
318
+
319
+ def _sse_safe_text(s: str) -> str:
320
+ return s.replace("\u2028", "\\u2028").replace("\u2029", "\\u2029")
321
+
322
+ for it in items:
323
+ if not isinstance(it, dict):
324
+ continue
325
+ content = it.get("content")
326
+ if isinstance(content, str) and content:
327
+ text_parts.append(_sse_safe_text(content))
328
+
329
+ # Preserve a couple of useful fields if present
330
+ # Keep this small to avoid bloating the message payload
331
+ rm = it.get("response_metadata")
332
+ if isinstance(rm, dict) and rm:
333
+ extra_data.setdefault("response_metadata", rm)
334
+ tc = it.get("tool_calls")
335
+ if isinstance(tc, list) and tc:
336
+ extra_data.setdefault("tool_calls", tc)
337
+
338
+ parts: list[dict[str, Any]] = []
339
+ if text_parts:
340
+ parts.append({"kind": "text", "text": "".join(text_parts)})
341
+ if extra_data:
342
+ parts.append({"kind": "data", "data": extra_data})
343
+
344
+ # Ensure we always produce a minimally valid A2A Message
345
+ if not parts:
346
+ parts = [{"kind": "text", "text": ""}]
347
+
348
+ return {
349
+ "role": role,
350
+ "parts": parts,
351
+ "messageId": str(uuid.uuid4()),
352
+ "taskId": task_id,
353
+ "contextId": context_id,
354
+ "kind": "message",
355
+ }
356
+
357
+
358
+ def _lc_items_to_status_update_event(
359
+ items: list[dict[str, Any]],
360
+ *,
361
+ task_id: str,
362
+ context_id: str,
363
+ state: str = "working",
364
+ ) -> dict[str, Any]:
365
+ """Build a TaskStatusUpdateEvent embedding a converted A2A Message.
366
+
367
+ This avoids emitting standalone Message results (which some clients reject)
368
+ and keeps message content within the status update per spec.
369
+ """
370
+ message = _lc_stream_items_to_a2a_message(
371
+ items, task_id=task_id, context_id=context_id, role="agent"
372
+ )
373
+ return {
374
+ "taskId": task_id,
375
+ "contextId": context_id,
376
+ "kind": "status-update",
377
+ "status": {
378
+ "state": state,
379
+ "message": message,
380
+ "timestamp": datetime.now(UTC).isoformat(),
381
+ },
382
+ "final": False,
383
+ }
384
+
385
+
386
+ def _map_runs_create_error_to_rpc(
387
+ exception: Exception, assistant_id: str, thread_id: str | None = None
388
+ ) -> dict[str, Any]:
389
+ """Map runs.create() exceptions to A2A JSON-RPC error responses.
390
+
391
+ Args:
392
+ exception: Exception from runs.create()
393
+ assistant_id: The assistant ID that was used
394
+ thread_id: The thread ID that was used (if any)
395
+
396
+ Returns:
397
+ A2A error response dictionary
398
+ """
399
+ if hasattr(exception, "response") and hasattr(exception.response, "status_code"):
400
+ status_code = exception.response.status_code
401
+ error_text = str(exception)
402
+
403
+ if status_code == 404:
404
+ # Check if it's a thread or assistant not found
405
+ if "thread" in error_text.lower() or "Thread" in error_text:
406
+ return {
407
+ "error": {
408
+ "code": ERROR_CODE_INVALID_PARAMS,
409
+ "message": f"Thread '{thread_id}' not found. Please create the thread first before sending messages to it.",
410
+ "data": {
411
+ "thread_id": thread_id,
412
+ "error_type": "thread_not_found",
413
+ },
414
+ }
415
+ }
416
+ else:
417
+ return {
418
+ "error": {
419
+ "code": ERROR_CODE_INVALID_PARAMS,
420
+ "message": f"Assistant '{assistant_id}' not found",
421
+ }
422
+ }
423
+ elif status_code == 400:
424
+ return {
425
+ "error": {
426
+ "code": ERROR_CODE_INVALID_PARAMS,
427
+ "message": f"Invalid request: {error_text}",
428
+ }
429
+ }
430
+ elif status_code == 403:
431
+ return {
432
+ "error": {
433
+ "code": ERROR_CODE_INVALID_PARAMS,
434
+ "message": "Access denied to assistant or thread",
435
+ }
436
+ }
437
+ else:
438
+ return {
439
+ "error": {
440
+ "code": ERROR_CODE_INVALID_PARAMS,
441
+ "message": f"Failed to create run: {error_text}",
442
+ }
443
+ }
444
+
445
+ return {
446
+ "error": {
447
+ "code": ERROR_CODE_INTERNAL_ERROR,
448
+ "message": f"Internal server error: {exception!s}",
449
+ }
450
+ }
451
+
452
+
453
+ def _map_runs_get_error_to_rpc(
454
+ exception: Exception, task_id: str, thread_id: str
455
+ ) -> dict[str, Any]:
456
+ """Map runs.get() exceptions to A2A JSON-RPC error responses.
457
+
458
+ Args:
459
+ exception: Exception from runs.get()
460
+ task_id: The task/run ID that was requested
461
+ thread_id: The thread ID that was requested
462
+
463
+ Returns:
464
+ A2A error response dictionary
465
+ """
466
+ if hasattr(exception, "response") and hasattr(exception.response, "status_code"):
467
+ status_code = exception.response.status_code
468
+ error_text = str(exception)
469
+
470
+ status_code_handlers = {
471
+ 404: {
472
+ "error": {
473
+ "code": ERROR_CODE_TASK_NOT_FOUND,
474
+ "message": f"Task '{task_id}' not found in thread '{thread_id}'",
475
+ }
476
+ },
477
+ 400: {
478
+ "error": {
479
+ "code": ERROR_CODE_INVALID_PARAMS,
480
+ "message": f"Invalid request: {error_text}",
481
+ }
482
+ },
483
+ 403: {
484
+ "error": {
485
+ "code": ERROR_CODE_INVALID_PARAMS,
486
+ "message": "Access denied to task",
487
+ }
488
+ },
489
+ }
490
+
491
+ return status_code_handlers.get(
492
+ status_code,
493
+ {
494
+ "error": {
495
+ "code": ERROR_CODE_INVALID_PARAMS,
496
+ "message": f"Failed to get task: {error_text}",
497
+ }
498
+ },
499
+ )
500
+
501
+ return {
502
+ "error": {
503
+ "code": ERROR_CODE_INTERNAL_ERROR,
504
+ "message": f"Internal server error: {exception!s}",
505
+ }
506
+ }
507
+
508
+
509
+ def _convert_messages_to_a2a_format(
510
+ messages: list[dict[str, Any]],
511
+ task_id: str,
512
+ context_id: str,
513
+ ) -> list[dict[str, Any]]:
514
+ """Convert LangChain messages to A2A message format.
515
+
516
+ Args:
517
+ messages: List of LangChain messages
518
+ task_id: The task ID to assign to all messages
519
+ context_id: The context ID to assign to all messages
520
+
521
+ Returns:
522
+ List of A2A messages
523
+ """
524
+
525
+ # Convert each LangChain message to A2A format
526
+ a2a_messages = []
527
+ for msg in messages:
528
+ if isinstance(msg, dict):
529
+ msg_type = msg.get("type", "ai")
530
+ msg_role = msg.get("role", "")
531
+ content = msg.get("content", "")
532
+
533
+ # Support both LangChain style (type: "human"/"ai") and OpenAI style (role: "user"/"assistant")
534
+ # Map to A2A roles: "human"/"user" -> "user", everything else -> "agent"
535
+ a2a_role = "user" if msg_type == "human" or msg_role == "user" else "agent"
536
+
537
+ a2a_message = {
538
+ "role": a2a_role,
539
+ "parts": [{"kind": "text", "text": str(content)}],
540
+ "messageId": str(uuid.uuid4()),
541
+ "taskId": task_id,
542
+ "contextId": context_id,
543
+ "kind": "message",
544
+ }
545
+ a2a_messages.append(a2a_message)
546
+
547
+ return a2a_messages
548
+
549
+
550
+ async def _create_task_response(
551
+ task_id: str,
552
+ context_id: str,
553
+ result: dict[str, Any],
554
+ assistant_id: str,
555
+ ) -> dict[str, Any]:
556
+ """Create A2A Task response structure for both success and failure cases.
557
+
558
+ Args:
559
+ task_id: The task/run ID
560
+ context_id: The context/thread ID
561
+ message: Original A2A message from request
562
+ result: LangGraph execution result
563
+ assistant_id: The assistant ID used
564
+ headers: Request headers
565
+
566
+ Returns:
567
+ A2A Task response dictionary
568
+ """
569
+ # Convert result messages to A2A message format
570
+ messages = result.get("messages", []) or []
571
+ thread_history = _convert_messages_to_a2a_format(messages, task_id, context_id)
572
+
573
+ base_task = {
574
+ "id": task_id,
575
+ "contextId": context_id,
576
+ "history": thread_history,
577
+ "kind": "task",
578
+ }
579
+
580
+ if "__error__" in result:
581
+ base_task["status"] = {
582
+ "state": "failed",
583
+ "message": {
584
+ "role": "agent",
585
+ "parts": [
586
+ {
587
+ "kind": "text",
588
+ "text": f"Error executing assistant: {result['__error__']['error']}",
589
+ }
590
+ ],
591
+ "messageId": str(uuid.uuid4()),
592
+ "taskId": task_id,
593
+ "contextId": context_id,
594
+ "kind": "message",
595
+ },
596
+ }
597
+ else:
598
+ artifact_id = str(uuid.uuid4())
599
+ artifacts = [
600
+ {
601
+ "artifactId": artifact_id,
602
+ "name": "Assistant Response",
603
+ "description": f"Response from assistant {assistant_id}",
604
+ "parts": [
605
+ {
606
+ "kind": "text",
607
+ "text": _extract_a2a_response(result),
608
+ }
609
+ ],
610
+ }
611
+ ]
612
+
613
+ base_task["status"] = {
614
+ "state": "completed",
615
+ "timestamp": datetime.now(UTC).isoformat(),
616
+ }
617
+ base_task["artifacts"] = artifacts
618
+
619
+ return {"result": base_task}
620
+
621
+
622
+ # ============================================================================
623
+ # Main A2A Endpoint Handler
624
+ # ============================================================================
625
+
626
+
627
+ def handle_get_request() -> Response:
628
+ """Handle HTTP GET requests (streaming not currently supported).
629
+
630
+ Returns:
631
+ 405 Method Not Allowed
632
+ """
633
+ return Response(status_code=405)
634
+
635
+
636
+ def handle_delete_request() -> Response:
637
+ """Handle HTTP DELETE requests (session termination not currently supported).
638
+
639
+ Returns:
640
+ 404 Not Found
641
+ """
642
+ return Response(status_code=405)
643
+
644
+
645
+ async def handle_post_request(request: ApiRequest, assistant_id: str) -> Response:
646
+ """Handle HTTP POST requests containing JSON-RPC messages.
647
+
648
+ Args:
649
+ request: The incoming HTTP request
650
+ assistant_id: The assistant ID from the URL path
651
+
652
+ Returns:
653
+ JSON-RPC response
654
+ """
655
+ body = await request.body()
656
+
657
+ try:
658
+ message = orjson.loads(body)
659
+ except orjson.JSONDecodeError:
660
+ return create_error_response("Invalid JSON payload", 400)
661
+
662
+ if not isinstance(message, dict):
663
+ return create_error_response("Invalid message format", 400)
664
+
665
+ if message.get("jsonrpc") != "2.0":
666
+ return create_error_response(
667
+ "Invalid JSON-RPC message. Missing or invalid jsonrpc version", 400
668
+ )
669
+
670
+ # Route based on message type
671
+ id_ = message.get("id")
672
+ method = message.get("method")
673
+
674
+ accept_header = request.headers.get("Accept") or ""
675
+ if method == "message/stream":
676
+ if not _accepts_media_type(accept_header, "text/event-stream"):
677
+ return create_error_response(
678
+ "Accept header must include text/event-stream for streaming", 400
679
+ )
680
+ else:
681
+ if not _accepts_media_type(accept_header, "application/json"):
682
+ return create_error_response(
683
+ "Accept header must include application/json", 400
684
+ )
685
+
686
+ if id_ is not None and method:
687
+ # JSON-RPC request
688
+ return await handle_jsonrpc_request(
689
+ request, cast("JsonRpcRequest", message), assistant_id
690
+ )
691
+ elif id_ is not None:
692
+ # JSON-RPC response (not expected in A2A server context)
693
+ return handle_jsonrpc_response()
694
+ elif method:
695
+ # JSON-RPC notification
696
+ return handle_jsonrpc_notification(cast("JsonRpcNotification", message))
697
+ else:
698
+ return create_error_response(
699
+ "Invalid message format. Message must be a JSON-RPC request, "
700
+ "response, or notification",
701
+ 400,
702
+ )
703
+
704
+
705
+ def create_error_response(message: str, status_code: int) -> Response:
706
+ """Create a JSON error response.
707
+
708
+ Args:
709
+ message: Error message
710
+ status_code: HTTP status code
711
+
712
+ Returns:
713
+ JSON error response
714
+ """
715
+ return Response(
716
+ content=orjson.dumps({"error": message}),
717
+ status_code=status_code,
718
+ media_type="application/json",
719
+ )
720
+
721
+
722
+ def _accepts_media_type(accept_header: str, media_type: str) -> bool:
723
+ """Return True if the Accept header allows the provided media type."""
724
+ if not accept_header:
725
+ return False
726
+
727
+ target = media_type.lower()
728
+ for media_range in accept_header.split(","):
729
+ value = media_range.strip().lower()
730
+ if not value:
731
+ continue
732
+ candidate = value.split(";", 1)[0].strip()
733
+ if candidate == "*/*" or candidate == target:
734
+ return True
735
+ if candidate.endswith("/*"):
736
+ type_prefix = candidate.split("/", 1)[0]
737
+ if target.startswith(f"{type_prefix}/"):
738
+ return True
739
+ return False
740
+
741
+
742
+ # ============================================================================
743
+ # JSON-RPC Message Handlers
744
+ # ============================================================================
745
+
746
+
747
+ async def handle_jsonrpc_request(
748
+ request: ApiRequest, message: JsonRpcRequest, assistant_id: str
749
+ ) -> Response:
750
+ """Handle JSON-RPC requests with A2A methods.
751
+
752
+ Args:
753
+ request: The HTTP request
754
+ message: Parsed JSON-RPC request
755
+ assistant_id: The assistant ID from the URL path
756
+
757
+ Returns:
758
+ JSON-RPC response
759
+ """
760
+ method = message["method"]
761
+ params = message.get("params", {})
762
+ # Route to appropriate A2A method handler
763
+ if method == "message/stream":
764
+ return await handle_message_stream(request, params, assistant_id, message["id"])
765
+ elif method == "message/send":
766
+ result_or_error = await handle_message_send(request, params, assistant_id)
767
+ elif method == "tasks/get":
768
+ result_or_error = await handle_tasks_get(request, params)
769
+ elif method == "tasks/cancel":
770
+ result_or_error = await handle_tasks_cancel(request, params)
771
+ else:
772
+ result_or_error = {
773
+ "error": {
774
+ "code": ERROR_CODE_METHOD_NOT_FOUND,
775
+ "message": f"Method not found: {method}",
776
+ }
777
+ }
778
+
779
+ response_keys = set(result_or_error.keys())
780
+ if not (response_keys == {"result"} or response_keys == {"error"}):
781
+ raise AssertionError(
782
+ "Internal server error. Invalid response format in A2A implementation"
783
+ )
784
+
785
+ return JSONResponse(
786
+ {
787
+ "jsonrpc": "2.0",
788
+ "id": message["id"],
789
+ **result_or_error,
790
+ }
791
+ )
792
+
793
+
794
+ def handle_jsonrpc_response() -> Response:
795
+ """Handle JSON-RPC responses (not expected in server context).
796
+
797
+ Args:
798
+ message: Parsed JSON-RPC response
799
+
800
+ Returns:
801
+ 202 Accepted acknowledgement
802
+ """
803
+ return Response(status_code=202)
804
+
805
+
806
+ def handle_jsonrpc_notification(message: JsonRpcNotification) -> Response:
807
+ """Handle JSON-RPC notifications.
808
+
809
+ Args:
810
+ message: Parsed JSON-RPC notification
811
+
812
+ Returns:
813
+ 202 Accepted acknowledgement
814
+ """
815
+ return Response(status_code=202)
816
+
817
+
818
+ # ============================================================================
819
+ # A2A Method Implementations
820
+ # ============================================================================
821
+
822
+
823
+ async def handle_message_send(
824
+ request: ApiRequest, params: dict[str, Any], assistant_id: str
825
+ ) -> dict[str, Any]:
826
+ """Handle message/send requests to create or continue tasks.
827
+
828
+ This method:
829
+ 1. Accepts A2A Messages containing text/file/data parts
830
+ 2. Maps to LangGraph assistant execution
831
+ 3. Returns Task objects with status and results
832
+
833
+ Args:
834
+ request: HTTP request for auth/headers
835
+ params: A2A MessageSendParams
836
+ assistant_id: The target assistant ID from the URL
837
+
838
+ Returns:
839
+ {"result": Task} or {"error": JsonRpcErrorObject}
840
+ """
841
+ client = _client()
842
+
843
+ try:
844
+ message = params.get("message")
845
+ if not message:
846
+ return {
847
+ "error": {
848
+ "code": ERROR_CODE_INVALID_PARAMS,
849
+ "message": "Missing 'message' in params",
850
+ }
851
+ }
852
+
853
+ parts = message.get("parts", [])
854
+ if not parts:
855
+ return {
856
+ "error": {
857
+ "code": ERROR_CODE_INVALID_PARAMS,
858
+ "message": "Message must contain at least one part",
859
+ }
860
+ }
861
+
862
+ try:
863
+ assistant = await _get_assistant(assistant_id, request.headers)
864
+ await _validate_supports_messages(assistant, request.headers, parts)
865
+ except ValueError as e:
866
+ return {
867
+ "error": {
868
+ "code": ERROR_CODE_INVALID_PARAMS,
869
+ "message": str(e),
870
+ }
871
+ }
872
+
873
+ # Process A2A message parts into LangChain messages format
874
+ try:
875
+ message_role = message.get(
876
+ "role", "user"
877
+ ) # Default to "user" if role not specified
878
+ input_content = _process_a2a_message_parts(parts, message_role)
879
+ except ValueError as e:
880
+ return {
881
+ "error": {
882
+ "code": ERROR_CODE_CONTENT_TYPE_NOT_SUPPORTED,
883
+ "message": str(e),
884
+ }
885
+ }
886
+
887
+ context_id = message.get("contextId")
888
+
889
+ # If no contextId provided, generate a UUID so we don't pass None to runs.create
890
+ if context_id is None:
891
+ context_id = str(uuid.uuid4())
892
+
893
+ try:
894
+ run = await client.runs.create(
895
+ thread_id=context_id,
896
+ assistant_id=assistant_id,
897
+ input=input_content,
898
+ if_not_exists="create",
899
+ headers=request.headers,
900
+ )
901
+ except Exception as e:
902
+ error_response = _map_runs_create_error_to_rpc(e, assistant_id, context_id)
903
+ if error_response.get("error", {}).get("code") == ERROR_CODE_INTERNAL_ERROR:
904
+ raise
905
+ return error_response
906
+
907
+ result = await client.runs.join(
908
+ thread_id=run["thread_id"],
909
+ run_id=run["run_id"],
910
+ headers=request.headers,
911
+ )
912
+
913
+ task_id = run["run_id"]
914
+ context_id = run["thread_id"]
915
+
916
+ return await _create_task_response(
917
+ task_id=task_id,
918
+ context_id=context_id,
919
+ result=result,
920
+ assistant_id=assistant_id,
921
+ )
922
+
923
+ except Exception as e:
924
+ logger.exception(f"Error in message/send for assistant {assistant_id}")
925
+ return {
926
+ "error": {
927
+ "code": ERROR_CODE_INTERNAL_ERROR,
928
+ "message": f"Internal server error: {e!s}",
929
+ }
930
+ }
931
+
932
+
933
+ async def _get_historical_messages_for_task(
934
+ context_id: str,
935
+ task_run_id: str,
936
+ request_headers: Headers,
937
+ history_length: int | None = None,
938
+ ) -> list[Any]:
939
+ """Get historical messages for a specific task by matching run_id."""
940
+ history = await get_client().threads.get_history(
941
+ context_id,
942
+ limit=LANGGRAPH_HISTORY_QUERY_LIMIT,
943
+ metadata={"run_id": task_run_id},
944
+ headers=request_headers,
945
+ )
946
+
947
+ if history:
948
+ # Find the checkpoint with the highest step number (final state for this task)
949
+ target_checkpoint = max(
950
+ history, key=lambda c: c.get("metadata", {}).get("step", 0)
951
+ )
952
+ values = target_checkpoint["values"]
953
+ messages = values.get("messages", [])
954
+
955
+ # Apply client-requested history length limit per A2A spec
956
+ if history_length is not None and len(messages) > history_length:
957
+ # Return the most recent messages up to the limit
958
+ messages = messages[-history_length:]
959
+ return messages
960
+ else:
961
+ return []
962
+
963
+
964
+ async def handle_tasks_get(
965
+ request: ApiRequest, params: dict[str, Any]
966
+ ) -> dict[str, Any]:
967
+ """Handle tasks/get requests to retrieve task status.
968
+
969
+ This method:
970
+ 1. Accepts task ID from params
971
+ 2. Maps to LangGraph run/thread status
972
+ 3. Returns current Task state and results
973
+
974
+ Args:
975
+ request: HTTP request for auth/headers
976
+ params: A2A TaskQueryParams containing task ID
977
+
978
+ Returns:
979
+ {"result": Task} or {"error": JsonRpcErrorObject}
980
+ """
981
+ client = _client()
982
+
983
+ try:
984
+ task_id = params.get("id")
985
+ context_id = params.get("contextId")
986
+ history_length = params.get("historyLength")
987
+
988
+ if not task_id:
989
+ return {
990
+ "error": {
991
+ "code": ERROR_CODE_INVALID_PARAMS,
992
+ "message": "Missing required parameter: id (task_id)",
993
+ }
994
+ }
995
+
996
+ if not context_id:
997
+ return {
998
+ "error": {
999
+ "code": ERROR_CODE_INVALID_PARAMS,
1000
+ "message": "Missing required parameter: contextId (thread_id)",
1001
+ }
1002
+ }
1003
+
1004
+ # Validate history_length parameter per A2A spec
1005
+ if history_length is not None:
1006
+ if not isinstance(history_length, int) or history_length < 0:
1007
+ return {
1008
+ "error": {
1009
+ "code": ERROR_CODE_INVALID_PARAMS,
1010
+ "message": "historyLength must be a non-negative integer",
1011
+ }
1012
+ }
1013
+ if history_length > MAX_HISTORY_LENGTH_REQUESTED:
1014
+ return {
1015
+ "error": {
1016
+ "code": ERROR_CODE_INVALID_PARAMS,
1017
+ "message": f"historyLength cannot exceed {MAX_HISTORY_LENGTH_REQUESTED}",
1018
+ }
1019
+ }
1020
+
1021
+ try:
1022
+ # TODO: fix the N+1 query issue
1023
+ run_info, thread_info = await asyncio.gather(
1024
+ client.runs.get(
1025
+ thread_id=context_id,
1026
+ run_id=task_id,
1027
+ headers=request.headers,
1028
+ ),
1029
+ client.threads.get(
1030
+ thread_id=context_id,
1031
+ headers=request.headers,
1032
+ ),
1033
+ )
1034
+ except Exception as e:
1035
+ error_response = _map_runs_get_error_to_rpc(e, task_id, context_id)
1036
+ if error_response.get("error", {}).get("code") == ERROR_CODE_INTERNAL_ERROR:
1037
+ # For unmapped errors, re-raise to be caught by outer exception handler
1038
+ raise
1039
+ return error_response
1040
+
1041
+ lg_status = run_info.get("status", "unknown")
1042
+
1043
+ if lg_status == "pending":
1044
+ a2a_state = "submitted"
1045
+ elif lg_status == "running":
1046
+ a2a_state = "working"
1047
+ elif lg_status == "success":
1048
+ # Hack hack: if the thread **at present** is interrupted, assume
1049
+ # the run also is interrupted
1050
+ if thread_info.get("status") == "interrupted":
1051
+ a2a_state = "input-required"
1052
+ else:
1053
+ # Inspect whether there are next tasks
1054
+ a2a_state = "completed"
1055
+ elif (
1056
+ lg_status == "interrupted"
1057
+ ): # Note that this is if you interrupt FROM the outside (i.e., with double texting)
1058
+ a2a_state = "input-required"
1059
+ elif lg_status in ["error", "timeout"]:
1060
+ a2a_state = "failed"
1061
+ else:
1062
+ a2a_state = "submitted"
1063
+
1064
+ try:
1065
+ task_run_id = run_info.get("run_id")
1066
+ messages = await _get_historical_messages_for_task(
1067
+ context_id, task_run_id, request.headers, history_length
1068
+ )
1069
+ thread_history = _convert_messages_to_a2a_format(
1070
+ messages, task_id, context_id
1071
+ )
1072
+ except Exception as e:
1073
+ await logger.aexception(f"Failed to get thread state for tasks/get: {e}")
1074
+ thread_history = []
1075
+
1076
+ # Build the A2A Task response
1077
+ task_response = {
1078
+ "id": task_id,
1079
+ "contextId": context_id,
1080
+ "history": thread_history,
1081
+ "kind": "task",
1082
+ "status": {
1083
+ "state": a2a_state,
1084
+ },
1085
+ }
1086
+
1087
+ # Add result message if completed
1088
+ if a2a_state == "completed":
1089
+ task_response["status"]["message"] = {
1090
+ "role": "agent",
1091
+ "parts": [{"kind": "text", "text": "Task completed successfully"}],
1092
+ "messageId": str(uuid.uuid4()),
1093
+ "taskId": task_id,
1094
+ }
1095
+ elif a2a_state == "failed":
1096
+ task_response["status"]["message"] = {
1097
+ "role": "agent",
1098
+ "parts": [
1099
+ {"kind": "text", "text": f"Task failed with status: {lg_status}"}
1100
+ ],
1101
+ "messageId": str(uuid.uuid4()),
1102
+ "taskId": task_id,
1103
+ }
1104
+
1105
+ return {"result": task_response}
1106
+
1107
+ except Exception as e:
1108
+ await logger.aerror(
1109
+ f"Error in tasks/get for task {params.get('id')}: {e!s}", exc_info=True
1110
+ )
1111
+ return {
1112
+ "error": {
1113
+ "code": ERROR_CODE_INTERNAL_ERROR,
1114
+ "message": f"Internal server error: {e!s}",
1115
+ }
1116
+ }
1117
+
1118
+
1119
+ async def handle_tasks_cancel(
1120
+ request: ApiRequest, params: dict[str, Any]
1121
+ ) -> dict[str, Any]:
1122
+ """Handle tasks/cancel requests to cancel running tasks.
1123
+
1124
+ This method:
1125
+ 1. Accepts task ID from params
1126
+ 2. Maps to LangGraph run cancellation
1127
+ 3. Returns updated Task with canceled state
1128
+
1129
+ Args:
1130
+ request: HTTP request for auth/headers
1131
+ params: A2A TaskIdParams containing task ID
1132
+
1133
+ Returns:
1134
+ {"result": Task} or {"error": JsonRpcErrorObject}
1135
+ """
1136
+ # TODO: Implement tasks/cancel
1137
+ # - Extract task_id from params
1138
+ # - Map task_id to run_id
1139
+ # - Cancel run via client if possible
1140
+ # - Return updated Task with canceled status
1141
+
1142
+ return {
1143
+ "error": {
1144
+ "code": ERROR_CODE_UNSUPPORTED_OPERATION,
1145
+ "message": "Task cancellation is not currently supported",
1146
+ }
1147
+ }
1148
+
1149
+
1150
+ # ============================================================================
1151
+ # Agent Card Generation
1152
+ # ============================================================================
1153
+
1154
+
1155
+ # TODO: add routes for /a2a/agents/{id}/card
1156
+ async def generate_agent_card(request: ApiRequest, assistant_id: str) -> dict[str, Any]:
1157
+ """Generate A2A Agent Card for a specific assistant.
1158
+
1159
+ Each LangGraph assistant becomes its own A2A agent with a dedicated
1160
+ agent card describing its individual capabilities and skills.
1161
+
1162
+ Args:
1163
+ request: HTTP request for auth/headers
1164
+ assistant_id: The specific assistant ID to generate card for
1165
+
1166
+ Returns:
1167
+ A2A AgentCard dictionary for the specific assistant
1168
+ """
1169
+ client = _client()
1170
+
1171
+ assistant = await _get_assistant(assistant_id, request.headers)
1172
+ schemas = await client.assistants.get_schemas(assistant_id, headers=request.headers)
1173
+
1174
+ # Extract schema information for metadata
1175
+ input_schema = schemas.get("input_schema", {})
1176
+ properties = input_schema.get("properties", {})
1177
+ required = input_schema.get("required", [])
1178
+
1179
+ assistant_name = assistant["name"]
1180
+ assistant_description = (
1181
+ assistant.get("description") or f"{assistant_name} assistant"
1182
+ )
1183
+
1184
+ # For now, each assistant has one main skill - itself
1185
+ skills = [
1186
+ {
1187
+ "id": f"{assistant_id}-main",
1188
+ "name": f"{assistant_name} Capabilities",
1189
+ "description": assistant_description,
1190
+ "tags": ["assistant", "langgraph"],
1191
+ "examples": [],
1192
+ "inputModes": ["application/json", "text/plain"],
1193
+ "outputModes": ["application/json", "text/plain"],
1194
+ "metadata": {
1195
+ "inputSchema": {
1196
+ "required": required,
1197
+ "properties": sorted(properties.keys()),
1198
+ "supportsA2A": "messages" in properties,
1199
+ }
1200
+ },
1201
+ }
1202
+ ]
1203
+
1204
+ if USER_API_URL:
1205
+ base_url = USER_API_URL.rstrip("/")
1206
+ else:
1207
+ # Fallback to constructing from request
1208
+ scheme = request.url.scheme
1209
+ host = request.url.hostname or "localhost"
1210
+ port = request.url.port
1211
+ path = request.url.path.removesuffix("/.well-known/agent-card.json")
1212
+ if port and (
1213
+ (scheme == "http" and port != 80) or (scheme == "https" and port != 443)
1214
+ ):
1215
+ base_url = f"{scheme}://{host}:{port}{path}"
1216
+ else:
1217
+ base_url = f"{scheme}://{host}"
1218
+
1219
+ return {
1220
+ "protocolVersion": A2A_PROTOCOL_VERSION,
1221
+ "name": assistant_name,
1222
+ "description": assistant_description,
1223
+ "url": f"{base_url}/a2a/{assistant_id}",
1224
+ "preferredTransport": "JSONRPC",
1225
+ "capabilities": {
1226
+ "streaming": True,
1227
+ "pushNotifications": False, # Not implemented yet
1228
+ "stateTransitionHistory": False,
1229
+ },
1230
+ "defaultInputModes": ["application/json", "text/plain"],
1231
+ "defaultOutputModes": ["application/json", "text/plain"],
1232
+ "skills": skills,
1233
+ "version": __version__,
1234
+ }
1235
+
1236
+
1237
+ async def handle_agent_card_endpoint(request: ApiRequest) -> Response:
1238
+ """Serve Agent Card for a specific assistant.
1239
+
1240
+ Expected URL: /.well-known/agent-card.json?assistant_id=uuid
1241
+
1242
+ Args:
1243
+ request: HTTP request
1244
+
1245
+ Returns:
1246
+ JSON response with Agent Card for the specific assistant
1247
+ """
1248
+ try:
1249
+ # Get assistant_id from query parameters
1250
+ assistant_id = request.query_params.get("assistant_id")
1251
+
1252
+ if not assistant_id:
1253
+ error_response = {
1254
+ "error": {
1255
+ "code": ERROR_CODE_INVALID_PARAMS,
1256
+ "message": "Missing required query parameter: assistant_id",
1257
+ }
1258
+ }
1259
+ return Response(
1260
+ content=orjson.dumps(error_response),
1261
+ status_code=400,
1262
+ media_type="application/json",
1263
+ )
1264
+
1265
+ agent_card = await generate_agent_card(request, assistant_id)
1266
+ return JSONResponse(agent_card)
1267
+
1268
+ except ValueError as e:
1269
+ # A2A validation error or assistant not found
1270
+ error_response = {
1271
+ "error": {
1272
+ "code": ERROR_CODE_INVALID_PARAMS,
1273
+ "message": str(e),
1274
+ }
1275
+ }
1276
+ return Response(
1277
+ content=orjson.dumps(error_response),
1278
+ status_code=400,
1279
+ media_type="application/json",
1280
+ )
1281
+ except Exception as e:
1282
+ logger.exception("Failed to generate agent card")
1283
+ error_response = {
1284
+ "error": {
1285
+ "code": ERROR_CODE_INTERNAL_ERROR,
1286
+ "message": f"Internal server error: {e!s}",
1287
+ }
1288
+ }
1289
+ return Response(
1290
+ content=orjson.dumps(error_response),
1291
+ status_code=500,
1292
+ media_type="application/json",
1293
+ )
1294
+
1295
+
1296
+ # ============================================================================
1297
+ # Message Streaming
1298
+ # ============================================================================
1299
+
1300
+
1301
+ async def handle_message_stream(
1302
+ request: ApiRequest,
1303
+ params: dict[str, Any],
1304
+ assistant_id: str,
1305
+ rpc_id: str | int,
1306
+ ) -> Response:
1307
+ """Handle message/stream requests and stream JSON-RPC responses via SSE.
1308
+
1309
+ Each SSE "data" is a JSON-RPC 2.0 response object. We emit:
1310
+ - An initial TaskStatusUpdateEvent with state "submitted".
1311
+ - Optionally a TaskStatusUpdateEvent with state "working" on first update.
1312
+ - A final Task result when the run completes.
1313
+ - A JSON-RPC error if anything fails.
1314
+ """
1315
+ client = _client()
1316
+
1317
+ async def stream_body():
1318
+ try:
1319
+ message = params.get("message")
1320
+ if not message:
1321
+ yield (
1322
+ b"message",
1323
+ {
1324
+ "jsonrpc": "2.0",
1325
+ "id": rpc_id,
1326
+ "error": {
1327
+ "code": ERROR_CODE_INVALID_PARAMS,
1328
+ "message": "Missing 'message' in params",
1329
+ },
1330
+ },
1331
+ )
1332
+ return
1333
+
1334
+ parts = message.get("parts", [])
1335
+ if not parts:
1336
+ yield (
1337
+ b"message",
1338
+ {
1339
+ "jsonrpc": "2.0",
1340
+ "id": rpc_id,
1341
+ "error": {
1342
+ "code": ERROR_CODE_INVALID_PARAMS,
1343
+ "message": "Message must contain at least one part",
1344
+ },
1345
+ },
1346
+ )
1347
+ return
1348
+
1349
+ try:
1350
+ assistant = await _get_assistant(assistant_id, request.headers)
1351
+ await _validate_supports_messages(assistant, request.headers, parts)
1352
+ except ValueError as e:
1353
+ yield (
1354
+ b"message",
1355
+ {
1356
+ "jsonrpc": "2.0",
1357
+ "id": rpc_id,
1358
+ "error": {
1359
+ "code": ERROR_CODE_INVALID_PARAMS,
1360
+ "message": str(e),
1361
+ },
1362
+ },
1363
+ )
1364
+ return
1365
+
1366
+ # Process A2A message parts into LangChain messages format
1367
+ try:
1368
+ message_role = message.get("role", "user")
1369
+ input_content = _process_a2a_message_parts(parts, message_role)
1370
+ except ValueError as e:
1371
+ yield (
1372
+ b"message",
1373
+ {
1374
+ "jsonrpc": "2.0",
1375
+ "id": rpc_id,
1376
+ "error": {
1377
+ "code": ERROR_CODE_CONTENT_TYPE_NOT_SUPPORTED,
1378
+ "message": str(e),
1379
+ },
1380
+ },
1381
+ )
1382
+ return
1383
+
1384
+ run = await client.runs.create(
1385
+ thread_id=message.get("contextId"),
1386
+ assistant_id=assistant_id,
1387
+ stream_mode=["messages", "values"],
1388
+ if_not_exists="create",
1389
+ input=input_content,
1390
+ headers=request.headers,
1391
+ )
1392
+ context_id = run["thread_id"]
1393
+ # Emit initial Task object to establish task context
1394
+ initial_task = {
1395
+ "id": run["run_id"],
1396
+ "contextId": context_id,
1397
+ "history": [
1398
+ {
1399
+ **message,
1400
+ "taskId": run["run_id"],
1401
+ "contextId": context_id,
1402
+ "kind": "message",
1403
+ }
1404
+ ],
1405
+ "kind": "task",
1406
+ "status": {
1407
+ "state": "submitted",
1408
+ "timestamp": datetime.now(UTC).isoformat(),
1409
+ },
1410
+ }
1411
+ yield (b"message", {"jsonrpc": "2.0", "id": rpc_id, "result": initial_task})
1412
+ task_id = run["run_id"]
1413
+ stream = client.runs.join_stream(
1414
+ run_id=task_id,
1415
+ thread_id=context_id,
1416
+ headers=request.headers,
1417
+ )
1418
+ result = None
1419
+ err = None
1420
+ notified_is_working = False
1421
+ async for chunk in stream:
1422
+ try:
1423
+ if chunk.event == "metadata":
1424
+ data = chunk.data or {}
1425
+ if data.get("status") == "run_done":
1426
+ final_message = None
1427
+ if isinstance(result, dict):
1428
+ try:
1429
+ final_text = _extract_a2a_response(result)
1430
+ final_message = {
1431
+ "role": "agent",
1432
+ "parts": [{"kind": "text", "text": final_text}],
1433
+ "messageId": str(uuid.uuid4()),
1434
+ "taskId": task_id,
1435
+ "contextId": context_id,
1436
+ "kind": "message",
1437
+ }
1438
+ except Exception:
1439
+ await logger.aexception(
1440
+ "Failed to extract final message from result",
1441
+ result=result,
1442
+ )
1443
+ if final_message is None:
1444
+ final_message = {
1445
+ "role": "agent",
1446
+ "parts": [{"kind": "text", "text": str(result)}],
1447
+ "messageId": str(uuid.uuid4()),
1448
+ "taskId": task_id,
1449
+ "contextId": context_id,
1450
+ "kind": "message",
1451
+ }
1452
+ completed = {
1453
+ "taskId": task_id,
1454
+ "contextId": context_id,
1455
+ "kind": "status-update",
1456
+ "status": {
1457
+ "state": "completed",
1458
+ "message": final_message,
1459
+ "timestamp": datetime.now(UTC).isoformat(),
1460
+ },
1461
+ "final": True,
1462
+ }
1463
+ yield (
1464
+ b"message",
1465
+ {"jsonrpc": "2.0", "id": rpc_id, "result": completed},
1466
+ )
1467
+ return
1468
+ if data.get("run_id") and not notified_is_working:
1469
+ notified_is_working = True
1470
+ yield (
1471
+ b"message",
1472
+ {
1473
+ "jsonrpc": "2.0",
1474
+ "id": rpc_id,
1475
+ "result": {
1476
+ "taskId": task_id,
1477
+ "contextId": context_id,
1478
+ "kind": "status-update",
1479
+ "status": {"state": "working"},
1480
+ "final": False,
1481
+ },
1482
+ },
1483
+ )
1484
+ elif chunk.event == "error":
1485
+ err = chunk.data
1486
+ elif chunk.event == "values":
1487
+ err = None # Error was retriable
1488
+ result = chunk.data
1489
+ elif chunk.event.startswith("messages"):
1490
+ err = None # Error was retriable
1491
+ items = chunk.data or []
1492
+ if isinstance(items, list) and items:
1493
+ update = _lc_items_to_status_update_event(
1494
+ items,
1495
+ task_id=task_id,
1496
+ context_id=context_id,
1497
+ state="working",
1498
+ )
1499
+ yield (
1500
+ b"message",
1501
+ {"jsonrpc": "2.0", "id": rpc_id, "result": update},
1502
+ )
1503
+ else:
1504
+ await logger.awarning(
1505
+ "Ignoring unknown event type: " + chunk.event
1506
+ )
1507
+
1508
+ except Exception as e:
1509
+ await logger.aexception("Failed to process message stream")
1510
+ err = {"error": type(e).__name__, "message": str(e)}
1511
+ continue
1512
+
1513
+ # If we exit unexpectedly, send a final status based on error presence
1514
+ final_message = None
1515
+ if isinstance(err, dict) and ("__error__" in err or "error" in err):
1516
+ msg = (
1517
+ err.get("__error__", {}).get("error")
1518
+ if isinstance(err.get("__error__"), dict)
1519
+ else err.get("message")
1520
+ )
1521
+ await logger.aerror("Failed to process message stream", err=err)
1522
+ final_message = {
1523
+ "role": "agent",
1524
+ "parts": [{"kind": "text", "text": str(msg or "")}],
1525
+ "messageId": str(uuid.uuid4()),
1526
+ "taskId": task_id,
1527
+ "contextId": context_id,
1528
+ "kind": "message",
1529
+ }
1530
+ fallback = {
1531
+ "taskId": task_id,
1532
+ "contextId": context_id,
1533
+ "kind": "status-update",
1534
+ "status": {
1535
+ "state": "failed" if err else "completed",
1536
+ **({"message": final_message} if final_message else {}),
1537
+ "timestamp": datetime.now(UTC).isoformat(),
1538
+ },
1539
+ "final": True,
1540
+ }
1541
+ yield (b"message", {"jsonrpc": "2.0", "id": rpc_id, "result": fallback})
1542
+ except Exception as e:
1543
+ await logger.aerror(
1544
+ f"Error in message/stream for assistant {assistant_id}: {e!s}",
1545
+ exc_info=True,
1546
+ )
1547
+ yield (
1548
+ b"message",
1549
+ {
1550
+ "jsonrpc": "2.0",
1551
+ "id": rpc_id,
1552
+ "error": {
1553
+ "code": ERROR_CODE_INTERNAL_ERROR,
1554
+ "message": f"Internal server error: {e!s}",
1555
+ },
1556
+ },
1557
+ )
1558
+
1559
+ async def consume_():
1560
+ async for chunk in stream_body():
1561
+ await logger.adebug("A2A.stream_body: Yielding chunk", chunk=chunk)
1562
+ yield chunk
1563
+
1564
+ return EventSourceResponse(
1565
+ consume_(), headers={"Content-Type": "text/event-stream"}
1566
+ )
1567
+
1568
+
1569
+ # ============================================================================
1570
+ # Route Definitions
1571
+ # ============================================================================
1572
+
1573
+
1574
+ async def handle_a2a_assistant_endpoint(request: ApiRequest) -> Response:
1575
+ """A2A endpoint handler for specific assistant.
1576
+
1577
+ Expected URL: /a2a/{assistant_id}
1578
+
1579
+ Args:
1580
+ request: The incoming HTTP request
1581
+
1582
+ Returns:
1583
+ JSON-RPC response or appropriate HTTP error response
1584
+ """
1585
+ # Extract assistant_id from URL path params
1586
+ assistant_id = request.path_params.get("assistant_id")
1587
+ if not assistant_id:
1588
+ return create_error_response("Missing assistant ID in URL", 400)
1589
+
1590
+ if request.method == "POST":
1591
+ return await handle_post_request(request, assistant_id)
1592
+ elif request.method == "GET":
1593
+ return handle_get_request()
1594
+ elif request.method == "DELETE":
1595
+ return handle_delete_request()
1596
+ else:
1597
+ return Response(status_code=405) # Method Not Allowed
1598
+
1599
+
1600
+ a2a_routes = [
1601
+ # Per-assistant A2A endpoints: /a2a/{assistant_id}
1602
+ ApiRoute(
1603
+ "/a2a/{assistant_id}",
1604
+ handle_a2a_assistant_endpoint,
1605
+ methods=["GET", "POST", "DELETE"],
1606
+ ),
1607
+ ApiRoute(
1608
+ "/.well-known/agent-card.json", handle_agent_card_endpoint, methods=["GET"]
1609
+ ),
1610
+ ]