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.
- abstractflow/__init__.py +75 -95
- abstractflow/__main__.py +2 -0
- abstractflow/adapters/__init__.py +11 -0
- abstractflow/adapters/agent_adapter.py +124 -0
- abstractflow/adapters/control_adapter.py +615 -0
- abstractflow/adapters/effect_adapter.py +645 -0
- abstractflow/adapters/event_adapter.py +307 -0
- abstractflow/adapters/function_adapter.py +97 -0
- abstractflow/adapters/subflow_adapter.py +74 -0
- abstractflow/adapters/variable_adapter.py +317 -0
- abstractflow/cli.py +2 -0
- abstractflow/compiler.py +2027 -0
- abstractflow/core/__init__.py +5 -0
- abstractflow/core/flow.py +247 -0
- abstractflow/py.typed +2 -0
- abstractflow/runner.py +348 -0
- abstractflow/visual/__init__.py +43 -0
- abstractflow/visual/agent_ids.py +29 -0
- abstractflow/visual/builtins.py +789 -0
- abstractflow/visual/code_executor.py +214 -0
- abstractflow/visual/event_ids.py +33 -0
- abstractflow/visual/executor.py +2789 -0
- abstractflow/visual/interfaces.py +347 -0
- abstractflow/visual/models.py +252 -0
- abstractflow/visual/session_runner.py +168 -0
- abstractflow/visual/workspace_scoped_tools.py +261 -0
- abstractflow-0.3.0.dist-info/METADATA +413 -0
- abstractflow-0.3.0.dist-info/RECORD +32 -0
- {abstractflow-0.1.0.dist-info → abstractflow-0.3.0.dist-info}/licenses/LICENSE +2 -0
- abstractflow-0.1.0.dist-info/METADATA +0 -238
- abstractflow-0.1.0.dist-info/RECORD +0 -10
- {abstractflow-0.1.0.dist-info → abstractflow-0.3.0.dist-info}/WHEEL +0 -0
- {abstractflow-0.1.0.dist-info → abstractflow-0.3.0.dist-info}/entry_points.txt +0 -0
- {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
|
+
|