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.
@@ -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
- _STATUS_EVENT_NAME = "abstractcode.status"
17
- _MESSAGE_EVENT_NAME = "abstractcode.message"
18
- _TOOL_EXEC_EVENT_NAME = "abstractcode.tool_execution"
19
- _TOOL_RESULT_EVENT_NAME = "abstractcode.tool_result"
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 _load_visual_flows(flows_dir: Path) -> Dict[str, Any]:
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
- from abstractflow.visual.models import VisualFlow
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
- "AbstractFlow is required to run VisualFlow workflows.\n"
67
- 'Install with: pip install "abstractcode[flow]"'
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
- flows: Dict[str, Any] = {}
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
- flows[str(vf.id)] = vf
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(flow_ref: str, *, flows_dir: Optional[str]) -> ResolvedVisualFlow:
84
- """Resolve a VisualFlow by id, name, or path to a .json file."""
85
- ref = str(flow_ref or "").strip()
86
- if not ref:
87
- raise ValueError("flow reference is required (flow id, name, or .json path)")
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
- try:
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
- flows[str(vf.id)] = vf
109
- return ResolvedVisualFlow(visual_flow=vf, flows=flows, flows_dir=flows_dir_path)
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 = getattr(vf, "name", None)
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 '{ref}' not found in {flows_dir_path}")
304
+ raise ValueError(f"Flow '{ref_raw}' not found in {flows_dir_path}")
127
305
  if len(matches) > 1:
128
- options = ", ".join([f"{getattr(v, 'name', '')} ({getattr(v, 'id', '')})" for v in matches])
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 abstractflow.compiler import compile_flow
187
- from abstractflow.visual.agent_ids import visual_react_workflow_id
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
- ordered: List[Any] = []
373
+ ordered_ids: List[str] = []
192
374
  seen: set[str] = set()
193
- queue: List[str] = [str(getattr(root, "id", "") or "")]
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
- vf = flows.get(fid)
200
- if vf is None:
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
- ordered.append(vf)
385
+ ordered_ids.append(fid)
204
386
 
205
- for n in getattr(vf, "nodes", []) or []:
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
- for vf in ordered:
216
- f = visual_to_flow(vf)
217
- spec = compile_flow(f)
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 ordered:
228
- for n in getattr(vf, "nodes", []) or []:
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 = getattr(n, "data", None)
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=vf.id, node_id=n.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(getattr(root, "id", "") or "")
296
- root_spec = specs_by_id.get(root_id)
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 an AbstractCode agent.
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 pin: `request` (string)
308
- - On Flow End input pin: `response` (string)
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
- resolved = resolve_visual_flow(self._flow_ref, flows_dir=flows_dir)
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
- # Validate interface contract before creating the workflow spec.
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 = validate_visual_flow_interface(self.visual_flow, ABSTRACTCODE_AGENT_V1)
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(root=self.visual_flow, flows=self.flows, tools=tools, runtime=self.runtime)
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(self, task: str, *, allowed_tools: Optional[List[str]] = None, **_: Any) -> str:
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
- "request": task,
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 getattr(self.visual_flow, "nodes", []) or []:
443
- nid = getattr(n, "id", None)
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 = getattr(n, "data", None)
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(getattr(self.visual_flow, "id", "") or ""),
469
- "flow_name": str(getattr(self.visual_flow, "name", "") or ""),
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
- raw_result_out: Any = None
1302
+ workflow_success: Optional[bool] = None
808
1303
  out = getattr(state, "output", None)
809
1304
  if isinstance(out, dict):
810
- result_payload = out.get("result") if isinstance(out.get("result"), dict) else None
811
- if isinstance(out.get("response"), str):
812
- response_text = str(out.get("response") or "")
813
- else:
814
- result = out.get("result")
815
- if isinstance(result, dict) and "response" in result:
816
- response_text = str(result.get("response") or "")
817
- elif isinstance(result, str):
818
- response_text = str(result or "")
819
- raw_meta = out.get("meta")
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
- elif isinstance(result_payload, dict) and isinstance(result_payload.get("meta"), dict):
823
- meta_out = dict(result_payload.get("meta") or {})
824
- scratchpad_out = out.get("scratchpad")
825
- if scratchpad_out is None and isinstance(result_payload, dict) and "scratchpad" in result_payload:
826
- scratchpad_out = result_payload.get("scratchpad")
827
- raw_result_out = out.get("raw_result")
828
- if raw_result_out is None and isinstance(result_payload, dict) and "raw_result" in result_payload:
829
- raw_result_out = result_payload.get("raw_result")
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 raw_result_out is not None:
847
- assistant_meta["workflow_raw_result"] = raw_result_out
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: