workflow-exec-engine 0.0.2__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.
- workflow_engine/__init__.py +95 -0
- workflow_engine/client/__init__.py +47 -0
- workflow_engine/client/a2a_transport.py +560 -0
- workflow_engine/client/agentcard_normalizer.py +106 -0
- workflow_engine/client/auth_manager.py +127 -0
- workflow_engine/client/auth_provider.py +47 -0
- workflow_engine/client/credential_crypto.py +102 -0
- workflow_engine/client/credential_service.py +229 -0
- workflow_engine/client/engine_client.py +374 -0
- workflow_engine/client/env_file_loader.py +68 -0
- workflow_engine/client/extension_handlers.py +197 -0
- workflow_engine/client/extension_interceptor.py +76 -0
- workflow_engine/client/extension_sender.py +203 -0
- workflow_engine/client/extensions.py +43 -0
- workflow_engine/client/protocol_logger.py +78 -0
- workflow_engine/client/sse_normalization.py +87 -0
- workflow_engine/client/ssl_context.py +84 -0
- workflow_engine/client/stub_engine_client.py +68 -0
- workflow_engine/control/__init__.py +26 -0
- workflow_engine/control/control_points.py +223 -0
- workflow_engine/core/__init__.py +34 -0
- workflow_engine/core/context_builder.py +101 -0
- workflow_engine/core/executor.py +278 -0
- workflow_engine/core/models.py +184 -0
- workflow_engine/registry/__init__.py +21 -0
- workflow_engine/registry/registry_client.py +177 -0
- workflow_engine/runner.py +247 -0
- workflow_exec_engine-0.0.2.dist-info/METADATA +309 -0
- workflow_exec_engine-0.0.2.dist-info/RECORD +32 -0
- workflow_exec_engine-0.0.2.dist-info/WHEEL +5 -0
- workflow_exec_engine-0.0.2.dist-info/licenses/LICENSE +17 -0
- workflow_exec_engine-0.0.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
# Copyright (c) 2026 Huawei Technologies Co., Ltd.
|
|
2
|
+
# All Rights Reserved.
|
|
3
|
+
#
|
|
4
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
|
|
6
|
+
"""A2ATransport -- shared low-level A2A communication layer.
|
|
7
|
+
|
|
8
|
+
Single responsibility: own the httpx client, auth manager, agent-card
|
|
9
|
+
map, the A2ATClient handle, and the SSE stream consumer. This is the
|
|
10
|
+
shared base over which the two single-responsibility facades sit:
|
|
11
|
+
|
|
12
|
+
* ``WorkflowEngineClient`` (engine_client.py) -- workflow execution
|
|
13
|
+
path: Task-T prompt generation, Negotiation-T auto-loop, extension
|
|
14
|
+
handlers, event callback, control point.
|
|
15
|
+
* ``ExtensionSender`` (extension_sender.py) -- one-shot pre-positioning
|
|
16
|
+
sends: Authorization-T / Notification-T.
|
|
17
|
+
|
|
18
|
+
Neither facade duplicates transport code; both delegate here.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import asyncio
|
|
22
|
+
import uuid
|
|
23
|
+
from typing import Dict, Any, List, Optional, Callable
|
|
24
|
+
from loguru import logger
|
|
25
|
+
|
|
26
|
+
import httpx
|
|
27
|
+
|
|
28
|
+
# protobuf imports are always available (independent of a2a SDK)
|
|
29
|
+
from google.protobuf.json_format import MessageToDict, MessageToJson
|
|
30
|
+
from google.protobuf.struct_pb2 import Struct
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
from a2a.client import ClientConfig, ClientFactory
|
|
34
|
+
from a2a.helpers import new_text_message
|
|
35
|
+
from a2a.types import SendMessageRequest, TaskState
|
|
36
|
+
_A2A_AVAILABLE = True
|
|
37
|
+
except ImportError:
|
|
38
|
+
_A2A_AVAILABLE = False
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
from a2a_t.client import A2ATClient
|
|
42
|
+
_A2AT_AVAILABLE = True
|
|
43
|
+
except ImportError:
|
|
44
|
+
_A2AT_AVAILABLE = False
|
|
45
|
+
A2ATClient = None
|
|
46
|
+
|
|
47
|
+
from workflow_engine.client.ssl_context import create_ssl_context
|
|
48
|
+
from workflow_engine.client.auth_manager import AuthManager
|
|
49
|
+
from workflow_engine.client.protocol_logger import log_request, log_response
|
|
50
|
+
from workflow_engine.client.sse_normalization import apply_sse_normalization
|
|
51
|
+
from workflow_engine.client.agentcard_normalizer import normalize_agent_dict
|
|
52
|
+
from workflow_engine.control.control_points import EventType
|
|
53
|
+
from workflow_engine.core.models import SendMessageResult
|
|
54
|
+
from workflow_engine.client.credential_crypto import decrypt_if_needed as _decrypt_credential
|
|
55
|
+
from workflow_engine.client.env_file_loader import load_to_environ as _load_env_file
|
|
56
|
+
from workflow_engine.client.auth_provider import AuthProvider
|
|
57
|
+
|
|
58
|
+
# Apply SSE response normalization once at import time.
|
|
59
|
+
apply_sse_normalization()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class A2ATransport:
|
|
63
|
+
"""Shared A2A communication base (httpx + auth + SSE consumer).
|
|
64
|
+
|
|
65
|
+
Owns the httpx.AsyncClient, AgentAuthManager, agent-card map, the
|
|
66
|
+
A2ATClient handle, and the streaming-response consumer. Facades
|
|
67
|
+
(WorkflowEngineClient / ExtensionSender) delegate all wire-level
|
|
68
|
+
work here.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
agent_cards: List[Any],
|
|
74
|
+
httpx_client: Optional[httpx.AsyncClient] = None,
|
|
75
|
+
credentials_config: Optional[str | Dict] = None,
|
|
76
|
+
a2at_env_path: Optional[str] = None,
|
|
77
|
+
ssl_verify: bool = True,
|
|
78
|
+
ca_certs_path: Optional[str] = None,
|
|
79
|
+
auth_provider: Optional[AuthProvider] = None,
|
|
80
|
+
preferred_protocol: Optional[str] = None,
|
|
81
|
+
send_timeout_seconds: int = 600,
|
|
82
|
+
):
|
|
83
|
+
if a2at_env_path:
|
|
84
|
+
_load_env_file(a2at_env_path)
|
|
85
|
+
normalized_cards = self._normalize_cards(agent_cards)
|
|
86
|
+
self._card_map = {
|
|
87
|
+
card.name: card for card in normalized_cards if hasattr(card, "name")
|
|
88
|
+
}
|
|
89
|
+
self._send_timeout_seconds = send_timeout_seconds
|
|
90
|
+
self._httpx_client = httpx_client or self._create_httpx_client(
|
|
91
|
+
ssl_verify, ca_certs_path
|
|
92
|
+
)
|
|
93
|
+
self._auth_manager = AuthManager(agent_cards, credentials_config)
|
|
94
|
+
self._auth_manager.set_httpx_client(self._httpx_client)
|
|
95
|
+
self._a2at_client = self._init_a2at_client(a2at_env_path)
|
|
96
|
+
self._context_id = str(uuid.uuid4())
|
|
97
|
+
self._auth_provider = auth_provider
|
|
98
|
+
self._preferred_protocol = preferred_protocol
|
|
99
|
+
logger.info(
|
|
100
|
+
f"[Transport] Initialized with {len(self._card_map)} agent(s), "
|
|
101
|
+
f"ssl_verify={ssl_verify}, a2at={self._a2at_client is not None}, "
|
|
102
|
+
f"send_timeout={send_timeout_seconds}s"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# ------------------------------------------------------------------
|
|
106
|
+
# Setup helpers
|
|
107
|
+
# ------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
def _init_a2at_client(self, a2at_env_path):
|
|
110
|
+
if not a2at_env_path or not _A2AT_AVAILABLE:
|
|
111
|
+
return None
|
|
112
|
+
from pathlib import Path
|
|
113
|
+
env_path = Path(a2at_env_path) if not isinstance(a2at_env_path, Path) else a2at_env_path
|
|
114
|
+
try:
|
|
115
|
+
client = A2ATClient(env_path=env_path)
|
|
116
|
+
logger.info("A2ATClient initialized")
|
|
117
|
+
return client
|
|
118
|
+
except Exception as e:
|
|
119
|
+
logger.warning(f"Failed to init A2ATClient: {e}")
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
def _create_httpx_client(self, ssl_verify, ca_certs_path) -> httpx.AsyncClient:
|
|
123
|
+
if not ssl_verify:
|
|
124
|
+
logger.warning(
|
|
125
|
+
"[Transport] ssl_verify=False -- TLS server certificate "
|
|
126
|
+
"validation disabled. Not recommended for production."
|
|
127
|
+
)
|
|
128
|
+
ssl_ctx = create_ssl_context(
|
|
129
|
+
verify_server=ssl_verify, ca_certs_path=ca_certs_path
|
|
130
|
+
)
|
|
131
|
+
return httpx.AsyncClient(
|
|
132
|
+
timeout=httpx.Timeout(connect=60, read=self._send_timeout_seconds, write=60, pool=10.0),
|
|
133
|
+
verify=ssl_ctx,
|
|
134
|
+
follow_redirects=True,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
@staticmethod
|
|
138
|
+
def normalize_agent_dict(agent_dict: Dict[str, Any]) -> Dict[str, Any]:
|
|
139
|
+
"""Normalize an AgentCard dict to protobuf-compatible format."""
|
|
140
|
+
return normalize_agent_dict(agent_dict)
|
|
141
|
+
|
|
142
|
+
@staticmethod
|
|
143
|
+
def _normalize_cards(agent_cards: List[Any]) -> List[Any]:
|
|
144
|
+
import json
|
|
145
|
+
try:
|
|
146
|
+
from a2a.types import AgentCard
|
|
147
|
+
from google.protobuf.json_format import Parse
|
|
148
|
+
except ImportError:
|
|
149
|
+
AgentCard = None
|
|
150
|
+
Parse = None
|
|
151
|
+
result = []
|
|
152
|
+
for card in agent_cards:
|
|
153
|
+
if isinstance(card, dict):
|
|
154
|
+
normalized = normalize_agent_dict(card)
|
|
155
|
+
if AgentCard is None or Parse is None:
|
|
156
|
+
raise TypeError(
|
|
157
|
+
"agent_cards contains dict entries but a2a-sdk is not "
|
|
158
|
+
"installed; pass protobuf AgentCard objects instead "
|
|
159
|
+
"(e.g. via RegistryClient.fetch_agent_cards())."
|
|
160
|
+
)
|
|
161
|
+
try:
|
|
162
|
+
card = Parse(json.dumps(normalized), AgentCard())
|
|
163
|
+
except Exception as e:
|
|
164
|
+
raise TypeError(f"Failed to parse AgentCard dict: {e}") from e
|
|
165
|
+
name = getattr(card, "name", "") or "<unknown>"
|
|
166
|
+
logger.info(f"[Transport] Auto-normalized dict AgentCard -> {name}")
|
|
167
|
+
result.append(card)
|
|
168
|
+
return result
|
|
169
|
+
|
|
170
|
+
# ------------------------------------------------------------------
|
|
171
|
+
# Accessors
|
|
172
|
+
# ------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def agent_names(self) -> List[str]:
|
|
176
|
+
return list(self._card_map.keys())
|
|
177
|
+
|
|
178
|
+
@property
|
|
179
|
+
def httpx_client(self) -> httpx.AsyncClient:
|
|
180
|
+
return self._httpx_client
|
|
181
|
+
|
|
182
|
+
def get_a2at_client(self):
|
|
183
|
+
return self._a2at_client
|
|
184
|
+
|
|
185
|
+
def get_card(self, agent_name: str):
|
|
186
|
+
return self._card_map.get(agent_name)
|
|
187
|
+
|
|
188
|
+
def update_agent_cards(self, agent_cards: List[Any]):
|
|
189
|
+
self._card_map = {
|
|
190
|
+
card.name: card for card in agent_cards if hasattr(card, "name")
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
# ------------------------------------------------------------------
|
|
194
|
+
# Wire-level send primitives (shared by both facades)
|
|
195
|
+
# ------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
def create_a2a_client(self, agent_card):
|
|
198
|
+
interfaces = [
|
|
199
|
+
iface for iface in agent_card.supported_interfaces
|
|
200
|
+
if iface.protocol_binding
|
|
201
|
+
]
|
|
202
|
+
if self._preferred_protocol and interfaces:
|
|
203
|
+
matched = [
|
|
204
|
+
iface for iface in interfaces
|
|
205
|
+
if iface.protocol_binding.upper() == self._preferred_protocol.upper()
|
|
206
|
+
]
|
|
207
|
+
if matched:
|
|
208
|
+
interfaces = matched
|
|
209
|
+
else:
|
|
210
|
+
logger.warning(
|
|
211
|
+
f"[Transport] Preferred protocol {self._preferred_protocol} "
|
|
212
|
+
f"not in supportedInterfaces for {agent_card.name}, using first available"
|
|
213
|
+
)
|
|
214
|
+
protocol_bindings = (
|
|
215
|
+
[iface.protocol_binding for iface in interfaces]
|
|
216
|
+
or ["HTTP+JSON", "JSONRPC"]
|
|
217
|
+
)
|
|
218
|
+
streaming = (
|
|
219
|
+
agent_card.capabilities.streaming if agent_card.capabilities else False
|
|
220
|
+
)
|
|
221
|
+
config = ClientConfig(
|
|
222
|
+
httpx_client=self._httpx_client,
|
|
223
|
+
supported_protocol_bindings=protocol_bindings,
|
|
224
|
+
streaming=streaming,
|
|
225
|
+
)
|
|
226
|
+
interceptors = self._auth_manager.get_interceptors(agent_card.name)
|
|
227
|
+
if self._auth_provider is not None:
|
|
228
|
+
from workflow_engine.client.auth_manager import AuthProviderInterceptor
|
|
229
|
+
interceptors = list(interceptors) + [AuthProviderInterceptor(
|
|
230
|
+
self._auth_provider, agent_card.name)]
|
|
231
|
+
logger.info(f"[Transport] Created A2A client for {agent_card.name}: protocol={protocol_bindings}, streaming={streaming}, interceptors={len(interceptors)}")
|
|
232
|
+
return ClientFactory(config).create(agent_card, interceptors=interceptors)
|
|
233
|
+
|
|
234
|
+
def build_send_request(self, message, context_id, metadata):
|
|
235
|
+
ctx = context_id or self._context_id
|
|
236
|
+
request_msg = new_text_message(text=message, context_id=ctx)
|
|
237
|
+
if metadata:
|
|
238
|
+
meta = Struct()
|
|
239
|
+
meta.update(metadata)
|
|
240
|
+
request_msg.metadata.CopyFrom(meta)
|
|
241
|
+
return SendMessageRequest(message=request_msg)
|
|
242
|
+
|
|
243
|
+
async def consume_stream(
|
|
244
|
+
self, client, send_req,
|
|
245
|
+
on_intermediate: Optional[Callable[[str, Dict[str, Any]], None]] = None,
|
|
246
|
+
agent_name: str = "",
|
|
247
|
+
):
|
|
248
|
+
"""Iterate over streaming responses, extract text/task/state/metadata.
|
|
249
|
+
|
|
250
|
+
Optionally forwards intermediate events (status updates, artifact
|
|
251
|
+
updates, message events) through ``on_intermediate`` when provided
|
|
252
|
+
by the calling facade (the workflow facade wires it to its
|
|
253
|
+
EventCallback). Merges task-level AND artifact-level metadata into
|
|
254
|
+
the result so extension payloads on artifacts reach the extension
|
|
255
|
+
handlers.
|
|
256
|
+
"""
|
|
257
|
+
response_text = None
|
|
258
|
+
last_task_result = None
|
|
259
|
+
last_metadata_dict: Dict[str, Any] = {}
|
|
260
|
+
task_state = ""
|
|
261
|
+
|
|
262
|
+
async for response in client.send_message(send_req):
|
|
263
|
+
has_task = response.HasField("task")
|
|
264
|
+
has_message = response.HasField("message")
|
|
265
|
+
|
|
266
|
+
if has_task:
|
|
267
|
+
task = response.task
|
|
268
|
+
state = self._extract_task_state(task)
|
|
269
|
+
logger.info(f"[Transport] Received StreamResponse with task: state={state or None}")
|
|
270
|
+
try:
|
|
271
|
+
task_json = MessageToJson(task, ensure_ascii=False, indent=2)
|
|
272
|
+
except Exception as _e:
|
|
273
|
+
logger.warning(f"[Transport] MessageToJson task failed: {type(_e).__name__}: {_e}")
|
|
274
|
+
task_json = str(task)
|
|
275
|
+
log_response(agent_name, "Task", task_json)
|
|
276
|
+
last_task_result = task
|
|
277
|
+
response_text = self._extract_task_text(task, response_text)
|
|
278
|
+
task_state = state or task_state
|
|
279
|
+
last_metadata_dict = self._merge_task_metadata(task, last_metadata_dict)
|
|
280
|
+
if response_text is None:
|
|
281
|
+
response_text = self._text_from_metadata(last_metadata_dict)
|
|
282
|
+
if on_intermediate is not None:
|
|
283
|
+
is_final = task_state in (
|
|
284
|
+
"TASK_STATE_COMPLETED", "TASK_STATE_FAILED",
|
|
285
|
+
"TASK_STATE_CANCELED", "TASK_STATE_REJECTED",
|
|
286
|
+
)
|
|
287
|
+
on_intermediate(EventType.AGENT_STATUS_UPDATE, {
|
|
288
|
+
"agent": agent_name,
|
|
289
|
+
"state": task_state,
|
|
290
|
+
"is_final": is_final,
|
|
291
|
+
"text": response_text or "",
|
|
292
|
+
"metadata": dict(last_metadata_dict) if last_metadata_dict else {},
|
|
293
|
+
})
|
|
294
|
+
# Emit artifact update events for each artifact in the task
|
|
295
|
+
for art in (task.artifacts or []):
|
|
296
|
+
art_text = ""
|
|
297
|
+
for part in (art.parts or []):
|
|
298
|
+
if part.text:
|
|
299
|
+
art_text += part.text
|
|
300
|
+
art_meta = {}
|
|
301
|
+
am = getattr(art, "metadata", None)
|
|
302
|
+
if am:
|
|
303
|
+
if isinstance(am, dict):
|
|
304
|
+
art_meta = am
|
|
305
|
+
else:
|
|
306
|
+
try:
|
|
307
|
+
art_meta = MessageToDict(am, preserving_proto_field_name=True)
|
|
308
|
+
except Exception:
|
|
309
|
+
pass
|
|
310
|
+
on_intermediate(EventType.AGENT_ARTIFACT_UPDATE, {
|
|
311
|
+
"agent": agent_name,
|
|
312
|
+
"artifact_id": getattr(art, "artifact_id", "") or "",
|
|
313
|
+
"artifact_name": getattr(art, "name", "") or "",
|
|
314
|
+
"append": getattr(art, "append", False),
|
|
315
|
+
"last_chunk": getattr(art, "last_chunk", True),
|
|
316
|
+
"text": art_text,
|
|
317
|
+
"metadata": art_meta,
|
|
318
|
+
})
|
|
319
|
+
elif has_message:
|
|
320
|
+
logger.info("[Transport] Received StreamResponse with message")
|
|
321
|
+
msg = response.message
|
|
322
|
+
try:
|
|
323
|
+
msg_json = MessageToJson(msg, ensure_ascii=False, indent=2)
|
|
324
|
+
except Exception as _e:
|
|
325
|
+
logger.warning(f"[Transport] MessageToJson msg failed: {type(_e).__name__}: {_e}")
|
|
326
|
+
msg_json = str(msg)
|
|
327
|
+
log_response(agent_name, "Message", msg_json)
|
|
328
|
+
msg_text = self._extract_message_text(msg, None)
|
|
329
|
+
response_text = self._extract_message_text(msg, response_text)
|
|
330
|
+
msg_role = ""
|
|
331
|
+
try:
|
|
332
|
+
msg_role = type(msg).Role.Name(msg.role)
|
|
333
|
+
except Exception:
|
|
334
|
+
msg_role = str(getattr(msg, "role", ""))
|
|
335
|
+
msg_meta = {}
|
|
336
|
+
mm = getattr(msg, "metadata", None)
|
|
337
|
+
if mm:
|
|
338
|
+
if isinstance(mm, dict):
|
|
339
|
+
msg_meta = mm
|
|
340
|
+
else:
|
|
341
|
+
try:
|
|
342
|
+
msg_meta = MessageToDict(mm, preserving_proto_field_name=True)
|
|
343
|
+
except Exception:
|
|
344
|
+
pass
|
|
345
|
+
if on_intermediate is not None:
|
|
346
|
+
on_intermediate(EventType.AGENT_MESSAGE_EVENT, {
|
|
347
|
+
"agent": agent_name,
|
|
348
|
+
"role": msg_role,
|
|
349
|
+
"text": msg_text or "",
|
|
350
|
+
"metadata": msg_meta,
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
return response_text, last_task_result, last_metadata_dict, task_state
|
|
354
|
+
|
|
355
|
+
async def consume_notification_stream(
|
|
356
|
+
self, client, send_req,
|
|
357
|
+
event_callback: Optional[Callable[[Dict[str, Any]], None]] = None,
|
|
358
|
+
agent_name: str = "",
|
|
359
|
+
) -> "asyncio.Task":
|
|
360
|
+
"""Open a long-lived SSE stream for Notification-T subscription.
|
|
361
|
+
|
|
362
|
+
Returns an ``asyncio.Task`` that keeps the stream alive. The task
|
|
363
|
+
completes when the stream closes or is cancelled. Each SSE event
|
|
364
|
+
is forwarded to ``event_callback`` (if provided) as a dict with
|
|
365
|
+
keys: ``agent``, ``type``, ``state``, ``text``, ``metadata``, etc.
|
|
366
|
+
|
|
367
|
+
The first event (subscription confirmation) is also forwarded.
|
|
368
|
+
Unlike ``consume_stream``, this method does NOT return a result --
|
|
369
|
+
the stream stays open and events flow asynchronously.
|
|
370
|
+
"""
|
|
371
|
+
async def _consume():
|
|
372
|
+
try:
|
|
373
|
+
logger.info(f"[Transport] Opening Notification-T long-lived stream to {agent_name}")
|
|
374
|
+
async for response in client.send_message(send_req):
|
|
375
|
+
has_task = response.HasField("task")
|
|
376
|
+
has_message = response.HasField("message")
|
|
377
|
+
|
|
378
|
+
event_data: Dict[str, Any] = {"agent": agent_name}
|
|
379
|
+
|
|
380
|
+
if has_task:
|
|
381
|
+
task = response.task
|
|
382
|
+
state = self._extract_task_state(task)
|
|
383
|
+
logger.info(f"[Transport] Notification-T event from {agent_name}: state={state or None}")
|
|
384
|
+
event_data["type"] = "task_update"
|
|
385
|
+
event_data["state"] = state or ""
|
|
386
|
+
is_final = state in (
|
|
387
|
+
"TASK_STATE_COMPLETED", "TASK_STATE_FAILED",
|
|
388
|
+
"TASK_STATE_CANCELED", "TASK_STATE_REJECTED",
|
|
389
|
+
)
|
|
390
|
+
event_data["is_final"] = is_final
|
|
391
|
+
text = self._extract_task_text(task, None)
|
|
392
|
+
if text:
|
|
393
|
+
event_data["text"] = text
|
|
394
|
+
md = self._extract_task_metadata(task)
|
|
395
|
+
if md:
|
|
396
|
+
event_data["metadata"] = md
|
|
397
|
+
for art in (task.artifacts or []):
|
|
398
|
+
art_text = ""
|
|
399
|
+
for part in (art.parts or []):
|
|
400
|
+
if part.text:
|
|
401
|
+
art_text += part.text
|
|
402
|
+
art_data = {
|
|
403
|
+
"artifact_id": getattr(art, "artifact_id", "") or "",
|
|
404
|
+
"artifact_name": getattr(art, "name", "") or "",
|
|
405
|
+
"text": art_text,
|
|
406
|
+
}
|
|
407
|
+
am = getattr(art, "metadata", None)
|
|
408
|
+
if am:
|
|
409
|
+
if isinstance(am, dict):
|
|
410
|
+
art_data["metadata"] = am
|
|
411
|
+
else:
|
|
412
|
+
try:
|
|
413
|
+
art_data["metadata"] = MessageToDict(am, preserving_proto_field_name=True)
|
|
414
|
+
except Exception:
|
|
415
|
+
pass
|
|
416
|
+
event_data.setdefault("artifacts", []).append(art_data)
|
|
417
|
+
|
|
418
|
+
elif has_message:
|
|
419
|
+
logger.info(f"[Transport] Notification-T message event from {agent_name}")
|
|
420
|
+
msg = response.message
|
|
421
|
+
event_data["type"] = "message"
|
|
422
|
+
msg_text = self._extract_message_text(msg, None)
|
|
423
|
+
if msg_text:
|
|
424
|
+
event_data["text"] = msg_text
|
|
425
|
+
try:
|
|
426
|
+
event_data["role"] = type(msg).Role.Name(msg.role)
|
|
427
|
+
except Exception:
|
|
428
|
+
event_data["role"] = str(getattr(msg, "role", ""))
|
|
429
|
+
mm = getattr(msg, "metadata", None)
|
|
430
|
+
if mm:
|
|
431
|
+
if isinstance(mm, dict):
|
|
432
|
+
event_data["metadata"] = mm
|
|
433
|
+
else:
|
|
434
|
+
try:
|
|
435
|
+
event_data["metadata"] = MessageToDict(mm, preserving_proto_field_name=True)
|
|
436
|
+
except Exception:
|
|
437
|
+
pass
|
|
438
|
+
|
|
439
|
+
if event_callback and event_data.get("type"):
|
|
440
|
+
try:
|
|
441
|
+
event_callback(event_data)
|
|
442
|
+
except Exception as e:
|
|
443
|
+
logger.warning(f"[Transport] Notification-T callback error for {agent_name}: {e}")
|
|
444
|
+
|
|
445
|
+
logger.info(f"[Transport] Notification-T stream closed for {agent_name}")
|
|
446
|
+
except asyncio.CancelledError:
|
|
447
|
+
logger.info(f"[Transport] Notification-T stream cancelled for {agent_name}")
|
|
448
|
+
except Exception as e:
|
|
449
|
+
msg = str(e)
|
|
450
|
+
if "connection closed" in msg.lower() or "reading_length" in msg.lower():
|
|
451
|
+
logger.info(f"[Transport] Notification-T stream closed for {agent_name}")
|
|
452
|
+
else:
|
|
453
|
+
logger.warning(f"[Transport] Notification-T stream error for {agent_name}: {e}")
|
|
454
|
+
|
|
455
|
+
return asyncio.create_task(_consume(), name=f"notif-t-{agent_name}")
|
|
456
|
+
|
|
457
|
+
# ------------------------------------------------------------------
|
|
458
|
+
# Parsing helpers (static)
|
|
459
|
+
# ------------------------------------------------------------------
|
|
460
|
+
|
|
461
|
+
@staticmethod
|
|
462
|
+
def _merge_task_metadata(task, current: Dict[str, Any]) -> Dict[str, Any]:
|
|
463
|
+
"""Merge task-level AND each artifact's metadata into the result map."""
|
|
464
|
+
result = dict(current) if current else {}
|
|
465
|
+
md = task.metadata
|
|
466
|
+
if md:
|
|
467
|
+
if isinstance(md, dict):
|
|
468
|
+
result.update(md)
|
|
469
|
+
else:
|
|
470
|
+
try:
|
|
471
|
+
result.update(MessageToDict(md, preserving_proto_field_name=True))
|
|
472
|
+
except Exception:
|
|
473
|
+
pass
|
|
474
|
+
artifacts = task.artifacts if hasattr(task, "artifacts") else None
|
|
475
|
+
if artifacts:
|
|
476
|
+
for artifact in artifacts:
|
|
477
|
+
am = getattr(artifact, "metadata", None)
|
|
478
|
+
if am:
|
|
479
|
+
if isinstance(am, dict):
|
|
480
|
+
result.update(am)
|
|
481
|
+
else:
|
|
482
|
+
try:
|
|
483
|
+
result.update(MessageToDict(am, preserving_proto_field_name=True))
|
|
484
|
+
except Exception:
|
|
485
|
+
pass
|
|
486
|
+
return result
|
|
487
|
+
|
|
488
|
+
@staticmethod
|
|
489
|
+
def _extract_task_text(task, current_text: Optional[str]) -> Optional[str]:
|
|
490
|
+
if not task.artifacts:
|
|
491
|
+
return current_text
|
|
492
|
+
for artifact in task.artifacts:
|
|
493
|
+
if artifact.parts:
|
|
494
|
+
for part in artifact.parts:
|
|
495
|
+
if part.text:
|
|
496
|
+
current_text = (current_text or "") + part.text
|
|
497
|
+
return current_text
|
|
498
|
+
|
|
499
|
+
@staticmethod
|
|
500
|
+
def _extract_task_state(task) -> str:
|
|
501
|
+
if not (task.status and task.status.state):
|
|
502
|
+
return ""
|
|
503
|
+
try:
|
|
504
|
+
return TaskState.Name(task.status.state)
|
|
505
|
+
except Exception:
|
|
506
|
+
return str(task.status.state)
|
|
507
|
+
|
|
508
|
+
@staticmethod
|
|
509
|
+
def _extract_task_metadata(task) -> Dict[str, Any]:
|
|
510
|
+
if not task.metadata:
|
|
511
|
+
return {}
|
|
512
|
+
md = task.metadata
|
|
513
|
+
if isinstance(md, dict):
|
|
514
|
+
return md
|
|
515
|
+
return MessageToDict(md, preserving_proto_field_name=True)
|
|
516
|
+
|
|
517
|
+
@staticmethod
|
|
518
|
+
def _text_from_metadata(metadata: Dict[str, Any]) -> Optional[str]:
|
|
519
|
+
if not isinstance(metadata, dict):
|
|
520
|
+
return None
|
|
521
|
+
for val in metadata.values():
|
|
522
|
+
if isinstance(val, str) and len(val) > 20:
|
|
523
|
+
return val
|
|
524
|
+
return None
|
|
525
|
+
|
|
526
|
+
@staticmethod
|
|
527
|
+
def _extract_message_text(message, current_text: Optional[str]) -> Optional[str]:
|
|
528
|
+
if not message.parts:
|
|
529
|
+
return current_text
|
|
530
|
+
for part in message.parts:
|
|
531
|
+
if part.text:
|
|
532
|
+
current_text = (current_text or "") + part.text
|
|
533
|
+
return current_text
|
|
534
|
+
|
|
535
|
+
@staticmethod
|
|
536
|
+
def _get_extensions(agent_card) -> List[str]:
|
|
537
|
+
uris = []
|
|
538
|
+
exts = getattr(
|
|
539
|
+
getattr(agent_card, "capabilities", None), "extensions", None
|
|
540
|
+
) or []
|
|
541
|
+
for ext in exts:
|
|
542
|
+
uri = getattr(ext, "uri", "")
|
|
543
|
+
if uri:
|
|
544
|
+
uris.append(uri)
|
|
545
|
+
return uris
|
|
546
|
+
|
|
547
|
+
# ------------------------------------------------------------------
|
|
548
|
+
# Lifecycle
|
|
549
|
+
# ------------------------------------------------------------------
|
|
550
|
+
|
|
551
|
+
async def close(self):
|
|
552
|
+
if self._httpx_client:
|
|
553
|
+
logger.info("[Transport] Closing httpx client")
|
|
554
|
+
await self._httpx_client.aclose()
|
|
555
|
+
|
|
556
|
+
async def __aenter__(self):
|
|
557
|
+
return self
|
|
558
|
+
|
|
559
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
560
|
+
await self.close()
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# Copyright (c) 2026 Huawei Technologies Co., Ltd.
|
|
2
|
+
# All Rights Reserved.
|
|
3
|
+
#
|
|
4
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
#
|
|
6
|
+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
|
7
|
+
# not use this file except in compliance with the License. You may obtain
|
|
8
|
+
# a copy of the License at
|
|
9
|
+
#
|
|
10
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
#
|
|
12
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
|
14
|
+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
|
15
|
+
# License for the specific language governing permissions and limitations
|
|
16
|
+
# under the License.
|
|
17
|
+
|
|
18
|
+
"""AgentCard normalization -- self-contained.
|
|
19
|
+
|
|
20
|
+
Converts OpenAPI-style security scheme notation to protobuf-compatible format,
|
|
21
|
+
so AgentCard dicts can be parsed by a2a-sdk's Parse().
|
|
22
|
+
|
|
23
|
+
Handles two input formats:
|
|
24
|
+
1. Protobuf JSON (camelCase, from registry center) -- already correct,
|
|
25
|
+
normalization is a no-op.
|
|
26
|
+
2. OpenAPI-style (flat scheme field, list-style securityRequirements) --
|
|
27
|
+
converted to protobuf-compatible structure.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from typing import Any, Dict, List
|
|
31
|
+
from loguru import logger
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _normalize_security_schemes(sec_schemes: Any) -> Dict[str, Any]:
|
|
35
|
+
if not isinstance(sec_schemes, dict):
|
|
36
|
+
return sec_schemes if sec_schemes else {}
|
|
37
|
+
result = {}
|
|
38
|
+
for name, scheme in sec_schemes.items():
|
|
39
|
+
if not isinstance(scheme, dict):
|
|
40
|
+
result[name] = scheme
|
|
41
|
+
continue
|
|
42
|
+
# Already in protobuf format (has oneof field like httpAuthSecurityScheme)
|
|
43
|
+
if any(k in scheme for k in (
|
|
44
|
+
"httpAuthSecurityScheme", "apiKeySecurityScheme",
|
|
45
|
+
"oauth2SecurityScheme", "openIdConnectSecurityScheme",
|
|
46
|
+
"mtlsSecurityScheme",
|
|
47
|
+
)):
|
|
48
|
+
result[name] = scheme
|
|
49
|
+
continue
|
|
50
|
+
# OpenAPI-style: flat "scheme": "bearer" -> wrap in httpAuthSecurityScheme
|
|
51
|
+
if "scheme" in scheme and isinstance(scheme["scheme"], str):
|
|
52
|
+
http_auth = {"scheme": scheme["scheme"]}
|
|
53
|
+
for extra_key in ("description", "bearerFormat"):
|
|
54
|
+
if extra_key in scheme:
|
|
55
|
+
http_auth[extra_key] = scheme[extra_key]
|
|
56
|
+
result[name] = {"httpAuthSecurityScheme": http_auth}
|
|
57
|
+
continue
|
|
58
|
+
# OpenAPI-style: apiKey
|
|
59
|
+
if scheme.get("type") == "apiKey":
|
|
60
|
+
api_key = {}
|
|
61
|
+
in_val = scheme.get("in")
|
|
62
|
+
if in_val:
|
|
63
|
+
api_key["location"] = in_val
|
|
64
|
+
name_val = scheme.get("name")
|
|
65
|
+
if name_val:
|
|
66
|
+
api_key["name"] = name_val
|
|
67
|
+
desc_val = scheme.get("description")
|
|
68
|
+
if desc_val:
|
|
69
|
+
api_key["description"] = desc_val
|
|
70
|
+
result[name] = {"apiKeySecurityScheme": api_key}
|
|
71
|
+
continue
|
|
72
|
+
result[name] = scheme
|
|
73
|
+
return result
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _normalize_security_requirements(sec_reqs: Any) -> List[Dict[str, Any]]:
|
|
77
|
+
if not isinstance(sec_reqs, list):
|
|
78
|
+
return []
|
|
79
|
+
result = []
|
|
80
|
+
for req in sec_reqs:
|
|
81
|
+
if not isinstance(req, dict):
|
|
82
|
+
continue
|
|
83
|
+
schemes = req.get("schemes")
|
|
84
|
+
if isinstance(schemes, list):
|
|
85
|
+
result.append({"schemes": {s: {} for s in schemes}})
|
|
86
|
+
elif isinstance(schemes, dict):
|
|
87
|
+
result.append(req)
|
|
88
|
+
else:
|
|
89
|
+
result.append(req)
|
|
90
|
+
return result
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def normalize_agent_dict(agent_dict: Dict[str, Any]) -> Dict[str, Any]:
|
|
94
|
+
"""Normalize an AgentCard dict to protobuf-compatible format."""
|
|
95
|
+
if not isinstance(agent_dict, dict):
|
|
96
|
+
return agent_dict
|
|
97
|
+
result = dict(agent_dict)
|
|
98
|
+
if "securitySchemes" in result:
|
|
99
|
+
result["securitySchemes"] = _normalize_security_schemes(result["securitySchemes"])
|
|
100
|
+
if "securityRequirements" in result:
|
|
101
|
+
result["securityRequirements"] = _normalize_security_requirements(result["securityRequirements"])
|
|
102
|
+
if result.get("securitySchemes") and not result.get("securityRequirements"):
|
|
103
|
+
scheme_names = list(result["securitySchemes"].keys())
|
|
104
|
+
result["securityRequirements"] = [{"schemes": {s: {} for s in scheme_names}}]
|
|
105
|
+
logger.info(f"Auto-populated securityRequirements from securitySchemes: {scheme_names}")
|
|
106
|
+
return result
|