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.
- langgraph_api/__init__.py +1 -1
- langgraph_api/api/__init__.py +111 -51
- langgraph_api/api/a2a.py +1610 -0
- langgraph_api/api/assistants.py +212 -89
- langgraph_api/api/mcp.py +3 -3
- langgraph_api/api/meta.py +52 -28
- langgraph_api/api/openapi.py +27 -17
- langgraph_api/api/profile.py +108 -0
- langgraph_api/api/runs.py +342 -195
- langgraph_api/api/store.py +19 -2
- langgraph_api/api/threads.py +209 -27
- langgraph_api/asgi_transport.py +14 -9
- langgraph_api/asyncio.py +14 -4
- langgraph_api/auth/custom.py +52 -37
- langgraph_api/auth/langsmith/backend.py +4 -3
- langgraph_api/auth/langsmith/client.py +13 -8
- langgraph_api/cli.py +230 -133
- langgraph_api/command.py +5 -3
- langgraph_api/config/__init__.py +532 -0
- langgraph_api/config/_parse.py +58 -0
- langgraph_api/config/schemas.py +431 -0
- langgraph_api/cron_scheduler.py +17 -1
- langgraph_api/encryption/__init__.py +15 -0
- langgraph_api/encryption/aes_json.py +158 -0
- langgraph_api/encryption/context.py +35 -0
- langgraph_api/encryption/custom.py +280 -0
- langgraph_api/encryption/middleware.py +632 -0
- langgraph_api/encryption/shared.py +63 -0
- langgraph_api/errors.py +12 -1
- langgraph_api/executor_entrypoint.py +11 -6
- langgraph_api/feature_flags.py +29 -0
- langgraph_api/graph.py +176 -76
- langgraph_api/grpc/client.py +313 -0
- langgraph_api/grpc/config_conversion.py +231 -0
- langgraph_api/grpc/generated/__init__.py +29 -0
- langgraph_api/grpc/generated/checkpointer_pb2.py +63 -0
- langgraph_api/grpc/generated/checkpointer_pb2.pyi +99 -0
- langgraph_api/grpc/generated/checkpointer_pb2_grpc.py +329 -0
- langgraph_api/grpc/generated/core_api_pb2.py +216 -0
- langgraph_api/grpc/generated/core_api_pb2.pyi +905 -0
- langgraph_api/grpc/generated/core_api_pb2_grpc.py +1621 -0
- langgraph_api/grpc/generated/engine_common_pb2.py +219 -0
- langgraph_api/grpc/generated/engine_common_pb2.pyi +722 -0
- langgraph_api/grpc/generated/engine_common_pb2_grpc.py +24 -0
- langgraph_api/grpc/generated/enum_cancel_run_action_pb2.py +37 -0
- langgraph_api/grpc/generated/enum_cancel_run_action_pb2.pyi +12 -0
- langgraph_api/grpc/generated/enum_cancel_run_action_pb2_grpc.py +24 -0
- langgraph_api/grpc/generated/enum_control_signal_pb2.py +37 -0
- langgraph_api/grpc/generated/enum_control_signal_pb2.pyi +16 -0
- langgraph_api/grpc/generated/enum_control_signal_pb2_grpc.py +24 -0
- langgraph_api/grpc/generated/enum_durability_pb2.py +37 -0
- langgraph_api/grpc/generated/enum_durability_pb2.pyi +16 -0
- langgraph_api/grpc/generated/enum_durability_pb2_grpc.py +24 -0
- langgraph_api/grpc/generated/enum_multitask_strategy_pb2.py +37 -0
- langgraph_api/grpc/generated/enum_multitask_strategy_pb2.pyi +16 -0
- langgraph_api/grpc/generated/enum_multitask_strategy_pb2_grpc.py +24 -0
- langgraph_api/grpc/generated/enum_run_status_pb2.py +37 -0
- langgraph_api/grpc/generated/enum_run_status_pb2.pyi +22 -0
- langgraph_api/grpc/generated/enum_run_status_pb2_grpc.py +24 -0
- langgraph_api/grpc/generated/enum_stream_mode_pb2.py +37 -0
- langgraph_api/grpc/generated/enum_stream_mode_pb2.pyi +28 -0
- langgraph_api/grpc/generated/enum_stream_mode_pb2_grpc.py +24 -0
- langgraph_api/grpc/generated/enum_thread_status_pb2.py +37 -0
- langgraph_api/grpc/generated/enum_thread_status_pb2.pyi +16 -0
- langgraph_api/grpc/generated/enum_thread_status_pb2_grpc.py +24 -0
- langgraph_api/grpc/generated/enum_thread_stream_mode_pb2.py +37 -0
- langgraph_api/grpc/generated/enum_thread_stream_mode_pb2.pyi +16 -0
- langgraph_api/grpc/generated/enum_thread_stream_mode_pb2_grpc.py +24 -0
- langgraph_api/grpc/generated/errors_pb2.py +39 -0
- langgraph_api/grpc/generated/errors_pb2.pyi +21 -0
- langgraph_api/grpc/generated/errors_pb2_grpc.py +24 -0
- langgraph_api/grpc/ops/__init__.py +370 -0
- langgraph_api/grpc/ops/assistants.py +424 -0
- langgraph_api/grpc/ops/runs.py +792 -0
- langgraph_api/grpc/ops/threads.py +1013 -0
- langgraph_api/http.py +16 -5
- langgraph_api/http_metrics.py +15 -35
- langgraph_api/http_metrics_utils.py +38 -0
- langgraph_api/js/build.mts +1 -1
- langgraph_api/js/client.http.mts +13 -7
- langgraph_api/js/client.mts +2 -5
- langgraph_api/js/package.json +29 -28
- langgraph_api/js/remote.py +56 -30
- langgraph_api/js/src/graph.mts +20 -0
- langgraph_api/js/sse.py +2 -2
- langgraph_api/js/ui.py +1 -1
- langgraph_api/js/yarn.lock +1204 -1006
- langgraph_api/logging.py +29 -2
- langgraph_api/metadata.py +99 -28
- langgraph_api/middleware/http_logger.py +7 -2
- langgraph_api/middleware/private_network.py +7 -7
- langgraph_api/models/run.py +54 -93
- langgraph_api/otel_context.py +205 -0
- langgraph_api/patch.py +5 -3
- langgraph_api/queue_entrypoint.py +154 -65
- langgraph_api/route.py +47 -5
- langgraph_api/schema.py +88 -10
- langgraph_api/self_hosted_logs.py +124 -0
- langgraph_api/self_hosted_metrics.py +450 -0
- langgraph_api/serde.py +79 -37
- langgraph_api/server.py +138 -60
- langgraph_api/state.py +4 -3
- langgraph_api/store.py +25 -16
- langgraph_api/stream.py +80 -29
- langgraph_api/thread_ttl.py +31 -13
- langgraph_api/timing/__init__.py +25 -0
- langgraph_api/timing/profiler.py +200 -0
- langgraph_api/timing/timer.py +318 -0
- langgraph_api/utils/__init__.py +53 -8
- langgraph_api/utils/cache.py +47 -10
- langgraph_api/utils/config.py +2 -1
- langgraph_api/utils/errors.py +77 -0
- langgraph_api/utils/future.py +10 -6
- langgraph_api/utils/headers.py +76 -2
- langgraph_api/utils/retriable_client.py +74 -0
- langgraph_api/utils/stream_codec.py +315 -0
- langgraph_api/utils/uuids.py +29 -62
- langgraph_api/validation.py +9 -0
- langgraph_api/webhook.py +120 -6
- langgraph_api/worker.py +55 -24
- {langgraph_api-0.4.1.dist-info → langgraph_api-0.7.3.dist-info}/METADATA +16 -8
- langgraph_api-0.7.3.dist-info/RECORD +168 -0
- {langgraph_api-0.4.1.dist-info → langgraph_api-0.7.3.dist-info}/WHEEL +1 -1
- langgraph_runtime/__init__.py +1 -0
- langgraph_runtime/routes.py +11 -0
- logging.json +1 -3
- openapi.json +839 -478
- langgraph_api/config.py +0 -387
- langgraph_api/js/isolate-0x130008000-46649-46649-v8.log +0 -4430
- langgraph_api/js/isolate-0x138008000-44681-44681-v8.log +0 -4430
- langgraph_api/js/package-lock.json +0 -3308
- langgraph_api-0.4.1.dist-info/RECORD +0 -107
- /langgraph_api/{utils.py → grpc/__init__.py} +0 -0
- {langgraph_api-0.4.1.dist-info → langgraph_api-0.7.3.dist-info}/entry_points.txt +0 -0
- {langgraph_api-0.4.1.dist-info → langgraph_api-0.7.3.dist-info}/licenses/LICENSE +0 -0
langgraph_api/api/a2a.py
ADDED
|
@@ -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
|
+
]
|