abstractflow 0.1.0__py3-none-any.whl → 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. abstractflow/__init__.py +75 -95
  2. abstractflow/__main__.py +2 -0
  3. abstractflow/adapters/__init__.py +11 -0
  4. abstractflow/adapters/agent_adapter.py +124 -0
  5. abstractflow/adapters/control_adapter.py +615 -0
  6. abstractflow/adapters/effect_adapter.py +645 -0
  7. abstractflow/adapters/event_adapter.py +307 -0
  8. abstractflow/adapters/function_adapter.py +97 -0
  9. abstractflow/adapters/subflow_adapter.py +74 -0
  10. abstractflow/adapters/variable_adapter.py +317 -0
  11. abstractflow/cli.py +2 -0
  12. abstractflow/compiler.py +2027 -0
  13. abstractflow/core/__init__.py +5 -0
  14. abstractflow/core/flow.py +247 -0
  15. abstractflow/py.typed +2 -0
  16. abstractflow/runner.py +348 -0
  17. abstractflow/visual/__init__.py +43 -0
  18. abstractflow/visual/agent_ids.py +29 -0
  19. abstractflow/visual/builtins.py +789 -0
  20. abstractflow/visual/code_executor.py +214 -0
  21. abstractflow/visual/event_ids.py +33 -0
  22. abstractflow/visual/executor.py +2789 -0
  23. abstractflow/visual/interfaces.py +347 -0
  24. abstractflow/visual/models.py +252 -0
  25. abstractflow/visual/session_runner.py +168 -0
  26. abstractflow/visual/workspace_scoped_tools.py +261 -0
  27. abstractflow-0.3.0.dist-info/METADATA +413 -0
  28. abstractflow-0.3.0.dist-info/RECORD +32 -0
  29. {abstractflow-0.1.0.dist-info → abstractflow-0.3.0.dist-info}/licenses/LICENSE +2 -0
  30. abstractflow-0.1.0.dist-info/METADATA +0 -238
  31. abstractflow-0.1.0.dist-info/RECORD +0 -10
  32. {abstractflow-0.1.0.dist-info → abstractflow-0.3.0.dist-info}/WHEEL +0 -0
  33. {abstractflow-0.1.0.dist-info → abstractflow-0.3.0.dist-info}/entry_points.txt +0 -0
  34. {abstractflow-0.1.0.dist-info → abstractflow-0.3.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,789 @@
1
+ """Built-in function handlers for visual nodes.
2
+
3
+ These are intentionally pure and JSON-friendly so visual workflows can run in
4
+ any host that can compile the VisualFlow JSON to a WorkflowSpec.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import ast
10
+ from datetime import datetime
11
+ import json
12
+ import locale
13
+ import os
14
+ from typing import Any, Callable, Dict, List, Optional
15
+
16
+ from abstractruntime.rendering import render_agent_trace_markdown as runtime_render_agent_trace_markdown
17
+ from abstractruntime.rendering import stringify_json as runtime_stringify_json
18
+
19
+
20
+ def get_builtin_handler(node_type: str) -> Optional[Callable[[Any], Any]]:
21
+ """Get a built-in handler function for a node type."""
22
+ return BUILTIN_HANDLERS.get(node_type)
23
+
24
+
25
+ def _path_tokens(path: str) -> list[Any]:
26
+ """Parse a dotted/bracket path into tokens.
27
+
28
+ Supported:
29
+ - `a.b.c`
30
+ - `a[0].b`
31
+
32
+ Returns tokens as str keys and int indices.
33
+ """
34
+ import re
35
+
36
+ p = str(path or "").strip()
37
+ if not p:
38
+ return []
39
+ token_re = re.compile(r"([^\.\[\]]+)|\[(\d+)\]")
40
+ out: list[Any] = []
41
+ for m in token_re.finditer(p):
42
+ key = m.group(1)
43
+ if key is not None:
44
+ k = key.strip()
45
+ if k:
46
+ out.append(k)
47
+ continue
48
+ idx = m.group(2)
49
+ if idx is not None:
50
+ try:
51
+ out.append(int(idx))
52
+ except Exception:
53
+ continue
54
+ return out
55
+
56
+
57
+ def _get_path(value: Any, path: str) -> Any:
58
+ """Best-effort nested lookup (dict keys + list indices)."""
59
+ tokens = _path_tokens(path)
60
+ if not tokens:
61
+ return None
62
+ current: Any = value
63
+ for tok in tokens:
64
+ if isinstance(current, dict) and isinstance(tok, str):
65
+ current = current.get(tok)
66
+ continue
67
+ if isinstance(current, list):
68
+ idx: Optional[int] = None
69
+ if isinstance(tok, int):
70
+ idx = tok
71
+ elif isinstance(tok, str) and tok.isdigit():
72
+ idx = int(tok)
73
+ if idx is None:
74
+ return None
75
+ if idx < 0 or idx >= len(current):
76
+ return None
77
+ current = current[idx]
78
+ continue
79
+ return None
80
+ return current
81
+
82
+
83
+ # Math operations
84
+ def math_add(inputs: Dict[str, Any]) -> float:
85
+ """Add two numbers."""
86
+ return float(inputs.get("a", 0)) + float(inputs.get("b", 0))
87
+
88
+
89
+ def math_subtract(inputs: Dict[str, Any]) -> float:
90
+ """Subtract b from a."""
91
+ return float(inputs.get("a", 0)) - float(inputs.get("b", 0))
92
+
93
+
94
+ def math_multiply(inputs: Dict[str, Any]) -> float:
95
+ """Multiply two numbers."""
96
+ return float(inputs.get("a", 0)) * float(inputs.get("b", 0))
97
+
98
+
99
+ def math_divide(inputs: Dict[str, Any]) -> float:
100
+ """Divide a by b."""
101
+ b = float(inputs.get("b", 1))
102
+ if b == 0:
103
+ raise ValueError("Division by zero")
104
+ return float(inputs.get("a", 0)) / b
105
+
106
+
107
+ def math_modulo(inputs: Dict[str, Any]) -> float:
108
+ """Get remainder of a divided by b."""
109
+ b = float(inputs.get("b", 1))
110
+ if b == 0:
111
+ raise ValueError("Modulo by zero")
112
+ return float(inputs.get("a", 0)) % b
113
+
114
+
115
+ def math_power(inputs: Dict[str, Any]) -> float:
116
+ """Raise base to exponent power."""
117
+ return float(inputs.get("base", 0)) ** float(inputs.get("exp", 1))
118
+
119
+
120
+ def math_abs(inputs: Dict[str, Any]) -> float:
121
+ """Get absolute value."""
122
+ return abs(float(inputs.get("value", 0)))
123
+
124
+
125
+ def math_round(inputs: Dict[str, Any]) -> float:
126
+ """Round to specified decimal places."""
127
+ value = float(inputs.get("value", 0))
128
+ decimals = int(inputs.get("decimals", 0))
129
+ return round(value, decimals)
130
+
131
+
132
+ # String operations
133
+ def string_concat(inputs: Dict[str, Any]) -> str:
134
+ """Concatenate two strings."""
135
+ return str(inputs.get("a", "")) + str(inputs.get("b", ""))
136
+
137
+
138
+ def string_split(inputs: Dict[str, Any]) -> List[str]:
139
+ """Split a string by a delimiter (defaults are tuned for real-world workflow usage).
140
+
141
+ Notes:
142
+ - Visual workflows often use human-edited / LLM-generated text where trailing
143
+ delimiters are common (e.g. "A@@B@@"). A strict `str.split` would produce an
144
+ empty last element and create a spurious downstream loop iteration.
145
+ - We therefore support optional normalization flags with sensible defaults:
146
+ - `trim` (default True): strip whitespace around parts
147
+ - `drop_empty` (default True): drop empty parts after trimming
148
+ - Delimiters may be entered as escape sequences (e.g. "\\n") from the UI.
149
+ """
150
+
151
+ raw_text = inputs.get("text", "")
152
+ text = "" if raw_text is None else str(raw_text)
153
+
154
+ raw_delim = inputs.get("delimiter", ",")
155
+ delimiter = "" if raw_delim is None else str(raw_delim)
156
+ delimiter = delimiter.replace("\\n", "\n").replace("\\t", "\t").replace("\\r", "\r")
157
+
158
+ trim = bool(inputs.get("trim", True))
159
+ drop_empty = bool(inputs.get("drop_empty", True))
160
+
161
+ # Avoid ValueError from Python's `split("")` and keep behavior predictable.
162
+ if delimiter == "":
163
+ parts = [text] if text else []
164
+ else:
165
+ raw_maxsplit = inputs.get("maxsplit")
166
+ maxsplit: Optional[int] = None
167
+ if raw_maxsplit is not None:
168
+ try:
169
+ maxsplit = int(raw_maxsplit)
170
+ except Exception:
171
+ maxsplit = None
172
+ if maxsplit is not None and maxsplit >= 0:
173
+ parts = text.split(delimiter, maxsplit)
174
+ else:
175
+ parts = text.split(delimiter)
176
+
177
+ if trim:
178
+ parts = [p.strip() for p in parts]
179
+
180
+ if drop_empty:
181
+ parts = [p for p in parts if p != ""]
182
+
183
+ return parts
184
+
185
+
186
+ def string_join(inputs: Dict[str, Any]) -> str:
187
+ """Join array items with delimiter."""
188
+ items = inputs.get("items")
189
+ # Visual workflows frequently pass optional pins; treat `null` as empty.
190
+ if items is None:
191
+ items_list: list[Any] = []
192
+ elif isinstance(items, list):
193
+ items_list = items
194
+ elif isinstance(items, tuple):
195
+ items_list = list(items)
196
+ else:
197
+ # Defensive: if a non-array leaks in, treat it as a single element instead of
198
+ # iterating characters (strings) or keys (dicts).
199
+ items_list = [items]
200
+
201
+ delimiter = str(inputs.get("delimiter", ","))
202
+ # UI often stores escape sequences (e.g. "\\n") in JSON.
203
+ delimiter = delimiter.replace("\\n", "\n").replace("\\t", "\t").replace("\\r", "\r")
204
+ return delimiter.join("" if item is None else str(item) for item in items_list)
205
+
206
+
207
+ def string_format(inputs: Dict[str, Any]) -> str:
208
+ """Format string with values."""
209
+ template = str(inputs.get("template", ""))
210
+ values = inputs.get("values", {})
211
+ if isinstance(values, dict):
212
+ return template.format(**values)
213
+ return template
214
+
215
+
216
+ def string_uppercase(inputs: Dict[str, Any]) -> str:
217
+ """Convert to uppercase."""
218
+ return str(inputs.get("text", "")).upper()
219
+
220
+
221
+ def string_lowercase(inputs: Dict[str, Any]) -> str:
222
+ """Convert to lowercase."""
223
+ return str(inputs.get("text", "")).lower()
224
+
225
+
226
+ def string_trim(inputs: Dict[str, Any]) -> str:
227
+ """Trim whitespace."""
228
+ return str(inputs.get("text", "")).strip()
229
+
230
+
231
+ def string_substring(inputs: Dict[str, Any]) -> str:
232
+ """Get substring."""
233
+ text = str(inputs.get("text", ""))
234
+ start = int(inputs.get("start", 0))
235
+ end = inputs.get("end")
236
+ if end is not None:
237
+ return text[start : int(end)]
238
+ return text[start:]
239
+
240
+
241
+ def string_length(inputs: Dict[str, Any]) -> int:
242
+ """Get string length."""
243
+ return len(str(inputs.get("text", "")))
244
+
245
+
246
+ def string_template(inputs: Dict[str, Any]) -> str:
247
+ """Render a template with placeholders like `{{path.to.value}}`.
248
+
249
+ Supported filters:
250
+ - `| json` -> json.dumps(value)
251
+ - `| join(", ")` -> join array values with delimiter
252
+ - `| trim` / `| lower` / `| upper`
253
+ """
254
+ import re
255
+
256
+ template = str(inputs.get("template", "") or "")
257
+ vars_raw = inputs.get("vars")
258
+ vars_obj = vars_raw if isinstance(vars_raw, dict) else {}
259
+
260
+ pat = re.compile(r"\{\{\s*(.*?)\s*\}\}")
261
+
262
+ def _apply_filters(value: Any, filters: list[str]) -> Any:
263
+ cur = value
264
+ for f in filters:
265
+ f = f.strip()
266
+ if not f:
267
+ continue
268
+ if f == "json":
269
+ cur = json.dumps(cur, ensure_ascii=False, sort_keys=True)
270
+ continue
271
+ if f.startswith("join"):
272
+ m = re.match(r"join\((.*)\)$", f)
273
+ delim = ", "
274
+ if m:
275
+ raw = m.group(1).strip()
276
+ if (raw.startswith('"') and raw.endswith('"')) or (raw.startswith("'") and raw.endswith("'")):
277
+ raw = raw[1:-1]
278
+ delim = raw
279
+ if isinstance(cur, list):
280
+ cur = delim.join("" if x is None else str(x) for x in cur)
281
+ else:
282
+ cur = "" if cur is None else str(cur)
283
+ continue
284
+ if f == "trim":
285
+ cur = ("" if cur is None else str(cur)).strip()
286
+ continue
287
+ if f == "lower":
288
+ cur = ("" if cur is None else str(cur)).lower()
289
+ continue
290
+ if f == "upper":
291
+ cur = ("" if cur is None else str(cur)).upper()
292
+ continue
293
+ # Unknown filters are ignored (best-effort, stable).
294
+ return cur
295
+
296
+ def _render_expr(expr: str) -> str:
297
+ parts = [p.strip() for p in str(expr or "").split("|")]
298
+ path = parts[0] if parts else ""
299
+ filters = parts[1:] if len(parts) > 1 else []
300
+ value = _get_path(vars_obj, path)
301
+ if value is None:
302
+ return ""
303
+ value = _apply_filters(value, filters)
304
+ return "" if value is None else str(value)
305
+
306
+ return pat.sub(lambda m: _render_expr(m.group(1)), template)
307
+
308
+
309
+ # Control flow helpers (these return decision values, not execution control)
310
+ def control_compare(inputs: Dict[str, Any]) -> bool:
311
+ """Compare two values."""
312
+ a = inputs.get("a")
313
+ b = inputs.get("b")
314
+ op = str(inputs.get("op", "=="))
315
+
316
+ if op == "==":
317
+ return a == b
318
+ if op == "!=":
319
+ return a != b
320
+ if op == "<":
321
+ try:
322
+ return a < b
323
+ except Exception:
324
+ return False
325
+ if op == "<=":
326
+ try:
327
+ return a <= b
328
+ except Exception:
329
+ return False
330
+ if op == ">":
331
+ try:
332
+ return a > b
333
+ except Exception:
334
+ return False
335
+ if op == ">=":
336
+ try:
337
+ return a >= b
338
+ except Exception:
339
+ return False
340
+ raise ValueError(f"Unknown comparison operator: {op}")
341
+
342
+
343
+ def control_not(inputs: Dict[str, Any]) -> bool:
344
+ """Logical NOT."""
345
+ return not bool(inputs.get("value", False))
346
+
347
+
348
+ def control_and(inputs: Dict[str, Any]) -> bool:
349
+ """Logical AND."""
350
+ return bool(inputs.get("a", False)) and bool(inputs.get("b", False))
351
+
352
+
353
+ def control_or(inputs: Dict[str, Any]) -> bool:
354
+ """Logical OR."""
355
+ return bool(inputs.get("a", False)) or bool(inputs.get("b", False))
356
+
357
+
358
+ def control_coalesce(inputs: Dict[str, Any]) -> Any:
359
+ """Return the first non-None input in pin order.
360
+
361
+ Pin order is injected by the visual executor as `_pin_order` based on the node's
362
+ input pin list, so selection is deterministic and matches the visual layout.
363
+ """
364
+ order = inputs.get("_pin_order")
365
+ pin_order: list[str] = []
366
+ if isinstance(order, list):
367
+ for x in order:
368
+ if isinstance(x, str) and x:
369
+ pin_order.append(x)
370
+ if not pin_order:
371
+ pin_order = ["a", "b"]
372
+
373
+ for pid in pin_order:
374
+ if pid not in inputs:
375
+ continue
376
+ v = inputs.get(pid)
377
+ if v is not None:
378
+ return v
379
+ return None
380
+
381
+
382
+ # Data operations
383
+ def data_get(inputs: Dict[str, Any]) -> Any:
384
+ """Get property from object."""
385
+ obj = inputs.get("object", {})
386
+ key = str(inputs.get("key", ""))
387
+ default = inputs.get("default")
388
+
389
+ value = _get_path(obj, key)
390
+ if value is None:
391
+ return {"value": default}
392
+ return {"value": value}
393
+
394
+
395
+ def data_set(inputs: Dict[str, Any]) -> Dict[str, Any]:
396
+ """Set property on object (returns new object)."""
397
+ obj = dict(inputs.get("object", {}))
398
+ key = str(inputs.get("key", ""))
399
+ value = inputs.get("value")
400
+
401
+ # Support dot notation
402
+ parts = key.split(".")
403
+ current = obj
404
+ for part in parts[:-1]:
405
+ nxt = current.get(part)
406
+ if not isinstance(nxt, dict):
407
+ nxt = {}
408
+ current[part] = nxt
409
+ current = nxt
410
+ current[parts[-1]] = value
411
+ return obj
412
+
413
+
414
+ def data_merge(inputs: Dict[str, Any]) -> Dict[str, Any]:
415
+ """Merge two objects."""
416
+ a = dict(inputs.get("a", {}))
417
+ b = dict(inputs.get("b", {}))
418
+ return {**a, **b}
419
+
420
+
421
+ def data_array_map(inputs: Dict[str, Any]) -> List[Any]:
422
+ """Map array items (extract property from each)."""
423
+ items = inputs.get("items", [])
424
+ key = str(inputs.get("key", ""))
425
+
426
+ result: list[Any] = []
427
+ for item in items:
428
+ if isinstance(item, dict):
429
+ result.append(item.get(key))
430
+ else:
431
+ result.append(item)
432
+ return result
433
+
434
+
435
+ def data_array_filter(inputs: Dict[str, Any]) -> List[Any]:
436
+ """Filter array by condition."""
437
+ items = inputs.get("items", [])
438
+ key = str(inputs.get("key", ""))
439
+ value = inputs.get("value")
440
+
441
+ result: list[Any] = []
442
+ for item in items:
443
+ if isinstance(item, dict):
444
+ if item.get(key) == value:
445
+ result.append(item)
446
+ elif item == value:
447
+ result.append(item)
448
+ return result
449
+
450
+
451
+ def data_array_length(inputs: Dict[str, Any]) -> int:
452
+ """Return array length (0 if not an array)."""
453
+ items = inputs.get("array")
454
+ if isinstance(items, list):
455
+ return len(items)
456
+ if isinstance(items, tuple):
457
+ return len(list(items))
458
+ return 0
459
+
460
+
461
+ def data_array_append(inputs: Dict[str, Any]) -> List[Any]:
462
+ """Append an item to an array (returns a new array)."""
463
+ items = inputs.get("array")
464
+ item = inputs.get("item")
465
+ out: list[Any]
466
+ if isinstance(items, list):
467
+ out = list(items)
468
+ elif isinstance(items, tuple):
469
+ out = list(items)
470
+ elif items is None:
471
+ out = []
472
+ else:
473
+ out = [items]
474
+ out.append(item)
475
+ return out
476
+
477
+
478
+ def data_array_dedup(inputs: Dict[str, Any]) -> List[Any]:
479
+ """Stable-order dedup for arrays.
480
+
481
+ If `key` is provided (string path), dedup objects by that path value.
482
+ """
483
+ items = inputs.get("array")
484
+ if not isinstance(items, list):
485
+ if isinstance(items, tuple):
486
+ items = list(items)
487
+ else:
488
+ return []
489
+
490
+ key = inputs.get("key")
491
+ key_path = str(key or "").strip()
492
+
493
+ def _fingerprint(v: Any) -> str:
494
+ if v is None or isinstance(v, (bool, int, float, str)):
495
+ return f"{type(v).__name__}:{v}"
496
+ try:
497
+ return json.dumps(v, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str)
498
+ except Exception:
499
+ return str(v)
500
+
501
+ seen: set[str] = set()
502
+ out: list[Any] = []
503
+ for item in items:
504
+ if key_path:
505
+ k = _get_path(item, key_path)
506
+ fp = _fingerprint(k) if k is not None else _fingerprint(item)
507
+ else:
508
+ fp = _fingerprint(item)
509
+ if fp in seen:
510
+ continue
511
+ seen.add(fp)
512
+ out.append(item)
513
+ return out
514
+
515
+
516
+ def system_datetime(_: Dict[str, Any]) -> Dict[str, Any]:
517
+ """Return current system date/time and best-effort locale metadata.
518
+
519
+ All values are JSON-serializable and stable-keyed.
520
+ """
521
+ now = datetime.now().astimezone()
522
+ offset = now.utcoffset()
523
+ offset_minutes = int(offset.total_seconds() // 60) if offset is not None else 0
524
+
525
+ tzname = now.tzname() or ""
526
+
527
+ # Avoid deprecated locale.getdefaultlocale() in Python 3.12+.
528
+ lang = os.environ.get("LC_ALL") or os.environ.get("LANG") or os.environ.get("LC_CTYPE") or ""
529
+ env_locale = lang.split(".", 1)[0] if lang else ""
530
+
531
+ loc = locale.getlocale()[0] or env_locale
532
+
533
+ return {
534
+ "iso": now.isoformat(),
535
+ "timezone": tzname,
536
+ "utc_offset_minutes": offset_minutes,
537
+ "locale": loc or "",
538
+ }
539
+
540
+
541
+ def data_parse_json(inputs: Dict[str, Any]) -> Any:
542
+ """Parse JSON (or JSON-ish) text into a JSON-serializable Python value.
543
+
544
+ Primary use-case: turn an LLM string response into an object/array that can be
545
+ fed into `Break Object` (dynamic pins) or other data nodes.
546
+
547
+ Behavior:
548
+ - If the input is already a dict/list, returns it unchanged (idempotent).
549
+ - Tries strict `json.loads` first.
550
+ - If that fails, tries to extract the first JSON object/array substring and parse it.
551
+ - As a last resort, tries `ast.literal_eval` to handle Python-style dicts/lists
552
+ (common in LLM output), then converts to JSON-friendly types.
553
+ - If the parsed value is a scalar, wraps it as `{ "value": <scalar> }` by default,
554
+ so `Break Object` can still expose it.
555
+ """
556
+
557
+ def _strip_code_fence(text: str) -> str:
558
+ s = text.strip()
559
+ if not s.startswith("```"):
560
+ return s
561
+ # Opening fence line can be ```json / ```js etc; drop it.
562
+ nl = s.find("\n")
563
+ if nl == -1:
564
+ return s.strip("`").strip()
565
+ body = s[nl + 1 :]
566
+ end = body.rfind("```")
567
+ if end != -1:
568
+ body = body[:end]
569
+ return body.strip()
570
+
571
+ def _jsonify(value: Any) -> Any:
572
+ if value is None or isinstance(value, (bool, int, float, str)):
573
+ return value
574
+ if isinstance(value, dict):
575
+ return {str(k): _jsonify(v) for k, v in value.items()}
576
+ if isinstance(value, list):
577
+ return [_jsonify(v) for v in value]
578
+ if isinstance(value, tuple):
579
+ return [_jsonify(v) for v in value]
580
+ return str(value)
581
+
582
+ raw = inputs.get("text")
583
+ if isinstance(raw, (dict, list)):
584
+ parsed: Any = raw
585
+ else:
586
+ if raw is None:
587
+ raise ValueError("parse_json requires a non-empty 'text' input.")
588
+ text = _strip_code_fence(str(raw))
589
+ if not text.strip():
590
+ raise ValueError("parse_json requires a non-empty 'text' input.")
591
+
592
+ parsed = None
593
+ text_stripped = text.strip()
594
+
595
+ try:
596
+ parsed = json.loads(text_stripped)
597
+ except Exception:
598
+ # Best-effort: find and parse the first JSON object/array substring.
599
+ decoder = json.JSONDecoder()
600
+ starts: list[int] = []
601
+ for i, ch in enumerate(text_stripped):
602
+ if ch in "{[":
603
+ starts.append(i)
604
+ if len(starts) >= 64:
605
+ break
606
+ for i in starts:
607
+ try:
608
+ parsed, _end = decoder.raw_decode(text_stripped[i:])
609
+ break
610
+ except Exception:
611
+ continue
612
+
613
+ if parsed is None:
614
+ # Last resort: tolerate Python-literal dict/list output.
615
+ try:
616
+ parsed = ast.literal_eval(text_stripped)
617
+ except Exception as e:
618
+ raise ValueError(f"Invalid JSON: {e}") from e
619
+
620
+ parsed = _jsonify(parsed)
621
+
622
+ wrap_scalar = bool(inputs.get("wrap_scalar", True))
623
+ if wrap_scalar and not isinstance(parsed, (dict, list)):
624
+ return {"value": parsed}
625
+ return parsed
626
+
627
+
628
+ def data_stringify_json(inputs: Dict[str, Any]) -> str:
629
+ """Render a JSON-like value into a string (runtime-owned implementation).
630
+
631
+ The core stringify logic lives in AbstractRuntime so multiple hosts can reuse it.
632
+
633
+ Supported inputs (backward compatible):
634
+ - `value`: JSON value (dict/list/scalar) OR a JSON-ish string.
635
+ - `mode`: none | beautify | minified
636
+ - Legacy: `indent` (<=0 => minified; >0 => beautify with that indent)
637
+ - Legacy: `sort_keys` (bool)
638
+ """
639
+
640
+ value = inputs.get("value")
641
+
642
+ raw_mode = inputs.get("mode")
643
+ mode = str(raw_mode).strip().lower() if isinstance(raw_mode, str) else ""
644
+
645
+ raw_indent = inputs.get("indent")
646
+ indent_n: Optional[int] = None
647
+ if raw_indent is not None:
648
+ try:
649
+ indent_n = int(raw_indent)
650
+ except Exception:
651
+ indent_n = None
652
+
653
+ raw_sort_keys = inputs.get("sort_keys")
654
+ sort_keys = bool(raw_sort_keys) if isinstance(raw_sort_keys, bool) else False
655
+
656
+ # If mode not provided, infer from legacy indent.
657
+ if not mode:
658
+ if indent_n is not None and indent_n <= 0:
659
+ mode = "minified"
660
+ elif indent_n is not None and indent_n > 0:
661
+ mode = "beautify"
662
+ else:
663
+ mode = "beautify"
664
+
665
+ return runtime_stringify_json(
666
+ value,
667
+ mode=mode,
668
+ beautify_indent=indent_n if isinstance(indent_n, int) and indent_n > 0 else 2,
669
+ sort_keys=sort_keys,
670
+ parse_strings=True,
671
+ )
672
+
673
+
674
+ def data_agent_trace_report(inputs: Dict[str, Any]) -> str:
675
+ """Render an agent scratchpad (runtime-owned node traces) into Markdown."""
676
+ scratchpad = inputs.get("scratchpad")
677
+ return runtime_render_agent_trace_markdown(scratchpad)
678
+
679
+
680
+ # Literal value handlers - return configured constant values
681
+ def literal_string(inputs: Dict[str, Any]) -> str:
682
+ """Return string literal value."""
683
+ return str(inputs.get("_literalValue", ""))
684
+
685
+
686
+ def literal_number(inputs: Dict[str, Any]) -> float:
687
+ """Return number literal value."""
688
+ value = inputs.get("_literalValue", 0)
689
+ try:
690
+ return float(value)
691
+ except (TypeError, ValueError):
692
+ return 0.0
693
+
694
+
695
+ def literal_boolean(inputs: Dict[str, Any]) -> bool:
696
+ """Return boolean literal value."""
697
+ return bool(inputs.get("_literalValue", False))
698
+
699
+
700
+ def literal_json(inputs: Dict[str, Any]) -> Dict[str, Any]:
701
+ """Return JSON literal value."""
702
+ value = inputs.get("_literalValue", {})
703
+ if isinstance(value, (dict, list)):
704
+ return value # type: ignore[return-value]
705
+ return {}
706
+
707
+
708
+ def literal_array(inputs: Dict[str, Any]) -> List[Any]:
709
+ """Return array literal value."""
710
+ value = inputs.get("_literalValue", [])
711
+ if isinstance(value, list):
712
+ return value
713
+ return []
714
+
715
+
716
+ def tools_allowlist(inputs: Dict[str, Any]) -> Dict[str, Any]:
717
+ """Return a workflow-scope tool allowlist as a named output.
718
+
719
+ The visual editor stores the selected tools as a JSON array of strings in the
720
+ node's `literalValue`. The executor injects it as `_literalValue`.
721
+ """
722
+ value = inputs.get("_literalValue", [])
723
+ if not isinstance(value, list):
724
+ return {"tools": []}
725
+ out: list[str] = []
726
+ for x in value:
727
+ if isinstance(x, str) and x.strip():
728
+ out.append(x.strip())
729
+ # Preserve order; remove duplicates.
730
+ seen: set[str] = set()
731
+ uniq: list[str] = []
732
+ for t in out:
733
+ if t in seen:
734
+ continue
735
+ seen.add(t)
736
+ uniq.append(t)
737
+ return {"tools": uniq}
738
+
739
+
740
+ # Handler registry
741
+ BUILTIN_HANDLERS: Dict[str, Callable[[Dict[str, Any]], Any]] = {
742
+ # Math
743
+ "add": math_add,
744
+ "subtract": math_subtract,
745
+ "multiply": math_multiply,
746
+ "divide": math_divide,
747
+ "modulo": math_modulo,
748
+ "power": math_power,
749
+ "abs": math_abs,
750
+ "round": math_round,
751
+ # String
752
+ "concat": string_concat,
753
+ "split": string_split,
754
+ "join": string_join,
755
+ "format": string_format,
756
+ "string_template": string_template,
757
+ "uppercase": string_uppercase,
758
+ "lowercase": string_lowercase,
759
+ "trim": string_trim,
760
+ "substring": string_substring,
761
+ "length": string_length,
762
+ # Control
763
+ "compare": control_compare,
764
+ "not": control_not,
765
+ "and": control_and,
766
+ "or": control_or,
767
+ "coalesce": control_coalesce,
768
+ # Data
769
+ "get": data_get,
770
+ "set": data_set,
771
+ "merge": data_merge,
772
+ "array_map": data_array_map,
773
+ "array_filter": data_array_filter,
774
+ "array_length": data_array_length,
775
+ "array_append": data_array_append,
776
+ "array_dedup": data_array_dedup,
777
+ "parse_json": data_parse_json,
778
+ "stringify_json": data_stringify_json,
779
+ "agent_trace_report": data_agent_trace_report,
780
+ "system_datetime": system_datetime,
781
+ # Literals
782
+ "literal_string": literal_string,
783
+ "literal_number": literal_number,
784
+ "literal_boolean": literal_boolean,
785
+ "literal_json": literal_json,
786
+ "literal_array": literal_array,
787
+ "tools_allowlist": tools_allowlist,
788
+ }
789
+