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,223 @@
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
+ """Control point interfaces -- user implements the decision layer.
19
+
20
+ ControlPoint (workflow control -- drives the workflow forward):
21
+ - on_task: send a task to an agent (user decides when/how) [required]
22
+ - on_self_task: handle a self-loop task locally [default]
23
+ - on_route: choose a branch (user decides which path) [required]
24
+ - on_negotiation: supply clarification during Negotiation-T [default]
25
+
26
+ Authorization-T and Notification-T are pre-positioning concerns handled
27
+ once before the workflow starts via ExtensionSender, not in-workflow
28
+ callbacks. EventCallback is optional; instantiate directly as a no-op
29
+ sink or subclass.
30
+ """
31
+
32
+ from abc import ABC, abstractmethod
33
+ from typing import Dict, Any, List, Optional, TYPE_CHECKING
34
+ from loguru import logger
35
+
36
+ if TYPE_CHECKING:
37
+ from workflow_engine.client.engine_client import WorkflowEngineClient
38
+ from workflow_engine.core.models import (
39
+ TaskRequest, TaskResponse, RouteDecision, JumpCondition,
40
+ )
41
+
42
+
43
+ class EventType:
44
+ """Execution event types emitted by the SDK.
45
+
46
+ Values are stable strings, so direct string comparison
47
+ (``event_type == "step_start"``) also works.
48
+
49
+ These constants cover every event emitted across the three layers:
50
+ lifecycle (``START``/``COMPLETE``/``ERROR``/``CLOSE`` from the runner),
51
+ step/task execution (``STEP_*``/``TASK_*`` from the executor), agent
52
+ traffic (``AGENT_*`` from the engine client), and the A2A-T extension
53
+ handlers (``NEGOTIATION_*``/``AUTHORIZATION_*``/``NOTIFICATION``).
54
+ The executor also emits ``WORKFLOW_COMPLETE`` just before the runner
55
+ emits ``COMPLETE`` (or ``ERROR``); see the Developer Guide for the full
56
+ event ordering.
57
+ """
58
+ # Runner lifecycle (execute_psop)
59
+ START = "start"
60
+ COMPLETE = "complete"
61
+ CLOSE = "close"
62
+ # Step / task execution (WorkflowExecutor)
63
+ STEP_START = "step_start"
64
+ STEP_COMPLETE = "step_complete"
65
+ TASK_REQUEST = "task_request"
66
+ TASK_RESPONSE = "task_response"
67
+ TASK_STATUS_CHANGED = "task_status_changed"
68
+ ROUTE_DECISION = "route_decision"
69
+ WORKFLOW_COMPLETE = "workflow_complete"
70
+ # Agent traffic (WorkflowEngineClient)
71
+ AGENT_REQUEST = "agent_request"
72
+ AGENT_RESPONSE = "agent_response"
73
+ AGENT_STATUS_UPDATE = "agent_status_update"
74
+ AGENT_ARTIFACT_UPDATE = "agent_artifact_update"
75
+ AGENT_MESSAGE_EVENT = "agent_message_event"
76
+ # A2A-T extensions (negotiation / authorization / notification)
77
+ NEGOTIATION_REQUEST = "negotiation_request"
78
+ NEGOTIATION_RESOLVED = "negotiation_resolved"
79
+ NEGOTIATION_FAILED = "negotiation_failed"
80
+ AUTHORIZATION_REQUEST = "authorization_request"
81
+ AUTHORIZATION_RESOLVED = "authorization_resolved"
82
+ NOTIFICATION = "notification"
83
+ # Emitted by both the executor (step failure) and the runner (final
84
+ # failure). On failure you may see two "error" events with different
85
+ # data shapes -- see the Developer Guide.
86
+ ERROR = "error"
87
+
88
+
89
+ class ControlPoint(ABC):
90
+ """Workflow-control decision interface.
91
+
92
+ Each method drives the workflow forward and is called by the
93
+ WorkflowExecutor (``on_task`` / ``on_self_task`` / ``on_route``) or the
94
+ client auto-negotiate loop (``on_negotiation``).
95
+ """
96
+
97
+ @abstractmethod
98
+ async def on_task(self, request: TaskRequest, engine_client: "WorkflowEngineClient") -> TaskResponse:
99
+ """Called when a step needs to send a task. User decides how to send.
100
+
101
+ ``request.message`` holds the full assembled message (upstream context
102
+ + task + language hint); ``request.context`` holds just the upstream
103
+ context. Call ``engine_client.send_message(request.agent_name,
104
+ request.message)`` to dispatch, or skip / transform as you see fit.
105
+ """
106
+ ...
107
+
108
+ async def on_self_task(self, request: TaskRequest) -> TaskResponse:
109
+ """Handle a self-loop task locally, WITHOUT sending an A2A-T message.
110
+
111
+ Called when a workflow step is marked ``SELF_LOOP``: the agent
112
+ executing the workflow processes the task itself. No
113
+ ``engine_client`` is passed on purpose: self-loop tasks must not
114
+ send A2A-T messages. Override to handle local aggregation, merge,
115
+ or any business logic the workflow-executing agent owns.
116
+
117
+ Default: echoes the task message back as the output.
118
+ """
119
+ return TaskResponse(success=True, output=request.message)
120
+
121
+ @abstractmethod
122
+ async def on_route(self, step_name: str, results: Dict[str, Any],
123
+ conditions: List[JumpCondition]) -> RouteDecision:
124
+ """Called at a branch. User decides which branch to take.
125
+
126
+ ``conditions`` is the list of ``JumpCondition(step, condition)``
127
+ declared on the current step. Return a ``RouteDecision`` whose
128
+ ``next_step`` matches one of the conditions' step names. An invalid
129
+ ``next_step`` logs a warning and ends the workflow.
130
+ """
131
+ ...
132
+
133
+ async def on_negotiation(self, agent_name: str, negotiation_text: str,
134
+ receive_result: Dict[str, Any]) -> str:
135
+ """Provide supplementary data when an agent returns INPUT_REQUIRED.
136
+
137
+ Return the clarification text -- the SDK internally resends the
138
+ follow-up message. Do NOT send messages here. The engine's
139
+ ``send_message`` auto-negotiation loop calls this method when an
140
+ agent returns INPUT_REQUIRED.
141
+
142
+ Default: returns a generic clarification.
143
+ """
144
+ return "Please proceed with the original task using available information."
145
+
146
+
147
+ class NegotiationStrategy(ABC):
148
+ """Strategy for generating negotiation clarifications.
149
+
150
+ Single responsibility: when an agent returns INPUT_REQUIRED
151
+ (Negotiation-T), produce the clarification text to send back. This is a
152
+ separate concern from workflow orchestration (task dispatch, routing).
153
+ Users who need custom negotiation logic (LLM-based clarification, DAG-
154
+ predecessor forwarding, etc.) implement this interface and inject it
155
+ into DefaultControlPoint rather than mixing negotiation policy into
156
+ their ControlPoint class.
157
+ """
158
+
159
+ @abstractmethod
160
+ async def resolve(self, agent_name: str, negotiation_text: str,
161
+ receive_result: Dict[str, Any]) -> str:
162
+ """Generate a clarification for the given negotiation request."""
163
+ ...
164
+
165
+
166
+ class DefaultControlPoint(ControlPoint):
167
+ """Default ControlPoint with single-responsibility methods.
168
+
169
+ Negotiation-T auto-loop delegates to an injected NegotiationStrategy
170
+ (or returns a generic clarification if none is provided). Override
171
+ on_negotiation directly when a full strategy object is overkill.
172
+ """
173
+
174
+ def __init__(self, negotiation_strategy: Optional["NegotiationStrategy"] = None):
175
+ self._negotiation_strategy = negotiation_strategy
176
+
177
+ async def on_task(self, request: TaskRequest, engine_client: "WorkflowEngineClient") -> TaskResponse:
178
+ logger.info(f"[DefaultCP] onTask: agent={request.agent_name}, step={request.step_name}")
179
+ try:
180
+ result = await engine_client.send_message(request.agent_name, request.message)
181
+ success = bool(result.text)
182
+ logger.info(
183
+ f"[DefaultCP] Response from {request.agent_name}: "
184
+ f"{len(result.text or '')} chars, success={success}"
185
+ )
186
+ return TaskResponse(success=success, output=result.text or "")
187
+ except Exception as e:
188
+ logger.error(f"[DefaultCP] Task failed for {request.agent_name}: {e}")
189
+ return TaskResponse(success=False, error=f"Agent call failed: {e}")
190
+
191
+ async def on_self_task(self, request: TaskRequest) -> TaskResponse:
192
+ logger.info(f"[DefaultCP] onSelfTask: step={request.step_name}, agent={request.agent_name} (local, no A2A-T)")
193
+ return TaskResponse(success=True, output=request.message)
194
+
195
+ async def on_route(self, step_name: str, results: Dict[str, Any],
196
+ conditions: List[JumpCondition]) -> RouteDecision:
197
+ next_step = conditions[0].step
198
+ for jc in conditions:
199
+ if jc.step not in ("end", "retry", "endNode"):
200
+ next_step = jc.step
201
+ break
202
+ logger.info(f"[DefaultCP] onRoute: {step_name} -> {next_step}")
203
+ return RouteDecision(next_step=next_step, reason="default: first non-terminal branch")
204
+
205
+ async def on_negotiation(self, agent_name: str, negotiation_text: str,
206
+ receive_result: Dict[str, Any]) -> str:
207
+ if self._negotiation_strategy is not None:
208
+ return await self._negotiation_strategy.resolve(
209
+ agent_name, negotiation_text, receive_result)
210
+ logger.info(f"[DefaultCP] onNegotiation: agent={agent_name}, concern={negotiation_text}")
211
+ return "Please proceed with the original task using available information."
212
+
213
+
214
+ class EventCallback:
215
+ """Optional callback for execution progress events.
216
+
217
+ Subclass and override ``on_event`` to receive events, or instantiate
218
+ directly as a no-op sink. Event types are listed in :class:`EventType`.
219
+ """
220
+
221
+ def on_event(self, event_type: str, data: Dict[str, Any]):
222
+ """Called for each execution event. Default: no-op."""
223
+ return None
@@ -0,0 +1,34 @@
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.core.models import (
20
+ Workflow, WorkflowStep, Task, JumpCondition,
21
+ StepType, TaskStatus, ExecutionResult,
22
+ SendMessageResult, TaskRequest, TaskResponse, RouteDecision,
23
+ WorkflowSearchResult,
24
+ )
25
+ from workflow_engine.core.context_builder import ContextBuilder
26
+ from workflow_engine.core.executor import WorkflowExecutor
27
+
28
+ __all__ = [
29
+ "Workflow", "WorkflowStep", "Task", "JumpCondition",
30
+ "StepType", "TaskStatus", "ExecutionResult",
31
+ "SendMessageResult", "TaskRequest", "TaskResponse", "RouteDecision",
32
+ "WorkflowSearchResult",
33
+ "ContextBuilder", "WorkflowExecutor",
34
+ ]
@@ -0,0 +1,101 @@
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
+ """Context assembly for the Workflow Execution SDK."""
19
+
20
+ from collections import deque
21
+ from typing import Dict, Any, List, Optional
22
+ from loguru import logger
23
+
24
+ from workflow_engine.core.models import Workflow, WorkflowStep
25
+
26
+
27
+ class ContextBuilder:
28
+ def __init__(self, workflow: Workflow, runtime_intent: str = ""):
29
+ self.workflow = workflow
30
+ self.runtime_intent = runtime_intent
31
+ self._step_index = {s.name: i for i, s in enumerate(workflow.steps)}
32
+
33
+ def get_step_predecessors(self, step_name: str) -> List[str]:
34
+ predecessors = []
35
+ for s in self.workflow.steps:
36
+ if s.next:
37
+ for jc in s.next:
38
+ if jc.step == step_name and s.name != step_name:
39
+ predecessors.append(s.name)
40
+ break
41
+ return predecessors
42
+
43
+ def get_all_predecessors(self, step_name: str) -> List[str]:
44
+ ancestors = set()
45
+ queue = deque([step_name])
46
+ while queue:
47
+ current = queue.popleft()
48
+ for s in self.workflow.steps:
49
+ if s.next:
50
+ for jc in s.next:
51
+ if jc.step == current and s.name != current and s.name not in ancestors:
52
+ ancestors.add(s.name)
53
+ queue.append(s.name)
54
+ break
55
+ return list(ancestors)
56
+
57
+ def build_context(self, step: WorkflowStep, step_outputs: Dict[str, Dict[str, Any]]) -> str:
58
+ if step.layer <= 0:
59
+ if self.runtime_intent:
60
+ logger.info(f"[Context] Step {step.name}: layer 0, using runtime intent only")
61
+ return f"## Runtime Context\n\n{self.runtime_intent}"
62
+ logger.info(f"[Context] Step {step.name}: layer 0, no context")
63
+ return ""
64
+ parts = []
65
+ if self.runtime_intent:
66
+ parts.append(f"## Runtime Context\n\n{self.runtime_intent}")
67
+ parts.append("## Previous Step Execution Results\n")
68
+ if step.context_from and "*" in step.context_from:
69
+ all_pred = self.get_all_predecessors(step.name)
70
+ ref_pairs = [(n, step_outputs[n]) for n in all_pred if n in step_outputs]
71
+ logger.info(f"[Context] Step {step.name}: using ALL predecessors ({len(ref_pairs)} available)")
72
+ elif step.context_from:
73
+ ref_pairs = [(n, step_outputs[n]) for n in step.context_from if n in step_outputs]
74
+ logger.info(f"[Context] Step {step.name}: using context_from={step.context_from} ({len(ref_pairs)} available)")
75
+ else:
76
+ pred_names = self.get_step_predecessors(step.name)
77
+ ref_pairs = [(n, step_outputs[n]) for n in pred_names if n in step_outputs]
78
+ logger.info(f"[Context] Step {step.name}: using direct predecessors={pred_names} ({len(ref_pairs)} available)")
79
+ for ref_step_name, ref_results in ref_pairs:
80
+ parts.append(f"### {ref_step_name} Results\n")
81
+ for task_desc, output in ref_results.items():
82
+ text = output if isinstance(output, str) else str(output)
83
+ parts.append(f"**Task**: {task_desc}\n**Output**: {text}\n\n")
84
+ result = "\n".join(parts).strip()
85
+ logger.info(f"[Context] Step {step.name}: built context ({len(result)} chars)")
86
+ if result:
87
+ logger.info(f"[Context] Content:\n{result[:2000]}")
88
+ return result
89
+
90
+ def build_task_message(self, task_description: str, context_message: str, lang: str = "zh") -> str:
91
+ lang_hint = ""
92
+ if lang == "en":
93
+ lang_hint = "\n\nPlease respond in English."
94
+ elif lang == "zh":
95
+ lang_hint = "\n\n请用中文回复。"
96
+ if context_message:
97
+ return f"{context_message}\n\n## Current Task\n{task_description}{lang_hint}"
98
+ return f"{task_description}{lang_hint}"
99
+
100
+ def find_step_index(self, step_name: str) -> Optional[int]:
101
+ return self._step_index.get(step_name)
@@ -0,0 +1,278 @@
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
+ """WorkflowExecutor - DAG traversal, delegates to ControlPoint."""
19
+
20
+ import asyncio
21
+ import time
22
+ from collections import deque
23
+ from typing import Dict, Any, List, Optional, TYPE_CHECKING
24
+ from loguru import logger
25
+
26
+ from workflow_engine.core.models import (
27
+ Workflow, WorkflowStep, Task, StepType, TaskStatus,
28
+ ExecutionResult, TaskRequest, TaskResponse, RouteDecision,
29
+ )
30
+ from workflow_engine.core.context_builder import ContextBuilder
31
+ from workflow_engine.control.control_points import ControlPoint, EventCallback
32
+
33
+ if TYPE_CHECKING:
34
+ from workflow_engine.client.engine_client import WorkflowEngineClient
35
+
36
+
37
+ class WorkflowExecutor:
38
+ """Main entry point. Traverses DAG, calls ControlPoint at decision points."""
39
+
40
+ def __init__(
41
+ self,
42
+ workflow: Workflow,
43
+ control_point: ControlPoint,
44
+ engine_client: "WorkflowEngineClient",
45
+ event_callback: Optional[EventCallback] = None,
46
+ runtime_intent: str = "",
47
+ lang: str = "zh",
48
+ ):
49
+ self.workflow = workflow
50
+ self.control_point = control_point
51
+ self.engine_client = engine_client
52
+ self.engine_client.set_control_point(control_point)
53
+ try:
54
+ self.engine_client.set_event_callback(event_callback)
55
+ except Exception:
56
+ pass
57
+ self.event_callback = event_callback
58
+ self.lang = lang
59
+ self.context_builder = ContextBuilder(workflow, runtime_intent)
60
+ self.step_outputs: Dict[str, Dict[str, Any]] = {}
61
+ self.execution_history: List[Dict[str, Any]] = []
62
+ logger.info(f"[Executor] Workflow: {workflow.name}, steps={len(workflow.steps)}, intent={runtime_intent[:80] if runtime_intent else None}, lang={lang}")
63
+
64
+ def _emit_event(self, event_type: str, data: Dict[str, Any]):
65
+ if self.event_callback:
66
+ try:
67
+ self.event_callback.on_event(event_type, data)
68
+ except Exception as e:
69
+ logger.warning(f"Event callback error: {e}")
70
+
71
+ async def run(self) -> ExecutionResult:
72
+ """Execute the workflow DAG with parallel step dispatch.
73
+
74
+ Mirrors Java's executeSteps: collects all ready steps (predecessors
75
+ satisfied), dispatches them concurrently via asyncio.gather, then
76
+ processes their next-step indices. Steps at the same layer run in
77
+ parallel; subtasks within a step also run in parallel.
78
+ """
79
+ logger.info(f"[Executor] Starting workflow: {self.workflow.name} ({len(self.workflow.steps)} steps)")
80
+ pending = deque([
81
+ i for i, s in enumerate(self.workflow.steps)
82
+ if s.layer == 0 and not self.context_builder.get_step_predecessors(s.name)
83
+ ])
84
+ executed: set = set()
85
+ defer_count: Dict[int, int] = {}
86
+ failed = False
87
+ try:
88
+ while pending and not failed:
89
+ ready, deferred = self._collect_ready(pending, executed, defer_count)
90
+ for idx in deferred:
91
+ pending.append(idx)
92
+ if not ready:
93
+ if deferred:
94
+ await asyncio.sleep(0.05)
95
+ continue
96
+ break
97
+ executed.update(ready)
98
+ results = await asyncio.gather(
99
+ *[self._execute_step(idx) for idx in ready],
100
+ return_exceptions=True,
101
+ )
102
+ failed = self._process_results(ready, results, pending, executed)
103
+ except Exception as e:
104
+ logger.critical(f"DAG traversal error: {e}", exc_info=True)
105
+ return ExecutionResult(success=False, history=self.execution_history,
106
+ step_outputs=self.step_outputs, error=str(e))
107
+ self._emit_event("workflow_complete", {})
108
+ logger.info(f"[Executor] Workflow completed: {self.workflow.name}, {len(self.execution_history)} task(s) executed")
109
+ return ExecutionResult(success=not failed, history=self.execution_history,
110
+ step_outputs=self.step_outputs,
111
+ error=("Step execution failed" if failed else None))
112
+
113
+ def _collect_ready(self, pending: deque, executed: set,
114
+ defer_count: Dict[int, int]) -> tuple:
115
+ """Drain pending into ready (predecessors satisfied) and deferred."""
116
+ ready: List[int] = []
117
+ deferred: List[int] = []
118
+ while pending:
119
+ idx = pending.popleft()
120
+ if idx >= len(self.workflow.steps) or idx in executed:
121
+ continue
122
+ step = self.workflow.steps[idx]
123
+ predecessors = self.context_builder.get_step_predecessors(step.name)
124
+ if all(p in self.step_outputs for p in predecessors):
125
+ ready.append(idx)
126
+ else:
127
+ dc = defer_count.get(idx, 0) + 1
128
+ if dc > len(self.workflow.steps):
129
+ executed.add(idx)
130
+ continue
131
+ defer_count[idx] = dc
132
+ deferred.append(idx)
133
+ return ready, deferred
134
+
135
+ async def _execute_step(self, idx: int) -> tuple:
136
+ """Execute one step: subtasks + next-step determination.
137
+
138
+ Returns (step_name, step_result, success, next_indices).
139
+ """
140
+ step = self.workflow.steps[idx]
141
+ t_step = time.time()
142
+ logger.info(f"--- Executing step: {step.name} ---")
143
+ self._emit_event("step_start", {"step": step.name})
144
+ step_result, success = await self._execute_subtasks(step)
145
+ self.step_outputs[step.name] = step_result
146
+ next_indices: List[int] = []
147
+ if success:
148
+ self._emit_event("step_complete", {"step": step.name, "results": step_result})
149
+ next_indices = await self._determine_next_steps(step, step_result)
150
+ else:
151
+ logger.error(f"Step {step.name} failed, stopping.")
152
+ self._emit_event("error", {"step": step.name, "results": step_result})
153
+ logger.info(f"[Timing] Step '{step.name}' total: {time.time()-t_step:.2f}s, success={success}")
154
+ return step.name, step_result, success, next_indices
155
+
156
+ def _process_results(self, ready: List[int], results: list,
157
+ pending: deque, executed: set) -> bool:
158
+ """Process asyncio.gather results, enqueue next steps. Returns failed."""
159
+ for idx, result in zip(ready, results):
160
+ if isinstance(result, Exception):
161
+ step = self.workflow.steps[idx]
162
+ logger.error(f"Step {step.name} raised: {result}")
163
+ self._emit_event("error", {"step": step.name, "error": str(result)})
164
+ return True
165
+ _, _, success, next_indices = result
166
+ if not success:
167
+ return True
168
+ for nxt in reversed(next_indices):
169
+ if nxt not in executed and nxt not in pending:
170
+ pending.appendleft(nxt)
171
+ return False
172
+
173
+ async def _execute_subtasks(self, step: WorkflowStep) -> tuple[Dict[str, Any], bool]:
174
+ context_message = self.context_builder.build_context(step, self.step_outputs)
175
+ results: Dict[str, Any] = {}
176
+ logger.info(f"[Executor] Step {step.name}: {len(step.subtasks)} subtask(s), type={step.step_type.value}")
177
+
178
+ async def execute_single(task: Task, subtask_index: int) -> tuple[str, Any, bool]:
179
+ task_message = self.context_builder.build_task_message(task.description, context_message, self.lang)
180
+ request = TaskRequest(agent_name=task.agent, skill=task.skill, message=task_message,
181
+ description=task.description,
182
+ context=context_message, step_name=step.name, subtask_index=subtask_index)
183
+ self._emit_event("task_request", {"step": step.name, "agent": task.agent, "task": task.description})
184
+ logger.info(f"[Executor] Dispatching task: step={step.name}, agent={task.agent}, subtask_index={subtask_index}, desc={task.description}")
185
+ logger.debug(f"[Executor] Task message to {task.agent}: [{task_message}]")
186
+ t_task = time.time()
187
+ try:
188
+ # SELF_LOOP steps are handled locally without sending an
189
+ # A2A-T message (mirrors Java dispatchTask SELF_LOOP branch).
190
+ if step.step_type == StepType.SELF_LOOP:
191
+ logger.info(f"[Executor] Self-loop task: step={step.name}, agent={task.agent} (local, no A2A-T)")
192
+ response = await self.control_point.on_self_task(request)
193
+ else:
194
+ response = await self.control_point.on_task(request, self.engine_client)
195
+ logger.info(f"[Timing] Task '{task.description}' -> {task.agent}: {time.time()-t_task:.2f}s")
196
+ task.status = TaskStatus.SUCCESS if response.success else TaskStatus.FAILED
197
+ self._emit_event("task_status_changed", {"step": step.name, "subtask_index": subtask_index, "agent": task.agent, "status": task.status.value})
198
+ status = "success" if response.success else "failed"
199
+ logger.info(f"[Executor] Task {task.description[:60]} -> {task.agent}: {status}")
200
+ if response.success and response.output:
201
+ logger.debug(f"[Executor] Task output from {task.agent}: [{response.output}]")
202
+ self.execution_history.append({"step": step.name, "task": task.description, "agent": task.agent,
203
+ "status": status,
204
+ "output": response.output if response.success else (response.error or "")})
205
+ self._emit_event("task_response", {"step": step.name, "agent": task.agent, "task": task.description,
206
+ "output": response.output if response.success else (response.error or "")})
207
+ return task.description, response.output, response.success
208
+ except Exception as e:
209
+ logger.info(f"[Timing] Task '{task.description}' -> {task.agent}: {time.time()-t_task:.2f}s (failed)")
210
+ task.status = TaskStatus.FAILED
211
+ self._emit_event("task_status_changed", {"step": step.name, "subtask_index": subtask_index, "agent": task.agent, "status": task.status.value})
212
+ logger.error(f"[Executor] Task {task.description[:60]} -> {task.agent}: exception: {e}")
213
+ self.execution_history.append({"step": step.name, "task": task.description, "agent": task.agent,
214
+ "status": "failed", "output": str(e)})
215
+ return task.description, {"error": str(e)}, False
216
+
217
+ if step.step_type == StepType.ANY_SUCCESS:
218
+ tasks = [asyncio.create_task(execute_single(t, i)) for i, t in enumerate(step.subtasks)]
219
+ for coro in asyncio.as_completed(tasks):
220
+ desc, output, success = await coro
221
+ results[desc] = output
222
+ if success:
223
+ logger.info(f"[Executor] Step {step.name}: ANY_SUCCESS, first success for task: {desc}")
224
+ for t in tasks:
225
+ if not t.done(): t.cancel()
226
+ await asyncio.gather(*tasks, return_exceptions=True)
227
+ return results, True
228
+ return results, False
229
+ gathered = await asyncio.gather(*[execute_single(t, i) for i, t in enumerate(step.subtasks)])
230
+ failed = False
231
+ for desc, output, success in gathered:
232
+ results[desc] = output
233
+ if not success: failed = True
234
+ return results, not failed
235
+
236
+ async def _determine_next_steps(self, step: WorkflowStep, step_result: Dict[str, Any]) -> List[int]:
237
+ if not step.next:
238
+ return []
239
+ # All-unconditional next steps -> fan out (parallel execution),
240
+ # skipping terminal markers. Mirrors the original engine's semantics:
241
+ # empty conditions mean "go to all of them", not "pick one".
242
+ if all(not jc.condition for jc in step.next):
243
+ indices = []
244
+ for jc in step.next:
245
+ if jc.step in ("end", "retry", "endNode"):
246
+ continue
247
+ idx = self.context_builder.find_step_index(jc.step)
248
+ if idx is not None:
249
+ indices.append(idx)
250
+ return indices
251
+ # Has conditional branches -> user decides via on_route.
252
+ # Build route context: merge context_from upstream results + current
253
+ # step results (mirrors Java's determineNextSteps routeContext).
254
+ route_context: Dict[str, Any] = {}
255
+ if step.context_from:
256
+ for ref in step.context_from:
257
+ if ref in self.step_outputs:
258
+ route_context[ref] = self.step_outputs[ref]
259
+ route_context[step.name] = step_result
260
+ decision = await self.control_point.on_route(step.name, route_context, step.next)
261
+ logger.info(f"Route for '{step.name}': {decision.next_step} ({decision.reason})")
262
+ self._emit_event("route_decision", {"step": step.name, "next": decision.next_step, "reason": decision.reason})
263
+ idx = self.context_builder.find_step_index(decision.next_step)
264
+ if idx is None:
265
+ allowed = [jc.step for jc in step.next]
266
+ logger.warning(
267
+ f"on_route returned '{decision.next_step}' for step '{step.name}', "
268
+ f"not in allowed next steps {allowed}; workflow will end."
269
+ )
270
+ return [idx] if idx is not None else []
271
+
272
+
273
+ @property
274
+ def current_step_outputs(self) -> Dict[str, Dict[str, Any]]:
275
+ return self.step_outputs
276
+ @property
277
+ def history(self) -> List[Dict[str, Any]]:
278
+ return self.execution_history