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.
Files changed (32) hide show
  1. workflow_engine/__init__.py +95 -0
  2. workflow_engine/client/__init__.py +47 -0
  3. workflow_engine/client/a2a_transport.py +560 -0
  4. workflow_engine/client/agentcard_normalizer.py +106 -0
  5. workflow_engine/client/auth_manager.py +127 -0
  6. workflow_engine/client/auth_provider.py +47 -0
  7. workflow_engine/client/credential_crypto.py +102 -0
  8. workflow_engine/client/credential_service.py +229 -0
  9. workflow_engine/client/engine_client.py +374 -0
  10. workflow_engine/client/env_file_loader.py +68 -0
  11. workflow_engine/client/extension_handlers.py +197 -0
  12. workflow_engine/client/extension_interceptor.py +76 -0
  13. workflow_engine/client/extension_sender.py +203 -0
  14. workflow_engine/client/extensions.py +43 -0
  15. workflow_engine/client/protocol_logger.py +78 -0
  16. workflow_engine/client/sse_normalization.py +87 -0
  17. workflow_engine/client/ssl_context.py +84 -0
  18. workflow_engine/client/stub_engine_client.py +68 -0
  19. workflow_engine/control/__init__.py +26 -0
  20. workflow_engine/control/control_points.py +223 -0
  21. workflow_engine/core/__init__.py +34 -0
  22. workflow_engine/core/context_builder.py +101 -0
  23. workflow_engine/core/executor.py +278 -0
  24. workflow_engine/core/models.py +184 -0
  25. workflow_engine/registry/__init__.py +21 -0
  26. workflow_engine/registry/registry_client.py +177 -0
  27. workflow_engine/runner.py +247 -0
  28. workflow_exec_engine-0.0.2.dist-info/METADATA +309 -0
  29. workflow_exec_engine-0.0.2.dist-info/RECORD +32 -0
  30. workflow_exec_engine-0.0.2.dist-info/WHEEL +5 -0
  31. workflow_exec_engine-0.0.2.dist-info/licenses/LICENSE +17 -0
  32. 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