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,374 @@
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
+ """WorkflowEngineClient -- workflow-execution facade over A2ATransport.
19
+
20
+ Single responsibility: the workflow execution send path. Owns the
21
+ Task-T/Negotiation-T extension handler chain, the Negotiation-T
22
+ auto-loop, the global EventCallback, and the ControlPoint wiring.
23
+ All wire-level work (httpx, auth, SSE consumer) delegates to
24
+ :class:`A2ATransport`.
25
+
26
+ One-shot pre-positioning sends (Authorization-T / Notification-T) are
27
+ a separate concern and live on :class:`ExtensionSender` -- callers
28
+ that only need pre-positioning hold that lighter facade instead.
29
+ """
30
+
31
+ import uuid
32
+ import asyncio
33
+ import time
34
+ from typing import Dict, Any, List, Optional, Callable, Union, Awaitable
35
+ from loguru import logger
36
+
37
+ import httpx
38
+
39
+ try:
40
+ from a2a.types import Task
41
+ _A2A_AVAILABLE = True
42
+ except ImportError:
43
+ _A2A_AVAILABLE = False
44
+
45
+ from workflow_engine.client.a2a_transport import A2ATransport
46
+ from workflow_engine.client.auth_manager import AuthManager
47
+ from workflow_engine.client.extension_handlers import ExtensionRegistry, ExtensionHandler
48
+ from workflow_engine.client.protocol_logger import log_request, log_response
49
+ from workflow_engine.control.control_points import (
50
+ EventCallback, EventType,
51
+ )
52
+ from workflow_engine.core.models import SendMessageResult
53
+ from workflow_engine.client.extensions import A2ATExtension
54
+
55
+ # Type alias for the negotiation resolver callback. May be sync (returning
56
+ # str/None) or async (returning an awaitable of str/None). The SDK awaits
57
+ # coroutine results automatically, so an `async def` resolver is supported.
58
+ NegotiationResolver = Union[
59
+ Callable[[str, str, Dict[str, Any]], Optional[str]],
60
+ Callable[[str, str, Dict[str, Any]], Awaitable[Optional[str]]],
61
+ ]
62
+
63
+
64
+ class WorkflowEngineClient:
65
+ """Workflow-execution send facade built on a shared :class:`A2ATransport`."""
66
+
67
+ def __init__(
68
+ self,
69
+ transport: A2ATransport,
70
+ custom_handlers: Optional[List[ExtensionHandler]] = None,
71
+ event_callback: Optional[EventCallback] = None,
72
+ max_negotiation_rounds: int = 3,
73
+ ):
74
+ self._transport = transport
75
+ self._extension_registry = ExtensionRegistry()
76
+ if custom_handlers:
77
+ for h in custom_handlers:
78
+ self._extension_registry.register(h)
79
+ self._control_point = None
80
+ self._event_callback = event_callback
81
+ self._max_negotiation_rounds = max_negotiation_rounds
82
+ logger.info(
83
+ f"[EngineClient] Initialized over transport "
84
+ f"({len(self._transport.agent_names)} agent(s)), "
85
+ f"max_neg={max_negotiation_rounds}"
86
+ )
87
+
88
+ # ------------------------------------------------------------------
89
+ # Wiring
90
+ # ------------------------------------------------------------------
91
+
92
+ def set_control_point(self, control_point):
93
+ self._control_point = control_point
94
+
95
+ def set_event_callback(self, callback):
96
+ """Attach an EventCallback so send_message emits agent_request/response."""
97
+ self._event_callback = callback
98
+
99
+ def _emit(self, event_type: str, data: Dict[str, Any]):
100
+ if self._event_callback:
101
+ self._event_callback.on_event(event_type, data)
102
+
103
+ def _forward_intermediate_event(self, event_type: str, data: Dict[str, Any]):
104
+ """Forward intermediate SSE events with structured logging (mirrors Java forwardIntermediateEvent)."""
105
+ agent = data.get("agent", "?")
106
+ if event_type == EventType.AGENT_STATUS_UPDATE:
107
+ state = data.get("state", "")
108
+ is_final = data.get("is_final", False)
109
+ logger.info(f"[EngineClient] Agent {agent} status update: {state} (final={is_final})")
110
+ elif event_type == EventType.AGENT_ARTIFACT_UPDATE:
111
+ art_name = data.get("artifact_name", "")
112
+ art_id = data.get("artifact_id", "")
113
+ logger.info(f"[EngineClient] Agent {agent} artifact update: {art_name} ({art_id})")
114
+ elif event_type == EventType.AGENT_MESSAGE_EVENT:
115
+ text = data.get("text", "")
116
+ logger.info(f"[EngineClient] Agent {agent} message event: {len(text)} chars")
117
+ self._emit(event_type, data)
118
+
119
+ def register_handler(self, handler: ExtensionHandler):
120
+ self._extension_registry.register(handler)
121
+
122
+ # ------------------------------------------------------------------
123
+ # Delegated transport accessors (for convenience)
124
+ # ------------------------------------------------------------------
125
+
126
+ @property
127
+ def agent_names(self) -> List[str]:
128
+ return self._transport.agent_names
129
+
130
+ @property
131
+ def httpx_client(self) -> httpx.AsyncClient:
132
+ return self._transport.httpx_client
133
+
134
+ def get_a2at_client(self):
135
+ return self._transport.get_a2at_client()
136
+
137
+ def get_card(self, agent_name: str):
138
+ return self._transport.get_card(agent_name)
139
+
140
+ def update_agent_cards(self, agent_cards: List[Any]):
141
+ self._transport.update_agent_cards(agent_cards)
142
+
143
+ async def close(self):
144
+ await self._transport.close()
145
+
146
+ async def __aenter__(self):
147
+ return self
148
+
149
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
150
+ await self.close()
151
+
152
+ # ------------------------------------------------------------------
153
+ # Workflow send path
154
+ # ------------------------------------------------------------------
155
+
156
+ async def send_message(
157
+ self,
158
+ agent_name: str,
159
+ message: str,
160
+ context_id: Optional[str] = None,
161
+ metadata: Optional[Dict[str, Any]] = None,
162
+ skip_extensions: bool = False,
163
+ ) -> SendMessageResult:
164
+ t_total = time.time()
165
+ agent_card = self._transport.get_card(agent_name)
166
+ if not agent_card:
167
+ logger.error(f"[EngineClient] Agent not found: {agent_name}")
168
+ raise RuntimeError(f"Agent not found: {agent_name}")
169
+ logger.info(f"[EngineClient] send_message to {agent_name}: {len(message)} chars")
170
+ if skip_extensions:
171
+ logger.info(f"[EngineClient] Skipping A2A-T extensions for {agent_name} (self-loop)")
172
+ else:
173
+ t_ext = time.time()
174
+ metadata = await self._run_before_send_handlers(agent_card, message, metadata)
175
+ logger.info(f"[Timing] Extensions before_send for {agent_name}: {time.time()-t_ext:.2f}s")
176
+ logger.info(f"[EngineClient] Emitting agent_request for {agent_name}")
177
+ self._emit(EventType.AGENT_REQUEST, {"agent": agent_name, "request": message, "metadata": metadata or {}})
178
+ client = self._transport.create_a2a_client(agent_card)
179
+ send_req = self._transport.build_send_request(message, context_id, metadata)
180
+ # Log full protocol request (endpoint + headers + body) after build, before send.
181
+ endpoint = "?"
182
+ if hasattr(agent_card, "supported_interfaces") and agent_card.supported_interfaces:
183
+ endpoint = agent_card.supported_interfaces[0].url or "?"
184
+ from google.protobuf.json_format import MessageToJson
185
+ try:
186
+ body_json = MessageToJson(send_req, ensure_ascii=False, indent=2)
187
+ except Exception:
188
+ body_json = str(send_req)
189
+ # Build HTTP header view: only real headers belong here, not message metadata.
190
+ # A2A-Extensions is derived from which extension URIs appear as metadata keys.
191
+ # Authorization is held by the credential service / auth interceptor at send time.
192
+ ext_uris = [k for k in (metadata or {}) if "tmforum.org" in k]
193
+ header_view = {}
194
+ if ext_uris:
195
+ header_view["A2A-Extensions"] = ",".join(ext_uris)
196
+ if agent_card.security_schemes and agent_card.security_requirements:
197
+ header_view["Authorization"] = "Bearer <injected-by-interceptor-at-send-time>"
198
+ log_request(agent_name, endpoint, body_json, header_view)
199
+
200
+ t_stream = time.time()
201
+ response_text, last_task, last_meta, task_state = (
202
+ await self._transport.consume_stream(client, send_req, self._forward_intermediate_event, agent_name)
203
+ )
204
+ logger.info(f"[Timing] A2A stream for {agent_name}: {time.time()-t_stream:.2f}s")
205
+
206
+ if response_text is None and last_task is not None:
207
+ response_text = str(last_task)
208
+
209
+ logger.info(f"[EngineClient] Response from {agent_name}: text={len(response_text or '')} chars, state={task_state}")
210
+ result = SendMessageResult(
211
+ text=response_text or "",
212
+ task=last_task,
213
+ metadata=last_meta,
214
+ task_state=task_state,
215
+ )
216
+ if skip_extensions:
217
+ self._emit(EventType.AGENT_RESPONSE, {"agent": agent_name, "response": result.text, "metadata": result.metadata or {}})
218
+ logger.info(f"[Timing] send_message to {agent_name} total: {time.time()-t_total:.2f}s")
219
+ return result
220
+ result = await self._run_after_receive_handlers(agent_card, result)
221
+ result = await self._auto_negotiate(agent_card, agent_name, message, context_id, result, 1)
222
+ logger.info(f"[Timing] send_message to {agent_name} total: {time.time()-t_total:.2f}s")
223
+ return result
224
+
225
+ # ------------------------------------------------------------------
226
+ # Auto-negotiation (integrated into send_message)
227
+ # ------------------------------------------------------------------
228
+
229
+ async def _auto_negotiate(
230
+ self, agent_card, agent_name, original_message,
231
+ context_id, result, round_num,
232
+ ) -> SendMessageResult:
233
+ if not self._is_negotiation_needed(result) or round_num > self._max_negotiation_rounds:
234
+ self._emit(EventType.AGENT_RESPONSE, {"agent": agent_name, "response": result.text, "metadata": result.metadata or {}})
235
+ return result
236
+ neg_meta = result.metadata or {}
237
+ neg_text = neg_meta.get("negotiation_message", "") or ""
238
+ logger.info(f"[Negotiation] Round {round_num} for '{agent_name}': {neg_text}")
239
+ self._emit(EventType.NEGOTIATION_REQUEST, {
240
+ "agent": agent_name, "round": round_num, "concern": neg_text,
241
+ })
242
+ if self._control_point is not None:
243
+ try:
244
+ clarification = self._control_point.on_negotiation(agent_name, neg_text, neg_meta)
245
+ if asyncio.iscoroutine(clarification):
246
+ clarification = await clarification
247
+ except Exception as e:
248
+ logger.warning(f"[Negotiation] on_negotiation raised: {e}")
249
+ clarification = None
250
+ else:
251
+ clarification = "Please proceed with the original task using available information."
252
+ if not clarification:
253
+ self._emit(EventType.NEGOTIATION_FAILED, {
254
+ "agent": agent_name, "round": round_num, "reason": "no clarification",
255
+ })
256
+ self._emit(EventType.AGENT_RESPONSE, {"agent": agent_name, "response": result.text, "metadata": result.metadata or {}})
257
+ return result
258
+ logger.info(f"[Negotiation] Clarification for '{agent_name}' round {round_num}: {clarification}")
259
+ self._emit(EventType.NEGOTIATION_RESOLVED, {
260
+ "agent": agent_name, "round": round_num, "clarification": clarification,
261
+ })
262
+ follow_up = (
263
+ "[NEGOTIATION_RESOLUTION]\n"
264
+ "The engine has reviewed your negotiation request and provides "
265
+ "the following clarification:\n\n" + clarification + "\n\n"
266
+ "---\nOriginal Task:\n" + original_message + "\n\n"
267
+ "Please re-execute the task based on the clarification above."
268
+ )
269
+ follow_up_meta = await self._build_negotiation_follow_up_meta(
270
+ agent_name, neg_meta, clarification)
271
+ follow_up_meta = await self._run_before_send_handlers(agent_card, follow_up, follow_up_meta)
272
+ client = self._transport.create_a2a_client(agent_card)
273
+ send_req = self._transport.build_send_request(follow_up, context_id, follow_up_meta)
274
+ t_neg_stream = time.time()
275
+ logger.info(f"[Timing] Negotiation round {round_num} A2A stream to {agent_name}: starting")
276
+ response_text, last_task, last_meta, task_state = (
277
+ await self._transport.consume_stream(client, send_req, self._forward_intermediate_event, agent_name)
278
+ )
279
+ logger.info(f"[Timing] Negotiation round {round_num} A2A stream to {agent_name}: {time.time()-t_neg_stream:.2f}s")
280
+ if response_text is None and last_task is not None:
281
+ response_text = str(last_task)
282
+ r = SendMessageResult(
283
+ text=response_text or "", task=last_task,
284
+ metadata=last_meta, task_state=task_state,
285
+ )
286
+ r = await self._run_after_receive_handlers(agent_card, r)
287
+ return await self._auto_negotiate(agent_card, agent_name, original_message, context_id, r, round_num + 1)
288
+
289
+ async def _build_negotiation_follow_up_meta(
290
+ self, agent_name, neg_meta, clarification,
291
+ ):
292
+ """Build follow-up metadata, preferring SDK continue_negotiation.
293
+
294
+ Calls a2a-t-sdk continue_negotiation to generate a structured
295
+ Negotiation-T payload (with DATA-NEGOTIATION-T context). Falls
296
+ back to manual metadata construction when the SDK is unavailable
297
+ or the negotiation context is missing.
298
+ """
299
+ a2at_client = self._transport.get_a2at_client()
300
+ if a2at_client:
301
+ try:
302
+ receive_result = neg_meta.get("negotiation_context")
303
+ if isinstance(receive_result, dict):
304
+ context_dict = receive_result.get("context")
305
+ if isinstance(context_dict, dict):
306
+ from a2a_t.negotiation.common.models import (
307
+ ContinueNegotiationInput, NegotiationContext,
308
+ )
309
+ from a2a_t.negotiation.common.enums import NegotiationStatus
310
+ context = NegotiationContext.from_context(context_dict)
311
+ input_obj = ContinueNegotiationInput(
312
+ context=context,
313
+ status=NegotiationStatus.AGREED,
314
+ content_text=clarification,
315
+ )
316
+ payload = await asyncio.to_thread(a2at_client.continue_negotiation, input_obj)
317
+ logger.info(
318
+ f"[Negotiation] SDK continue_negotiation payload "
319
+ f"for '{agent_name}': round {context.round} -> AGREED"
320
+ )
321
+ return dict(payload)
322
+ except Exception as e:
323
+ logger.warning(
324
+ f"[Negotiation] continue_negotiation failed for "
325
+ f"'{agent_name}': {e}; using fallback"
326
+ )
327
+ return {
328
+ A2ATExtension.NEGOTIATION_T.uri:
329
+ "## Data Return Confirmation\n" + clarification + "\n",
330
+ }
331
+ # ------------------------------------------------------------------
332
+ # Extension handler chain
333
+ # ------------------------------------------------------------------
334
+
335
+ async def _run_before_send_handlers(
336
+ self, agent_card, message: str,
337
+ preset_metadata: Optional[Dict[str, Any]] = None,
338
+ ) -> Dict[str, Any]:
339
+ metadata: Dict[str, Any] = dict(preset_metadata) if preset_metadata else {}
340
+ ext_uris = self._transport._get_extensions(agent_card)
341
+ handlers = self._extension_registry.get_handlers_for_extensions(ext_uris)
342
+ agent_name = getattr(agent_card, "name", "?")
343
+ for handler in handlers:
344
+ t_h = time.time()
345
+ metadata = await handler.before_send(
346
+ agent_card, message, metadata,
347
+ self._transport.get_a2at_client(), self._control_point,
348
+ )
349
+ logger.info(
350
+ f"[Timing] Handler {type(handler).__name__}.before_send "
351
+ f"for {agent_name}: {time.time()-t_h:.2f}s"
352
+ )
353
+ return metadata
354
+
355
+ async def _run_after_receive_handlers(
356
+ self, agent_card, result: SendMessageResult,
357
+ ) -> SendMessageResult:
358
+ ext_uris = self._transport._get_extensions(agent_card)
359
+ handlers = self._extension_registry.get_handlers_for_extensions(ext_uris)
360
+ for handler in handlers:
361
+ result = await handler.after_receive(
362
+ agent_card, result,
363
+ self._transport.get_a2at_client(), self._control_point,
364
+ self._event_callback,
365
+ )
366
+ return result
367
+
368
+ # ------------------------------------------------------------------
369
+ # Negotiation helpers
370
+ # ------------------------------------------------------------------
371
+
372
+ @staticmethod
373
+ def _is_negotiation_needed(result: SendMessageResult) -> bool:
374
+ return bool(result.task_state and "INPUT_REQUIRED" in result.task_state)
@@ -0,0 +1,68 @@
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
+ """Loads key-value pairs from a ``.env`` file into ``os.environ``.
19
+
20
+ Only sets keys that are not already present in the OS environment. This
21
+ bridges the gap between the A2A-T SDK's internal ``.env`` loading and
22
+ engine components that read configuration values like ``A2AT_CRED_KEY``.
23
+ Mirrors the Java SDK's ``EnvFileLoader``.
24
+ """
25
+
26
+ import os
27
+ from pathlib import Path
28
+ from typing import Optional, Union
29
+
30
+ from loguru import logger
31
+
32
+
33
+ def load_to_environ(env_file_path: Optional[Union[str, Path]]) -> int:
34
+ """Parse a ``.env`` file and set each key as an env var unless already set.
35
+
36
+ Returns the number of keys loaded.
37
+ """
38
+ if env_file_path is None:
39
+ return 0
40
+ p = Path(env_file_path)
41
+ if not p.exists():
42
+ logger.debug(f"[EnvLoader] File not found: {p}")
43
+ return 0
44
+ try:
45
+ lines = p.read_text(encoding="utf-8").splitlines()
46
+ except Exception as e:
47
+ logger.warning(f"[EnvLoader] Failed to read {p}: {e}")
48
+ return 0
49
+ count = 0
50
+ for line in lines:
51
+ trimmed = line.strip()
52
+ if not trimmed or trimmed.startswith("#"):
53
+ continue
54
+ eq = trimmed.find("=")
55
+ if eq <= 0:
56
+ continue
57
+ key = trimmed[:eq].strip()
58
+ value = trimmed[eq + 1:].strip()
59
+ # Strip surrounding quotes
60
+ if len(value) >= 2 and value.startswith('"') and value.endswith('"'):
61
+ value = value[1:-1]
62
+ if key in os.environ:
63
+ continue
64
+ os.environ[key] = value
65
+ count += 1
66
+ if count > 0:
67
+ logger.info(f"[EnvLoader] Loaded {count} env var(s) from {p}")
68
+ return count
@@ -0,0 +1,197 @@
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
+ """Extension handler registry for A2A-T extensions (SDK-internal).
19
+
20
+ The in-workflow handler chain registers Task-T and Negotiation-T. Task-T
21
+ generates the structured task prompt on send; Negotiation-T extracts the
22
+ negotiation context on receive and feeds the auto-loop.
23
+
24
+ Authorization-T and Notification-T are pre-positioning concerns handled
25
+ once before the workflow starts via ExtensionSender, not part of this
26
+ in-workflow handler chain.
27
+ """
28
+
29
+ from abc import ABC, abstractmethod
30
+ from typing import Any, Dict, Optional, List, TYPE_CHECKING
31
+ from loguru import logger
32
+
33
+ if TYPE_CHECKING:
34
+ from workflow_engine.control.control_points import ControlPoint
35
+
36
+ from workflow_engine.core.models import SendMessageResult
37
+
38
+
39
+ class ExtensionHandler(ABC):
40
+ extension_keyword: str = ""
41
+
42
+ @abstractmethod
43
+ async def before_send(self, agent_card, message_text, metadata,
44
+ a2at_client=None, control_point=None) -> Dict[str, Any]:
45
+ ...
46
+
47
+ @abstractmethod
48
+ async def after_receive(self, agent_card, result, a2at_client=None,
49
+ control_point=None, event_callback=None) -> SendMessageResult:
50
+ ...
51
+
52
+
53
+ class TaskTHandler(ExtensionHandler):
54
+ extension_keyword = "Task-T"
55
+
56
+ def __init__(self):
57
+ self._prompt_cache: Dict[str, str] = {}
58
+
59
+ async def before_send(self, agent_card, message_text, metadata, a2at_client=None, control_point=None):
60
+ if not a2at_client:
61
+ return metadata
62
+ if "[NEGOTIATION_RESOLUTION]" in message_text:
63
+ logger.info("[Task-T] Skipping prompt generation for negotiation follow-up")
64
+ return metadata
65
+ task_t_uri = None
66
+ extensions = getattr(getattr(agent_card, "capabilities", None), "extensions", None) or []
67
+ for ext in extensions:
68
+ uri = getattr(ext, "uri", "") or ""
69
+ if "Task-T" in uri:
70
+ task_t_uri = uri
71
+ break
72
+ if not task_t_uri:
73
+ return metadata
74
+ if task_t_uri in metadata:
75
+ logger.info(f"[Task-T] Metadata already preset, skipping generation")
76
+ return metadata
77
+ cache_key = message_text
78
+ if cache_key in self._prompt_cache:
79
+ cached = self._prompt_cache[cache_key]
80
+ metadata[task_t_uri] = cached
81
+ logger.info(
82
+ f"[Task-T] Cache hit for '{getattr(agent_card, 'name', '?')}', "
83
+ f"{len(cached)} chars"
84
+ )
85
+ return metadata
86
+ try:
87
+ import asyncio
88
+ prompt_result = await asyncio.to_thread(a2at_client.generate_task_prompt, message_text)
89
+ if hasattr(prompt_result, "success") and prompt_result.success:
90
+ if hasattr(prompt_result, "prompt_text") and prompt_result.prompt_text:
91
+ metadata[task_t_uri] = prompt_result.prompt_text
92
+ self._prompt_cache[cache_key] = prompt_result.prompt_text
93
+ logger.info(f"[Task-T] Generated prompt for '{getattr(agent_card, 'name', '?')}', {len(prompt_result.prompt_text)} chars")
94
+ logger.debug(f"[Task-T] Prompt content: [{prompt_result.prompt_text}]")
95
+ else:
96
+ failure = getattr(prompt_result, "failure", None)
97
+ if failure:
98
+ logger.warning(f"[Task-T] Prompt generation failed: {getattr(failure, 'message', '')}")
99
+ logger.info(f"[Task-T] Failure detail: {failure}")
100
+ except Exception as e:
101
+ logger.warning(f"[Task-T] Failed: {e}")
102
+ return metadata
103
+
104
+ async def after_receive(self, agent_card, result, a2at_client=None, control_point=None, event_callback=None):
105
+ return result
106
+
107
+
108
+ class NegotiationTHandler(ExtensionHandler):
109
+ extension_keyword = "Negotiation-T"
110
+
111
+ async def before_send(self, agent_card, message_text, metadata, a2at_client=None, control_point=None):
112
+ return metadata
113
+
114
+ async def after_receive(self, agent_card, result, a2at_client=None, control_point=None, event_callback=None):
115
+ if not a2at_client:
116
+ return result
117
+ if not result.task_state or "INPUT_REQUIRED" not in result.task_state:
118
+ return result
119
+ extensions = getattr(getattr(agent_card, "capabilities", None), "extensions", None) or []
120
+ supports_neg = any("NEGOTIATION-T" in (getattr(ext, "uri", "") or "") for ext in extensions)
121
+ if not supports_neg:
122
+ return result
123
+ metadata = dict(result.metadata) if result.metadata else {}
124
+ context_map = self._extract_negotiation_context(metadata)
125
+ if context_map is None:
126
+ context_map = metadata
127
+ try:
128
+ import asyncio as _aio
129
+ receive_result = await _aio.to_thread(
130
+ a2at_client.receive_negotiation, message=result.text, context=context_map)
131
+ if receive_result.get("needResponse", False):
132
+ result.metadata["negotiation_message"] = receive_result.get("message", "")
133
+ result.metadata["negotiation_context"] = receive_result
134
+ logger.info(f"[Negotiation-T] Agent '{getattr(agent_card, 'name', '?')}' requested negotiation: {result.metadata['negotiation_message']}")
135
+ except Exception as e:
136
+ msg = str(e) if e else ""
137
+ if "Unsupported negotiation type" in msg:
138
+ logger.debug(f"[Negotiation-T] SDK receiveNegotiation unavailable for '{getattr(agent_card, 'name', '?')}' ({msg}), using fallback")
139
+ else:
140
+ logger.warning(f"[Negotiation-T] receiveNegotiation failed for '{getattr(agent_card, 'name', '?')}': {msg}, using fallback")
141
+ fallback_text = self._extract_negotiation_text(metadata)
142
+ if fallback_text:
143
+ result.metadata["negotiation_message"] = fallback_text
144
+ logger.info(f"[Negotiation-T] Agent '{getattr(agent_card, 'name', '?')}' requested negotiation (fallback): {fallback_text}")
145
+ result.metadata = metadata
146
+ return result
147
+
148
+ @staticmethod
149
+ def _extract_negotiation_context(metadata):
150
+ if not metadata:
151
+ return None
152
+ for key, value in metadata.items():
153
+ if "DATA-NEGOTIATION-T" in str(key) and isinstance(value, dict):
154
+ return value
155
+ return None
156
+
157
+ @staticmethod
158
+ def _extract_negotiation_text(metadata):
159
+ if not metadata:
160
+ return None
161
+ for key, value in metadata.items():
162
+ key_str = str(key)
163
+ if "NEGOTIATION-T" in key_str and "DATA-NEGOTIATION-T" not in key_str and isinstance(value, str):
164
+ return value
165
+ return None
166
+
167
+
168
+ class ExtensionRegistry:
169
+ """Registry of in-workflow extension handlers.
170
+
171
+ Pre-registers the Task-T and Negotiation-T handlers, which participate
172
+ in every send_message lifecycle. Authorization-T / Notification-T are
173
+ excluded by design: they are one-shot pre-positioning operations
174
+ (see ExtensionSender), not in-workflow handlers.
175
+ """
176
+
177
+ def __init__(self):
178
+ self._handlers: Dict[str, ExtensionHandler] = {}
179
+ self.register(TaskTHandler())
180
+ self.register(NegotiationTHandler())
181
+
182
+ def register(self, handler: ExtensionHandler):
183
+ self._handlers[handler.extension_keyword] = handler
184
+
185
+ def get_handlers_for_extensions(self, extension_uris: List[str]) -> List[ExtensionHandler]:
186
+ matched = []
187
+ seen = set()
188
+ for uri in extension_uris:
189
+ for keyword, handler in self._handlers.items():
190
+ # Case-insensitive match: extension URIs commonly use
191
+ # uppercase (e.g. "NEGOTIATION-T") while the handler keyword
192
+ # uses mixed case ("Negotiation-T").
193
+ if keyword.lower() in uri.lower() and keyword not in seen:
194
+ matched.append(handler)
195
+ seen.add(keyword)
196
+ break
197
+ return matched