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,76 @@
|
|
|
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
|
+
"""A2A Extension interceptor - injects A2A-Extensions HTTP header.
|
|
19
|
+
|
|
20
|
+
Reads the agent's declared extensions from AgentCard.capabilities.extensions[].uri
|
|
21
|
+
and sets the A2A-Extensions header so the server knows which extensions
|
|
22
|
+
the client supports.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from typing import List
|
|
26
|
+
from loguru import logger
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
from a2a.client.interceptors import ClientCallInterceptor, BeforeArgs, AfterArgs
|
|
30
|
+
from a2a.client.client import ClientCallContext
|
|
31
|
+
from a2a.extensions.common import HTTP_EXTENSION_HEADER, get_requested_extensions
|
|
32
|
+
_A2A_AVAILABLE = True
|
|
33
|
+
except ImportError:
|
|
34
|
+
_A2A_AVAILABLE = False
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ExtensionInterceptor(ClientCallInterceptor if _A2A_AVAILABLE else object):
|
|
38
|
+
"""Injects A2A extension URIs from AgentCard into HTTP headers."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, extension_uris: List[str]):
|
|
41
|
+
self._uris = list(extension_uris) if extension_uris else []
|
|
42
|
+
|
|
43
|
+
async def before(self, args: BeforeArgs) -> None:
|
|
44
|
+
if not self._uris:
|
|
45
|
+
return
|
|
46
|
+
if args.context is None:
|
|
47
|
+
args.context = ClientCallContext()
|
|
48
|
+
if args.context.service_parameters is None:
|
|
49
|
+
args.context.service_parameters = {}
|
|
50
|
+
# Only advertise extensions that are actually present in this message's metadata.
|
|
51
|
+
# Java SDK's ExtensionInterceptor.filterActiveExtensions does the same: it inspects
|
|
52
|
+
# the payload (message metadata) and only includes URIs that appear as keys.
|
|
53
|
+
payload = args.payload if hasattr(args, "payload") else None
|
|
54
|
+
active_uris = []
|
|
55
|
+
if isinstance(payload, dict):
|
|
56
|
+
for uri in self._uris:
|
|
57
|
+
if uri in payload:
|
|
58
|
+
active_uris.append(uri)
|
|
59
|
+
elif hasattr(payload, "message") and hasattr(payload.message, "metadata"):
|
|
60
|
+
meta = payload.message.metadata
|
|
61
|
+
meta_keys = set(meta.keys()) if meta else set()
|
|
62
|
+
for uri in self._uris:
|
|
63
|
+
if uri in meta_keys:
|
|
64
|
+
active_uris.append(uri)
|
|
65
|
+
if not active_uris:
|
|
66
|
+
return
|
|
67
|
+
existing = args.context.service_parameters.get(HTTP_EXTENSION_HEADER, "")
|
|
68
|
+
existing_values = [existing] if existing else []
|
|
69
|
+
merged = sorted(get_requested_extensions([*existing_values, *active_uris]))
|
|
70
|
+
extension_value = ",".join(merged)
|
|
71
|
+
args.context.service_parameters[HTTP_EXTENSION_HEADER] = extension_value
|
|
72
|
+
args.context.service_parameters["x-a2a-extensions"] = extension_value
|
|
73
|
+
logger.info(f"[Extensions] Set {HTTP_EXTENSION_HEADER}={extension_value}")
|
|
74
|
+
|
|
75
|
+
async def after(self, args: AfterArgs) -> None:
|
|
76
|
+
pass
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# Copyright (c) 2026 Huawei Technologies Co., Ltd.
|
|
2
|
+
# All Rights Reserved.
|
|
3
|
+
#
|
|
4
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
|
|
6
|
+
"""ExtensionSender -- one-shot pre-positioning facade over A2ATransport.
|
|
7
|
+
|
|
8
|
+
Single responsibility: send Authorization-T / Notification-T (and any
|
|
9
|
+
other one-shot extension) messages to agents BEFORE the workflow starts.
|
|
10
|
+
Bypasses Task-T prompt generation and the Negotiation-T auto-loop, and
|
|
11
|
+
does not emit events through the global EventCallback (the returned
|
|
12
|
+
result is the callback).
|
|
13
|
+
|
|
14
|
+
Kept separate from WorkflowEngineClient so a caller that only wants to
|
|
15
|
+
pre-position is not forced to hold a workflow-machinery facade.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from typing import Dict, Any, Optional, Callable
|
|
19
|
+
import asyncio
|
|
20
|
+
from loguru import logger
|
|
21
|
+
|
|
22
|
+
from workflow_engine.client.a2a_transport import A2ATransport
|
|
23
|
+
from workflow_engine.client.extensions import A2ATExtension
|
|
24
|
+
from workflow_engine.core.models import SendMessageResult
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ExtensionSender:
|
|
28
|
+
"""One-shot extension message sender built on a shared A2ATransport."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, transport: A2ATransport):
|
|
31
|
+
self._transport = transport
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def transport(self) -> A2ATransport:
|
|
35
|
+
return self._transport
|
|
36
|
+
|
|
37
|
+
def get_a2at_client(self):
|
|
38
|
+
return self._transport.get_a2at_client()
|
|
39
|
+
|
|
40
|
+
async def send_extension_message(
|
|
41
|
+
self,
|
|
42
|
+
agent_name: str,
|
|
43
|
+
instruction: str,
|
|
44
|
+
natural_language_input: str,
|
|
45
|
+
extension: A2ATExtension,
|
|
46
|
+
) -> SendMessageResult:
|
|
47
|
+
"""Send a one-shot extension message for pre-positioning.
|
|
48
|
+
|
|
49
|
+
The metadata value is generated by the A2A-T SDK (LLM + prompt
|
|
50
|
+
template) from the natural-language input; if the SDK cannot
|
|
51
|
+
generate, the input text is used as-is.
|
|
52
|
+
|
|
53
|
+
For NOTIFICATION_T, automatically routes to a long-lived SSE stream
|
|
54
|
+
(subscription stays open). The returned result contains the
|
|
55
|
+
subscription confirmation.
|
|
56
|
+
"""
|
|
57
|
+
agent_card = self._transport.get_card(agent_name)
|
|
58
|
+
if not agent_card:
|
|
59
|
+
raise RuntimeError(f"Agent not found: {agent_name}")
|
|
60
|
+
metadata_value = await asyncio.to_thread(
|
|
61
|
+
self._generate_extension_prompt, extension, natural_language_input
|
|
62
|
+
)
|
|
63
|
+
if not metadata_value:
|
|
64
|
+
metadata_value = natural_language_input
|
|
65
|
+
logger.info(f"[ExtensionSender] SDK prompt generation unavailable for {agent_name} ({extension.display_name}), using input as metadata")
|
|
66
|
+
logger.info(f"[ExtensionSender] sendExtensionMessage to {agent_name}: extension={extension.display_name}, metadataValue={len(metadata_value)} chars")
|
|
67
|
+
metadata = {extension.uri: metadata_value}
|
|
68
|
+
client = self._transport.create_a2a_client(agent_card)
|
|
69
|
+
send_req = self._transport.build_send_request(instruction, None, metadata)
|
|
70
|
+
|
|
71
|
+
if extension == A2ATExtension.NOTIFICATION_T:
|
|
72
|
+
logger.info(f"[ExtensionSender] Notification-T auto-routed to long-lived SSE stream")
|
|
73
|
+
await self._transport.consume_notification_stream(
|
|
74
|
+
client, send_req, event_callback=None, agent_name=agent_name
|
|
75
|
+
)
|
|
76
|
+
return SendMessageResult(
|
|
77
|
+
text="Subscribed", task=None,
|
|
78
|
+
metadata=metadata, task_state="TASK_STATE_WORKING",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
response_text, last_task, last_meta, task_state = (
|
|
82
|
+
await self._transport.consume_stream(client, send_req)
|
|
83
|
+
)
|
|
84
|
+
if response_text is None and last_task is not None:
|
|
85
|
+
response_text = str(last_task)
|
|
86
|
+
result = SendMessageResult(
|
|
87
|
+
text=response_text or "", task=last_task,
|
|
88
|
+
metadata=last_meta, task_state=task_state,
|
|
89
|
+
)
|
|
90
|
+
logger.info(f"[ExtensionSender] Extension response from {agent_name}: state={result.task_state}")
|
|
91
|
+
return result
|
|
92
|
+
|
|
93
|
+
async def send_authorization(
|
|
94
|
+
self, agent_name: str, instruction: str, natural_language_input: str,
|
|
95
|
+
) -> SendMessageResult:
|
|
96
|
+
"""Convenience for Authorization-T pre-positioning."""
|
|
97
|
+
return await self.send_extension_message(
|
|
98
|
+
agent_name, instruction, natural_language_input, A2ATExtension.AUTHORIZATION_T)
|
|
99
|
+
|
|
100
|
+
async def send_notification(
|
|
101
|
+
self, agent_name: str, instruction: str, natural_language_input: str,
|
|
102
|
+
event_callback: Optional[Callable[[Dict[str, Any]], None]] = None,
|
|
103
|
+
) -> SendMessageResult:
|
|
104
|
+
"""Notification-T pre-positioning with long-lived SSE subscription.
|
|
105
|
+
|
|
106
|
+
Always opens a long-lived SSE stream. The returned
|
|
107
|
+
``SendMessageResult`` contains the subscription confirmation.
|
|
108
|
+
Subsequent events pushed by the agent (e.g. recovery results) are
|
|
109
|
+
forwarded to ``event_callback`` as dicts containing ``agent``,
|
|
110
|
+
``type``, ``state``, ``text``, ``metadata``, etc.
|
|
111
|
+
|
|
112
|
+
When ``event_callback`` is ``None``, the stream stays open and
|
|
113
|
+
events are logged at INFO level but not forwarded.
|
|
114
|
+
|
|
115
|
+
The background stream task is stored on the sender instance and
|
|
116
|
+
can be cancelled via ``cancel_notification_streams()``.
|
|
117
|
+
"""
|
|
118
|
+
agent_card = self._transport.get_card(agent_name)
|
|
119
|
+
if not agent_card:
|
|
120
|
+
raise RuntimeError(f"Agent not found: {agent_name}")
|
|
121
|
+
metadata_value = await asyncio.to_thread(
|
|
122
|
+
self._generate_extension_prompt, A2ATExtension.NOTIFICATION_T, natural_language_input
|
|
123
|
+
)
|
|
124
|
+
if not metadata_value:
|
|
125
|
+
metadata_value = natural_language_input
|
|
126
|
+
logger.info(f"[ExtensionSender] SDK prompt generation unavailable for {agent_name} (Notification-T), using input as metadata")
|
|
127
|
+
metadata = {A2ATExtension.NOTIFICATION_T.uri: metadata_value}
|
|
128
|
+
client = self._transport.create_a2a_client(agent_card)
|
|
129
|
+
send_req = self._transport.build_send_request(instruction, None, metadata)
|
|
130
|
+
|
|
131
|
+
logger.info(f"[ExtensionSender] sendNotification to {agent_name}: long-lived SSE, callback={'yes' if event_callback else 'no'}")
|
|
132
|
+
bg_task = await self._transport.consume_notification_stream(
|
|
133
|
+
client, send_req, event_callback, agent_name
|
|
134
|
+
)
|
|
135
|
+
if not hasattr(self, '_notification_tasks'):
|
|
136
|
+
self._notification_tasks = []
|
|
137
|
+
self._notification_tasks.append(bg_task)
|
|
138
|
+
return SendMessageResult(
|
|
139
|
+
text="Subscribed", task=None,
|
|
140
|
+
metadata=metadata, task_state="TASK_STATE_WORKING",
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def cancel_notification_streams(self):
|
|
144
|
+
"""Cancel all active Notification-T background streams."""
|
|
145
|
+
tasks = getattr(self, '_notification_tasks', [])
|
|
146
|
+
for t in tasks:
|
|
147
|
+
if not t.done():
|
|
148
|
+
t.cancel()
|
|
149
|
+
tasks.clear()
|
|
150
|
+
logger.info("[ExtensionSender] All notification streams cancelled")
|
|
151
|
+
|
|
152
|
+
# ------------------------------------------------------------------
|
|
153
|
+
# Extension prompt generation dispatch
|
|
154
|
+
# ------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
def _generate_extension_prompt(self, extension, natural_language_input):
|
|
157
|
+
"""Dispatch to the SDK extension-specific prompt generation."""
|
|
158
|
+
if extension == A2ATExtension.TASK_T:
|
|
159
|
+
return self.generate_prompt_text(natural_language_input)
|
|
160
|
+
if extension == A2ATExtension.NEGOTIATION_T:
|
|
161
|
+
return self.generate_negotiation_prompt(natural_language_input)
|
|
162
|
+
if extension == A2ATExtension.AUTHORIZATION_T:
|
|
163
|
+
return self.generate_authorization_prompt(natural_language_input)
|
|
164
|
+
if extension == A2ATExtension.NOTIFICATION_T:
|
|
165
|
+
return self.generate_notification_prompt(natural_language_input)
|
|
166
|
+
return ''
|
|
167
|
+
|
|
168
|
+
def generate_prompt_text(self, natural_language_input: str) -> str:
|
|
169
|
+
"""Generate structured Task-T prompt text from natural-language input."""
|
|
170
|
+
a2at_client = self._transport.get_a2at_client()
|
|
171
|
+
if not a2at_client:
|
|
172
|
+
return ''
|
|
173
|
+
try:
|
|
174
|
+
prompt_result = a2at_client.generate_task_prompt(natural_language_input)
|
|
175
|
+
if hasattr(prompt_result, 'success') and prompt_result.success:
|
|
176
|
+
text = getattr(prompt_result, 'prompt_text', None)
|
|
177
|
+
if text:
|
|
178
|
+
return text
|
|
179
|
+
else:
|
|
180
|
+
failure = getattr(prompt_result, 'failure', None)
|
|
181
|
+
if failure:
|
|
182
|
+
logger.warning('[ExtensionSender] SDK Task-T prompt generation failed: ' + str(getattr(failure, 'message', '')))
|
|
183
|
+
except Exception as e:
|
|
184
|
+
logger.warning('[ExtensionSender] SDK Task-T prompt generation error: ' + str(e))
|
|
185
|
+
return ''
|
|
186
|
+
|
|
187
|
+
def generate_negotiation_prompt(self, natural_language_input: str) -> str:
|
|
188
|
+
"""Generate Negotiation-T prompt text. Reserved for SDK support."""
|
|
189
|
+
if not self._transport.get_a2at_client():
|
|
190
|
+
return ''
|
|
191
|
+
return ''
|
|
192
|
+
|
|
193
|
+
def generate_authorization_prompt(self, natural_language_input: str) -> str:
|
|
194
|
+
"""Generate Authorization-T prompt text. Reserved for SDK support."""
|
|
195
|
+
if not self._transport.get_a2at_client():
|
|
196
|
+
return ''
|
|
197
|
+
return ''
|
|
198
|
+
|
|
199
|
+
def generate_notification_prompt(self, natural_language_input: str) -> str:
|
|
200
|
+
"""Generate Notification-T prompt text. Reserved for SDK support."""
|
|
201
|
+
if not self._transport.get_a2at_client():
|
|
202
|
+
return ''
|
|
203
|
+
return ''
|
|
@@ -0,0 +1,43 @@
|
|
|
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
|
+
"""A2A-T extension type constants.
|
|
19
|
+
|
|
20
|
+
Each constant encapsulates the full extension URI so callers never need to
|
|
21
|
+
hardcode URI strings. Use these with ``WorkflowEngineClient.send_extension_message``.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from enum import Enum
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class A2ATExtension(Enum):
|
|
28
|
+
"""A2A-T extension types supported by the workflow execution engine."""
|
|
29
|
+
|
|
30
|
+
TASK_T = "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1"
|
|
31
|
+
NEGOTIATION_T = "https://projects.tmforum.org/a2aproject/telecommunication/extensions/NEGOTIATION-T"
|
|
32
|
+
AUTHORIZATION_T = "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Authorization-T/v1"
|
|
33
|
+
NOTIFICATION_T = "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1"
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def uri(self) -> str:
|
|
37
|
+
"""The full extension URI used as metadata key and A2A-Extensions header value."""
|
|
38
|
+
return self.value
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def display_name(self) -> str:
|
|
42
|
+
"""Short display name (e.g. 'Authorization-T')."""
|
|
43
|
+
return self.name.replace("_", "-")
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Copyright (c) 2026 Huawei Technologies Co., Ltd.
|
|
2
|
+
# All Rights Reserved.
|
|
3
|
+
#
|
|
4
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
|
|
6
|
+
"""Protocol-level request/response logger.
|
|
7
|
+
|
|
8
|
+
Mirrors the Java SDK's ProtocolLogger: prints full HTTP headers and
|
|
9
|
+
body content at INFO level for protocol debugging. Enable by setting
|
|
10
|
+
the logging level to INFO for this module.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from typing import Any, Dict, Optional
|
|
15
|
+
from loguru import logger
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def log_request(agent_name: str, endpoint: str,
|
|
19
|
+
params: Any, headers: Optional[Dict[str, str]] = None) -> None:
|
|
20
|
+
"""Log an outgoing A2A request (headers + body)."""
|
|
21
|
+
if isinstance(params, str):
|
|
22
|
+
body = params
|
|
23
|
+
else:
|
|
24
|
+
try:
|
|
25
|
+
body = json.dumps(params, ensure_ascii=False, indent=2, default=str)
|
|
26
|
+
except Exception:
|
|
27
|
+
body = str(params)
|
|
28
|
+
header_lines = []
|
|
29
|
+
if headers:
|
|
30
|
+
for k, v in sorted(headers.items()):
|
|
31
|
+
header_lines.append(f" {k}: {v if isinstance(v, str) else str(v)[:200]}")
|
|
32
|
+
header_str = "\n".join(header_lines) if header_lines else " (none)"
|
|
33
|
+
logger.info(f">>> [{agent_name}] REQUEST to {endpoint}\n=== Headers ===\n{header_str}\n=== Body ===\n{body}")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def log_response(agent_name: str, event_type: str, body: str) -> None:
|
|
37
|
+
"""Log an incoming A2A response (event type + body)."""
|
|
38
|
+
logger.info(f"<<< [{agent_name}] RESPONSE [{event_type}]\n{body}")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def log_response_event(agent_name: str, event: Any) -> None:
|
|
42
|
+
"""Log a structured SSE response event (mirrors Java ProtocolLogger.logResponseEvent).
|
|
43
|
+
|
|
44
|
+
Extracts the inner payload from TaskUpdateEvent / MessageEvent wrappers
|
|
45
|
+
and logs the full JSON for protocol-level debugging.
|
|
46
|
+
"""
|
|
47
|
+
try:
|
|
48
|
+
event_type = type(event).__name__
|
|
49
|
+
payload = _extract_payload(event)
|
|
50
|
+
if payload is None:
|
|
51
|
+
logger.info(f"<<< [{agent_name}] RESPONSE [{event_type}]: (no serializable payload)")
|
|
52
|
+
return
|
|
53
|
+
if isinstance(payload, str):
|
|
54
|
+
body = payload
|
|
55
|
+
else:
|
|
56
|
+
try:
|
|
57
|
+
body = json.dumps(payload, ensure_ascii=False, indent=2, default=str)
|
|
58
|
+
except Exception:
|
|
59
|
+
body = str(payload)
|
|
60
|
+
logger.info(f"<<< [{agent_name}] RESPONSE [{event_type}]\n{body}")
|
|
61
|
+
except Exception as e:
|
|
62
|
+
logger.warning(f"<<< [{agent_name}] Failed to serialize response event: {e}")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _extract_payload(event: Any) -> Any:
|
|
66
|
+
"""Extract the serializable protocol payload from a ClientEvent."""
|
|
67
|
+
if hasattr(event, "task"):
|
|
68
|
+
task = event.task
|
|
69
|
+
if hasattr(task, "status_updates") and task.status_updates:
|
|
70
|
+
return task.status_updates[-1]
|
|
71
|
+
if hasattr(task, "artifacts") and task.artifacts:
|
|
72
|
+
return task.artifacts[-1]
|
|
73
|
+
return task
|
|
74
|
+
if hasattr(event, "message"):
|
|
75
|
+
return event.message
|
|
76
|
+
if hasattr(event, "update_event"):
|
|
77
|
+
return event.update_event
|
|
78
|
+
return event
|
|
@@ -0,0 +1,87 @@
|
|
|
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
|
+
"""SSE response normalization for non-standard agent responses.
|
|
19
|
+
|
|
20
|
+
Some A2A agents return bare Task or Message objects instead of properly
|
|
21
|
+
wrapped StreamResponse envelopes. This module patches google.protobuf
|
|
22
|
+
json_format.Parse/ParseDict to coerce such responses into the expected
|
|
23
|
+
StreamResponse shape, mirroring the orchestration center's exec_engine.
|
|
24
|
+
|
|
25
|
+
Import this module once at startup; the patch is process-global.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import json as _json
|
|
29
|
+
import google.protobuf.json_format as _json_format
|
|
30
|
+
|
|
31
|
+
_STREAM_RESPONSE_KEYS = frozenset({"task", "message", "statusUpdate", "artifactUpdate"})
|
|
32
|
+
|
|
33
|
+
_original_parse = _json_format.Parse
|
|
34
|
+
_original_parse_dict = _json_format.ParseDict
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _normalize_stream_response(data: dict) -> dict:
|
|
38
|
+
"""Coerce a non-SSE dict into a StreamResponse-shaped dict."""
|
|
39
|
+
if _STREAM_RESPONSE_KEYS.intersection(data):
|
|
40
|
+
return data
|
|
41
|
+
if "id" in data and "status" in data:
|
|
42
|
+
return {"task": data}
|
|
43
|
+
if "artifact" in data and "taskId" in data:
|
|
44
|
+
return {"artifactUpdate": data}
|
|
45
|
+
if "status" in data and "taskId" in data:
|
|
46
|
+
return {"statusUpdate": data}
|
|
47
|
+
return data
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _parse_with_unknown(text, message, ignore_unknown_fields=False, **kwargs):
|
|
51
|
+
from a2a.types.a2a_pb2 import StreamResponse
|
|
52
|
+
is_stream = isinstance(message, StreamResponse)
|
|
53
|
+
if is_stream:
|
|
54
|
+
try:
|
|
55
|
+
data = _json.loads(text)
|
|
56
|
+
if isinstance(data, dict):
|
|
57
|
+
if not _STREAM_RESPONSE_KEYS.intersection(data):
|
|
58
|
+
logger = __import__("loguru").logger
|
|
59
|
+
logger.warning(f"[A2A] Non-SSE response from server: {text[:2048]}")
|
|
60
|
+
data = _normalize_stream_response(data)
|
|
61
|
+
text = _json.dumps(data)
|
|
62
|
+
except Exception:
|
|
63
|
+
pass
|
|
64
|
+
kwargs["ignore_unknown_fields"] = True
|
|
65
|
+
return _original_parse(text, message, ignore_unknown_fields=ignore_unknown_fields, **kwargs)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _parse_dict_with_unknown(js, message, *args, **kwargs):
|
|
69
|
+
from a2a.types.a2a_pb2 import StreamResponse
|
|
70
|
+
is_stream = isinstance(message, StreamResponse)
|
|
71
|
+
if is_stream and isinstance(js, dict):
|
|
72
|
+
js = _normalize_stream_response(js)
|
|
73
|
+
if not is_stream:
|
|
74
|
+
return _original_parse_dict(js, message, *args, **kwargs)
|
|
75
|
+
kwargs.pop("ignore_unknown_fields", None)
|
|
76
|
+
args = list(args)
|
|
77
|
+
if args:
|
|
78
|
+
args[0] = True
|
|
79
|
+
else:
|
|
80
|
+
kwargs["ignore_unknown_fields"] = True
|
|
81
|
+
return _original_parse_dict(js, message, *args, **kwargs)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def apply_sse_normalization():
|
|
85
|
+
"""Apply the global Parse/ParseDict patches (idempotent)."""
|
|
86
|
+
_json_format.Parse = _parse_with_unknown
|
|
87
|
+
_json_format.ParseDict = _parse_dict_with_unknown
|
|
@@ -0,0 +1,84 @@
|
|
|
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
|
+
"""SSL context factory for outbound HTTPS calls.
|
|
19
|
+
|
|
20
|
+
Self-contained - does not depend on the orchestration center's config system.
|
|
21
|
+
Accepts a config dict with SSL parameters, or returns False (skip verification)
|
|
22
|
+
when no config is provided.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import os
|
|
26
|
+
import ssl
|
|
27
|
+
from typing import Union, Optional
|
|
28
|
+
from loguru import logger
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def create_ssl_context(
|
|
32
|
+
verify_server: bool = False,
|
|
33
|
+
ca_certs_path: Optional[str] = None,
|
|
34
|
+
cert_path: Optional[str] = None,
|
|
35
|
+
key_path: Optional[str] = None,
|
|
36
|
+
key_password: Optional[str] = None,
|
|
37
|
+
crl_path: Optional[str] = None,
|
|
38
|
+
) -> Union[ssl.SSLContext, bool]:
|
|
39
|
+
"""Build an SSL context for httpx verify parameter.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
verify_server: Whether to verify remote server certificates.
|
|
43
|
+
ca_certs_path: Path to CA trust store file.
|
|
44
|
+
cert_path: Path to client certificate (for mTLS).
|
|
45
|
+
key_path: Path to client private key.
|
|
46
|
+
key_password: Password for the private key.
|
|
47
|
+
crl_path: Path to CRL file.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
ssl.SSLContext if verification enabled, False otherwise.
|
|
51
|
+
"""
|
|
52
|
+
if not verify_server:
|
|
53
|
+
logger.warning("Outbound TLS verification disabled. Insecure for production.")
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH)
|
|
58
|
+
|
|
59
|
+
if ca_certs_path and os.path.exists(ca_certs_path):
|
|
60
|
+
ctx.load_verify_locations(ca_certs_path)
|
|
61
|
+
logger.info(f"Client SSL: loaded CA trust store from {ca_certs_path}")
|
|
62
|
+
else:
|
|
63
|
+
logger.warning(f"Client SSL: CA trust store not found at {ca_certs_path}, using system default")
|
|
64
|
+
|
|
65
|
+
if cert_path and key_path and os.path.exists(cert_path) and os.path.exists(key_path):
|
|
66
|
+
try:
|
|
67
|
+
ctx.load_cert_chain(
|
|
68
|
+
certfile=cert_path,
|
|
69
|
+
keyfile=key_path,
|
|
70
|
+
password=key_password if key_password else None,
|
|
71
|
+
)
|
|
72
|
+
logger.info("Client SSL: loaded client identity cert for mTLS")
|
|
73
|
+
except Exception as e:
|
|
74
|
+
logger.warning(f"Client SSL: could not load client cert chain: {e}")
|
|
75
|
+
|
|
76
|
+
if crl_path and os.path.exists(crl_path):
|
|
77
|
+
ctx.load_verify_locations(crl_path)
|
|
78
|
+
ctx.verify_flags |= ssl.VERIFY_CRL_CHECK_LEAF
|
|
79
|
+
logger.info(f"Client SSL: enabled CRL checking from {crl_path}")
|
|
80
|
+
|
|
81
|
+
return ctx
|
|
82
|
+
except Exception as e:
|
|
83
|
+
logger.error(f"Failed to build SSL context: {e}. Falling back to no verification.")
|
|
84
|
+
return False
|
|
@@ -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
|
+
"""Stub WorkflowEngineClient for testing.
|
|
7
|
+
|
|
8
|
+
Mirrors the Java SDK's StubWorkflowEngineClient. Records all sends
|
|
9
|
+
and returns canned responses. Useful for unit tests that need to
|
|
10
|
+
verify the workflow engine dispatches correctly without real A2A.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from typing import Any, Dict, List, Optional
|
|
14
|
+
from workflow_engine.core.models import SendMessageResult
|
|
15
|
+
from workflow_engine.control.control_points import EventType
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class StubWorkflowEngineClient:
|
|
19
|
+
"""Minimal stub that records sends and returns canned text.
|
|
20
|
+
|
|
21
|
+
Implements the workflow-send surface only (send_message). The
|
|
22
|
+
pre-positioning surface (send_extension_message) lives on
|
|
23
|
+
ExtensionSender in production; tests that need to stub it can
|
|
24
|
+
subclass ExtensionSender or build a transport-backed stub.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self):
|
|
28
|
+
self.sent: List[tuple] = []
|
|
29
|
+
self._control_point = None
|
|
30
|
+
self._event_callback = None
|
|
31
|
+
|
|
32
|
+
async def send_message(self, agent_name: str, message: str,
|
|
33
|
+
context_id: Optional[str] = None,
|
|
34
|
+
metadata: Optional[Dict[str, Any]] = None) -> SendMessageResult:
|
|
35
|
+
self.sent.append((agent_name, message))
|
|
36
|
+
return SendMessageResult(
|
|
37
|
+
text=f"OK from {agent_name}", task_state="COMPLETED")
|
|
38
|
+
|
|
39
|
+
def set_control_point(self, control_point):
|
|
40
|
+
self._control_point = control_point
|
|
41
|
+
|
|
42
|
+
def set_event_callback(self, callback):
|
|
43
|
+
self._event_callback = callback
|
|
44
|
+
|
|
45
|
+
def register_handler(self, handler):
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def agent_names(self) -> List[str]:
|
|
50
|
+
return []
|
|
51
|
+
|
|
52
|
+
def update_agent_cards(self, agent_cards: List[Any]):
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
def get_a2at_client(self):
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
def get_card(self, agent_name: str):
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
async def close(self):
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
async def __aenter__(self):
|
|
65
|
+
return self
|
|
66
|
+
|
|
67
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
68
|
+
await self.close()
|
|
@@ -0,0 +1,26 @@
|
|
|
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
|
+
|
|
19
|
+
from workflow_engine.control.control_points import (
|
|
20
|
+
ControlPoint,
|
|
21
|
+
EventCallback, EventType, NegotiationStrategy, DefaultControlPoint,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__all__ = ["ControlPoint",
|
|
25
|
+
"EventCallback", "EventType",
|
|
26
|
+
"NegotiationStrategy", "DefaultControlPoint"]
|