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