AbstractRuntime 0.4.0__py3-none-any.whl → 0.4.1__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 (65) hide show
  1. abstractruntime/__init__.py +76 -1
  2. abstractruntime/core/config.py +68 -1
  3. abstractruntime/core/models.py +5 -0
  4. abstractruntime/core/policy.py +74 -3
  5. abstractruntime/core/runtime.py +1002 -126
  6. abstractruntime/core/vars.py +8 -2
  7. abstractruntime/evidence/recorder.py +1 -1
  8. abstractruntime/history_bundle.py +772 -0
  9. abstractruntime/integrations/abstractcore/__init__.py +3 -0
  10. abstractruntime/integrations/abstractcore/default_tools.py +127 -3
  11. abstractruntime/integrations/abstractcore/effect_handlers.py +2440 -99
  12. abstractruntime/integrations/abstractcore/embeddings_client.py +69 -0
  13. abstractruntime/integrations/abstractcore/factory.py +68 -20
  14. abstractruntime/integrations/abstractcore/llm_client.py +447 -15
  15. abstractruntime/integrations/abstractcore/mcp_worker.py +1 -0
  16. abstractruntime/integrations/abstractcore/session_attachments.py +946 -0
  17. abstractruntime/integrations/abstractcore/tool_executor.py +31 -10
  18. abstractruntime/integrations/abstractcore/workspace_scoped_tools.py +561 -0
  19. abstractruntime/integrations/abstractmemory/__init__.py +3 -0
  20. abstractruntime/integrations/abstractmemory/effect_handlers.py +946 -0
  21. abstractruntime/memory/active_context.py +6 -1
  22. abstractruntime/memory/kg_packets.py +164 -0
  23. abstractruntime/memory/memact_composer.py +175 -0
  24. abstractruntime/memory/recall_levels.py +163 -0
  25. abstractruntime/memory/token_budget.py +86 -0
  26. abstractruntime/storage/__init__.py +4 -1
  27. abstractruntime/storage/artifacts.py +158 -30
  28. abstractruntime/storage/base.py +17 -1
  29. abstractruntime/storage/commands.py +339 -0
  30. abstractruntime/storage/in_memory.py +41 -1
  31. abstractruntime/storage/json_files.py +195 -12
  32. abstractruntime/storage/observable.py +38 -1
  33. abstractruntime/storage/offloading.py +433 -0
  34. abstractruntime/storage/sqlite.py +836 -0
  35. abstractruntime/visualflow_compiler/__init__.py +29 -0
  36. abstractruntime/visualflow_compiler/adapters/__init__.py +11 -0
  37. abstractruntime/visualflow_compiler/adapters/agent_adapter.py +126 -0
  38. abstractruntime/visualflow_compiler/adapters/context_adapter.py +109 -0
  39. abstractruntime/visualflow_compiler/adapters/control_adapter.py +615 -0
  40. abstractruntime/visualflow_compiler/adapters/effect_adapter.py +1051 -0
  41. abstractruntime/visualflow_compiler/adapters/event_adapter.py +307 -0
  42. abstractruntime/visualflow_compiler/adapters/function_adapter.py +97 -0
  43. abstractruntime/visualflow_compiler/adapters/memact_adapter.py +114 -0
  44. abstractruntime/visualflow_compiler/adapters/subflow_adapter.py +74 -0
  45. abstractruntime/visualflow_compiler/adapters/variable_adapter.py +316 -0
  46. abstractruntime/visualflow_compiler/compiler.py +3832 -0
  47. abstractruntime/visualflow_compiler/flow.py +247 -0
  48. abstractruntime/visualflow_compiler/visual/__init__.py +13 -0
  49. abstractruntime/visualflow_compiler/visual/agent_ids.py +29 -0
  50. abstractruntime/visualflow_compiler/visual/builtins.py +1376 -0
  51. abstractruntime/visualflow_compiler/visual/code_executor.py +214 -0
  52. abstractruntime/visualflow_compiler/visual/executor.py +2804 -0
  53. abstractruntime/visualflow_compiler/visual/models.py +211 -0
  54. abstractruntime/workflow_bundle/__init__.py +52 -0
  55. abstractruntime/workflow_bundle/models.py +236 -0
  56. abstractruntime/workflow_bundle/packer.py +317 -0
  57. abstractruntime/workflow_bundle/reader.py +87 -0
  58. abstractruntime/workflow_bundle/registry.py +587 -0
  59. abstractruntime-0.4.1.dist-info/METADATA +177 -0
  60. abstractruntime-0.4.1.dist-info/RECORD +86 -0
  61. abstractruntime-0.4.0.dist-info/METADATA +0 -167
  62. abstractruntime-0.4.0.dist-info/RECORD +0 -49
  63. {abstractruntime-0.4.0.dist-info → abstractruntime-0.4.1.dist-info}/WHEEL +0 -0
  64. {abstractruntime-0.4.0.dist-info → abstractruntime-0.4.1.dist-info}/entry_points.txt +0 -0
  65. {abstractruntime-0.4.0.dist-info → abstractruntime-0.4.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,1376 @@
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
+ import random
15
+ from typing import Any, Callable, Dict, List, Optional
16
+
17
+ from abstractruntime.rendering import render_agent_trace_markdown as runtime_render_agent_trace_markdown
18
+ from abstractruntime.rendering import stringify_json as runtime_stringify_json
19
+
20
+
21
+ def get_builtin_handler(node_type: str) -> Optional[Callable[[Any], Any]]:
22
+ """Get a built-in handler function for a node type."""
23
+ return BUILTIN_HANDLERS.get(node_type)
24
+
25
+
26
+ def _path_tokens(path: str) -> list[Any]:
27
+ """Parse a dotted/bracket path into tokens.
28
+
29
+ Supported:
30
+ - `a.b.c`
31
+ - `a[0].b`
32
+
33
+ Returns tokens as str keys and int indices.
34
+ """
35
+ import re
36
+
37
+ p = str(path or "").strip()
38
+ if not p:
39
+ return []
40
+ token_re = re.compile(r"([^\.\[\]]+)|\[(\d+)\]")
41
+ out: list[Any] = []
42
+ for m in token_re.finditer(p):
43
+ key = m.group(1)
44
+ if key is not None:
45
+ k = key.strip()
46
+ if k:
47
+ out.append(k)
48
+ continue
49
+ idx = m.group(2)
50
+ if idx is not None:
51
+ try:
52
+ out.append(int(idx))
53
+ except Exception:
54
+ continue
55
+ return out
56
+
57
+
58
+ def _get_path(value: Any, path: str) -> Any:
59
+ """Best-effort nested lookup (dict keys + list indices)."""
60
+ tokens = _path_tokens(path)
61
+ if not tokens:
62
+ return None
63
+ current: Any = value
64
+ for tok in tokens:
65
+ if isinstance(current, dict) and isinstance(tok, str):
66
+ current = current.get(tok)
67
+ continue
68
+ if isinstance(current, list):
69
+ idx: Optional[int] = None
70
+ if isinstance(tok, int):
71
+ idx = tok
72
+ elif isinstance(tok, str) and tok.isdigit():
73
+ idx = int(tok)
74
+ if idx is None:
75
+ return None
76
+ if idx < 0 or idx >= len(current):
77
+ return None
78
+ current = current[idx]
79
+ continue
80
+ return None
81
+ return current
82
+
83
+
84
+ # Math operations
85
+ def math_add(inputs: Dict[str, Any]) -> float:
86
+ """Add two numbers."""
87
+ return float(inputs.get("a", 0)) + float(inputs.get("b", 0))
88
+
89
+
90
+ def math_subtract(inputs: Dict[str, Any]) -> float:
91
+ """Subtract b from a."""
92
+ return float(inputs.get("a", 0)) - float(inputs.get("b", 0))
93
+
94
+
95
+ def math_multiply(inputs: Dict[str, Any]) -> float:
96
+ """Multiply two numbers."""
97
+ return float(inputs.get("a", 0)) * float(inputs.get("b", 0))
98
+
99
+
100
+ def math_divide(inputs: Dict[str, Any]) -> float:
101
+ """Divide a by b."""
102
+ b = float(inputs.get("b", 1))
103
+ if b == 0:
104
+ raise ValueError("Division by zero")
105
+ return float(inputs.get("a", 0)) / b
106
+
107
+
108
+ def math_modulo(inputs: Dict[str, Any]) -> float:
109
+ """Get remainder of a divided by b."""
110
+ b = float(inputs.get("b", 1))
111
+ if b == 0:
112
+ raise ValueError("Modulo by zero")
113
+ return float(inputs.get("a", 0)) % b
114
+
115
+
116
+ def math_power(inputs: Dict[str, Any]) -> float:
117
+ """Raise base to exponent power."""
118
+ return float(inputs.get("base", 0)) ** float(inputs.get("exp", 1))
119
+
120
+
121
+ def math_abs(inputs: Dict[str, Any]) -> float:
122
+ """Get absolute value."""
123
+ return abs(float(inputs.get("value", 0)))
124
+
125
+
126
+ def math_round(inputs: Dict[str, Any]) -> float:
127
+ """Round to specified decimal places."""
128
+ value = float(inputs.get("value", 0))
129
+ decimals = int(inputs.get("decimals", 0))
130
+ return round(value, decimals)
131
+
132
+
133
+ def math_random_int(inputs: Dict[str, Any]) -> int:
134
+ """Random integer in [min, max] (inclusive)."""
135
+ raw_min = inputs.get("min", 0)
136
+ raw_max = inputs.get("max", 100)
137
+ try:
138
+ min_v = int(float(raw_min))
139
+ except Exception:
140
+ min_v = 0
141
+ try:
142
+ max_v = int(float(raw_max))
143
+ except Exception:
144
+ max_v = 100
145
+
146
+ if max_v < min_v:
147
+ min_v, max_v = max_v, min_v
148
+ return random.randint(min_v, max_v)
149
+
150
+
151
+ def math_random_float(inputs: Dict[str, Any]) -> float:
152
+ """Random float in [min, max]."""
153
+ raw_min = inputs.get("min", 0)
154
+ raw_max = inputs.get("max", 1)
155
+ try:
156
+ min_v = float(raw_min)
157
+ except Exception:
158
+ min_v = 0.0
159
+ try:
160
+ max_v = float(raw_max)
161
+ except Exception:
162
+ max_v = 1.0
163
+
164
+ if max_v < min_v:
165
+ min_v, max_v = max_v, min_v
166
+ if max_v == min_v:
167
+ return float(min_v)
168
+ return min_v + (max_v - min_v) * random.random()
169
+
170
+
171
+ # String operations
172
+ def string_concat(inputs: Dict[str, Any]) -> str:
173
+ """Concatenate two strings."""
174
+ return str(inputs.get("a", "")) + str(inputs.get("b", ""))
175
+
176
+
177
+ def string_split(inputs: Dict[str, Any]) -> List[str]:
178
+ """Split a string by a delimiter (defaults are tuned for real-world workflow usage).
179
+
180
+ Notes:
181
+ - Visual workflows often use human-edited / LLM-generated text where trailing
182
+ delimiters are common (e.g. "A@@B@@"). A strict `str.split` would produce an
183
+ empty last element and create a spurious downstream loop iteration.
184
+ - We therefore support optional normalization flags with sensible defaults:
185
+ - `trim` (default True): strip whitespace around parts
186
+ - `drop_empty` (default True): drop empty parts after trimming
187
+ - Delimiters may be entered as escape sequences (e.g. "\\n") from the UI.
188
+ """
189
+
190
+ raw_text = inputs.get("text", "")
191
+ text = "" if raw_text is None else str(raw_text)
192
+
193
+ raw_delim = inputs.get("delimiter", ",")
194
+ delimiter = "" if raw_delim is None else str(raw_delim)
195
+ delimiter = delimiter.replace("\\n", "\n").replace("\\t", "\t").replace("\\r", "\r")
196
+
197
+ trim = bool(inputs.get("trim", True))
198
+ drop_empty = bool(inputs.get("drop_empty", True))
199
+
200
+ # Avoid ValueError from Python's `split("")` and keep behavior predictable.
201
+ if delimiter == "":
202
+ parts = [text] if text else []
203
+ else:
204
+ raw_maxsplit = inputs.get("maxsplit")
205
+ maxsplit: Optional[int] = None
206
+ if raw_maxsplit is not None:
207
+ try:
208
+ maxsplit = int(raw_maxsplit)
209
+ except Exception:
210
+ maxsplit = None
211
+ if maxsplit is not None and maxsplit >= 0:
212
+ parts = text.split(delimiter, maxsplit)
213
+ else:
214
+ parts = text.split(delimiter)
215
+
216
+ if trim:
217
+ parts = [p.strip() for p in parts]
218
+
219
+ if drop_empty:
220
+ parts = [p for p in parts if p != ""]
221
+
222
+ return parts
223
+
224
+
225
+ def string_join(inputs: Dict[str, Any]) -> str:
226
+ """Join array items with delimiter."""
227
+ items = inputs.get("items")
228
+ # Visual workflows frequently pass optional pins; treat `null` as empty.
229
+ if items is None:
230
+ items_list: list[Any] = []
231
+ elif isinstance(items, list):
232
+ items_list = items
233
+ elif isinstance(items, tuple):
234
+ items_list = list(items)
235
+ else:
236
+ # Defensive: if a non-array leaks in, treat it as a single element instead of
237
+ # iterating characters (strings) or keys (dicts).
238
+ items_list = [items]
239
+
240
+ delimiter = str(inputs.get("delimiter", ","))
241
+ # UI often stores escape sequences (e.g. "\\n") in JSON.
242
+ delimiter = delimiter.replace("\\n", "\n").replace("\\t", "\t").replace("\\r", "\r")
243
+ return delimiter.join("" if item is None else str(item) for item in items_list)
244
+
245
+
246
+ def string_format(inputs: Dict[str, Any]) -> str:
247
+ """Format string with values."""
248
+ template = str(inputs.get("template", ""))
249
+ values = inputs.get("values", {})
250
+ if isinstance(values, dict):
251
+ return template.format(**values)
252
+ return template
253
+
254
+
255
+ def string_uppercase(inputs: Dict[str, Any]) -> str:
256
+ """Convert to uppercase."""
257
+ return str(inputs.get("text", "")).upper()
258
+
259
+
260
+ def string_lowercase(inputs: Dict[str, Any]) -> str:
261
+ """Convert to lowercase."""
262
+ return str(inputs.get("text", "")).lower()
263
+
264
+
265
+ def string_trim(inputs: Dict[str, Any]) -> str:
266
+ """Trim whitespace."""
267
+ return str(inputs.get("text", "")).strip()
268
+
269
+
270
+ def string_substring(inputs: Dict[str, Any]) -> str:
271
+ """Get substring."""
272
+ text = str(inputs.get("text", ""))
273
+ start = int(inputs.get("start", 0))
274
+ end = inputs.get("end")
275
+ if end is not None:
276
+ return text[start : int(end)]
277
+ return text[start:]
278
+
279
+
280
+ def string_length(inputs: Dict[str, Any]) -> int:
281
+ """Get string length."""
282
+ return len(str(inputs.get("text", "")))
283
+
284
+
285
+ def string_contains(inputs: Dict[str, Any]) -> bool:
286
+ """Return True if `text` contains `pattern` (substring match)."""
287
+ pattern_raw = inputs.get("pattern")
288
+ if pattern_raw is None:
289
+ return False
290
+ pattern = str(pattern_raw)
291
+ # Avoid surprising behavior where "" is "contained" in every string.
292
+ if pattern == "":
293
+ return False
294
+
295
+ text_raw = inputs.get("text")
296
+ text = "" if text_raw is None else str(text_raw)
297
+ return pattern in text
298
+
299
+
300
+ def string_replace(inputs: Dict[str, Any]) -> str:
301
+ """Replace `pattern` in `text` with `replacement`.
302
+
303
+ mode:
304
+ - "first": replace the first occurrence
305
+ - "all" (default): replace all occurrences
306
+ """
307
+ text_raw = inputs.get("text")
308
+ text = "" if text_raw is None else str(text_raw)
309
+
310
+ pattern_raw = inputs.get("pattern")
311
+ if pattern_raw is None:
312
+ return text
313
+ pattern = str(pattern_raw)
314
+ if pattern == "":
315
+ return text
316
+
317
+ replacement_raw = inputs.get("replacement")
318
+ replacement = "" if replacement_raw is None else str(replacement_raw)
319
+
320
+ mode_raw = inputs.get("mode")
321
+ mode = str(mode_raw).strip().lower() if mode_raw is not None else "all"
322
+
323
+ if mode in {"first", "once", "1"}:
324
+ return text.replace(pattern, replacement, 1)
325
+ if mode in {"all", "*", "global"}:
326
+ return text.replace(pattern, replacement)
327
+
328
+ # Best-effort support for numeric counts (e.g. mode="2").
329
+ try:
330
+ n = int(mode_raw) # type: ignore[arg-type]
331
+ if n < 0:
332
+ return text.replace(pattern, replacement)
333
+ return text.replace(pattern, replacement, n)
334
+ except Exception:
335
+ return text.replace(pattern, replacement)
336
+
337
+
338
+ def string_template(inputs: Dict[str, Any]) -> str:
339
+ """Render a template with placeholders like `{{path.to.value}}`.
340
+
341
+ Supported filters:
342
+ - `| json` -> json.dumps(value)
343
+ - `| join(", ")` -> join array values with delimiter
344
+ - `| trim` / `| lower` / `| upper`
345
+ """
346
+ import re
347
+
348
+ template = str(inputs.get("template", "") or "")
349
+ vars_raw = inputs.get("vars")
350
+ vars_obj = vars_raw if isinstance(vars_raw, dict) else {}
351
+
352
+ pat = re.compile(r"\{\{\s*(.*?)\s*\}\}")
353
+
354
+ def _apply_filters(value: Any, filters: list[str]) -> Any:
355
+ cur = value
356
+ for f in filters:
357
+ f = f.strip()
358
+ if not f:
359
+ continue
360
+ if f == "json":
361
+ cur = json.dumps(cur, ensure_ascii=False, sort_keys=True)
362
+ continue
363
+ if f.startswith("join"):
364
+ m = re.match(r"join\((.*)\)$", f)
365
+ delim = ", "
366
+ if m:
367
+ raw = m.group(1).strip()
368
+ if (raw.startswith('"') and raw.endswith('"')) or (raw.startswith("'") and raw.endswith("'")):
369
+ raw = raw[1:-1]
370
+ delim = raw
371
+ if isinstance(cur, list):
372
+ cur = delim.join("" if x is None else str(x) for x in cur)
373
+ else:
374
+ cur = "" if cur is None else str(cur)
375
+ continue
376
+ if f == "trim":
377
+ cur = ("" if cur is None else str(cur)).strip()
378
+ continue
379
+ if f == "lower":
380
+ cur = ("" if cur is None else str(cur)).lower()
381
+ continue
382
+ if f == "upper":
383
+ cur = ("" if cur is None else str(cur)).upper()
384
+ continue
385
+ # Unknown filters are ignored (best-effort, stable).
386
+ return cur
387
+
388
+ def _render_expr(expr: str) -> str:
389
+ parts = [p.strip() for p in str(expr or "").split("|")]
390
+ path = parts[0] if parts else ""
391
+ filters = parts[1:] if len(parts) > 1 else []
392
+ value = _get_path(vars_obj, path)
393
+ if value is None:
394
+ return ""
395
+ value = _apply_filters(value, filters)
396
+ return "" if value is None else str(value)
397
+
398
+ return pat.sub(lambda m: _render_expr(m.group(1)), template)
399
+
400
+
401
+ # Control flow helpers (these return decision values, not execution control)
402
+ def control_compare(inputs: Dict[str, Any]) -> bool:
403
+ """Compare two values."""
404
+ a = inputs.get("a")
405
+ b = inputs.get("b")
406
+ op = str(inputs.get("op", "=="))
407
+
408
+ if op == "==":
409
+ return a == b
410
+ if op == "!=":
411
+ return a != b
412
+ if op == "<":
413
+ try:
414
+ return a < b
415
+ except Exception:
416
+ return False
417
+ if op == "<=":
418
+ try:
419
+ return a <= b
420
+ except Exception:
421
+ return False
422
+ if op == ">":
423
+ try:
424
+ return a > b
425
+ except Exception:
426
+ return False
427
+ if op == ">=":
428
+ try:
429
+ return a >= b
430
+ except Exception:
431
+ return False
432
+ raise ValueError(f"Unknown comparison operator: {op}")
433
+
434
+
435
+ def control_not(inputs: Dict[str, Any]) -> bool:
436
+ """Logical NOT."""
437
+ return not bool(inputs.get("value", False))
438
+
439
+
440
+ def control_and(inputs: Dict[str, Any]) -> bool:
441
+ """Logical AND."""
442
+ return bool(inputs.get("a", False)) and bool(inputs.get("b", False))
443
+
444
+
445
+ def control_or(inputs: Dict[str, Any]) -> bool:
446
+ """Logical OR."""
447
+ return bool(inputs.get("a", False)) or bool(inputs.get("b", False))
448
+
449
+
450
+ def control_coalesce(inputs: Dict[str, Any]) -> Any:
451
+ """Return the first non-None input in pin order.
452
+
453
+ Pin order is injected by the visual executor as `_pin_order` based on the node's
454
+ input pin list, so selection is deterministic and matches the visual layout.
455
+ """
456
+ order = inputs.get("_pin_order")
457
+ pin_order: list[str] = []
458
+ if isinstance(order, list):
459
+ for x in order:
460
+ if isinstance(x, str) and x:
461
+ pin_order.append(x)
462
+ if not pin_order:
463
+ pin_order = ["a", "b"]
464
+
465
+ for pid in pin_order:
466
+ if pid not in inputs:
467
+ continue
468
+ v = inputs.get(pid)
469
+ if v is not None:
470
+ return v
471
+ return None
472
+
473
+
474
+ # Data operations
475
+ def data_get(inputs: Dict[str, Any]) -> Any:
476
+ """Get property from object."""
477
+ obj = inputs.get("object", {})
478
+ key = str(inputs.get("key", ""))
479
+ default = inputs.get("default")
480
+
481
+ value = _get_path(obj, key)
482
+ if value is None:
483
+ return {"value": default}
484
+ return {"value": value}
485
+
486
+
487
+ def data_set(inputs: Dict[str, Any]) -> Dict[str, Any]:
488
+ """Set property on object (returns new object)."""
489
+ obj = dict(inputs.get("object", {}))
490
+ key = str(inputs.get("key", ""))
491
+ value = inputs.get("value")
492
+
493
+ # Support dot notation
494
+ parts = key.split(".")
495
+ current = obj
496
+ for part in parts[:-1]:
497
+ nxt = current.get(part)
498
+ if not isinstance(nxt, dict):
499
+ nxt = {}
500
+ current[part] = nxt
501
+ current = nxt
502
+ current[parts[-1]] = value
503
+ return obj
504
+
505
+
506
+ def data_merge(inputs: Dict[str, Any]) -> Dict[str, Any]:
507
+ """Merge two objects."""
508
+ a = dict(inputs.get("a", {}))
509
+ b = dict(inputs.get("b", {}))
510
+ return {**a, **b}
511
+
512
+
513
+ def data_make_object(inputs: Dict[str, Any]) -> Dict[str, Any]:
514
+ """Build a flat JSON object from the provided inputs.
515
+
516
+ VisualFlow's executor injects helper keys like `_pin_order` / `_literalValue`;
517
+ this node ignores keys starting with "_" to keep output stable.
518
+ """
519
+ out: Dict[str, Any] = {}
520
+ for k, v in (inputs or {}).items():
521
+ if not isinstance(k, str):
522
+ continue
523
+ if k.startswith("_"):
524
+ continue
525
+ out[k] = v
526
+ return out
527
+
528
+
529
+ def data_make_context(inputs: Dict[str, Any]) -> Dict[str, Any]:
530
+ """Build a context object {task, messages, ...context_extra}."""
531
+ context_extra = inputs.get("context_extra")
532
+ out: Dict[str, Any] = dict(context_extra) if isinstance(context_extra, dict) else {}
533
+
534
+ task = inputs.get("task")
535
+ if task is None:
536
+ out["task"] = ""
537
+ elif isinstance(task, str):
538
+ out["task"] = task
539
+ else:
540
+ out["task"] = str(task)
541
+
542
+ messages = inputs.get("messages")
543
+ if isinstance(messages, list):
544
+ out["messages"] = list(messages)
545
+ elif isinstance(messages, tuple):
546
+ out["messages"] = list(messages)
547
+ elif messages is None:
548
+ out["messages"] = []
549
+ else:
550
+ out["messages"] = [messages]
551
+
552
+ return {"context": out}
553
+
554
+
555
+ def data_add_message(inputs: Dict[str, Any]) -> Dict[str, Any]:
556
+ """Build a canonical message object for context.messages.
557
+
558
+ Shape:
559
+ {
560
+ "role": "...",
561
+ "content": "...",
562
+ "timestamp": "<utc iso>",
563
+ "metadata": {"message_id": "msg_<hex>"}
564
+ }
565
+ """
566
+ role_raw = inputs.get("role")
567
+ role = str(role_raw) if role_raw is not None else "user"
568
+
569
+ content_raw = inputs.get("content")
570
+ content = str(content_raw) if content_raw is not None else ""
571
+
572
+ try:
573
+ from datetime import timezone
574
+
575
+ timestamp = datetime.now(timezone.utc).isoformat()
576
+ except Exception:
577
+ timestamp = datetime.utcnow().isoformat() + "Z"
578
+
579
+ import uuid
580
+
581
+ return {
582
+ "message": {
583
+ "role": role,
584
+ "content": content,
585
+ "timestamp": timestamp,
586
+ "metadata": {"message_id": f"msg_{uuid.uuid4().hex}"},
587
+ }
588
+ }
589
+
590
+
591
+ def data_make_meta(inputs: Dict[str, Any]) -> Dict[str, Any]:
592
+ """Build a host-facing meta envelope (portable, no strict schema enforcement)."""
593
+ extra = inputs.get("extra")
594
+ out: Dict[str, Any] = dict(extra) if isinstance(extra, dict) else {}
595
+
596
+ schema = inputs.get("schema")
597
+ schema_str = schema.strip() if isinstance(schema, str) and schema.strip() else "abstractcode.agent.v1.meta"
598
+
599
+ version = inputs.get("version")
600
+ version_int = 1
601
+ try:
602
+ if isinstance(version, bool):
603
+ version_int = 1
604
+ elif isinstance(version, (int, float)):
605
+ version_int = int(version)
606
+ elif isinstance(version, str) and version.strip():
607
+ version_int = int(float(version.strip()))
608
+ except Exception:
609
+ version_int = 1
610
+
611
+ out["schema"] = schema_str
612
+ out["version"] = version_int
613
+
614
+ output_mode = inputs.get("output_mode")
615
+ if isinstance(output_mode, str) and output_mode.strip():
616
+ out["output_mode"] = output_mode.strip()
617
+
618
+ provider = inputs.get("provider")
619
+ if isinstance(provider, str) and provider.strip():
620
+ out["provider"] = provider.strip()
621
+ elif provider is not None and str(provider).strip():
622
+ out["provider"] = str(provider).strip()
623
+
624
+ model = inputs.get("model")
625
+ if isinstance(model, str) and model.strip():
626
+ out["model"] = model.strip()
627
+ elif model is not None and str(model).strip():
628
+ out["model"] = str(model).strip()
629
+
630
+ sub_run_id = inputs.get("sub_run_id")
631
+ if isinstance(sub_run_id, str) and sub_run_id.strip():
632
+ out["sub_run_id"] = sub_run_id.strip()
633
+
634
+ iterations = inputs.get("iterations")
635
+ try:
636
+ if isinstance(iterations, bool):
637
+ pass
638
+ elif isinstance(iterations, (int, float)):
639
+ out["iterations"] = int(iterations)
640
+ elif isinstance(iterations, str) and iterations.strip():
641
+ out["iterations"] = int(float(iterations.strip()))
642
+ except Exception:
643
+ pass
644
+
645
+ tool_calls = inputs.get("tool_calls")
646
+ try:
647
+ if isinstance(tool_calls, bool):
648
+ pass
649
+ elif isinstance(tool_calls, (int, float)):
650
+ out["tool_calls"] = int(tool_calls)
651
+ elif isinstance(tool_calls, str) and tool_calls.strip():
652
+ out["tool_calls"] = int(float(tool_calls.strip()))
653
+ except Exception:
654
+ pass
655
+
656
+ tool_results = inputs.get("tool_results")
657
+ try:
658
+ if isinstance(tool_results, bool):
659
+ pass
660
+ elif isinstance(tool_results, (int, float)):
661
+ out["tool_results"] = int(tool_results)
662
+ elif isinstance(tool_results, str) and tool_results.strip():
663
+ out["tool_results"] = int(float(tool_results.strip()))
664
+ except Exception:
665
+ pass
666
+
667
+ finish_reason = inputs.get("finish_reason")
668
+ if isinstance(finish_reason, str) and finish_reason.strip():
669
+ out["finish_reason"] = finish_reason.strip()
670
+
671
+ gen_time = inputs.get("gen_time")
672
+ if gen_time is not None and not isinstance(gen_time, bool):
673
+ out["gen_time"] = gen_time
674
+
675
+ ttft_ms = inputs.get("ttft_ms")
676
+ if ttft_ms is not None and not isinstance(ttft_ms, bool):
677
+ out["ttft_ms"] = ttft_ms
678
+
679
+ usage = inputs.get("usage")
680
+ if isinstance(usage, dict):
681
+ out["usage"] = dict(usage)
682
+
683
+ trace = inputs.get("trace")
684
+ if isinstance(trace, dict):
685
+ out["trace"] = dict(trace)
686
+ elif isinstance(trace, str) and trace.strip():
687
+ out["trace"] = {"trace_id": trace.strip()}
688
+
689
+ trace_id = inputs.get("trace_id")
690
+ trace_id_str = trace_id.strip() if isinstance(trace_id, str) and trace_id.strip() else ""
691
+ if trace_id_str:
692
+ trace = out.get("trace")
693
+ trace_obj: Dict[str, Any] = dict(trace) if isinstance(trace, dict) else {}
694
+ trace_obj["trace_id"] = trace_id_str
695
+ out["trace"] = trace_obj
696
+
697
+ warnings = inputs.get("warnings")
698
+ if isinstance(warnings, list):
699
+ out["warnings"] = [str(w) for w in warnings if str(w).strip()]
700
+ elif isinstance(warnings, tuple):
701
+ out["warnings"] = [str(w) for w in list(warnings) if str(w).strip()]
702
+ elif isinstance(warnings, str) and warnings.strip():
703
+ out["warnings"] = [warnings.strip()]
704
+
705
+ debug = inputs.get("debug")
706
+ if isinstance(debug, dict):
707
+ out["debug"] = dict(debug)
708
+ elif debug is not None:
709
+ out["debug"] = debug
710
+
711
+ return {"meta": out}
712
+
713
+
714
+ def data_make_scratchpad(inputs: Dict[str, Any]) -> Dict[str, Any]:
715
+ """Build a scratchpad/trace envelope (commonly from Agent outputs)."""
716
+ out: Dict[str, Any] = {}
717
+
718
+ sub_run_id = inputs.get("sub_run_id")
719
+ if isinstance(sub_run_id, str) and sub_run_id.strip():
720
+ out["sub_run_id"] = sub_run_id.strip()
721
+
722
+ workflow_id = inputs.get("workflow_id")
723
+ if isinstance(workflow_id, str) and workflow_id.strip():
724
+ out["workflow_id"] = workflow_id.strip()
725
+
726
+ task = inputs.get("task")
727
+ if task is None:
728
+ out["task"] = ""
729
+ elif isinstance(task, str):
730
+ out["task"] = task
731
+ else:
732
+ out["task"] = str(task)
733
+
734
+ messages = inputs.get("messages")
735
+ if isinstance(messages, list):
736
+ out["messages"] = list(messages)
737
+ elif isinstance(messages, tuple):
738
+ out["messages"] = list(messages)
739
+ elif messages is None:
740
+ out["messages"] = []
741
+ else:
742
+ out["messages"] = [messages]
743
+
744
+ # Optional nested context extras (kept for backwards compatibility).
745
+ context_extra = inputs.get("context_extra")
746
+ if isinstance(context_extra, dict):
747
+ out["context_extra"] = dict(context_extra)
748
+
749
+ node_traces = inputs.get("node_traces")
750
+ out["node_traces"] = dict(node_traces) if isinstance(node_traces, dict) else {}
751
+
752
+ steps = inputs.get("steps")
753
+ if isinstance(steps, list):
754
+ out["steps"] = list(steps)
755
+ elif isinstance(steps, tuple):
756
+ out["steps"] = list(steps)
757
+ elif steps is None:
758
+ out["steps"] = []
759
+ else:
760
+ out["steps"] = [steps]
761
+
762
+ tool_calls = inputs.get("tool_calls")
763
+ if isinstance(tool_calls, list):
764
+ out["tool_calls"] = list(tool_calls)
765
+ elif isinstance(tool_calls, tuple):
766
+ out["tool_calls"] = list(tool_calls)
767
+ elif tool_calls is None:
768
+ out["tool_calls"] = []
769
+ else:
770
+ out["tool_calls"] = [tool_calls]
771
+
772
+ tool_results = inputs.get("tool_results")
773
+ if isinstance(tool_results, list):
774
+ out["tool_results"] = list(tool_results)
775
+ elif isinstance(tool_results, tuple):
776
+ out["tool_results"] = list(tool_results)
777
+ elif tool_results is None:
778
+ out["tool_results"] = []
779
+ else:
780
+ out["tool_results"] = [tool_results]
781
+
782
+ return {"scratchpad": out}
783
+
784
+
785
+ def data_get_element(inputs: Dict[str, Any]) -> Dict[str, Any]:
786
+ """Get an array element by index with an optional default.
787
+
788
+ - Supports list/tuple as arrays; other inputs are treated as empty.
789
+ - Index is coerced to int (floats truncated).
790
+ - Negative indices are supported (Python-style).
791
+ """
792
+ arr_raw = inputs.get("array")
793
+ if isinstance(arr_raw, list):
794
+ arr = arr_raw
795
+ elif isinstance(arr_raw, tuple):
796
+ arr = list(arr_raw)
797
+ else:
798
+ arr = []
799
+
800
+ default = inputs.get("default")
801
+ raw_index = inputs.get("index", 0)
802
+ try:
803
+ if isinstance(raw_index, bool):
804
+ idx = 0
805
+ elif isinstance(raw_index, (int, float)):
806
+ idx = int(raw_index)
807
+ elif isinstance(raw_index, str) and raw_index.strip():
808
+ idx = int(float(raw_index.strip()))
809
+ else:
810
+ idx = 0
811
+ except Exception:
812
+ idx = 0
813
+
814
+ if idx < 0:
815
+ idx = len(arr) + idx
816
+
817
+ if 0 <= idx < len(arr):
818
+ return {"result": arr[idx], "found": True}
819
+ return {"result": default, "found": False}
820
+
821
+
822
+ def data_get_random_element(inputs: Dict[str, Any]) -> Dict[str, Any]:
823
+ """Pick a random element from an array with an optional default."""
824
+ arr_raw = inputs.get("array")
825
+ if isinstance(arr_raw, list):
826
+ arr = arr_raw
827
+ elif isinstance(arr_raw, tuple):
828
+ arr = list(arr_raw)
829
+ else:
830
+ arr = []
831
+
832
+ default = inputs.get("default")
833
+ if not arr:
834
+ return {"result": default, "found": False}
835
+
836
+ # Use global RNG; tests only assert membership/non-empty, so this stays non-flaky.
837
+ return {"result": random.choice(arr), "found": True}
838
+
839
+
840
+ def data_array_map(inputs: Dict[str, Any]) -> List[Any]:
841
+ """Map array items (extract property from each)."""
842
+ items = inputs.get("items", [])
843
+ key = str(inputs.get("key", ""))
844
+
845
+ result: list[Any] = []
846
+ for item in items:
847
+ if isinstance(item, dict):
848
+ result.append(item.get(key))
849
+ else:
850
+ result.append(item)
851
+ return result
852
+
853
+
854
+ def data_array_filter(inputs: Dict[str, Any]) -> List[Any]:
855
+ """Filter array by condition."""
856
+ items = inputs.get("items", [])
857
+ key = str(inputs.get("key", ""))
858
+ value = inputs.get("value")
859
+
860
+ result: list[Any] = []
861
+ for item in items:
862
+ if isinstance(item, dict):
863
+ if item.get(key) == value:
864
+ result.append(item)
865
+ elif item == value:
866
+ result.append(item)
867
+ return result
868
+
869
+
870
+ def data_array_length(inputs: Dict[str, Any]) -> int:
871
+ """Return array length (0 if not an array)."""
872
+ items = inputs.get("array")
873
+ if isinstance(items, list):
874
+ return len(items)
875
+ if isinstance(items, tuple):
876
+ return len(list(items))
877
+ return 0
878
+
879
+
880
+ def data_has_tools(inputs: Dict[str, Any]) -> bool:
881
+ """Convenience: return True if the input array has at least one element.
882
+
883
+ Intended for checking LLM `tool_calls` arrays, but works with any array-like input.
884
+ Non-array / null inputs are treated as empty.
885
+ """
886
+ items = inputs.get("array")
887
+ if isinstance(items, list):
888
+ return len(items) > 0
889
+ if isinstance(items, tuple):
890
+ return len(items) > 0
891
+ return False
892
+
893
+
894
+ def data_array_append(inputs: Dict[str, Any]) -> List[Any]:
895
+ """Append an item to an array (returns a new array)."""
896
+ items = inputs.get("array")
897
+ item = inputs.get("item")
898
+ out: list[Any]
899
+ if isinstance(items, list):
900
+ out = list(items)
901
+ elif isinstance(items, tuple):
902
+ out = list(items)
903
+ elif items is None:
904
+ out = []
905
+ else:
906
+ out = [items]
907
+ out.append(item)
908
+ return out
909
+
910
+
911
+ def data_array_dedup(inputs: Dict[str, Any]) -> List[Any]:
912
+ """Stable-order dedup for arrays.
913
+
914
+ If `key` is provided (string path), dedup objects by that path value.
915
+ """
916
+ items = inputs.get("array")
917
+ if not isinstance(items, list):
918
+ if isinstance(items, tuple):
919
+ items = list(items)
920
+ else:
921
+ return []
922
+
923
+ key = inputs.get("key")
924
+ key_path = str(key or "").strip()
925
+
926
+ def _fingerprint(v: Any) -> str:
927
+ if v is None or isinstance(v, (bool, int, float, str)):
928
+ return f"{type(v).__name__}:{v}"
929
+ try:
930
+ return json.dumps(v, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str)
931
+ except Exception:
932
+ return str(v)
933
+
934
+ seen: set[str] = set()
935
+ out: list[Any] = []
936
+ for item in items:
937
+ if key_path:
938
+ k = _get_path(item, key_path)
939
+ fp = _fingerprint(k) if k is not None else _fingerprint(item)
940
+ else:
941
+ fp = _fingerprint(item)
942
+ if fp in seen:
943
+ continue
944
+ seen.add(fp)
945
+ out.append(item)
946
+ return out
947
+
948
+
949
+ def system_datetime(_: Dict[str, Any]) -> Dict[str, Any]:
950
+ """Return current system date/time and best-effort locale metadata.
951
+
952
+ All values are JSON-serializable and stable-keyed.
953
+ """
954
+ now = datetime.now().astimezone()
955
+ offset = now.utcoffset()
956
+ offset_minutes = int(offset.total_seconds() // 60) if offset is not None else 0
957
+
958
+ tzname = now.tzname() or ""
959
+
960
+ # Avoid deprecated locale.getdefaultlocale() in Python 3.12+.
961
+ lang = os.environ.get("LC_ALL") or os.environ.get("LANG") or os.environ.get("LC_CTYPE") or ""
962
+ env_locale = lang.split(".", 1)[0] if lang else ""
963
+
964
+ loc = locale.getlocale()[0] or env_locale
965
+
966
+ return {
967
+ "iso": now.isoformat(),
968
+ "timezone": tzname,
969
+ "utc_offset_minutes": offset_minutes,
970
+ "locale": loc or "",
971
+ }
972
+
973
+
974
+ def data_parse_json(inputs: Dict[str, Any]) -> Any:
975
+ """Parse JSON (or JSON-ish) text into a JSON-serializable Python value.
976
+
977
+ Primary use-case: turn an LLM string response into an object/array that can be
978
+ fed into `Break Object` (dynamic pins) or other data nodes.
979
+
980
+ Behavior:
981
+ - If the input is already a dict/list, returns it unchanged (idempotent).
982
+ - Tries strict `json.loads` first.
983
+ - If that fails, tries to extract the first JSON object/array substring and parse it.
984
+ - As a last resort, tries `ast.literal_eval` to handle Python-style dicts/lists
985
+ (common in LLM output), then converts to JSON-friendly types.
986
+ - If the parsed value is a scalar, wraps it as `{ "value": <scalar> }` by default,
987
+ so `Break Object` can still expose it.
988
+ """
989
+
990
+ def _strip_code_fence(text: str) -> str:
991
+ s = text.strip()
992
+ if not s.startswith("```"):
993
+ return s
994
+ # Opening fence line can be ```json / ```js etc; drop it.
995
+ nl = s.find("\n")
996
+ if nl == -1:
997
+ return s.strip("`").strip()
998
+ body = s[nl + 1 :]
999
+ end = body.rfind("```")
1000
+ if end != -1:
1001
+ body = body[:end]
1002
+ return body.strip()
1003
+
1004
+ def _jsonify(value: Any) -> Any:
1005
+ if value is None or isinstance(value, (bool, int, float, str)):
1006
+ return value
1007
+ if isinstance(value, dict):
1008
+ return {str(k): _jsonify(v) for k, v in value.items()}
1009
+ if isinstance(value, list):
1010
+ return [_jsonify(v) for v in value]
1011
+ if isinstance(value, tuple):
1012
+ return [_jsonify(v) for v in value]
1013
+ return str(value)
1014
+
1015
+ raw = inputs.get("text")
1016
+ if isinstance(raw, (dict, list)):
1017
+ parsed: Any = raw
1018
+ else:
1019
+ if raw is None:
1020
+ raise ValueError("parse_json requires a non-empty 'text' input.")
1021
+ text = _strip_code_fence(str(raw))
1022
+ if not text.strip():
1023
+ raise ValueError("parse_json requires a non-empty 'text' input.")
1024
+
1025
+ parsed = None
1026
+ text_stripped = text.strip()
1027
+
1028
+ try:
1029
+ parsed = json.loads(text_stripped)
1030
+ except Exception:
1031
+ # Best-effort: find and parse the first JSON object/array substring.
1032
+ decoder = json.JSONDecoder()
1033
+ starts: list[int] = []
1034
+ for i, ch in enumerate(text_stripped):
1035
+ if ch in "{[":
1036
+ starts.append(i)
1037
+ if len(starts) >= 64:
1038
+ break
1039
+ for i in starts:
1040
+ try:
1041
+ parsed, _end = decoder.raw_decode(text_stripped[i:])
1042
+ break
1043
+ except Exception:
1044
+ continue
1045
+
1046
+ if parsed is None:
1047
+ # Last resort: tolerate Python-literal dict/list output.
1048
+ try:
1049
+ parsed = ast.literal_eval(text_stripped)
1050
+ except Exception as e:
1051
+ raise ValueError(f"Invalid JSON: {e}") from e
1052
+
1053
+ parsed = _jsonify(parsed)
1054
+
1055
+ wrap_scalar = bool(inputs.get("wrap_scalar", True))
1056
+ if wrap_scalar and not isinstance(parsed, (dict, list)):
1057
+ return {"value": parsed}
1058
+ return parsed
1059
+
1060
+
1061
+ def data_stringify_json(inputs: Dict[str, Any]) -> str:
1062
+ """Render a JSON-like value into a string (runtime-owned implementation).
1063
+
1064
+ The core stringify logic lives in AbstractRuntime so multiple hosts can reuse it.
1065
+
1066
+ Supported inputs (backward compatible):
1067
+ - `value`: JSON value (dict/list/scalar) OR a JSON-ish string.
1068
+ - `mode`: none | beautify | minified
1069
+ - Legacy: `indent` (<=0 => minified; >0 => beautify with that indent)
1070
+ - Legacy: `sort_keys` (bool)
1071
+ """
1072
+
1073
+ value = inputs.get("value")
1074
+
1075
+ raw_mode = inputs.get("mode")
1076
+ mode = str(raw_mode).strip().lower() if isinstance(raw_mode, str) else ""
1077
+
1078
+ raw_indent = inputs.get("indent")
1079
+ indent_n: Optional[int] = None
1080
+ if raw_indent is not None:
1081
+ try:
1082
+ indent_n = int(raw_indent)
1083
+ except Exception:
1084
+ indent_n = None
1085
+
1086
+ raw_sort_keys = inputs.get("sort_keys")
1087
+ sort_keys = bool(raw_sort_keys) if isinstance(raw_sort_keys, bool) else False
1088
+
1089
+ # If mode not provided, infer from legacy indent.
1090
+ if not mode:
1091
+ if indent_n is not None and indent_n <= 0:
1092
+ mode = "minified"
1093
+ elif indent_n is not None and indent_n > 0:
1094
+ mode = "beautify"
1095
+ else:
1096
+ mode = "beautify"
1097
+
1098
+ return runtime_stringify_json(
1099
+ value,
1100
+ mode=mode,
1101
+ beautify_indent=indent_n if isinstance(indent_n, int) and indent_n > 0 else 2,
1102
+ sort_keys=sort_keys,
1103
+ parse_strings=True,
1104
+ )
1105
+
1106
+
1107
+ def data_agent_trace_report(inputs: Dict[str, Any]) -> str:
1108
+ """Render an agent scratchpad (runtime-owned node traces) into Markdown."""
1109
+ scratchpad = inputs.get("scratchpad")
1110
+ return runtime_render_agent_trace_markdown(scratchpad)
1111
+
1112
+
1113
+ def data_format_tool_results(inputs: Dict[str, Any]) -> str:
1114
+ """Render Tool Calls `results` into a condensed, human-readable digest string.
1115
+
1116
+ Expected input shape (Tool Calls node output):
1117
+ results: [{call_id, name, success, output, error}, ...]
1118
+
1119
+ The Tool Calls node does not currently include the original arguments, so this
1120
+ formatter best-effort derives a “primary” argument from:
1121
+ - `result.arguments` (when present)
1122
+ - the tool output object (e.g. execute_command.output.command)
1123
+
1124
+ When available, tool specs (AbstractCore ToolDefinitions) are used to pick a
1125
+ stable primary key without hardcoding tool parameter names.
1126
+ """
1127
+
1128
+ raw = inputs.get("tool_results")
1129
+ if raw is None:
1130
+ raw = inputs.get("results")
1131
+
1132
+ items: list[Any]
1133
+ if raw is None:
1134
+ items = []
1135
+ elif isinstance(raw, list):
1136
+ items = raw
1137
+ else:
1138
+ items = [raw]
1139
+
1140
+ tool_specs_by_name: Dict[str, Dict[str, Any]] = {}
1141
+ try:
1142
+ from abstractruntime.integrations.abstractcore.default_tools import list_default_tool_specs
1143
+
1144
+ for s in list_default_tool_specs():
1145
+ if not isinstance(s, dict):
1146
+ continue
1147
+ name = s.get("name")
1148
+ if isinstance(name, str) and name.strip():
1149
+ tool_specs_by_name[name.strip()] = dict(s)
1150
+ except Exception:
1151
+ tool_specs_by_name = {}
1152
+
1153
+ def _clamp(text: str, max_len: int) -> str:
1154
+ s = str(text or "").strip()
1155
+ if len(s) <= max_len:
1156
+ return s
1157
+ return f"{s[: max(0, max_len - 1)]}…"
1158
+
1159
+ def _one_line(text: str) -> str:
1160
+ s = str(text or "").replace("\r\n", "\n").replace("\r", "\n")
1161
+ s = " ".join([p for p in s.split("\n") if p.strip()])
1162
+ return s.strip()
1163
+
1164
+ def _format_value(value: Any, max_len: int = 160) -> str:
1165
+ if value is None:
1166
+ return ""
1167
+ if isinstance(value, bool):
1168
+ return "true" if value else "false"
1169
+ if isinstance(value, (int, float)):
1170
+ return str(value)
1171
+ if isinstance(value, str):
1172
+ return _clamp(_one_line(value), max_len=max_len)
1173
+ try:
1174
+ return _clamp(_one_line(json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True)), max_len=max_len)
1175
+ except Exception:
1176
+ return _clamp(_one_line(str(value)), max_len=max_len)
1177
+
1178
+ def _primary_key_order(name: str, args_obj: Dict[str, Any], out_obj: Dict[str, Any]) -> list[str]:
1179
+ spec = tool_specs_by_name.get(str(name or "").strip()) or {}
1180
+ order: list[str] = []
1181
+ required = spec.get("required_args")
1182
+ if isinstance(required, list):
1183
+ for k in required:
1184
+ if isinstance(k, str) and k.strip():
1185
+ order.append(k.strip())
1186
+ if not order:
1187
+ params = spec.get("parameters")
1188
+ if isinstance(params, dict):
1189
+ for k in params.keys():
1190
+ if isinstance(k, str) and k.strip():
1191
+ order.append(k.strip())
1192
+ if not order:
1193
+ for k in list(args_obj.keys()) + list(out_obj.keys()):
1194
+ if isinstance(k, str) and k.strip() and k not in order:
1195
+ order.append(k.strip())
1196
+ return order
1197
+
1198
+ def _signature(name: str, args_obj: Dict[str, Any], out_obj: Dict[str, Any]) -> str:
1199
+ tool = str(name or "").strip() or "tool"
1200
+ order = _primary_key_order(tool, args_obj, out_obj)
1201
+ primary_val: Any = None
1202
+ for k in order:
1203
+ if k in args_obj and args_obj.get(k) is not None:
1204
+ primary_val = args_obj.get(k)
1205
+ break
1206
+ if primary_val is None:
1207
+ for k in order:
1208
+ if k in out_obj and out_obj.get(k) is not None:
1209
+ primary_val = out_obj.get(k)
1210
+ break
1211
+ if primary_val is None:
1212
+ return f"{tool}()"
1213
+ return f"{tool}({_format_value(primary_val)})"
1214
+
1215
+ lines: list[str] = []
1216
+ for it in items:
1217
+ if not isinstance(it, dict):
1218
+ continue
1219
+ name = str(it.get("name") or it.get("tool") or "").strip() or "tool"
1220
+ success_raw = it.get("success")
1221
+ success = success_raw if isinstance(success_raw, bool) else None
1222
+ error_raw = it.get("error")
1223
+ error = str(error_raw).strip() if isinstance(error_raw, str) else (str(error_raw).strip() if error_raw is not None else "")
1224
+ output = it.get("output")
1225
+ args_obj = it.get("arguments") if isinstance(it.get("arguments"), dict) else {}
1226
+ out_obj = output if isinstance(output, dict) else {}
1227
+
1228
+ sig = _signature(name, args_obj, out_obj)
1229
+ status = "SUCCESS" if success is True else "FAILURE" if success is False else "DONE"
1230
+
1231
+ if success is False and error:
1232
+ obs = error
1233
+ else:
1234
+ if isinstance(output, dict):
1235
+ rendered = output.get("rendered")
1236
+ if isinstance(rendered, str) and rendered.strip():
1237
+ obs = rendered.strip()
1238
+ else:
1239
+ obs = _format_value(output, max_len=420)
1240
+ elif isinstance(output, str):
1241
+ obs = _clamp(_one_line(output), max_len=420)
1242
+ elif output is None:
1243
+ obs = "(no output)"
1244
+ else:
1245
+ obs = _clamp(_one_line(str(output)), max_len=420)
1246
+
1247
+ if not obs.strip():
1248
+ obs = "(no output)"
1249
+
1250
+ lines.append(f"- {sig} [{status}] : {obs}")
1251
+
1252
+ return "\n".join(lines).strip()
1253
+
1254
+
1255
+ # Literal value handlers - return configured constant values
1256
+ def literal_string(inputs: Dict[str, Any]) -> str:
1257
+ """Return string literal value."""
1258
+ return str(inputs.get("_literalValue", ""))
1259
+
1260
+
1261
+ def literal_number(inputs: Dict[str, Any]) -> float:
1262
+ """Return number literal value."""
1263
+ value = inputs.get("_literalValue", 0)
1264
+ try:
1265
+ return float(value)
1266
+ except (TypeError, ValueError):
1267
+ return 0.0
1268
+
1269
+
1270
+ def literal_boolean(inputs: Dict[str, Any]) -> bool:
1271
+ """Return boolean literal value."""
1272
+ return bool(inputs.get("_literalValue", False))
1273
+
1274
+
1275
+ def literal_json(inputs: Dict[str, Any]) -> Dict[str, Any]:
1276
+ """Return JSON literal value."""
1277
+ value = inputs.get("_literalValue", {})
1278
+ if isinstance(value, (dict, list)):
1279
+ return value # type: ignore[return-value]
1280
+ return {}
1281
+
1282
+
1283
+ def literal_array(inputs: Dict[str, Any]) -> List[Any]:
1284
+ """Return array literal value."""
1285
+ value = inputs.get("_literalValue", [])
1286
+ if isinstance(value, list):
1287
+ return value
1288
+ return []
1289
+
1290
+
1291
+ def tools_allowlist(inputs: Dict[str, Any]) -> Dict[str, Any]:
1292
+ """Return a workflow-scope tool allowlist as a named output.
1293
+
1294
+ The visual editor stores the selected tools as a JSON array of strings in the
1295
+ node's `literalValue`. The executor injects it as `_literalValue`.
1296
+ """
1297
+ value = inputs.get("_literalValue", [])
1298
+ if not isinstance(value, list):
1299
+ return {"tools": []}
1300
+ out: list[str] = []
1301
+ for x in value:
1302
+ if isinstance(x, str) and x.strip():
1303
+ out.append(x.strip())
1304
+ # Preserve order; remove duplicates.
1305
+ seen: set[str] = set()
1306
+ uniq: list[str] = []
1307
+ for t in out:
1308
+ if t in seen:
1309
+ continue
1310
+ seen.add(t)
1311
+ uniq.append(t)
1312
+ return {"tools": uniq}
1313
+
1314
+
1315
+ # Handler registry
1316
+ BUILTIN_HANDLERS: Dict[str, Callable[[Dict[str, Any]], Any]] = {
1317
+ # Math
1318
+ "add": math_add,
1319
+ "subtract": math_subtract,
1320
+ "multiply": math_multiply,
1321
+ "divide": math_divide,
1322
+ "modulo": math_modulo,
1323
+ "power": math_power,
1324
+ "abs": math_abs,
1325
+ "round": math_round,
1326
+ "random_int": math_random_int,
1327
+ "random_float": math_random_float,
1328
+ # String
1329
+ "concat": string_concat,
1330
+ "split": string_split,
1331
+ "join": string_join,
1332
+ "format": string_format,
1333
+ "string_template": string_template,
1334
+ "uppercase": string_uppercase,
1335
+ "lowercase": string_lowercase,
1336
+ "trim": string_trim,
1337
+ "substring": string_substring,
1338
+ "length": string_length,
1339
+ "contains": string_contains,
1340
+ "replace": string_replace,
1341
+ # Control
1342
+ "compare": control_compare,
1343
+ "not": control_not,
1344
+ "and": control_and,
1345
+ "or": control_or,
1346
+ "coalesce": control_coalesce,
1347
+ # Data
1348
+ "get": data_get,
1349
+ "set": data_set,
1350
+ "merge": data_merge,
1351
+ "make_object": data_make_object,
1352
+ "make_context": data_make_context,
1353
+ "add_message": data_add_message,
1354
+ "make_meta": data_make_meta,
1355
+ "make_scratchpad": data_make_scratchpad,
1356
+ "get_element": data_get_element,
1357
+ "get_random_element": data_get_random_element,
1358
+ "array_map": data_array_map,
1359
+ "array_filter": data_array_filter,
1360
+ "array_length": data_array_length,
1361
+ "has_tools": data_has_tools,
1362
+ "array_append": data_array_append,
1363
+ "array_dedup": data_array_dedup,
1364
+ "parse_json": data_parse_json,
1365
+ "stringify_json": data_stringify_json,
1366
+ "format_tool_results": data_format_tool_results,
1367
+ "agent_trace_report": data_agent_trace_report,
1368
+ "system_datetime": system_datetime,
1369
+ # Literals
1370
+ "literal_string": literal_string,
1371
+ "literal_number": literal_number,
1372
+ "literal_boolean": literal_boolean,
1373
+ "literal_json": literal_json,
1374
+ "literal_array": literal_array,
1375
+ "tools_allowlist": tools_allowlist,
1376
+ }