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,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
|