abstractcode 0.3.0__py3-none-any.whl → 0.3.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.
- abstractcode/__init__.py +1 -1
- abstractcode/cli.py +682 -3
- abstractcode/file_mentions.py +276 -0
- abstractcode/fullscreen_ui.py +1592 -74
- abstractcode/gateway_cli.py +715 -0
- abstractcode/react_shell.py +2474 -116
- abstractcode/terminal_markdown.py +426 -37
- abstractcode/theme.py +244 -0
- abstractcode/workflow_agent.py +630 -112
- abstractcode/workflow_cli.py +229 -0
- abstractcode-0.3.2.dist-info/METADATA +158 -0
- abstractcode-0.3.2.dist-info/RECORD +21 -0
- {abstractcode-0.3.0.dist-info → abstractcode-0.3.2.dist-info}/WHEEL +1 -1
- abstractcode-0.3.0.dist-info/METADATA +0 -270
- abstractcode-0.3.0.dist-info/RECORD +0 -17
- {abstractcode-0.3.0.dist-info → abstractcode-0.3.2.dist-info}/entry_points.txt +0 -0
- {abstractcode-0.3.0.dist-info → abstractcode-0.3.2.dist-info}/licenses/LICENSE +0 -0
- {abstractcode-0.3.0.dist-info → abstractcode-0.3.2.dist-info}/top_level.txt +0 -0
abstractcode/workflow_agent.py
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
import re
|
|
5
|
+
import zipfile
|
|
4
6
|
from dataclasses import dataclass
|
|
5
7
|
from datetime import datetime, timezone
|
|
6
8
|
from pathlib import Path
|
|
@@ -13,10 +15,19 @@ from abstractruntime import RunState, RunStatus, Runtime, WorkflowSpec
|
|
|
13
15
|
def _now_iso() -> str:
|
|
14
16
|
return datetime.now(timezone.utc).isoformat()
|
|
15
17
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
_UI_EVENT_NAMESPACE = "abstract"
|
|
19
|
+
|
|
20
|
+
_STATUS_EVENT_NAME = f"{_UI_EVENT_NAMESPACE}.status"
|
|
21
|
+
_MESSAGE_EVENT_NAME = f"{_UI_EVENT_NAMESPACE}.message"
|
|
22
|
+
_TOOL_EXEC_EVENT_NAME = f"{_UI_EVENT_NAMESPACE}.tool_execution"
|
|
23
|
+
_TOOL_RESULT_EVENT_NAME = f"{_UI_EVENT_NAMESPACE}.tool_result"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _normalize_ui_event_name(name: str) -> str:
|
|
27
|
+
s = str(name or "").strip()
|
|
28
|
+
if s.startswith("abstractcode."):
|
|
29
|
+
return f"{_UI_EVENT_NAMESPACE}.{s[len('abstractcode.'):]}".strip(".")
|
|
30
|
+
return s
|
|
20
31
|
|
|
21
32
|
|
|
22
33
|
def _new_message(*, role: str, content: str, metadata: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
@@ -44,9 +55,11 @@ def _copy_messages(messages: Any) -> List[Dict[str, Any]]:
|
|
|
44
55
|
|
|
45
56
|
@dataclass(frozen=True)
|
|
46
57
|
class ResolvedVisualFlow:
|
|
47
|
-
visual_flow: Any
|
|
48
|
-
flows: Dict[str, Any]
|
|
58
|
+
visual_flow: Dict[str, Any]
|
|
59
|
+
flows: Dict[str, Dict[str, Any]]
|
|
49
60
|
flows_dir: Path
|
|
61
|
+
bundle_id: Optional[str] = None
|
|
62
|
+
bundle_version: Optional[str] = None
|
|
50
63
|
|
|
51
64
|
|
|
52
65
|
def _default_flows_dir() -> Path:
|
|
@@ -58,77 +71,243 @@ def _default_flows_dir() -> Path:
|
|
|
58
71
|
return Path("flows")
|
|
59
72
|
|
|
60
73
|
|
|
61
|
-
def
|
|
74
|
+
def _default_bundles_dir() -> Path:
|
|
75
|
+
"""Best-effort location for `.flow` bundles (WorkflowBundle zips)."""
|
|
76
|
+
try:
|
|
77
|
+
from abstractruntime.workflow_bundle import default_workflow_bundles_dir # type: ignore
|
|
78
|
+
|
|
79
|
+
return default_workflow_bundles_dir()
|
|
80
|
+
except Exception:
|
|
81
|
+
candidate = Path("flows") / "bundles"
|
|
82
|
+
if candidate.exists() and candidate.is_dir():
|
|
83
|
+
return candidate
|
|
84
|
+
return Path("flows")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _is_flow_bundle(path: Path) -> bool:
|
|
88
|
+
try:
|
|
89
|
+
if path.suffix.lower() == ".flow":
|
|
90
|
+
return True
|
|
91
|
+
except Exception:
|
|
92
|
+
pass
|
|
62
93
|
try:
|
|
63
|
-
|
|
94
|
+
return zipfile.is_zipfile(path)
|
|
95
|
+
except Exception:
|
|
96
|
+
return False
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _load_visual_flows_from_bundle(bundle_path: Path) -> Tuple[Dict[str, Dict[str, Any]], Dict[str, Any]]:
|
|
100
|
+
"""Load VisualFlow JSON objects from a `.flow` bundle (zip).
|
|
101
|
+
|
|
102
|
+
Returns: (flows_by_id, manifest_dict)
|
|
103
|
+
"""
|
|
104
|
+
try:
|
|
105
|
+
from abstractruntime.workflow_bundle import open_workflow_bundle # type: ignore
|
|
64
106
|
except Exception as e: # pragma: no cover
|
|
65
107
|
raise RuntimeError(
|
|
66
|
-
"
|
|
67
|
-
'Install with: pip install "
|
|
108
|
+
"AbstractRuntime workflow_bundle support is required to run `.flow` bundles.\n"
|
|
109
|
+
'Install with: pip install "abstractruntime"'
|
|
68
110
|
) from e
|
|
69
111
|
|
|
70
|
-
|
|
112
|
+
bundle = open_workflow_bundle(bundle_path)
|
|
113
|
+
man = bundle.manifest
|
|
114
|
+
|
|
115
|
+
entrypoints: List[Dict[str, Any]] = []
|
|
116
|
+
for ep in getattr(man, "entrypoints", None) or []:
|
|
117
|
+
fid = str(getattr(ep, "flow_id", "") or "").strip()
|
|
118
|
+
if not fid:
|
|
119
|
+
continue
|
|
120
|
+
entrypoints.append(
|
|
121
|
+
{
|
|
122
|
+
"flow_id": fid,
|
|
123
|
+
"name": str(getattr(ep, "name", "") or ""),
|
|
124
|
+
"description": str(getattr(ep, "description", "") or ""),
|
|
125
|
+
"interfaces": list(getattr(ep, "interfaces", []) or []),
|
|
126
|
+
}
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
manifest: Dict[str, Any] = {
|
|
130
|
+
"bundle_id": str(getattr(man, "bundle_id", "") or ""),
|
|
131
|
+
"bundle_version": str(getattr(man, "bundle_version", "") or ""),
|
|
132
|
+
"default_entrypoint": str(getattr(man, "default_entrypoint", "") or ""),
|
|
133
|
+
"entrypoints": entrypoints,
|
|
134
|
+
"flows": dict(getattr(man, "flows", None) or {}),
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
flows: Dict[str, Dict[str, Any]] = {}
|
|
138
|
+
for fid, rel in (getattr(man, "flows", None) or {}).items():
|
|
139
|
+
if not isinstance(rel, str) or not rel.strip():
|
|
140
|
+
continue
|
|
141
|
+
try:
|
|
142
|
+
raw = bundle.read_json(rel)
|
|
143
|
+
except Exception:
|
|
144
|
+
continue
|
|
145
|
+
if not isinstance(raw, dict):
|
|
146
|
+
continue
|
|
147
|
+
flow_id = str(raw.get("id") or fid or "").strip()
|
|
148
|
+
if not flow_id:
|
|
149
|
+
continue
|
|
150
|
+
flows[flow_id] = raw
|
|
151
|
+
return flows, manifest
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _load_visual_flows(flows_dir: Path) -> Dict[str, Dict[str, Any]]:
|
|
155
|
+
flows: Dict[str, Dict[str, Any]] = {}
|
|
71
156
|
if not flows_dir.exists():
|
|
72
157
|
return flows
|
|
73
158
|
for path in sorted(flows_dir.glob("*.json")):
|
|
74
159
|
try:
|
|
75
|
-
raw = path.read_text(encoding="utf-8")
|
|
76
|
-
vf = VisualFlow.model_validate_json(raw)
|
|
160
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
77
161
|
except Exception:
|
|
78
162
|
continue
|
|
79
|
-
|
|
163
|
+
if not isinstance(raw, dict):
|
|
164
|
+
continue
|
|
165
|
+
fid = str(raw.get("id") or "").strip()
|
|
166
|
+
if not fid:
|
|
167
|
+
continue
|
|
168
|
+
flows[fid] = raw
|
|
80
169
|
return flows
|
|
81
170
|
|
|
82
171
|
|
|
83
|
-
def resolve_visual_flow(
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
172
|
+
def resolve_visual_flow(
|
|
173
|
+
flow_ref: str,
|
|
174
|
+
*,
|
|
175
|
+
flows_dir: Optional[str],
|
|
176
|
+
require_interface: Optional[str] = None,
|
|
177
|
+
) -> ResolvedVisualFlow:
|
|
178
|
+
"""Resolve a VisualFlow by id, name, or path to a `.json` or bundled `.flow` file.
|
|
179
|
+
|
|
180
|
+
Also accepts bundle refs (by bundle_id), e.g.:
|
|
181
|
+
- basic-agent
|
|
182
|
+
- basic-llm@0.0.2
|
|
183
|
+
- basic-llm.flow
|
|
184
|
+
- basic-llm@0.0.2.flow
|
|
185
|
+
- basic-llm:c4bd3db6 (bundle_id:flow_id)
|
|
186
|
+
- basic-llm@0.0.2:c4bd3db6
|
|
187
|
+
"""
|
|
188
|
+
ref_raw = str(flow_ref or "").strip()
|
|
189
|
+
if not ref_raw:
|
|
190
|
+
raise ValueError("flow reference is required (flow id, name, .json/.flow path, or bundle id)")
|
|
191
|
+
|
|
192
|
+
def _require_flow_interface(raw: Dict[str, Any]) -> None:
|
|
193
|
+
if not require_interface:
|
|
194
|
+
return
|
|
195
|
+
interfaces = raw.get("interfaces")
|
|
196
|
+
if isinstance(interfaces, list) and require_interface in interfaces:
|
|
197
|
+
return
|
|
198
|
+
raise ValueError(f"Workflow does not implement '{require_interface}'")
|
|
199
|
+
|
|
200
|
+
ref = ref_raw
|
|
201
|
+
bundle_flow_id: Optional[str] = None
|
|
202
|
+
# Support bundle_id:flow_id (like AbstractCode web). Avoid clobbering Windows drive letters.
|
|
203
|
+
if ":" in ref and not re.match(r"^[A-Za-z]:[\\\\/]", ref):
|
|
204
|
+
left, right = ref.split(":", 1)
|
|
205
|
+
if left.strip() and right.strip():
|
|
206
|
+
ref = left.strip()
|
|
207
|
+
bundle_flow_id = right.strip()
|
|
88
208
|
|
|
89
209
|
path = Path(ref).expanduser()
|
|
90
210
|
flows_dir_path: Path
|
|
211
|
+
if path.exists() and path.is_file() and _is_flow_bundle(path):
|
|
212
|
+
flows, manifest = _load_visual_flows_from_bundle(path)
|
|
213
|
+
bundle_id = str(manifest.get("bundle_id") or "").strip() or None
|
|
214
|
+
bundle_version = str(manifest.get("bundle_version") or "").strip() or None
|
|
215
|
+
default_id = str(manifest.get("default_entrypoint") or "").strip()
|
|
216
|
+
selected_id = bundle_flow_id or default_id
|
|
217
|
+
if not selected_id and flows:
|
|
218
|
+
selected_id = next(iter(flows.keys()))
|
|
219
|
+
vf = flows.get(selected_id) if selected_id else None
|
|
220
|
+
if vf is None:
|
|
221
|
+
available = ", ".join(sorted(flows.keys()))
|
|
222
|
+
raise ValueError(f"Bundle entrypoint '{selected_id}' not found in {path} (available: {available})")
|
|
223
|
+
# Prefer bundle-level interface markers when present; fall back to flow.interfaces.
|
|
224
|
+
if require_interface:
|
|
225
|
+
try:
|
|
226
|
+
eps = list(manifest.get("entrypoints") or [])
|
|
227
|
+
ep = next(
|
|
228
|
+
(
|
|
229
|
+
e
|
|
230
|
+
for e in eps
|
|
231
|
+
if isinstance(e, dict) and str(e.get("flow_id") or "").strip() == str(selected_id)
|
|
232
|
+
),
|
|
233
|
+
None,
|
|
234
|
+
)
|
|
235
|
+
if ep is None:
|
|
236
|
+
raise ValueError
|
|
237
|
+
if require_interface not in list(ep.get("interfaces") or []):
|
|
238
|
+
raise ValueError
|
|
239
|
+
except Exception:
|
|
240
|
+
_require_flow_interface(vf)
|
|
241
|
+
return ResolvedVisualFlow(
|
|
242
|
+
visual_flow=vf,
|
|
243
|
+
flows=flows,
|
|
244
|
+
flows_dir=path.resolve(),
|
|
245
|
+
bundle_id=bundle_id,
|
|
246
|
+
bundle_version=bundle_version,
|
|
247
|
+
)
|
|
248
|
+
|
|
91
249
|
if path.exists() and path.is_file():
|
|
92
250
|
try:
|
|
93
|
-
raw = path.read_text(encoding="utf-8")
|
|
251
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
94
252
|
except Exception as e:
|
|
95
253
|
raise ValueError(f"Cannot read flow file: {path}") from e
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
from abstractflow.visual.models import VisualFlow
|
|
99
|
-
except Exception as e: # pragma: no cover
|
|
100
|
-
raise RuntimeError(
|
|
101
|
-
"AbstractFlow is required to run VisualFlow workflows.\n"
|
|
102
|
-
'Install with: pip install "abstractcode[flow]"'
|
|
103
|
-
) from e
|
|
104
|
-
|
|
105
|
-
vf = VisualFlow.model_validate_json(raw)
|
|
254
|
+
if not isinstance(raw, dict):
|
|
255
|
+
raise ValueError(f"Flow JSON must be an object: {path}")
|
|
106
256
|
flows_dir_path = Path(flows_dir).expanduser().resolve() if flows_dir else path.parent.resolve()
|
|
107
257
|
flows = _load_visual_flows(flows_dir_path)
|
|
108
|
-
|
|
109
|
-
|
|
258
|
+
fid = str(raw.get("id") or "").strip()
|
|
259
|
+
if fid:
|
|
260
|
+
flows[fid] = raw
|
|
261
|
+
_require_flow_interface(raw)
|
|
262
|
+
return ResolvedVisualFlow(visual_flow=raw, flows=flows, flows_dir=flows_dir_path)
|
|
263
|
+
|
|
264
|
+
# Prefer installed bundle refs when a bundle_id (or entrypoint name) matches.
|
|
265
|
+
try:
|
|
266
|
+
from abstractruntime.workflow_bundle import WorkflowBundleRegistry, WorkflowBundleRegistryError # type: ignore
|
|
267
|
+
|
|
268
|
+
reg = WorkflowBundleRegistry(_default_bundles_dir())
|
|
269
|
+
ep = reg.resolve_entrypoint(ref_raw, interface=require_interface)
|
|
270
|
+
b = reg.resolve_bundle(ep.bundle_ref)
|
|
271
|
+
flows2, _manifest = _load_visual_flows_from_bundle(b.path)
|
|
272
|
+
vf = flows2.get(ep.flow_id)
|
|
273
|
+
if vf is None:
|
|
274
|
+
available = ", ".join(sorted(flows2.keys()))
|
|
275
|
+
raise ValueError(f"Bundle entrypoint '{ep.flow_id}' not found in {b.path} (available: {available})")
|
|
276
|
+
return ResolvedVisualFlow(
|
|
277
|
+
visual_flow=vf,
|
|
278
|
+
flows=flows2,
|
|
279
|
+
flows_dir=b.path.resolve(),
|
|
280
|
+
bundle_id=str(ep.bundle_id),
|
|
281
|
+
bundle_version=str(ep.bundle_version),
|
|
282
|
+
)
|
|
283
|
+
except WorkflowBundleRegistryError:
|
|
284
|
+
pass
|
|
285
|
+
except Exception:
|
|
286
|
+
pass
|
|
110
287
|
|
|
111
288
|
flows_dir_path = Path(flows_dir).expanduser().resolve() if flows_dir else _default_flows_dir().resolve()
|
|
112
289
|
flows = _load_visual_flows(flows_dir_path)
|
|
113
290
|
|
|
114
291
|
if ref in flows:
|
|
292
|
+
_require_flow_interface(flows[ref])
|
|
115
293
|
return ResolvedVisualFlow(visual_flow=flows[ref], flows=flows, flows_dir=flows_dir_path)
|
|
116
294
|
|
|
117
295
|
# Fall back to exact name match (case-insensitive).
|
|
118
|
-
matches = []
|
|
296
|
+
matches: list[Dict[str, Any]] = []
|
|
119
297
|
needle = ref.casefold()
|
|
120
298
|
for vf in flows.values():
|
|
121
|
-
name =
|
|
299
|
+
name = vf.get("name")
|
|
122
300
|
if isinstance(name, str) and name.strip() and name.strip().casefold() == needle:
|
|
123
301
|
matches.append(vf)
|
|
124
302
|
|
|
125
303
|
if not matches:
|
|
126
|
-
raise ValueError(f"Flow '{
|
|
304
|
+
raise ValueError(f"Flow '{ref_raw}' not found in {flows_dir_path}")
|
|
127
305
|
if len(matches) > 1:
|
|
128
|
-
options = ", ".join([f"{
|
|
306
|
+
options = ", ".join([f"{str(v.get('name') or '')} ({str(v.get('id') or '')})" for v in matches])
|
|
129
307
|
raise ValueError(f"Multiple flows match '{ref}': {options}")
|
|
130
308
|
|
|
131
309
|
vf = matches[0]
|
|
310
|
+
_require_flow_interface(vf)
|
|
132
311
|
return ResolvedVisualFlow(visual_flow=vf, flows=flows, flows_dir=flows_dir_path)
|
|
133
312
|
|
|
134
313
|
|
|
@@ -162,12 +341,14 @@ def _workflow_registry() -> Any:
|
|
|
162
341
|
|
|
163
342
|
|
|
164
343
|
def _node_type_str(node: Any) -> str:
|
|
344
|
+
if isinstance(node, dict):
|
|
345
|
+
return str(node.get("type") or "")
|
|
165
346
|
t = getattr(node, "type", None)
|
|
166
347
|
return t.value if hasattr(t, "value") else str(t or "")
|
|
167
348
|
|
|
168
349
|
|
|
169
350
|
def _subflow_id(node: Any) -> Optional[str]:
|
|
170
|
-
data = getattr(node, "data", None)
|
|
351
|
+
data = node.get("data") if isinstance(node, dict) else getattr(node, "data", None)
|
|
171
352
|
if not isinstance(data, dict):
|
|
172
353
|
return None
|
|
173
354
|
sid = data.get("subflowId") or data.get("flowId") or data.get("workflowId") or data.get("workflow_id")
|
|
@@ -178,44 +359,103 @@ def _subflow_id(node: Any) -> Optional[str]:
|
|
|
178
359
|
|
|
179
360
|
def _compile_visual_flow_tree(
|
|
180
361
|
*,
|
|
181
|
-
root: Any,
|
|
182
|
-
flows: Dict[str, Any],
|
|
362
|
+
root: Dict[str, Any],
|
|
363
|
+
flows: Dict[str, Dict[str, Any]],
|
|
183
364
|
tools: List[Callable[..., Any]],
|
|
184
365
|
runtime: Runtime,
|
|
366
|
+
bundle_id: Optional[str] = None,
|
|
367
|
+
bundle_version: Optional[str] = None,
|
|
185
368
|
) -> Tuple[WorkflowSpec, Any]:
|
|
186
|
-
from
|
|
187
|
-
from
|
|
188
|
-
from abstractflow.visual.executor import visual_to_flow
|
|
369
|
+
from abstractruntime.visualflow_compiler import compile_visualflow
|
|
370
|
+
from abstractruntime.visualflow_compiler.visual.agent_ids import visual_react_workflow_id
|
|
189
371
|
|
|
190
372
|
# Collect referenced subflows (cycles are allowed; compile/register each id once).
|
|
191
|
-
|
|
373
|
+
ordered_ids: List[str] = []
|
|
192
374
|
seen: set[str] = set()
|
|
193
|
-
queue: List[str] = [str(
|
|
375
|
+
queue: List[str] = [str(root.get("id") or "")]
|
|
194
376
|
|
|
195
377
|
while queue:
|
|
196
378
|
fid = queue.pop(0)
|
|
197
379
|
if not fid or fid in seen:
|
|
198
380
|
continue
|
|
199
|
-
|
|
200
|
-
if
|
|
381
|
+
vf_raw = flows.get(fid)
|
|
382
|
+
if vf_raw is None:
|
|
201
383
|
raise ValueError(f"Subflow '{fid}' not found in loaded flows")
|
|
202
384
|
seen.add(fid)
|
|
203
|
-
|
|
385
|
+
ordered_ids.append(fid)
|
|
204
386
|
|
|
205
|
-
for n in
|
|
387
|
+
for n in list(vf_raw.get("nodes") or []):
|
|
206
388
|
if _node_type_str(n) != "subflow":
|
|
207
389
|
continue
|
|
208
390
|
sid = _subflow_id(n)
|
|
209
391
|
if sid:
|
|
210
392
|
queue.append(sid)
|
|
211
393
|
|
|
394
|
+
bundle_ref = None
|
|
395
|
+
if isinstance(bundle_id, str) and bundle_id.strip() and isinstance(bundle_version, str) and bundle_version.strip():
|
|
396
|
+
bundle_ref = f"{bundle_id.strip()}@{bundle_version.strip()}"
|
|
397
|
+
|
|
398
|
+
def _namespace(prefix: str, flow_id: str) -> str:
|
|
399
|
+
return f"{prefix}:{flow_id}"
|
|
400
|
+
|
|
401
|
+
def _namespace_visualflow_raw(*, raw: Dict[str, Any], prefix: str, flow_id: str, id_map: Dict[str, str]) -> Dict[str, Any]:
|
|
402
|
+
def _rewrite(v: Any) -> Any:
|
|
403
|
+
if isinstance(v, str):
|
|
404
|
+
s = v.strip()
|
|
405
|
+
return id_map.get(s) or v
|
|
406
|
+
if isinstance(v, list):
|
|
407
|
+
return [_rewrite(x) for x in v]
|
|
408
|
+
if isinstance(v, dict):
|
|
409
|
+
return {k: _rewrite(v2) for k, v2 in v.items()}
|
|
410
|
+
return v
|
|
411
|
+
|
|
412
|
+
out_any = _rewrite(raw)
|
|
413
|
+
out: Dict[str, Any] = dict(out_any) if isinstance(out_any, dict) else dict(raw)
|
|
414
|
+
out["id"] = id_map.get(flow_id) or _namespace(prefix, flow_id)
|
|
415
|
+
|
|
416
|
+
nodes_raw = out.get("nodes")
|
|
417
|
+
if isinstance(nodes_raw, list):
|
|
418
|
+
new_nodes: list[Any] = []
|
|
419
|
+
for n_any in nodes_raw:
|
|
420
|
+
n = dict(n_any) if isinstance(n_any, dict) else n_any
|
|
421
|
+
if isinstance(n, dict) and str(n.get("type") or "") == "agent":
|
|
422
|
+
node_id = str(n.get("id") or "").strip()
|
|
423
|
+
data = n.get("data")
|
|
424
|
+
data_d = dict(data) if isinstance(data, dict) else {}
|
|
425
|
+
cfg_raw = data_d.get("agentConfig")
|
|
426
|
+
cfg = dict(cfg_raw) if isinstance(cfg_raw, dict) else {}
|
|
427
|
+
if node_id:
|
|
428
|
+
cfg["_react_workflow_id"] = visual_react_workflow_id(flow_id=str(out.get("id") or ""), node_id=node_id)
|
|
429
|
+
data_d["agentConfig"] = cfg
|
|
430
|
+
n["data"] = data_d
|
|
431
|
+
new_nodes.append(n)
|
|
432
|
+
out["nodes"] = new_nodes
|
|
433
|
+
|
|
434
|
+
return out
|
|
435
|
+
|
|
212
436
|
registry = _workflow_registry()
|
|
213
437
|
|
|
214
438
|
specs_by_id: Dict[str, WorkflowSpec] = {}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
439
|
+
id_map: Dict[str, str] = {}
|
|
440
|
+
if bundle_ref:
|
|
441
|
+
id_map = {fid: _namespace(bundle_ref, fid) for fid in ordered_ids}
|
|
442
|
+
|
|
443
|
+
compiled_flows: list[Dict[str, Any]] = []
|
|
444
|
+
for fid in ordered_ids:
|
|
445
|
+
raw0 = flows.get(fid)
|
|
446
|
+
if raw0 is None:
|
|
447
|
+
continue
|
|
448
|
+
raw = (
|
|
449
|
+
_namespace_visualflow_raw(raw=raw0, prefix=bundle_ref, flow_id=fid, id_map=id_map)
|
|
450
|
+
if bundle_ref
|
|
451
|
+
else dict(raw0)
|
|
452
|
+
)
|
|
453
|
+
try:
|
|
454
|
+
spec = compile_visualflow(raw)
|
|
455
|
+
except Exception as e:
|
|
456
|
+
raise RuntimeError(f"Failed compiling VisualFlow '{fid}': {e}") from e
|
|
218
457
|
specs_by_id[str(spec.workflow_id)] = spec
|
|
458
|
+
compiled_flows.append(raw)
|
|
219
459
|
register = getattr(registry, "register", None)
|
|
220
460
|
if callable(register):
|
|
221
461
|
register(spec)
|
|
@@ -224,18 +464,19 @@ def _compile_visual_flow_tree(
|
|
|
224
464
|
|
|
225
465
|
# Register per-Agent-node ReAct subworkflows so visual Agent nodes can run.
|
|
226
466
|
agent_nodes: List[Tuple[str, Dict[str, Any]]] = []
|
|
227
|
-
for vf in
|
|
228
|
-
|
|
467
|
+
for vf in compiled_flows:
|
|
468
|
+
flow_id = str(vf.get("id") or "").strip()
|
|
469
|
+
for n in list(vf.get("nodes") or []):
|
|
229
470
|
if _node_type_str(n) != "agent":
|
|
230
471
|
continue
|
|
231
|
-
data =
|
|
472
|
+
data = n.get("data") if isinstance(n, dict) else None
|
|
232
473
|
cfg = data.get("agentConfig", {}) if isinstance(data, dict) else {}
|
|
233
474
|
cfg = dict(cfg) if isinstance(cfg, dict) else {}
|
|
234
475
|
wf_id_raw = cfg.get("_react_workflow_id")
|
|
235
476
|
wf_id = (
|
|
236
477
|
wf_id_raw.strip()
|
|
237
478
|
if isinstance(wf_id_raw, str) and wf_id_raw.strip()
|
|
238
|
-
else visual_react_workflow_id(flow_id=
|
|
479
|
+
else visual_react_workflow_id(flow_id=flow_id or "unknown", node_id=str((n.get("id") if isinstance(n, dict) else "") or ""))
|
|
239
480
|
)
|
|
240
481
|
agent_nodes.append((wf_id, cfg))
|
|
241
482
|
|
|
@@ -244,7 +485,9 @@ def _compile_visual_flow_tree(
|
|
|
244
485
|
from abstractagent.logic.builtins import (
|
|
245
486
|
ASK_USER_TOOL,
|
|
246
487
|
COMPACT_MEMORY_TOOL,
|
|
488
|
+
DELEGATE_AGENT_TOOL,
|
|
247
489
|
INSPECT_VARS_TOOL,
|
|
490
|
+
OPEN_ATTACHMENT_TOOL,
|
|
248
491
|
RECALL_MEMORY_TOOL,
|
|
249
492
|
REMEMBER_NOTE_TOOL,
|
|
250
493
|
REMEMBER_TOOL,
|
|
@@ -262,11 +505,13 @@ def _compile_visual_flow_tree(
|
|
|
262
505
|
|
|
263
506
|
tool_defs = [
|
|
264
507
|
ASK_USER_TOOL,
|
|
508
|
+
OPEN_ATTACHMENT_TOOL,
|
|
265
509
|
RECALL_MEMORY_TOOL,
|
|
266
510
|
INSPECT_VARS_TOOL,
|
|
267
511
|
REMEMBER_TOOL,
|
|
268
512
|
REMEMBER_NOTE_TOOL,
|
|
269
513
|
COMPACT_MEMORY_TOOL,
|
|
514
|
+
DELEGATE_AGENT_TOOL,
|
|
270
515
|
*_tool_definitions_from_callables(tools),
|
|
271
516
|
]
|
|
272
517
|
|
|
@@ -292,20 +537,135 @@ def _compile_visual_flow_tree(
|
|
|
292
537
|
else: # pragma: no cover
|
|
293
538
|
raise RuntimeError("Runtime does not support workflow registries (required for subflows/agent nodes).")
|
|
294
539
|
|
|
295
|
-
root_id = str(
|
|
296
|
-
|
|
540
|
+
root_id = str(root.get("id") or "")
|
|
541
|
+
root_wid = id_map.get(root_id) if bundle_ref else root_id
|
|
542
|
+
root_spec = specs_by_id.get(root_wid)
|
|
297
543
|
if root_spec is None:
|
|
298
544
|
# Shouldn't happen because root id was seeded into the queue.
|
|
299
|
-
raise RuntimeError(f"Root workflow '{root_id}' was not compiled/registered.")
|
|
545
|
+
raise RuntimeError(f"Root workflow '{root_wid or root_id}' was not compiled/registered.")
|
|
300
546
|
return root_spec, registry
|
|
301
547
|
|
|
302
548
|
|
|
549
|
+
def _apply_abstractcode_agent_v1_scaffold(flow: Dict[str, Any], *, include_recommended: bool = True) -> None:
|
|
550
|
+
"""Best-effort: ensure required pins exist for `abstractcode.agent.v1` flows.
|
|
551
|
+
|
|
552
|
+
This mirrors the VisualFlow interface scaffold in `abstractflow.visual.interfaces`,
|
|
553
|
+
but operates directly on raw dict JSON so AbstractCode can run bundles without
|
|
554
|
+
depending on AbstractFlow.
|
|
555
|
+
"""
|
|
556
|
+
iid = "abstractcode.agent.v1"
|
|
557
|
+
|
|
558
|
+
interfaces = flow.get("interfaces")
|
|
559
|
+
if not isinstance(interfaces, list):
|
|
560
|
+
interfaces = []
|
|
561
|
+
flow["interfaces"] = interfaces
|
|
562
|
+
if iid not in interfaces:
|
|
563
|
+
interfaces.append(iid)
|
|
564
|
+
|
|
565
|
+
nodes = flow.get("nodes")
|
|
566
|
+
if not isinstance(nodes, list):
|
|
567
|
+
return
|
|
568
|
+
|
|
569
|
+
def _ensure_node_data(node: Dict[str, Any]) -> Dict[str, Any]:
|
|
570
|
+
data = node.get("data")
|
|
571
|
+
if not isinstance(data, dict):
|
|
572
|
+
data = {}
|
|
573
|
+
node["data"] = data
|
|
574
|
+
return data
|
|
575
|
+
|
|
576
|
+
def _ensure_pin_list(data: Dict[str, Any], key: str) -> list[dict[str, Any]]:
|
|
577
|
+
pins_any = data.get(key)
|
|
578
|
+
if not isinstance(pins_any, list):
|
|
579
|
+
pins: list[dict[str, Any]] = []
|
|
580
|
+
data[key] = pins
|
|
581
|
+
return pins
|
|
582
|
+
if all(isinstance(p, dict) for p in pins_any):
|
|
583
|
+
return pins_any # type: ignore[return-value]
|
|
584
|
+
filtered: list[dict[str, Any]] = [p for p in pins_any if isinstance(p, dict)]
|
|
585
|
+
data[key] = filtered
|
|
586
|
+
return filtered
|
|
587
|
+
|
|
588
|
+
def _ensure_pin(pins: list[dict[str, Any]], *, pin_id: str, pin_type: str, label: Optional[str] = None) -> None:
|
|
589
|
+
if any(isinstance(p.get("id"), str) and p.get("id") == pin_id for p in pins):
|
|
590
|
+
return
|
|
591
|
+
pins.append({"id": pin_id, "label": label if isinstance(label, str) else pin_id, "type": pin_type})
|
|
592
|
+
|
|
593
|
+
start = next((n for n in nodes if isinstance(n, dict) and str(n.get("type") or "") == "on_flow_start"), None)
|
|
594
|
+
if isinstance(start, dict):
|
|
595
|
+
data = _ensure_node_data(start)
|
|
596
|
+
outs = _ensure_pin_list(data, "outputs")
|
|
597
|
+
_ensure_pin(outs, pin_id="exec-out", pin_type="execution", label="")
|
|
598
|
+
_ensure_pin(outs, pin_id="provider", pin_type="provider")
|
|
599
|
+
_ensure_pin(outs, pin_id="model", pin_type="model")
|
|
600
|
+
_ensure_pin(outs, pin_id="prompt", pin_type="string")
|
|
601
|
+
if include_recommended:
|
|
602
|
+
_ensure_pin(outs, pin_id="tools", pin_type="tools")
|
|
603
|
+
|
|
604
|
+
for end in [n for n in nodes if isinstance(n, dict) and str(n.get("type") or "") == "on_flow_end"]:
|
|
605
|
+
data = _ensure_node_data(end)
|
|
606
|
+
ins = _ensure_pin_list(data, "inputs")
|
|
607
|
+
_ensure_pin(ins, pin_id="exec-in", pin_type="execution", label="")
|
|
608
|
+
_ensure_pin(ins, pin_id="response", pin_type="string")
|
|
609
|
+
_ensure_pin(ins, pin_id="success", pin_type="boolean")
|
|
610
|
+
_ensure_pin(ins, pin_id="meta", pin_type="object")
|
|
611
|
+
if include_recommended:
|
|
612
|
+
_ensure_pin(ins, pin_id="scratchpad", pin_type="object")
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _validate_abstractcode_agent_v1(flow: Dict[str, Any]) -> List[str]:
|
|
616
|
+
"""Minimal contract validation for `abstractcode.agent.v1` workflows (stdlib-only)."""
|
|
617
|
+
errors: list[str] = []
|
|
618
|
+
nodes = flow.get("nodes")
|
|
619
|
+
if not isinstance(nodes, list):
|
|
620
|
+
return ["Flow.nodes must be a list"]
|
|
621
|
+
|
|
622
|
+
def _pins(node: Dict[str, Any], key: str) -> Optional[set[str]]:
|
|
623
|
+
data = node.get("data")
|
|
624
|
+
if not isinstance(data, dict):
|
|
625
|
+
return None
|
|
626
|
+
pins = data.get(key)
|
|
627
|
+
if not isinstance(pins, list):
|
|
628
|
+
return None
|
|
629
|
+
out: set[str] = set()
|
|
630
|
+
for p in pins:
|
|
631
|
+
if not isinstance(p, dict):
|
|
632
|
+
continue
|
|
633
|
+
if p.get("type") == "execution":
|
|
634
|
+
continue
|
|
635
|
+
pid = p.get("id")
|
|
636
|
+
if isinstance(pid, str) and pid.strip():
|
|
637
|
+
out.add(pid.strip())
|
|
638
|
+
return out
|
|
639
|
+
|
|
640
|
+
start = next((n for n in nodes if isinstance(n, dict) and str(n.get("type") or "") == "on_flow_start"), None)
|
|
641
|
+
if not isinstance(start, dict):
|
|
642
|
+
errors.append("Missing On Flow Start node (type=on_flow_start)")
|
|
643
|
+
else:
|
|
644
|
+
outs = _pins(start, "outputs")
|
|
645
|
+
required_out = {"prompt", "provider", "model", "tools"}
|
|
646
|
+
if outs is not None and not required_out.issubset(outs):
|
|
647
|
+
missing = ", ".join(sorted(required_out - outs))
|
|
648
|
+
errors.append(f"On Flow Start outputs missing: {missing}")
|
|
649
|
+
|
|
650
|
+
end = next((n for n in nodes if isinstance(n, dict) and str(n.get("type") or "") == "on_flow_end"), None)
|
|
651
|
+
if not isinstance(end, dict):
|
|
652
|
+
errors.append("Missing On Flow End node (type=on_flow_end)")
|
|
653
|
+
else:
|
|
654
|
+
ins = _pins(end, "inputs")
|
|
655
|
+
required_in = {"response", "success", "meta"}
|
|
656
|
+
if ins is not None and not required_in.issubset(ins):
|
|
657
|
+
missing = ", ".join(sorted(required_in - ins))
|
|
658
|
+
errors.append(f"On Flow End inputs missing: {missing}")
|
|
659
|
+
|
|
660
|
+
return errors
|
|
661
|
+
|
|
662
|
+
|
|
303
663
|
class WorkflowAgent(BaseAgent):
|
|
304
|
-
"""Run a VisualFlow workflow as
|
|
664
|
+
"""Run a VisualFlow workflow as a RunnableFlow in AbstractCode.
|
|
305
665
|
|
|
306
666
|
Contract: the workflow must declare `interfaces: ["abstractcode.agent.v1"]` and expose:
|
|
307
|
-
- On Flow Start output
|
|
308
|
-
- On Flow End input
|
|
667
|
+
- On Flow Start output pins (required): `provider` (provider), `model` (model), `prompt` (string)
|
|
668
|
+
- On Flow End input pins (required): `response` (string), `success` (boolean), `meta` (object)
|
|
309
669
|
"""
|
|
310
670
|
|
|
311
671
|
def __init__(
|
|
@@ -329,32 +689,17 @@ class WorkflowAgent(BaseAgent):
|
|
|
329
689
|
if not self._flow_ref:
|
|
330
690
|
raise ValueError("flow_ref is required")
|
|
331
691
|
|
|
332
|
-
|
|
692
|
+
ABSTRACTCODE_AGENT_V1 = "abstractcode.agent.v1"
|
|
693
|
+
resolved = resolve_visual_flow(self._flow_ref, flows_dir=flows_dir, require_interface=ABSTRACTCODE_AGENT_V1)
|
|
333
694
|
self.visual_flow = resolved.visual_flow
|
|
334
695
|
self.flows = resolved.flows
|
|
335
696
|
self.flows_dir = resolved.flows_dir
|
|
697
|
+
self._bundle_id = resolved.bundle_id
|
|
698
|
+
self._bundle_version = resolved.bundle_version
|
|
336
699
|
|
|
337
|
-
|
|
338
|
-
try:
|
|
339
|
-
from abstractflow.visual.interfaces import (
|
|
340
|
-
ABSTRACTCODE_AGENT_V1,
|
|
341
|
-
apply_visual_flow_interface_scaffold,
|
|
342
|
-
validate_visual_flow_interface,
|
|
343
|
-
)
|
|
344
|
-
except Exception as e: # pragma: no cover
|
|
345
|
-
raise RuntimeError(
|
|
346
|
-
"AbstractFlow is required to validate VisualFlow interfaces.\n"
|
|
347
|
-
'Install with: pip install "abstractcode[flow]"'
|
|
348
|
-
) from e
|
|
349
|
-
|
|
350
|
-
# Authoring UX: keep interface-marked flows scaffolded even if the underlying
|
|
351
|
-
# JSON was created before the contract expanded (or was edited manually).
|
|
352
|
-
try:
|
|
353
|
-
apply_visual_flow_interface_scaffold(self.visual_flow, ABSTRACTCODE_AGENT_V1, include_recommended=True)
|
|
354
|
-
except Exception:
|
|
355
|
-
pass
|
|
700
|
+
_apply_abstractcode_agent_v1_scaffold(self.visual_flow, include_recommended=True)
|
|
356
701
|
|
|
357
|
-
errors =
|
|
702
|
+
errors = _validate_abstractcode_agent_v1(self.visual_flow)
|
|
358
703
|
if errors:
|
|
359
704
|
joined = "\n".join([f"- {e}" for e in errors])
|
|
360
705
|
raise ValueError(f"Workflow does not implement '{ABSTRACTCODE_AGENT_V1}':\n{joined}")
|
|
@@ -373,10 +718,24 @@ class WorkflowAgent(BaseAgent):
|
|
|
373
718
|
|
|
374
719
|
def _create_workflow(self) -> WorkflowSpec:
|
|
375
720
|
tools = list(self.tools or [])
|
|
376
|
-
spec, _registry = _compile_visual_flow_tree(
|
|
721
|
+
spec, _registry = _compile_visual_flow_tree(
|
|
722
|
+
root=self.visual_flow,
|
|
723
|
+
flows=self.flows,
|
|
724
|
+
tools=tools,
|
|
725
|
+
runtime=self.runtime,
|
|
726
|
+
bundle_id=self._bundle_id,
|
|
727
|
+
bundle_version=self._bundle_version,
|
|
728
|
+
)
|
|
377
729
|
return spec
|
|
378
730
|
|
|
379
|
-
def start(
|
|
731
|
+
def start(
|
|
732
|
+
self,
|
|
733
|
+
task: str,
|
|
734
|
+
*,
|
|
735
|
+
allowed_tools: Optional[List[str]] = None,
|
|
736
|
+
attachments: Optional[List[Any]] = None,
|
|
737
|
+
**_: Any,
|
|
738
|
+
) -> str:
|
|
380
739
|
task = str(task or "").strip()
|
|
381
740
|
if not task:
|
|
382
741
|
raise ValueError("task must be a non-empty string")
|
|
@@ -406,11 +765,25 @@ class WorkflowAgent(BaseAgent):
|
|
|
406
765
|
runtime_model = getattr(getattr(self.runtime, "config", None), "model", None)
|
|
407
766
|
|
|
408
767
|
vars: Dict[str, Any] = {
|
|
409
|
-
"
|
|
768
|
+
"prompt": task,
|
|
410
769
|
"context": {"task": task, "messages": _copy_messages(self.session_messages)},
|
|
411
770
|
"_temp": {},
|
|
412
771
|
"_limits": limits,
|
|
413
772
|
}
|
|
773
|
+
if attachments:
|
|
774
|
+
items = list(attachments) if isinstance(attachments, tuple) else attachments if isinstance(attachments, list) else []
|
|
775
|
+
normalized: list[Any] = []
|
|
776
|
+
for a in items:
|
|
777
|
+
if isinstance(a, dict):
|
|
778
|
+
normalized.append(dict(a))
|
|
779
|
+
elif isinstance(a, str) and a.strip():
|
|
780
|
+
normalized.append(a.strip())
|
|
781
|
+
if normalized:
|
|
782
|
+
ctx = vars.get("context")
|
|
783
|
+
if not isinstance(ctx, dict):
|
|
784
|
+
ctx = {"task": task, "messages": _copy_messages(self.session_messages)}
|
|
785
|
+
vars["context"] = ctx
|
|
786
|
+
ctx["attachments"] = normalized
|
|
414
787
|
|
|
415
788
|
if isinstance(runtime_provider, str) and runtime_provider.strip():
|
|
416
789
|
vars["provider"] = runtime_provider.strip()
|
|
@@ -439,11 +812,13 @@ class WorkflowAgent(BaseAgent):
|
|
|
439
812
|
# Build a stable node_id -> label map for UX (used for status updates).
|
|
440
813
|
try:
|
|
441
814
|
labels: Dict[str, str] = {}
|
|
442
|
-
for n in
|
|
443
|
-
|
|
815
|
+
for n in list(self.visual_flow.get("nodes") or []):
|
|
816
|
+
if not isinstance(n, dict):
|
|
817
|
+
continue
|
|
818
|
+
nid = n.get("id")
|
|
444
819
|
if not isinstance(nid, str) or not nid:
|
|
445
820
|
continue
|
|
446
|
-
data =
|
|
821
|
+
data = n.get("data")
|
|
447
822
|
label = data.get("label") if isinstance(data, dict) else None
|
|
448
823
|
if isinstance(label, str) and label.strip():
|
|
449
824
|
labels[nid] = label.strip()
|
|
@@ -465,8 +840,10 @@ class WorkflowAgent(BaseAgent):
|
|
|
465
840
|
self.on_step(
|
|
466
841
|
"init",
|
|
467
842
|
{
|
|
468
|
-
"flow_id": str(
|
|
469
|
-
"flow_name": str(
|
|
843
|
+
"flow_id": str(self.visual_flow.get("id") or ""),
|
|
844
|
+
"flow_name": str(self.visual_flow.get("name") or ""),
|
|
845
|
+
"bundle_id": self._bundle_id,
|
|
846
|
+
"bundle_version": self._bundle_version,
|
|
470
847
|
},
|
|
471
848
|
)
|
|
472
849
|
except Exception:
|
|
@@ -635,6 +1012,7 @@ class WorkflowAgent(BaseAgent):
|
|
|
635
1012
|
name = str(payload.get("name") or payload.get("event_name") or "").strip()
|
|
636
1013
|
if not name:
|
|
637
1014
|
return
|
|
1015
|
+
name = _normalize_ui_event_name(name)
|
|
638
1016
|
|
|
639
1017
|
event_payload = payload.get("payload")
|
|
640
1018
|
if name == _STATUS_EVENT_NAME:
|
|
@@ -784,6 +1162,118 @@ class WorkflowAgent(BaseAgent):
|
|
|
784
1162
|
|
|
785
1163
|
time.sleep(min(0.25, max(0.0, float(remaining))))
|
|
786
1164
|
|
|
1165
|
+
def _auto_drive_subworkflow_wait(self, state: RunState) -> Optional[RunState]:
|
|
1166
|
+
"""Best-effort: drive async SUBWORKFLOW waits for non-interactive hosts.
|
|
1167
|
+
|
|
1168
|
+
Visual subflow nodes are compiled into async+wait subworkflow effects so
|
|
1169
|
+
interactive hosts (e.g. web) can stream nested runs. AbstractCode's agent
|
|
1170
|
+
loop expects `step()` to keep progressing without needing an external
|
|
1171
|
+
sub-run driver, so we tick sub-runs and bubble their completions up.
|
|
1172
|
+
"""
|
|
1173
|
+
from abstractruntime.core.models import WaitReason
|
|
1174
|
+
|
|
1175
|
+
waiting = getattr(state, "waiting", None)
|
|
1176
|
+
if waiting is None or getattr(waiting, "reason", None) != WaitReason.SUBWORKFLOW:
|
|
1177
|
+
return None
|
|
1178
|
+
|
|
1179
|
+
top_run_id = str(getattr(state, "run_id", "") or "")
|
|
1180
|
+
if not top_run_id:
|
|
1181
|
+
return None
|
|
1182
|
+
|
|
1183
|
+
def _extract_sub_run_id(wait_state: object) -> Optional[str]:
|
|
1184
|
+
details = getattr(wait_state, "details", None)
|
|
1185
|
+
if isinstance(details, dict):
|
|
1186
|
+
sub_run_id = details.get("sub_run_id")
|
|
1187
|
+
if isinstance(sub_run_id, str) and sub_run_id:
|
|
1188
|
+
return sub_run_id
|
|
1189
|
+
wait_key = getattr(wait_state, "wait_key", None)
|
|
1190
|
+
if isinstance(wait_key, str) and wait_key.startswith("subworkflow:"):
|
|
1191
|
+
return wait_key.split("subworkflow:", 1)[1] or None
|
|
1192
|
+
return None
|
|
1193
|
+
|
|
1194
|
+
def _workflow_for(run_state: object) -> Any:
|
|
1195
|
+
reg = getattr(self.runtime, "workflow_registry", None)
|
|
1196
|
+
getter = getattr(reg, "get", None) if reg is not None else None
|
|
1197
|
+
if callable(getter):
|
|
1198
|
+
wf = getter(getattr(run_state, "workflow_id", ""))
|
|
1199
|
+
if wf is not None:
|
|
1200
|
+
return wf
|
|
1201
|
+
if getattr(self.workflow, "workflow_id", None) == getattr(run_state, "workflow_id", None):
|
|
1202
|
+
return self.workflow
|
|
1203
|
+
raise RuntimeError(f"Workflow '{getattr(run_state, 'workflow_id', '')}' not found in runtime registry")
|
|
1204
|
+
|
|
1205
|
+
def _bubble_completion(child_state: object) -> Optional[str]:
|
|
1206
|
+
parent_id = getattr(child_state, "parent_run_id", None)
|
|
1207
|
+
if not isinstance(parent_id, str) or not parent_id:
|
|
1208
|
+
return None
|
|
1209
|
+
parent_state = self.runtime.get_state(parent_id)
|
|
1210
|
+
parent_wait = getattr(parent_state, "waiting", None)
|
|
1211
|
+
if parent_state.status != RunStatus.WAITING or parent_wait is None:
|
|
1212
|
+
return None
|
|
1213
|
+
if parent_wait.reason != WaitReason.SUBWORKFLOW:
|
|
1214
|
+
return None
|
|
1215
|
+
self.runtime.resume(
|
|
1216
|
+
workflow=_workflow_for(parent_state),
|
|
1217
|
+
run_id=parent_id,
|
|
1218
|
+
wait_key=None,
|
|
1219
|
+
payload={
|
|
1220
|
+
"sub_run_id": getattr(child_state, "run_id", None),
|
|
1221
|
+
"output": getattr(child_state, "output", None),
|
|
1222
|
+
"node_traces": self.runtime.get_node_traces(getattr(child_state, "run_id", "")),
|
|
1223
|
+
},
|
|
1224
|
+
max_steps=0,
|
|
1225
|
+
)
|
|
1226
|
+
return parent_id
|
|
1227
|
+
|
|
1228
|
+
# Drive subruns until we either make progress or hit a non-subworkflow wait.
|
|
1229
|
+
for _ in range(200):
|
|
1230
|
+
# Descend to the deepest sub-run referenced by SUBWORKFLOW waits.
|
|
1231
|
+
current_run_id = top_run_id
|
|
1232
|
+
for _ in range(25):
|
|
1233
|
+
cur_state = self.runtime.get_state(current_run_id)
|
|
1234
|
+
cur_wait = getattr(cur_state, "waiting", None)
|
|
1235
|
+
if cur_state.status != RunStatus.WAITING or cur_wait is None:
|
|
1236
|
+
break
|
|
1237
|
+
if cur_wait.reason != WaitReason.SUBWORKFLOW:
|
|
1238
|
+
break
|
|
1239
|
+
next_id = _extract_sub_run_id(cur_wait)
|
|
1240
|
+
if not next_id:
|
|
1241
|
+
break
|
|
1242
|
+
current_run_id = next_id
|
|
1243
|
+
|
|
1244
|
+
current_state = self.runtime.get_state(current_run_id)
|
|
1245
|
+
|
|
1246
|
+
# Tick running subruns until they block/complete.
|
|
1247
|
+
if current_state.status == RunStatus.RUNNING:
|
|
1248
|
+
current_state = self.runtime.tick(
|
|
1249
|
+
workflow=_workflow_for(current_state),
|
|
1250
|
+
run_id=current_run_id,
|
|
1251
|
+
max_steps=100,
|
|
1252
|
+
)
|
|
1253
|
+
|
|
1254
|
+
if current_state.status == RunStatus.RUNNING:
|
|
1255
|
+
continue
|
|
1256
|
+
|
|
1257
|
+
if current_state.status in (RunStatus.FAILED, RunStatus.CANCELLED):
|
|
1258
|
+
return current_state
|
|
1259
|
+
|
|
1260
|
+
if current_state.status == RunStatus.WAITING:
|
|
1261
|
+
cur_wait = getattr(current_state, "waiting", None)
|
|
1262
|
+
if cur_wait is None:
|
|
1263
|
+
break
|
|
1264
|
+
if cur_wait.reason == WaitReason.SUBWORKFLOW:
|
|
1265
|
+
continue
|
|
1266
|
+
# Blocked on a real wait (USER/EVENT/UNTIL/...): stop auto-driving.
|
|
1267
|
+
return self.runtime.get_state(top_run_id)
|
|
1268
|
+
|
|
1269
|
+
if current_state.status == RunStatus.COMPLETED:
|
|
1270
|
+
parent_id = _bubble_completion(current_state)
|
|
1271
|
+
if parent_id is None:
|
|
1272
|
+
return self.runtime.get_state(top_run_id)
|
|
1273
|
+
continue
|
|
1274
|
+
|
|
1275
|
+
return self.runtime.get_state(top_run_id)
|
|
1276
|
+
|
|
787
1277
|
def step(self) -> RunState:
|
|
788
1278
|
if not self._current_run_id:
|
|
789
1279
|
raise RuntimeError("No active run. Call start() first.")
|
|
@@ -800,33 +1290,61 @@ class WorkflowAgent(BaseAgent):
|
|
|
800
1290
|
# Time passed (or will pass within our polling loop): continue ticking once.
|
|
801
1291
|
state = self.runtime.tick(workflow=self.workflow, run_id=self._current_run_id, max_steps=1)
|
|
802
1292
|
|
|
1293
|
+
if state.status == RunStatus.WAITING:
|
|
1294
|
+
driven = self._auto_drive_subworkflow_wait(state)
|
|
1295
|
+
if isinstance(driven, RunState):
|
|
1296
|
+
state = driven
|
|
1297
|
+
|
|
803
1298
|
if state.status == RunStatus.COMPLETED:
|
|
804
1299
|
response_text = ""
|
|
805
1300
|
meta_out: Dict[str, Any] = {}
|
|
806
1301
|
scratchpad_out: Any = None
|
|
807
|
-
|
|
1302
|
+
workflow_success: Optional[bool] = None
|
|
808
1303
|
out = getattr(state, "output", None)
|
|
809
1304
|
if isinstance(out, dict):
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
if isinstance(
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
1305
|
+
def _pick_textish(value: Any) -> str:
|
|
1306
|
+
if isinstance(value, str):
|
|
1307
|
+
return value.strip()
|
|
1308
|
+
if value is None:
|
|
1309
|
+
return ""
|
|
1310
|
+
if isinstance(value, bool):
|
|
1311
|
+
return str(value).lower()
|
|
1312
|
+
if isinstance(value, (int, float)):
|
|
1313
|
+
return str(value)
|
|
1314
|
+
return ""
|
|
1315
|
+
|
|
1316
|
+
payload = out.get("result") if isinstance(out.get("result"), dict) else out
|
|
1317
|
+
|
|
1318
|
+
response_text = _pick_textish(payload.get("response"))
|
|
1319
|
+
if not response_text:
|
|
1320
|
+
response_text = (
|
|
1321
|
+
_pick_textish(payload.get("answer"))
|
|
1322
|
+
or _pick_textish(payload.get("message"))
|
|
1323
|
+
or _pick_textish(payload.get("text"))
|
|
1324
|
+
or _pick_textish(payload.get("content"))
|
|
1325
|
+
)
|
|
1326
|
+
if not response_text and isinstance(out.get("result"), str):
|
|
1327
|
+
response_text = str(out.get("result") or "").strip()
|
|
1328
|
+
|
|
1329
|
+
if isinstance(payload.get("success"), bool):
|
|
1330
|
+
workflow_success = bool(payload.get("success"))
|
|
1331
|
+
|
|
1332
|
+
raw_meta = payload.get("meta")
|
|
820
1333
|
if isinstance(raw_meta, dict):
|
|
821
1334
|
meta_out = dict(raw_meta)
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
1335
|
+
scratchpad_out = payload.get("scratchpad")
|
|
1336
|
+
if scratchpad_out is None and isinstance(out.get("scratchpad"), (dict, list, str, int, float, bool)):
|
|
1337
|
+
scratchpad_out = out.get("scratchpad")
|
|
1338
|
+
|
|
1339
|
+
# Backward-compat: older runs used meta.success instead of a first-class pin.
|
|
1340
|
+
if workflow_success is None and isinstance(meta_out.get("success"), bool):
|
|
1341
|
+
workflow_success = bool(meta_out.get("success"))
|
|
1342
|
+
|
|
1343
|
+
# Fallback: if the workflow doesn't expose success, treat run completion as success.
|
|
1344
|
+
if workflow_success is None and isinstance(out.get("success"), bool):
|
|
1345
|
+
workflow_success = bool(out.get("success"))
|
|
1346
|
+
if workflow_success is None:
|
|
1347
|
+
workflow_success = True
|
|
830
1348
|
|
|
831
1349
|
task = str(self._last_task or "")
|
|
832
1350
|
ctx = state.vars.get("context") if isinstance(getattr(state, "vars", None), dict) else None
|
|
@@ -843,8 +1361,8 @@ class WorkflowAgent(BaseAgent):
|
|
|
843
1361
|
assistant_meta["workflow_meta"] = meta_out
|
|
844
1362
|
if scratchpad_out is not None:
|
|
845
1363
|
assistant_meta["workflow_scratchpad"] = scratchpad_out
|
|
846
|
-
if
|
|
847
|
-
assistant_meta["
|
|
1364
|
+
if workflow_success is not None:
|
|
1365
|
+
assistant_meta["workflow_success"] = workflow_success
|
|
848
1366
|
|
|
849
1367
|
msgs.append(_new_message(role="assistant", content=response_text, metadata=assistant_meta))
|
|
850
1368
|
ctx["messages"] = msgs
|
|
@@ -866,9 +1384,9 @@ class WorkflowAgent(BaseAgent):
|
|
|
866
1384
|
"done",
|
|
867
1385
|
{
|
|
868
1386
|
"answer": response_text,
|
|
1387
|
+
"success": workflow_success,
|
|
869
1388
|
"meta": meta_out or None,
|
|
870
1389
|
"scratchpad": scratchpad_out,
|
|
871
|
-
"raw_result": raw_result_out,
|
|
872
1390
|
},
|
|
873
1391
|
)
|
|
874
1392
|
except Exception:
|