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