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