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,184 @@
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
+ """Data models for the Workflow Execution SDK."""
19
+
20
+ from dataclasses import dataclass, field
21
+ from enum import Enum
22
+ from typing import List, Optional, Dict, Any
23
+
24
+
25
+ class StepType(Enum):
26
+ ALL_SUCCESS = "AllSuccess"
27
+ ANY_SUCCESS = "AnySuccess"
28
+ SELF_LOOP = "SelfLoop"
29
+
30
+ @classmethod
31
+ def from_value(cls, value: Any) -> "StepType":
32
+ """Case-insensitive lookup by enum value or name.
33
+
34
+ Accepts "AllSuccess", "ALLSUCCESS", "allsuccess", "ALL_SUCCESS",
35
+ "any_success", etc. Falls back to ALL_SUCCESS when unknown.
36
+ Mirrors the Java SDK's StepType.fromValue().
37
+ """
38
+ if not value:
39
+ return cls.ALL_SUCCESS
40
+ if isinstance(value, StepType):
41
+ return value
42
+ # Handle enum objects from other modules (e.g. the orchestration
43
+ # center's own StepType): extract .value so str() does not produce
44
+ # "StepType.SELF_LOOP" which would never match.
45
+ if hasattr(value, "value"):
46
+ value = value.value
47
+ text = str(value).strip()
48
+ for member in cls:
49
+ if member.value.lower() == text.lower() or member.name.lower() == text.lower():
50
+ return member
51
+ return cls.ALL_SUCCESS
52
+
53
+
54
+ class TaskStatus(Enum):
55
+ PENDING = "pending"
56
+ RUNNING = "running"
57
+ SUCCESS = "success"
58
+ FAILED = "failed"
59
+
60
+
61
+ @dataclass
62
+ class WorkflowSearchResult:
63
+ """Summary of a PSOP workflow returned by the search endpoint.
64
+
65
+ Mirrors the Java SDK's WorkflowSearchResult. Returned by
66
+ ``search_psop()``. To get the full workflow with steps, take
67
+ ``workflow_id`` and call ``load_psop()``.
68
+ """
69
+ workflow_id: str = ""
70
+ workflow_type: str = ""
71
+ name: str = ""
72
+ description: str = ""
73
+ tags: List[str] = field(default_factory=list)
74
+ created_at: str = ""
75
+ score: float = 1.0
76
+ user_intent: str = ""
77
+ related_preflow: str = ""
78
+ tasks_summary: str = ""
79
+
80
+ @classmethod
81
+ def from_dict(cls, data: Dict[str, Any]) -> "WorkflowSearchResult":
82
+ return cls(
83
+ workflow_id=data.get("workflow_id", data.get("id", "")),
84
+ workflow_type=data.get("workflow_type", ""),
85
+ name=data.get("name", ""),
86
+ description=data.get("description", ""),
87
+ tags=data.get("tags", []),
88
+ created_at=str(data["created_at"]) if data.get("created_at") else "",
89
+ score=float(data["score"]) if isinstance(data.get("score"), (int, float)) else 1.0,
90
+ user_intent=data.get("user_intent", ""),
91
+ related_preflow=data.get("related_preflow", ""),
92
+ tasks_summary=data.get("tasks_summary", ""),
93
+ )
94
+
95
+
96
+ @dataclass
97
+ class JumpCondition:
98
+ step: str
99
+ condition: str = ""
100
+
101
+
102
+ @dataclass
103
+ class Task:
104
+ agent: str
105
+ skill: str = ""
106
+ description: str = ""
107
+ status: TaskStatus = TaskStatus.PENDING
108
+
109
+
110
+ @dataclass
111
+ class WorkflowStep:
112
+ name: str
113
+ subtasks: List[Task] = field(default_factory=list)
114
+ next: List[JumpCondition] = field(default_factory=list)
115
+ layer: int = 0
116
+ context_from: Optional[List[str]] = None
117
+ step_type: StepType = StepType.ALL_SUCCESS
118
+
119
+
120
+ @dataclass
121
+ class Workflow:
122
+ id: str = ""
123
+ name: str = ""
124
+ description: str = ""
125
+ steps: List[WorkflowStep] = field(default_factory=list)
126
+
127
+ @classmethod
128
+ def from_dict(cls, data: Dict[str, Any]) -> "Workflow":
129
+ steps = []
130
+ for s in data.get("steps", []):
131
+ subtasks = [Task(agent=t.get("agent",""), skill=t.get("skill",""), description=t.get("description","")) for t in (s.get("subtasks") or [])]
132
+ next_list = [JumpCondition(step=jc.get("step",""), condition=jc.get("condition","")) for jc in (s.get("next") or [])]
133
+ st = s.get("step_type", s.get("type", "AllSuccess"))
134
+ step_type = StepType.from_value(st)
135
+ cf = s.get("context_from")
136
+ if cf and not isinstance(cf, list): cf = [cf]
137
+ steps.append(WorkflowStep(name=s.get("name",""), subtasks=subtasks, next=next_list, layer=s.get("layer",0), context_from=cf, step_type=step_type))
138
+ return cls(id=data.get("id",""), name=data.get("name",""), description=data.get("description",""), steps=steps)
139
+
140
+ @classmethod
141
+ def from_json(cls, json_str: str) -> "Workflow":
142
+ import json
143
+ return cls.from_dict(json.loads(json_str))
144
+
145
+
146
+ @dataclass
147
+ class SendMessageResult:
148
+ text: str = ""
149
+ task: Any = None
150
+ metadata: Dict[str, Any] = field(default_factory=dict)
151
+ task_state: str = ""
152
+
153
+
154
+ @dataclass
155
+ class TaskRequest:
156
+ agent_name: str
157
+ skill: str
158
+ message: str
159
+ context: str
160
+ step_name: str
161
+ subtask_index: int = 0
162
+ description: str = ""
163
+
164
+
165
+ @dataclass
166
+ class TaskResponse:
167
+ success: bool
168
+ output: str = ""
169
+ error: Optional[str] = None
170
+ metadata: Optional[Dict[str, Any]] = None
171
+
172
+
173
+ @dataclass
174
+ class RouteDecision:
175
+ next_step: str
176
+ reason: str = ""
177
+
178
+
179
+ @dataclass
180
+ class ExecutionResult:
181
+ success: bool
182
+ history: List[Dict[str, Any]] = field(default_factory=list)
183
+ step_outputs: Dict[str, Dict[str, Any]] = field(default_factory=dict)
184
+ error: Optional[str] = None
@@ -0,0 +1,21 @@
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.registry.registry_client import RegistryClient, load_psop, search_psop
20
+
21
+ __all__ = ["RegistryClient", "load_psop", "search_psop"]
@@ -0,0 +1,177 @@
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
+ """Optional helper for fetching AgentCards from the Registry Center.
19
+
20
+ Users can use this, or fetch AgentCards from any other source.
21
+ The SDK does not depend on this module.
22
+ """
23
+
24
+ import json
25
+ from typing import List, Any
26
+ from loguru import logger
27
+
28
+ from workflow_engine.client.agentcard_normalizer import normalize_agent_dict
29
+
30
+
31
+
32
+ async def load_psop(
33
+ base_url: str,
34
+ psop_id: str,
35
+ access_token: str = None,
36
+ ssl_verify: bool = True,
37
+ ) -> "Workflow":
38
+ """Fetch a PSOP from the orchestration center external API.
39
+
40
+ Uses the public external endpoint GET /api/v1/orchestrate/psop/{psop_id}.
41
+ Pass access_token when the orchestration center has external auth enabled.
42
+ Set ssl_verify=False for self-signed certs (dev only).
43
+ """
44
+ import httpx
45
+ from workflow_engine.core.models import Workflow
46
+ url = f"{base_url}/api/v1/orchestrate/psop/{psop_id}"
47
+ params = {}
48
+ if access_token:
49
+ params["access_token"] = access_token
50
+ logger.info(f"[Registry] Loading PSOP from {url} (ssl_verify={ssl_verify})")
51
+ async with httpx.AsyncClient(verify=ssl_verify, timeout=30, follow_redirects=True) as client:
52
+ resp = await client.get(url, params=params)
53
+ resp.raise_for_status()
54
+ data = resp.json()
55
+ wf = Workflow.from_dict(data.get("data", data))
56
+ logger.info(f"[Registry] Loaded workflow: {wf.name}, {len(wf.steps)} steps")
57
+ return wf
58
+
59
+
60
+ async def search_psop(
61
+ base_url: str,
62
+ intent: str,
63
+ top_n: int = 5,
64
+ access_token: str = None,
65
+ ssl_verify: bool = True,
66
+ ) -> List["WorkflowSearchResult"]:
67
+ """Search for matching PSOP workflows from the orchestration center.
68
+
69
+ Uses the public external endpoint POST /api/v1/orchestrate/search.
70
+ Returns a list of WorkflowSearchResult summary objects. To get the full
71
+ workflow with steps, take ``workflow_id`` from a result and call
72
+ ``load_psop(base_url, workflow_id, ...)``. Mirrors the Java SDK's
73
+ LoadPsop.search which returns WorkflowSearchResult.
74
+ """
75
+ import httpx
76
+ from workflow_engine.core.models import WorkflowSearchResult
77
+ url = f"{base_url}/api/v1/orchestrate/search"
78
+ params = {}
79
+ if access_token:
80
+ params["access_token"] = access_token
81
+ body = {"intent": intent, "top_n": top_n}
82
+ logger.info(f"[Registry] Searching PSOP at {url} (intent={intent[:60]}, top_n={top_n})")
83
+ async with httpx.AsyncClient(verify=ssl_verify, timeout=30, follow_redirects=True) as client:
84
+ resp = await client.post(url, json=body, params=params)
85
+ resp.raise_for_status()
86
+ data = resp.json()
87
+ raw_results = data.get("data", [])
88
+ results = [WorkflowSearchResult.from_dict(r) for r in raw_results]
89
+ logger.info(f"[Registry] Search returned {len(results)} workflow(s)")
90
+ return results
91
+
92
+
93
+
94
+ class RegistryClient:
95
+ """Fetches AgentCards from the Registry Center."""
96
+
97
+ def __init__(self, url: str, ssl_verify: bool = False, verify_ssl: bool = None):
98
+ # Accept the legacy ``verify_ssl`` keyword for backward compatibility;
99
+ # ``ssl_verify`` matches WorkflowEngineClient / load_psop / execute_psop.
100
+ if verify_ssl is not None:
101
+ ssl_verify = verify_ssl
102
+ self.url = url.rstrip("/")
103
+ self.ssl_verify = ssl_verify
104
+
105
+ async def fetch_agent_cards(self) -> List[Any]:
106
+ """Fetch all AgentCards. Returns protobuf objects if a2a-sdk available, else dicts."""
107
+ import httpx
108
+ logger.info(f"[Registry] Fetching all agent cards from {self.url}")
109
+ async with httpx.AsyncClient(verify=self.ssl_verify, timeout=30) as client:
110
+ resp = await client.get(f"{self.url}/rest/v1/registry-center/agent-cards")
111
+ resp.raise_for_status()
112
+ data = resp.json()
113
+ raw_cards = data.get("agentCards", data.get("data", []))
114
+ logger.info(f"[Registry] Received {len(raw_cards)} agent card(s)")
115
+ try:
116
+ from a2a.types import AgentCard
117
+ from google.protobuf.json_format import Parse
118
+ cards = []
119
+ for raw in raw_cards:
120
+ normalized = normalize_agent_dict(raw)
121
+ cards.append(Parse(json.dumps(normalized), AgentCard()))
122
+ logger.info(f"[Registry] Parsed {len(cards)} AgentCard(s) into protobuf objects")
123
+ return cards
124
+ except ImportError:
125
+ logger.info(f"[Registry] a2a-sdk not available, returning raw dicts")
126
+ return raw_cards
127
+
128
+ async def fetch_agent_card(self, name: str, organization: str = None) -> Any:
129
+ """Fetch a single AgentCard by name."""
130
+ import httpx
131
+ logger.info(f"[Registry] Fetching agent card: name={name}, org={organization}")
132
+ params = {"name": name}
133
+ if organization:
134
+ params["organization"] = organization
135
+ async with httpx.AsyncClient(verify=self.ssl_verify, timeout=30) as client:
136
+ resp = await client.get(f"{self.url}/rest/v1/registry-center/agent-cards", params=params)
137
+ resp.raise_for_status()
138
+ data = resp.json()
139
+ cards = data.get("agentCards", data.get("data", []))
140
+ if not cards:
141
+ logger.warning(f"[Registry] Agent card not found: name={name}")
142
+ return None
143
+ raw = cards[0]
144
+ try:
145
+ from a2a.types import AgentCard
146
+ from google.protobuf.json_format import Parse
147
+ normalized = normalize_agent_dict(raw)
148
+ card = Parse(json.dumps(normalized), AgentCard())
149
+ logger.info(f"[Registry] Agent card parsed: name={name}")
150
+ return card
151
+ except ImportError:
152
+ logger.info(f"[Registry] a2a-sdk not available, returning raw dict")
153
+ return raw
154
+
155
+ async def register_agent_card(self, agent_card) -> dict:
156
+ """Register or update an AgentCard in the registry.
157
+
158
+ POSTs to /rest/v1/registry-center/agent-cards with the card wrapped
159
+ in an ``agentCards`` list. Mirrors the Java SDK's
160
+ RegistryClient.registerAgentCard.
161
+ """
162
+ import httpx
163
+ url = f"{self.url}/rest/v1/registry-center/agent-cards"
164
+ payload = {"agentCards": [agent_card]}
165
+ logger.info(f"[Registry] Registering agent card: name={agent_card.get('name') if isinstance(agent_card, dict) else getattr(agent_card, 'name', '?')}")
166
+ async with httpx.AsyncClient(verify=self.ssl_verify, timeout=30) as client:
167
+ resp = await client.post(url, json=payload)
168
+ result = resp.json()
169
+ if resp.status_code in (200, 201):
170
+ logger.info(f"[Registry] Agent card registered")
171
+ else:
172
+ logger.warning(f"[Registry] Registration returned {resp.status_code}: {resp.text}")
173
+ return result
174
+
175
+ @property
176
+ def base_url(self) -> str:
177
+ return self.url
@@ -0,0 +1,247 @@
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
+ """High-level PSOP runner -- execute + stream events + persistence hook.
19
+
20
+ This is Layer 2 of the SDK: it wraps the low-level WorkflowExecutor /
21
+ WorkflowEngineClient and adds the event-stream lifecycle that every host
22
+ (HTTP/SSE server, CLI, batch job) would otherwise rewrite. The business
23
+ provides only the decision callbacks (ControlPoint), an optional
24
+ persistence hook (on_finish), and the transport (drain the async
25
+ iterator).
26
+ """
27
+
28
+ import asyncio
29
+ import time
30
+ from typing import Any, AsyncIterator, Awaitable, Callable, Optional, Union
31
+
32
+ from loguru import logger
33
+
34
+ from workflow_engine.core.models import Workflow, ExecutionResult
35
+ from workflow_engine.core.executor import WorkflowExecutor
36
+ from workflow_engine.client.engine_client import WorkflowEngineClient
37
+ from workflow_engine.client.a2a_transport import A2ATransport
38
+ from workflow_engine.control.control_points import ControlPoint, EventCallback
39
+
40
+
41
+ def _serialize(data: Any) -> Any:
42
+ """Make event data JSON-serializable (pydantic models, protobuf, etc.)."""
43
+ if data is None or isinstance(data, (str, int, float, bool)):
44
+ return data
45
+ if hasattr(data, "model_dump"):
46
+ try:
47
+ return data.model_dump()
48
+ except Exception:
49
+ pass
50
+ if isinstance(data, dict):
51
+ return {k: _serialize(v) for k, v in data.items()}
52
+ if isinstance(data, (list, tuple)):
53
+ return [_serialize(v) for v in data]
54
+ if hasattr(data, "__dict__"):
55
+ try:
56
+ return {k: _serialize(v) for k, v in data.__dict__.items()
57
+ if not k.startswith("_")}
58
+ except Exception:
59
+ return str(data)
60
+ return str(data)
61
+
62
+
63
+ class _EventEmitter(EventCallback):
64
+ """Async-queue-backed event emitter shared by executor + engine_client.
65
+
66
+ Implements EventCallback (so WorkflowExecutor can use it directly) and
67
+ exposes emit() for WorkflowEngineClient to push agent_request/response.
68
+ """
69
+
70
+ def __init__(self):
71
+ self._queue: "asyncio.Queue[Optional[dict]]" = asyncio.Queue()
72
+ self._collected: list = []
73
+
74
+ def on_event(self, event_type: str, data: dict):
75
+ event = {"type": event_type, "data": _serialize(data), "timestamp": time.time()}
76
+ self._queue.put_nowait(event)
77
+ self._collected.append(event)
78
+
79
+ # Alias used by WorkflowEngineClient.
80
+ def emit(self, event_type: str, data: dict):
81
+ self.on_event(event_type, data)
82
+
83
+ def finish(self):
84
+ """Push the None sentinel so drain() exits."""
85
+ self._queue.put_nowait(None)
86
+
87
+ async def drain(self) -> AsyncIterator[dict]:
88
+ while True:
89
+ event = await self._queue.get()
90
+ if event is None:
91
+ return
92
+ yield event
93
+
94
+ @property
95
+ def collected(self) -> list:
96
+ return list(self._collected)
97
+
98
+
99
+ async def execute_psop(
100
+ psop: Union[dict, Workflow],
101
+ agent_cards: list,
102
+ control_point: ControlPoint,
103
+ *,
104
+ engine_client: Optional[WorkflowEngineClient] = None,
105
+ runtime_intent: str = "",
106
+ lang: str = "zh",
107
+ a2at_env_path: Optional[str] = None,
108
+ credentials_config: Optional[Union[str, dict]] = None,
109
+ ssl_verify: bool = True,
110
+ ca_certs_path: Optional[str] = None,
111
+ on_finish: Optional[Callable[[ExecutionResult, list], Awaitable[None]]] = None,
112
+ on_event: Optional[Callable[[dict], Any]] = None,
113
+ ) -> AsyncIterator[dict]:
114
+ """Execute a PSOP end-to-end, yielding serialized event dicts.
115
+
116
+ Draining this async iterator drives execution. The SDK manages:
117
+
118
+ - lifecycle events: ``start`` / ``complete`` / ``error`` / ``close``
119
+ - cancellation: closing the iterator cancels the running workflow
120
+ - event collection: the full event list is passed to ``on_finish``
121
+
122
+ The business provides:
123
+
124
+ - ``control_point``: decision callbacks (on_task / on_route /
125
+ on_negotiation) -- the only place flow-decision policy lives.
126
+ - ``on_finish``: optional persistence hook, called with
127
+ (ExecutionResult, collected_events) after the workflow ends.
128
+ - ``on_event``: optional event transformer. Called per event; may
129
+ return the event unchanged, a different event, a list of events
130
+ (to inject business-specific events like ``psop_update``), or
131
+ None (to skip).
132
+
133
+ Yields (in order): ``start`` then the executor/engine events
134
+ (``step_start``, ``agent_request``, ``agent_response``,
135
+ ``task_status_changed``, ``route_decision``, ``step_complete``,
136
+ ``negotiation_*``, ``authorization_request``, ``notification``),
137
+ then ``complete`` (or ``error``), then ``close``.
138
+ """
139
+ if isinstance(psop, dict):
140
+ workflow = Workflow.from_dict(psop)
141
+ else:
142
+ workflow = psop
143
+
144
+ emitter = _EventEmitter()
145
+ if engine_client is None:
146
+ transport = A2ATransport(
147
+ agent_cards=agent_cards,
148
+ a2at_env_path=a2at_env_path,
149
+ credentials_config=credentials_config,
150
+ ssl_verify=ssl_verify,
151
+ ca_certs_path=ca_certs_path,
152
+ )
153
+ engine_client = WorkflowEngineClient(
154
+ transport, event_callback=emitter,
155
+ )
156
+ else:
157
+ # Attach the emitter to a caller-provided client so its
158
+ # agent_request/agent_response events reach this stream.
159
+ engine_client.set_event_callback(emitter)
160
+ executor = WorkflowExecutor(
161
+ workflow=workflow,
162
+ control_point=control_point,
163
+ engine_client=engine_client,
164
+ event_callback=emitter,
165
+ runtime_intent=runtime_intent,
166
+ lang=lang,
167
+ )
168
+
169
+ emitter.emit("start", {"workflow": workflow.name, "steps": len(workflow.steps)})
170
+
171
+ holder: dict = {}
172
+
173
+ async def _run_and_finalize():
174
+ try:
175
+ holder["result"] = await executor.run()
176
+ except asyncio.CancelledError:
177
+ holder["error"] = "Workflow cancelled (client disconnected)"
178
+ except Exception as e:
179
+ logger.error(f"[execute_psop] Execution failed: {e}", exc_info=True)
180
+ holder["error"] = str(e)
181
+ finally:
182
+ try:
183
+ await engine_client.close()
184
+ except Exception:
185
+ pass
186
+
187
+ result: ExecutionResult = holder.get("result") or ExecutionResult(
188
+ success=False, error=holder.get("error") or "Unknown error"
189
+ )
190
+ if result.success and "error" not in holder:
191
+ emitter.emit("complete", {
192
+ "history": result.history,
193
+ "step_outputs": result.step_outputs,
194
+ })
195
+ else:
196
+ emitter.emit("error", {
197
+ "error": holder.get("error") or result.error or "Execution failed",
198
+ "history": result.history,
199
+ "step_outputs": result.step_outputs,
200
+ })
201
+
202
+ if on_finish:
203
+ try:
204
+ ret = on_finish(result, emitter.collected)
205
+ if hasattr(ret, "__await__"):
206
+ await ret
207
+ except Exception as e:
208
+ logger.error(f"[execute_psop] on_finish failed: {e}", exc_info=True)
209
+
210
+ emitter.emit("close", {})
211
+ emitter.finish()
212
+
213
+ run_task = asyncio.create_task(_run_and_finalize())
214
+
215
+ try:
216
+ async for event in emitter.drain():
217
+ if on_event is None:
218
+ yield event
219
+ continue
220
+ try:
221
+ transformed = on_event(event)
222
+ if asyncio.iscoroutine(transformed):
223
+ transformed = await transformed
224
+ except Exception as e:
225
+ logger.warning(f"[execute_psop] on_event raised: {e}")
226
+ transformed = event
227
+ if transformed is None:
228
+ continue
229
+ if isinstance(transformed, list):
230
+ for e in transformed:
231
+ yield e
232
+ else:
233
+ yield transformed
234
+ except GeneratorExit:
235
+ run_task.cancel()
236
+ try:
237
+ await run_task
238
+ except (asyncio.CancelledError, Exception):
239
+ pass
240
+ return
241
+
242
+ if not run_task.done():
243
+ run_task.cancel()
244
+ try:
245
+ await run_task
246
+ except (asyncio.CancelledError, Exception):
247
+ pass