AbstractRuntime 0.2.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.
- abstractruntime/__init__.py +83 -3
- abstractruntime/core/config.py +82 -2
- abstractruntime/core/event_keys.py +62 -0
- abstractruntime/core/models.py +17 -1
- abstractruntime/core/policy.py +74 -3
- abstractruntime/core/runtime.py +3334 -28
- abstractruntime/core/vars.py +103 -2
- abstractruntime/evidence/__init__.py +10 -0
- abstractruntime/evidence/recorder.py +325 -0
- abstractruntime/history_bundle.py +772 -0
- abstractruntime/integrations/abstractcore/__init__.py +6 -0
- abstractruntime/integrations/abstractcore/constants.py +19 -0
- abstractruntime/integrations/abstractcore/default_tools.py +258 -0
- abstractruntime/integrations/abstractcore/effect_handlers.py +2622 -32
- abstractruntime/integrations/abstractcore/embeddings_client.py +69 -0
- abstractruntime/integrations/abstractcore/factory.py +149 -16
- abstractruntime/integrations/abstractcore/llm_client.py +891 -55
- abstractruntime/integrations/abstractcore/mcp_worker.py +587 -0
- abstractruntime/integrations/abstractcore/observability.py +80 -0
- abstractruntime/integrations/abstractcore/session_attachments.py +946 -0
- abstractruntime/integrations/abstractcore/summarizer.py +154 -0
- abstractruntime/integrations/abstractcore/tool_executor.py +509 -31
- abstractruntime/integrations/abstractcore/workspace_scoped_tools.py +561 -0
- abstractruntime/integrations/abstractmemory/__init__.py +3 -0
- abstractruntime/integrations/abstractmemory/effect_handlers.py +946 -0
- abstractruntime/memory/__init__.py +21 -0
- abstractruntime/memory/active_context.py +751 -0
- abstractruntime/memory/active_memory.py +452 -0
- abstractruntime/memory/compaction.py +105 -0
- abstractruntime/memory/kg_packets.py +164 -0
- abstractruntime/memory/memact_composer.py +175 -0
- abstractruntime/memory/recall_levels.py +163 -0
- abstractruntime/memory/token_budget.py +86 -0
- abstractruntime/rendering/__init__.py +17 -0
- abstractruntime/rendering/agent_trace_report.py +256 -0
- abstractruntime/rendering/json_stringify.py +136 -0
- abstractruntime/scheduler/scheduler.py +93 -2
- abstractruntime/storage/__init__.py +7 -2
- abstractruntime/storage/artifacts.py +175 -32
- abstractruntime/storage/base.py +17 -1
- abstractruntime/storage/commands.py +339 -0
- abstractruntime/storage/in_memory.py +41 -1
- abstractruntime/storage/json_files.py +210 -14
- abstractruntime/storage/observable.py +136 -0
- abstractruntime/storage/offloading.py +433 -0
- abstractruntime/storage/sqlite.py +836 -0
- abstractruntime/visualflow_compiler/__init__.py +29 -0
- abstractruntime/visualflow_compiler/adapters/__init__.py +11 -0
- abstractruntime/visualflow_compiler/adapters/agent_adapter.py +126 -0
- abstractruntime/visualflow_compiler/adapters/context_adapter.py +109 -0
- abstractruntime/visualflow_compiler/adapters/control_adapter.py +615 -0
- abstractruntime/visualflow_compiler/adapters/effect_adapter.py +1051 -0
- abstractruntime/visualflow_compiler/adapters/event_adapter.py +307 -0
- abstractruntime/visualflow_compiler/adapters/function_adapter.py +97 -0
- abstractruntime/visualflow_compiler/adapters/memact_adapter.py +114 -0
- abstractruntime/visualflow_compiler/adapters/subflow_adapter.py +74 -0
- abstractruntime/visualflow_compiler/adapters/variable_adapter.py +316 -0
- abstractruntime/visualflow_compiler/compiler.py +3832 -0
- abstractruntime/visualflow_compiler/flow.py +247 -0
- abstractruntime/visualflow_compiler/visual/__init__.py +13 -0
- abstractruntime/visualflow_compiler/visual/agent_ids.py +29 -0
- abstractruntime/visualflow_compiler/visual/builtins.py +1376 -0
- abstractruntime/visualflow_compiler/visual/code_executor.py +214 -0
- abstractruntime/visualflow_compiler/visual/executor.py +2804 -0
- abstractruntime/visualflow_compiler/visual/models.py +211 -0
- abstractruntime/workflow_bundle/__init__.py +52 -0
- abstractruntime/workflow_bundle/models.py +236 -0
- abstractruntime/workflow_bundle/packer.py +317 -0
- abstractruntime/workflow_bundle/reader.py +87 -0
- abstractruntime/workflow_bundle/registry.py +587 -0
- abstractruntime-0.4.1.dist-info/METADATA +177 -0
- abstractruntime-0.4.1.dist-info/RECORD +86 -0
- abstractruntime-0.4.1.dist-info/entry_points.txt +2 -0
- abstractruntime-0.2.0.dist-info/METADATA +0 -163
- abstractruntime-0.2.0.dist-info/RECORD +0 -32
- {abstractruntime-0.2.0.dist-info → abstractruntime-0.4.1.dist-info}/WHEEL +0 -0
- {abstractruntime-0.2.0.dist-info → abstractruntime-0.4.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,3832 @@
|
|
|
1
|
+
"""Flow compiler - converts Flow definitions to AbstractRuntime WorkflowSpec."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple, TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from .flow import Flow
|
|
8
|
+
from .adapters.function_adapter import create_function_node_handler
|
|
9
|
+
from .adapters.agent_adapter import create_agent_node_handler
|
|
10
|
+
from .adapters.subflow_adapter import create_subflow_node_handler
|
|
11
|
+
from .adapters.effect_adapter import (
|
|
12
|
+
create_ask_user_handler,
|
|
13
|
+
create_answer_user_handler,
|
|
14
|
+
create_wait_until_handler,
|
|
15
|
+
create_wait_event_handler,
|
|
16
|
+
create_memory_kg_assert_handler,
|
|
17
|
+
create_memory_kg_query_handler,
|
|
18
|
+
create_memory_kg_resolve_handler,
|
|
19
|
+
create_memory_note_handler,
|
|
20
|
+
create_memory_query_handler,
|
|
21
|
+
create_memory_tag_handler,
|
|
22
|
+
create_memory_compact_handler,
|
|
23
|
+
create_memory_rehydrate_handler,
|
|
24
|
+
create_llm_call_handler,
|
|
25
|
+
create_tool_calls_handler,
|
|
26
|
+
create_call_tool_handler,
|
|
27
|
+
create_start_subworkflow_handler,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from abstractruntime.core.models import StepPlan
|
|
32
|
+
from abstractruntime.core.spec import WorkflowSpec
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _is_agent(obj: Any) -> bool:
|
|
36
|
+
"""Check if object is an agent (has workflow attribute)."""
|
|
37
|
+
return hasattr(obj, "workflow") and hasattr(obj, "start") and hasattr(obj, "step")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _is_flow(obj: Any) -> bool:
|
|
41
|
+
"""Check if object behaves like a Flow.
|
|
42
|
+
|
|
43
|
+
Accept duck-typed Flow implementations (e.g. AbstractFlow's Flow class)
|
|
44
|
+
so hosts can compile nested flows without depending on a specific class
|
|
45
|
+
identity.
|
|
46
|
+
"""
|
|
47
|
+
if isinstance(obj, Flow):
|
|
48
|
+
return True
|
|
49
|
+
try:
|
|
50
|
+
flow_id = getattr(obj, "flow_id", None)
|
|
51
|
+
nodes = getattr(obj, "nodes", None)
|
|
52
|
+
edges = getattr(obj, "edges", None)
|
|
53
|
+
validate = getattr(obj, "validate", None)
|
|
54
|
+
return (
|
|
55
|
+
isinstance(flow_id, str)
|
|
56
|
+
and isinstance(nodes, dict)
|
|
57
|
+
and isinstance(edges, list)
|
|
58
|
+
and callable(validate)
|
|
59
|
+
)
|
|
60
|
+
except Exception:
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _create_effect_node_handler(
|
|
65
|
+
node_id: str,
|
|
66
|
+
effect_type: str,
|
|
67
|
+
effect_config: Dict[str, Any],
|
|
68
|
+
next_node: Optional[str],
|
|
69
|
+
input_key: Optional[str],
|
|
70
|
+
output_key: Optional[str],
|
|
71
|
+
data_aware_handler: Optional[Callable] = None,
|
|
72
|
+
*,
|
|
73
|
+
flow: Optional[Flow] = None,
|
|
74
|
+
) -> Callable:
|
|
75
|
+
"""Create a node handler for effect nodes.
|
|
76
|
+
|
|
77
|
+
Effect nodes produce AbstractRuntime Effects that can pause execution
|
|
78
|
+
and wait for external input.
|
|
79
|
+
|
|
80
|
+
If data_aware_handler is provided (from visual flow's executor), it will
|
|
81
|
+
be called first to resolve data edge inputs before creating the effect.
|
|
82
|
+
"""
|
|
83
|
+
from abstractruntime.core.models import StepPlan, Effect, EffectType
|
|
84
|
+
|
|
85
|
+
# Build the base effect handler
|
|
86
|
+
if effect_type == "ask_user":
|
|
87
|
+
base_handler = create_ask_user_handler(
|
|
88
|
+
node_id=node_id,
|
|
89
|
+
next_node=next_node,
|
|
90
|
+
input_key=input_key,
|
|
91
|
+
output_key=output_key,
|
|
92
|
+
allow_free_text=effect_config.get("allowFreeText", True),
|
|
93
|
+
)
|
|
94
|
+
elif effect_type == "answer_user":
|
|
95
|
+
base_handler = create_answer_user_handler(
|
|
96
|
+
node_id=node_id,
|
|
97
|
+
next_node=next_node,
|
|
98
|
+
input_key=input_key,
|
|
99
|
+
output_key=output_key,
|
|
100
|
+
)
|
|
101
|
+
elif effect_type == "wait_until":
|
|
102
|
+
base_handler = create_wait_until_handler(
|
|
103
|
+
node_id=node_id,
|
|
104
|
+
next_node=next_node,
|
|
105
|
+
input_key=input_key,
|
|
106
|
+
output_key=output_key,
|
|
107
|
+
duration_type=effect_config.get("durationType", "seconds"),
|
|
108
|
+
)
|
|
109
|
+
elif effect_type == "wait_event":
|
|
110
|
+
base_handler = create_wait_event_handler(
|
|
111
|
+
node_id=node_id,
|
|
112
|
+
next_node=next_node,
|
|
113
|
+
input_key=input_key,
|
|
114
|
+
output_key=output_key,
|
|
115
|
+
)
|
|
116
|
+
elif effect_type == "memory_note":
|
|
117
|
+
base_handler = create_memory_note_handler(
|
|
118
|
+
node_id=node_id,
|
|
119
|
+
next_node=next_node,
|
|
120
|
+
input_key=input_key,
|
|
121
|
+
output_key=output_key,
|
|
122
|
+
)
|
|
123
|
+
elif effect_type == "memory_query":
|
|
124
|
+
base_handler = create_memory_query_handler(
|
|
125
|
+
node_id=node_id,
|
|
126
|
+
next_node=next_node,
|
|
127
|
+
input_key=input_key,
|
|
128
|
+
output_key=output_key,
|
|
129
|
+
)
|
|
130
|
+
elif effect_type == "memory_tag":
|
|
131
|
+
base_handler = create_memory_tag_handler(
|
|
132
|
+
node_id=node_id,
|
|
133
|
+
next_node=next_node,
|
|
134
|
+
input_key=input_key,
|
|
135
|
+
output_key=output_key,
|
|
136
|
+
)
|
|
137
|
+
elif effect_type == "memory_compact":
|
|
138
|
+
base_handler = create_memory_compact_handler(
|
|
139
|
+
node_id=node_id,
|
|
140
|
+
next_node=next_node,
|
|
141
|
+
input_key=input_key,
|
|
142
|
+
output_key=output_key,
|
|
143
|
+
)
|
|
144
|
+
elif effect_type == "memory_rehydrate":
|
|
145
|
+
base_handler = create_memory_rehydrate_handler(
|
|
146
|
+
node_id=node_id,
|
|
147
|
+
next_node=next_node,
|
|
148
|
+
input_key=input_key,
|
|
149
|
+
output_key=output_key,
|
|
150
|
+
)
|
|
151
|
+
elif effect_type == "memory_kg_assert":
|
|
152
|
+
base_handler = create_memory_kg_assert_handler(
|
|
153
|
+
node_id=node_id,
|
|
154
|
+
next_node=next_node,
|
|
155
|
+
input_key=input_key,
|
|
156
|
+
output_key=output_key,
|
|
157
|
+
)
|
|
158
|
+
elif effect_type == "memory_kg_query":
|
|
159
|
+
base_handler = create_memory_kg_query_handler(
|
|
160
|
+
node_id=node_id,
|
|
161
|
+
next_node=next_node,
|
|
162
|
+
input_key=input_key,
|
|
163
|
+
output_key=output_key,
|
|
164
|
+
)
|
|
165
|
+
elif effect_type == "memory_kg_resolve":
|
|
166
|
+
base_handler = create_memory_kg_resolve_handler(
|
|
167
|
+
node_id=node_id,
|
|
168
|
+
next_node=next_node,
|
|
169
|
+
input_key=input_key,
|
|
170
|
+
output_key=output_key,
|
|
171
|
+
)
|
|
172
|
+
elif effect_type == "llm_call":
|
|
173
|
+
base_handler = create_llm_call_handler(
|
|
174
|
+
node_id=node_id,
|
|
175
|
+
next_node=next_node,
|
|
176
|
+
input_key=input_key,
|
|
177
|
+
output_key=output_key,
|
|
178
|
+
provider=effect_config.get("provider"),
|
|
179
|
+
model=effect_config.get("model"),
|
|
180
|
+
temperature=effect_config.get("temperature", 0.7),
|
|
181
|
+
seed=effect_config.get("seed", -1),
|
|
182
|
+
)
|
|
183
|
+
elif effect_type == "tool_calls":
|
|
184
|
+
base_handler = create_tool_calls_handler(
|
|
185
|
+
node_id=node_id,
|
|
186
|
+
next_node=next_node,
|
|
187
|
+
input_key=input_key,
|
|
188
|
+
output_key=output_key,
|
|
189
|
+
allowed_tools=effect_config.get("allowed_tools") if isinstance(effect_config, dict) else None,
|
|
190
|
+
)
|
|
191
|
+
elif effect_type == "call_tool":
|
|
192
|
+
base_handler = create_call_tool_handler(
|
|
193
|
+
node_id=node_id,
|
|
194
|
+
next_node=next_node,
|
|
195
|
+
input_key=input_key,
|
|
196
|
+
output_key=output_key,
|
|
197
|
+
allowed_tools=effect_config.get("allowed_tools") if isinstance(effect_config, dict) else None,
|
|
198
|
+
)
|
|
199
|
+
elif effect_type == "start_subworkflow":
|
|
200
|
+
base_handler = create_start_subworkflow_handler(
|
|
201
|
+
node_id=node_id,
|
|
202
|
+
next_node=next_node,
|
|
203
|
+
input_key=input_key,
|
|
204
|
+
output_key=output_key,
|
|
205
|
+
workflow_id=effect_config.get("workflow_id"),
|
|
206
|
+
)
|
|
207
|
+
else:
|
|
208
|
+
raise ValueError(f"Unknown effect type: {effect_type}")
|
|
209
|
+
|
|
210
|
+
# If no data-aware handler, just return the base effect handler
|
|
211
|
+
if data_aware_handler is None:
|
|
212
|
+
return base_handler
|
|
213
|
+
|
|
214
|
+
# Wrap to resolve data edges before creating the effect
|
|
215
|
+
def wrapped_effect_handler(run: Any, ctx: Any) -> "StepPlan":
|
|
216
|
+
"""Resolve data edges via executor handler, then create the proper Effect."""
|
|
217
|
+
if flow is not None and hasattr(flow, "_node_outputs") and hasattr(flow, "_data_edge_map"):
|
|
218
|
+
_sync_effect_results_to_node_outputs(run, flow)
|
|
219
|
+
|
|
220
|
+
# Call the data-aware handler to resolve data edge inputs
|
|
221
|
+
# This reads from flow._node_outputs which has literal values
|
|
222
|
+
last_output = run.vars.get("_last_output", {})
|
|
223
|
+
resolved = data_aware_handler(last_output)
|
|
224
|
+
|
|
225
|
+
# Check if this returned a _pending_effect marker (from executor's effect handlers)
|
|
226
|
+
if isinstance(resolved, dict) and "_pending_effect" in resolved:
|
|
227
|
+
pending = resolved["_pending_effect"]
|
|
228
|
+
effect_type_str = pending.get("type", "")
|
|
229
|
+
|
|
230
|
+
# Get the EffectType enum value by name (avoid building dict with all members)
|
|
231
|
+
eff_type = None
|
|
232
|
+
try:
|
|
233
|
+
eff_type = EffectType(effect_type_str)
|
|
234
|
+
except ValueError:
|
|
235
|
+
pass # Unknown effect type
|
|
236
|
+
if eff_type:
|
|
237
|
+
# ------------------------------------------------------------
|
|
238
|
+
# 467: Memory-source access pins for Visual LLM Call nodes
|
|
239
|
+
# ------------------------------------------------------------
|
|
240
|
+
#
|
|
241
|
+
# Visual executor passes memory controls through on the pending effect dict.
|
|
242
|
+
# Here we:
|
|
243
|
+
# - schedule runtime-owned MEMORY_* effects before the LLM call
|
|
244
|
+
# - inject KG Active Memory into the call (bounded, deterministic)
|
|
245
|
+
# - map session attachment index pin -> LLM params override
|
|
246
|
+
#
|
|
247
|
+
# IMPORTANT:
|
|
248
|
+
# - Pre-call effects MUST NOT write to `_temp.effects.{node_id}` because that
|
|
249
|
+
# slot is reserved for the LLM_CALL outcome and is synced to node outputs.
|
|
250
|
+
if eff_type == EffectType.LLM_CALL and isinstance(pending, dict):
|
|
251
|
+
try:
|
|
252
|
+
import hashlib
|
|
253
|
+
import json
|
|
254
|
+
|
|
255
|
+
def _ensure_dict(parent: Any, key: str) -> dict:
|
|
256
|
+
if not isinstance(parent, dict):
|
|
257
|
+
return {}
|
|
258
|
+
cur = parent.get(key)
|
|
259
|
+
if not isinstance(cur, dict):
|
|
260
|
+
cur = {}
|
|
261
|
+
parent[key] = cur
|
|
262
|
+
return cur
|
|
263
|
+
|
|
264
|
+
def _coerce_boolish(value: Any) -> Optional[bool]:
|
|
265
|
+
if value is None:
|
|
266
|
+
return None
|
|
267
|
+
if isinstance(value, bool):
|
|
268
|
+
return bool(value)
|
|
269
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
270
|
+
try:
|
|
271
|
+
return float(value) != 0.0
|
|
272
|
+
except Exception:
|
|
273
|
+
return None
|
|
274
|
+
if isinstance(value, str):
|
|
275
|
+
s = value.strip().lower()
|
|
276
|
+
if not s:
|
|
277
|
+
return None
|
|
278
|
+
if s in {"false", "0", "no", "off"}:
|
|
279
|
+
return False
|
|
280
|
+
if s in {"true", "1", "yes", "on"}:
|
|
281
|
+
return True
|
|
282
|
+
return None
|
|
283
|
+
|
|
284
|
+
def _coerce_int(value: Any) -> Optional[int]:
|
|
285
|
+
if value is None or isinstance(value, bool):
|
|
286
|
+
return None
|
|
287
|
+
try:
|
|
288
|
+
iv = int(float(value))
|
|
289
|
+
except Exception:
|
|
290
|
+
return None
|
|
291
|
+
return iv
|
|
292
|
+
|
|
293
|
+
def _coerce_float(value: Any) -> Optional[float]:
|
|
294
|
+
if value is None or isinstance(value, bool):
|
|
295
|
+
return None
|
|
296
|
+
try:
|
|
297
|
+
fv = float(value)
|
|
298
|
+
except Exception:
|
|
299
|
+
return None
|
|
300
|
+
if not (fv == fv): # NaN
|
|
301
|
+
return None
|
|
302
|
+
return fv
|
|
303
|
+
|
|
304
|
+
def _nonempty_str(value: Any) -> str:
|
|
305
|
+
if not isinstance(value, str):
|
|
306
|
+
return ""
|
|
307
|
+
return value.strip()
|
|
308
|
+
|
|
309
|
+
def _hash_fingerprint(obj: dict) -> str:
|
|
310
|
+
try:
|
|
311
|
+
raw = json.dumps(obj, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
312
|
+
except Exception:
|
|
313
|
+
raw = str(obj)
|
|
314
|
+
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:32]
|
|
315
|
+
|
|
316
|
+
temp = _ensure_dict(run.vars, "_temp")
|
|
317
|
+
memory_sources = _ensure_dict(temp, "memory_sources")
|
|
318
|
+
bucket = memory_sources.get(node_id)
|
|
319
|
+
if not isinstance(bucket, dict):
|
|
320
|
+
bucket = {}
|
|
321
|
+
memory_sources[node_id] = bucket
|
|
322
|
+
meta = bucket.get("_meta")
|
|
323
|
+
if not isinstance(meta, dict):
|
|
324
|
+
meta = {}
|
|
325
|
+
bucket["_meta"] = meta
|
|
326
|
+
|
|
327
|
+
# Memory config (tri-state booleans: absent => no override).
|
|
328
|
+
use_span_memory = _coerce_boolish(pending.get("use_span_memory")) if "use_span_memory" in pending else None
|
|
329
|
+
use_kg_memory = _coerce_boolish(pending.get("use_kg_memory")) if "use_kg_memory" in pending else None
|
|
330
|
+
use_semantic_search = (
|
|
331
|
+
_coerce_boolish(pending.get("use_semantic_search")) if "use_semantic_search" in pending else None
|
|
332
|
+
)
|
|
333
|
+
use_session_attachments = (
|
|
334
|
+
_coerce_boolish(pending.get("use_session_attachments")) if "use_session_attachments" in pending else None
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
recall_level = _nonempty_str(pending.get("recall_level"))
|
|
338
|
+
memory_scope = _nonempty_str(pending.get("memory_scope")) or "run"
|
|
339
|
+
|
|
340
|
+
# Derive query text (best-effort) from pins or the call prompt.
|
|
341
|
+
query_text = _nonempty_str(pending.get("memory_query"))
|
|
342
|
+
if not query_text:
|
|
343
|
+
query_text = _nonempty_str(pending.get("prompt"))
|
|
344
|
+
if not query_text and isinstance(pending.get("messages"), list):
|
|
345
|
+
# Fallback: last user message in a message-based call.
|
|
346
|
+
for m in reversed(list(pending.get("messages") or [])):
|
|
347
|
+
if not isinstance(m, dict):
|
|
348
|
+
continue
|
|
349
|
+
if m.get("role") != "user":
|
|
350
|
+
continue
|
|
351
|
+
c = m.get("content")
|
|
352
|
+
if isinstance(c, str) and c.strip():
|
|
353
|
+
query_text = c.strip()
|
|
354
|
+
break
|
|
355
|
+
|
|
356
|
+
# Re-entry / loop safety:
|
|
357
|
+
# If the node is executed again with different inputs, clear the per-node
|
|
358
|
+
# memory-source bucket so we don't reuse stale recall results.
|
|
359
|
+
fp = _hash_fingerprint(
|
|
360
|
+
{
|
|
361
|
+
"prompt": _nonempty_str(pending.get("prompt")),
|
|
362
|
+
"system_prompt": _nonempty_str(pending.get("system_prompt")),
|
|
363
|
+
"provider": _nonempty_str(pending.get("provider")),
|
|
364
|
+
"model": _nonempty_str(pending.get("model")),
|
|
365
|
+
"memory_query": query_text,
|
|
366
|
+
"memory_scope": memory_scope,
|
|
367
|
+
"recall_level": recall_level,
|
|
368
|
+
"use_span_memory": use_span_memory,
|
|
369
|
+
"use_kg_memory": use_kg_memory,
|
|
370
|
+
"use_semantic_search": use_semantic_search,
|
|
371
|
+
"use_session_attachments": use_session_attachments,
|
|
372
|
+
"max_span_messages": _coerce_int(pending.get("max_span_messages")),
|
|
373
|
+
"kg_max_input_tokens": _coerce_int(pending.get("kg_max_input_tokens")),
|
|
374
|
+
"kg_limit": _coerce_int(pending.get("kg_limit")),
|
|
375
|
+
"kg_min_score": _coerce_float(pending.get("kg_min_score")),
|
|
376
|
+
}
|
|
377
|
+
)
|
|
378
|
+
if meta.get("fingerprint") != fp:
|
|
379
|
+
try:
|
|
380
|
+
bucket.clear()
|
|
381
|
+
except Exception:
|
|
382
|
+
bucket = {}
|
|
383
|
+
memory_sources[node_id] = bucket
|
|
384
|
+
meta = {"fingerprint": fp}
|
|
385
|
+
bucket["_meta"] = meta
|
|
386
|
+
|
|
387
|
+
base_key = f"_temp.memory_sources.{node_id}"
|
|
388
|
+
|
|
389
|
+
# Reserved: semantic search over artifacts is planned (464). Avoid silently doing nothing.
|
|
390
|
+
if use_semantic_search is True:
|
|
391
|
+
warnings = meta.get("warnings")
|
|
392
|
+
if not isinstance(warnings, list):
|
|
393
|
+
warnings = []
|
|
394
|
+
meta["warnings"] = warnings
|
|
395
|
+
msg = "use_semantic_search is not implemented yet (planned: 464); ignoring."
|
|
396
|
+
if msg not in warnings:
|
|
397
|
+
warnings.append(msg)
|
|
398
|
+
|
|
399
|
+
# Phase 1: span-index recall (metadata)
|
|
400
|
+
if use_span_memory is True and "span_query" not in bucket and query_text:
|
|
401
|
+
mq_payload: Dict[str, Any] = {"query": query_text, "scope": memory_scope, "return": "meta"}
|
|
402
|
+
if recall_level:
|
|
403
|
+
mq_payload["recall_level"] = recall_level
|
|
404
|
+
return StepPlan(
|
|
405
|
+
node_id=node_id,
|
|
406
|
+
effect=Effect(
|
|
407
|
+
type=EffectType.MEMORY_QUERY,
|
|
408
|
+
payload=mq_payload,
|
|
409
|
+
result_key=f"{base_key}.span_query",
|
|
410
|
+
),
|
|
411
|
+
next_node=node_id,
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
# Phase 2: span rehydration into context.messages
|
|
415
|
+
if use_span_memory is True and "span_rehydrate" not in bucket:
|
|
416
|
+
span_ids: list[str] = []
|
|
417
|
+
span_query = bucket.get("span_query")
|
|
418
|
+
if isinstance(span_query, dict):
|
|
419
|
+
results = span_query.get("results")
|
|
420
|
+
if isinstance(results, list) and results:
|
|
421
|
+
first = results[0] if isinstance(results[0], dict) else None
|
|
422
|
+
meta2 = first.get("meta") if isinstance(first, dict) else None
|
|
423
|
+
if isinstance(meta2, dict):
|
|
424
|
+
raw_ids = meta2.get("span_ids")
|
|
425
|
+
if isinstance(raw_ids, list):
|
|
426
|
+
span_ids = [str(x).strip() for x in raw_ids if str(x).strip()]
|
|
427
|
+
meta["span_ids"] = span_ids
|
|
428
|
+
|
|
429
|
+
mr_payload: Dict[str, Any] = {"span_ids": span_ids, "placement": "after_summary"}
|
|
430
|
+
if recall_level:
|
|
431
|
+
mr_payload["recall_level"] = recall_level
|
|
432
|
+
max_span_messages = _coerce_int(pending.get("max_span_messages"))
|
|
433
|
+
if max_span_messages is None and not recall_level:
|
|
434
|
+
# Safe default (explicit warning): without recall_level, MEMORY_REHYDRATE would
|
|
435
|
+
# otherwise insert all messages from selected spans.
|
|
436
|
+
max_span_messages = 80
|
|
437
|
+
warnings = meta.get("warnings")
|
|
438
|
+
if not isinstance(warnings, list):
|
|
439
|
+
warnings = []
|
|
440
|
+
meta["warnings"] = warnings
|
|
441
|
+
msg = "use_span_memory enabled without recall_level/max_span_messages; defaulting max_span_messages=80."
|
|
442
|
+
if msg not in warnings:
|
|
443
|
+
warnings.append(msg)
|
|
444
|
+
if isinstance(max_span_messages, int):
|
|
445
|
+
mr_payload["max_messages"] = max_span_messages
|
|
446
|
+
|
|
447
|
+
return StepPlan(
|
|
448
|
+
node_id=node_id,
|
|
449
|
+
effect=Effect(
|
|
450
|
+
type=EffectType.MEMORY_REHYDRATE,
|
|
451
|
+
payload=mr_payload,
|
|
452
|
+
result_key=f"{base_key}.span_rehydrate",
|
|
453
|
+
),
|
|
454
|
+
next_node=node_id,
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
# Phase 3: KG recall (packetized Active Memory)
|
|
458
|
+
if use_kg_memory is True and "kg_query" not in bucket and query_text:
|
|
459
|
+
kg_payload: Dict[str, Any] = {"query_text": query_text, "scope": memory_scope}
|
|
460
|
+
if recall_level:
|
|
461
|
+
kg_payload["recall_level"] = recall_level
|
|
462
|
+
kg_min_score = _coerce_float(pending.get("kg_min_score"))
|
|
463
|
+
if isinstance(kg_min_score, float):
|
|
464
|
+
kg_payload["min_score"] = kg_min_score
|
|
465
|
+
kg_limit = _coerce_int(pending.get("kg_limit"))
|
|
466
|
+
if isinstance(kg_limit, int):
|
|
467
|
+
kg_payload["limit"] = kg_limit
|
|
468
|
+
kg_budget = _coerce_int(pending.get("kg_max_input_tokens"))
|
|
469
|
+
if kg_budget is None and not recall_level:
|
|
470
|
+
# Safe default (explicit warning): ensure we actually get `active_memory_text`
|
|
471
|
+
# even when recall_level is not set.
|
|
472
|
+
kg_budget = 1200
|
|
473
|
+
warnings = meta.get("warnings")
|
|
474
|
+
if not isinstance(warnings, list):
|
|
475
|
+
warnings = []
|
|
476
|
+
meta["warnings"] = warnings
|
|
477
|
+
msg = "use_kg_memory enabled without recall_level/kg_max_input_tokens; defaulting kg_max_input_tokens=1200."
|
|
478
|
+
if msg not in warnings:
|
|
479
|
+
warnings.append(msg)
|
|
480
|
+
if isinstance(kg_budget, int):
|
|
481
|
+
kg_payload["max_input_tokens"] = kg_budget
|
|
482
|
+
model_name = pending.get("model")
|
|
483
|
+
if isinstance(model_name, str) and model_name.strip():
|
|
484
|
+
kg_payload["model"] = model_name.strip()
|
|
485
|
+
|
|
486
|
+
return StepPlan(
|
|
487
|
+
node_id=node_id,
|
|
488
|
+
effect=Effect(
|
|
489
|
+
type=EffectType.MEMORY_KG_QUERY,
|
|
490
|
+
payload=kg_payload,
|
|
491
|
+
result_key=f"{base_key}.kg_query",
|
|
492
|
+
),
|
|
493
|
+
next_node=node_id,
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
# Map session attachment index pin -> params override (explicit, auditable).
|
|
497
|
+
if use_session_attachments is not None:
|
|
498
|
+
params = pending.get("params")
|
|
499
|
+
if not isinstance(params, dict):
|
|
500
|
+
params = {}
|
|
501
|
+
pending["params"] = params
|
|
502
|
+
params["include_session_attachments_index"] = bool(use_session_attachments)
|
|
503
|
+
|
|
504
|
+
# Inject KG Active Memory.
|
|
505
|
+
if use_kg_memory is True:
|
|
506
|
+
active_text = ""
|
|
507
|
+
kg_query = bucket.get("kg_query")
|
|
508
|
+
if isinstance(kg_query, dict):
|
|
509
|
+
am = kg_query.get("active_memory_text")
|
|
510
|
+
active_text = am.strip() if isinstance(am, str) else ""
|
|
511
|
+
if active_text:
|
|
512
|
+
meta["kg_active_memory_text_len"] = len(active_text)
|
|
513
|
+
|
|
514
|
+
# Avoid duplicating the same block when the node loops.
|
|
515
|
+
def _has_active_memory_block(msgs: list[Any]) -> bool:
|
|
516
|
+
for m in msgs:
|
|
517
|
+
if not isinstance(m, dict) or m.get("role") != "system":
|
|
518
|
+
continue
|
|
519
|
+
c = m.get("content")
|
|
520
|
+
if isinstance(c, str) and "## KG ACTIVE MEMORY" in c:
|
|
521
|
+
return True
|
|
522
|
+
return False
|
|
523
|
+
|
|
524
|
+
if isinstance(pending.get("messages"), list):
|
|
525
|
+
msgs = [dict(m) for m in pending.get("messages") if isinstance(m, dict)]
|
|
526
|
+
if not _has_active_memory_block(msgs):
|
|
527
|
+
insert_at = 0
|
|
528
|
+
while insert_at < len(msgs):
|
|
529
|
+
if msgs[insert_at].get("role") != "system":
|
|
530
|
+
break
|
|
531
|
+
insert_at += 1
|
|
532
|
+
msgs.insert(insert_at, {"role": "system", "content": active_text})
|
|
533
|
+
pending["messages"] = msgs
|
|
534
|
+
else:
|
|
535
|
+
sys_raw = pending.get("system_prompt") or pending.get("system")
|
|
536
|
+
sys_text = sys_raw.strip() if isinstance(sys_raw, str) else ""
|
|
537
|
+
combined = f"{sys_text}\n\n{active_text}".strip() if sys_text else active_text
|
|
538
|
+
pending["system_prompt"] = combined
|
|
539
|
+
|
|
540
|
+
# Span rehydrate without conversation history:
|
|
541
|
+
# If span recall is enabled but include_context is false, inject the rehydrated
|
|
542
|
+
# messages as the call's message history so the current call can use them.
|
|
543
|
+
include_ctx = pending.get("include_context") is True or pending.get("use_context") is True
|
|
544
|
+
if use_span_memory is True and not include_ctx and "messages" not in pending:
|
|
545
|
+
span_ids = meta.get("span_ids")
|
|
546
|
+
span_ids_set = {str(x).strip() for x in span_ids if isinstance(x, str) and x.strip()} if isinstance(span_ids, list) else set()
|
|
547
|
+
recalled_msgs: list[Dict[str, Any]] = []
|
|
548
|
+
ctx_ns = run.vars.get("context") if isinstance(run.vars, dict) else None
|
|
549
|
+
raw_msgs = ctx_ns.get("messages") if isinstance(ctx_ns, dict) else None
|
|
550
|
+
if isinstance(raw_msgs, list) and span_ids_set:
|
|
551
|
+
for m_any in raw_msgs:
|
|
552
|
+
m = m_any if isinstance(m_any, dict) else None
|
|
553
|
+
if m is None:
|
|
554
|
+
continue
|
|
555
|
+
meta_m = m.get("metadata")
|
|
556
|
+
if not isinstance(meta_m, dict) or meta_m.get("rehydrated") is not True:
|
|
557
|
+
continue
|
|
558
|
+
src = meta_m.get("source_artifact_id")
|
|
559
|
+
src_id = str(src).strip() if isinstance(src, str) else ""
|
|
560
|
+
if src_id and src_id in span_ids_set:
|
|
561
|
+
recalled_msgs.append(dict(m))
|
|
562
|
+
|
|
563
|
+
if recalled_msgs:
|
|
564
|
+
sys_raw = pending.get("system_prompt") or pending.get("system")
|
|
565
|
+
sys_text = sys_raw.strip() if isinstance(sys_raw, str) else ""
|
|
566
|
+
prompt_raw = pending.get("prompt")
|
|
567
|
+
prompt_text = prompt_raw if isinstance(prompt_raw, str) else str(prompt_raw or "")
|
|
568
|
+
|
|
569
|
+
msgs: list[Dict[str, Any]] = []
|
|
570
|
+
if sys_text:
|
|
571
|
+
msgs.append({"role": "system", "content": sys_text})
|
|
572
|
+
msgs.extend(recalled_msgs)
|
|
573
|
+
msgs.append({"role": "user", "content": prompt_text})
|
|
574
|
+
|
|
575
|
+
# Best-effort token trim when an explicit max_input_tokens budget is provided.
|
|
576
|
+
try:
|
|
577
|
+
raw_max_in = pending.get("max_input_tokens")
|
|
578
|
+
max_in = int(raw_max_in) if raw_max_in is not None and not isinstance(raw_max_in, bool) else None
|
|
579
|
+
except Exception:
|
|
580
|
+
max_in = None
|
|
581
|
+
if isinstance(max_in, int) and max_in > 0:
|
|
582
|
+
try:
|
|
583
|
+
from abstractruntime.memory.token_budget import trim_messages_to_max_input_tokens
|
|
584
|
+
|
|
585
|
+
model_name = (
|
|
586
|
+
pending.get("model") if isinstance(pending.get("model"), str) else None
|
|
587
|
+
)
|
|
588
|
+
msgs = trim_messages_to_max_input_tokens(
|
|
589
|
+
msgs, max_input_tokens=int(max_in), model=model_name
|
|
590
|
+
)
|
|
591
|
+
except Exception:
|
|
592
|
+
pass
|
|
593
|
+
|
|
594
|
+
pending["messages"] = msgs
|
|
595
|
+
pending.pop("prompt", None)
|
|
596
|
+
pending.pop("system_prompt", None)
|
|
597
|
+
pending.pop("system", None)
|
|
598
|
+
except Exception:
|
|
599
|
+
# Never fail compilation/execution due to optional memory-source wiring.
|
|
600
|
+
pass
|
|
601
|
+
|
|
602
|
+
# Visual LLM Call UX: include the run's active context messages when possible.
|
|
603
|
+
#
|
|
604
|
+
# Why here (compiler) and not in AbstractRuntime:
|
|
605
|
+
# - LLM_CALL is a generic runtime effect; not all callers want implicit context.
|
|
606
|
+
# - Visual LLM Call nodes expect "Recall into context" to affect subsequent calls.
|
|
607
|
+
if (
|
|
608
|
+
eff_type == EffectType.LLM_CALL
|
|
609
|
+
and isinstance(pending, dict)
|
|
610
|
+
and "messages" not in pending
|
|
611
|
+
and pending.get("include_context") is True
|
|
612
|
+
):
|
|
613
|
+
try:
|
|
614
|
+
from abstractruntime.memory.active_context import ActiveContextPolicy
|
|
615
|
+
|
|
616
|
+
base = ActiveContextPolicy.select_active_messages_for_llm_from_run(run)
|
|
617
|
+
messages = [dict(m) for m in base if isinstance(m, dict)]
|
|
618
|
+
|
|
619
|
+
sys_raw = pending.get("system_prompt") or pending.get("system")
|
|
620
|
+
sys_text = str(sys_raw).strip() if isinstance(sys_raw, str) else ""
|
|
621
|
+
if sys_text:
|
|
622
|
+
# Insert after existing system messages (system must precede user/assistant).
|
|
623
|
+
insert_at = 0
|
|
624
|
+
while insert_at < len(messages):
|
|
625
|
+
m = messages[insert_at]
|
|
626
|
+
if not isinstance(m, dict) or m.get("role") != "system":
|
|
627
|
+
break
|
|
628
|
+
insert_at += 1
|
|
629
|
+
messages.insert(insert_at, {"role": "system", "content": sys_text})
|
|
630
|
+
|
|
631
|
+
prompt_raw = pending.get("prompt")
|
|
632
|
+
prompt_text = prompt_raw if isinstance(prompt_raw, str) else str(prompt_raw or "")
|
|
633
|
+
messages.append({"role": "user", "content": prompt_text})
|
|
634
|
+
|
|
635
|
+
# Token-budget enforcement (ADR-0008):
|
|
636
|
+
# If `_limits.max_input_tokens` is set, trim the oldest non-system messages
|
|
637
|
+
# so the final LLM request remains bounded even when contexts grow large.
|
|
638
|
+
try:
|
|
639
|
+
limits = run.vars.get("_limits") if isinstance(run.vars, dict) else None
|
|
640
|
+
limits = limits if isinstance(limits, dict) else {}
|
|
641
|
+
raw_max_in = (
|
|
642
|
+
pending.get("max_input_tokens")
|
|
643
|
+
if isinstance(pending, dict) and "max_input_tokens" in pending
|
|
644
|
+
else limits.get("max_input_tokens")
|
|
645
|
+
)
|
|
646
|
+
if raw_max_in is not None and not isinstance(raw_max_in, bool):
|
|
647
|
+
max_in = int(raw_max_in)
|
|
648
|
+
else:
|
|
649
|
+
max_in = None
|
|
650
|
+
except Exception:
|
|
651
|
+
max_in = None
|
|
652
|
+
if isinstance(max_in, int) and max_in > 0:
|
|
653
|
+
try:
|
|
654
|
+
from abstractruntime.memory.token_budget import (
|
|
655
|
+
trim_messages_to_max_input_tokens,
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
model_name = (
|
|
659
|
+
pending.get("model") if isinstance(pending.get("model"), str) else None
|
|
660
|
+
)
|
|
661
|
+
messages = trim_messages_to_max_input_tokens(
|
|
662
|
+
messages, max_input_tokens=int(max_in), model=model_name
|
|
663
|
+
)
|
|
664
|
+
except Exception:
|
|
665
|
+
# Never fail compilation/execution due to token trimming.
|
|
666
|
+
pass
|
|
667
|
+
|
|
668
|
+
pending["messages"] = messages
|
|
669
|
+
# Avoid double-including prompt/system_prompt if the LLM client also
|
|
670
|
+
# builds messages from them.
|
|
671
|
+
pending.pop("prompt", None)
|
|
672
|
+
pending.pop("system_prompt", None)
|
|
673
|
+
pending.pop("system", None)
|
|
674
|
+
except Exception:
|
|
675
|
+
pass
|
|
676
|
+
|
|
677
|
+
# If the run has context attachments, forward them into the LLM call as `media`
|
|
678
|
+
# (unless the node/pins already provided an explicit `media` override).
|
|
679
|
+
if (
|
|
680
|
+
eff_type == EffectType.LLM_CALL
|
|
681
|
+
and isinstance(pending, dict)
|
|
682
|
+
and pending.get("include_context") is True
|
|
683
|
+
and "media" not in pending
|
|
684
|
+
):
|
|
685
|
+
try:
|
|
686
|
+
ctx2 = run.vars.get("context") if isinstance(run.vars, dict) else None
|
|
687
|
+
attachments_raw = ctx2.get("attachments") if isinstance(ctx2, dict) else None
|
|
688
|
+
if isinstance(attachments_raw, list) and attachments_raw:
|
|
689
|
+
cleaned: list[Any] = []
|
|
690
|
+
for a in attachments_raw:
|
|
691
|
+
if isinstance(a, dict):
|
|
692
|
+
cleaned.append(dict(a))
|
|
693
|
+
elif isinstance(a, str) and a.strip():
|
|
694
|
+
cleaned.append(a.strip())
|
|
695
|
+
if cleaned:
|
|
696
|
+
pending["media"] = cleaned
|
|
697
|
+
except Exception:
|
|
698
|
+
pass
|
|
699
|
+
|
|
700
|
+
# Visual Subflow UX: optionally seed the child run's `context.messages` from the
|
|
701
|
+
# parent run's active context view (so LLM/Agent nodes inside the subflow can
|
|
702
|
+
# "Use context" without extra wiring).
|
|
703
|
+
if (
|
|
704
|
+
eff_type == EffectType.START_SUBWORKFLOW
|
|
705
|
+
and isinstance(pending, dict)
|
|
706
|
+
and pending.get("inherit_context") is True
|
|
707
|
+
):
|
|
708
|
+
try:
|
|
709
|
+
from abstractruntime.memory.active_context import ActiveContextPolicy
|
|
710
|
+
|
|
711
|
+
inherited = ActiveContextPolicy.select_active_messages_for_llm_from_run(run)
|
|
712
|
+
inherited_msgs = [dict(m) for m in inherited if isinstance(m, dict)]
|
|
713
|
+
if inherited_msgs:
|
|
714
|
+
sub_vars = pending.get("vars")
|
|
715
|
+
if not isinstance(sub_vars, dict):
|
|
716
|
+
sub_vars = {}
|
|
717
|
+
sub_ctx = sub_vars.get("context")
|
|
718
|
+
if not isinstance(sub_ctx, dict):
|
|
719
|
+
sub_ctx = {}
|
|
720
|
+
sub_vars["context"] = sub_ctx
|
|
721
|
+
|
|
722
|
+
existing = sub_ctx.get("messages")
|
|
723
|
+
existing_msgs = [dict(m) for m in existing if isinstance(m, dict)] if isinstance(existing, list) else []
|
|
724
|
+
|
|
725
|
+
# Merge semantics:
|
|
726
|
+
# - preserve any explicit child system messages at the top
|
|
727
|
+
# - include the parent's active context (history) so recursive subflows
|
|
728
|
+
# see the latest turns even when the child pins provide a stale subset
|
|
729
|
+
# - keep any extra explicit child messages (e.g. an injected "next step")
|
|
730
|
+
#
|
|
731
|
+
# Dedup strictly by metadata.message_id when present. (Messages without
|
|
732
|
+
# ids are treated as distinct to avoid collapsing legitimate repeats.)
|
|
733
|
+
seen_ids: set[str] = set()
|
|
734
|
+
|
|
735
|
+
def _msg_id(m: Dict[str, Any]) -> str:
|
|
736
|
+
meta = m.get("metadata")
|
|
737
|
+
if isinstance(meta, dict):
|
|
738
|
+
mid = meta.get("message_id")
|
|
739
|
+
if isinstance(mid, str) and mid.strip():
|
|
740
|
+
return mid.strip()
|
|
741
|
+
return ""
|
|
742
|
+
|
|
743
|
+
def _append(dst: list[Dict[str, Any]], m: Dict[str, Any]) -> None:
|
|
744
|
+
mid = _msg_id(m)
|
|
745
|
+
if mid:
|
|
746
|
+
if mid in seen_ids:
|
|
747
|
+
return
|
|
748
|
+
seen_ids.add(mid)
|
|
749
|
+
dst.append(m)
|
|
750
|
+
|
|
751
|
+
merged: list[Dict[str, Any]] = []
|
|
752
|
+
# 1) Child system messages first (explicit overrides)
|
|
753
|
+
for m in existing_msgs:
|
|
754
|
+
if m.get("role") == "system":
|
|
755
|
+
_append(merged, m)
|
|
756
|
+
# 2) Parent system messages
|
|
757
|
+
for m in inherited_msgs:
|
|
758
|
+
if m.get("role") == "system":
|
|
759
|
+
_append(merged, m)
|
|
760
|
+
# 3) Parent conversation history
|
|
761
|
+
for m in inherited_msgs:
|
|
762
|
+
if m.get("role") != "system":
|
|
763
|
+
_append(merged, m)
|
|
764
|
+
# 4) Remaining child messages (extras)
|
|
765
|
+
for m in existing_msgs:
|
|
766
|
+
if m.get("role") != "system":
|
|
767
|
+
_append(merged, m)
|
|
768
|
+
|
|
769
|
+
sub_ctx["messages"] = merged
|
|
770
|
+
|
|
771
|
+
# Best-effort: also inherit parent context attachments, unless explicitly set on the child.
|
|
772
|
+
if "attachments" not in sub_ctx:
|
|
773
|
+
parent_ctx = run.vars.get("context") if isinstance(run.vars, dict) else None
|
|
774
|
+
raw_attachments = parent_ctx.get("attachments") if isinstance(parent_ctx, dict) else None
|
|
775
|
+
if isinstance(raw_attachments, list) and raw_attachments:
|
|
776
|
+
cleaned_att: list[Any] = []
|
|
777
|
+
for a in raw_attachments:
|
|
778
|
+
if isinstance(a, dict):
|
|
779
|
+
cleaned_att.append(dict(a))
|
|
780
|
+
elif isinstance(a, str) and a.strip():
|
|
781
|
+
cleaned_att.append(a.strip())
|
|
782
|
+
if cleaned_att:
|
|
783
|
+
sub_ctx["attachments"] = cleaned_att
|
|
784
|
+
|
|
785
|
+
pending["vars"] = sub_vars
|
|
786
|
+
except Exception:
|
|
787
|
+
pass
|
|
788
|
+
# Keep payload clean (runtime ignores it, but it clutters traces).
|
|
789
|
+
pending.pop("inherit_context", None)
|
|
790
|
+
|
|
791
|
+
# Build the Effect with resolved values from data edges
|
|
792
|
+
effect = Effect(
|
|
793
|
+
type=eff_type,
|
|
794
|
+
payload={
|
|
795
|
+
**pending,
|
|
796
|
+
"resume_to_node": next_node,
|
|
797
|
+
},
|
|
798
|
+
# Always store effect outcomes per-node; visual syncing can optionally copy to output_key.
|
|
799
|
+
result_key=f"_temp.effects.{node_id}",
|
|
800
|
+
)
|
|
801
|
+
|
|
802
|
+
return StepPlan(
|
|
803
|
+
node_id=node_id,
|
|
804
|
+
effect=effect,
|
|
805
|
+
next_node=next_node,
|
|
806
|
+
)
|
|
807
|
+
|
|
808
|
+
# Fallback: run.vars won't have the values, but try anyway
|
|
809
|
+
return base_handler(run, ctx)
|
|
810
|
+
|
|
811
|
+
return wrapped_effect_handler
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
def _create_visual_agent_effect_handler(
|
|
815
|
+
*,
|
|
816
|
+
node_id: str,
|
|
817
|
+
next_node: Optional[str],
|
|
818
|
+
agent_config: Dict[str, Any],
|
|
819
|
+
data_aware_handler: Optional[Callable[[Any], Any]],
|
|
820
|
+
flow: Flow,
|
|
821
|
+
) -> Callable:
|
|
822
|
+
"""Create a handler for the visual Agent node.
|
|
823
|
+
|
|
824
|
+
Visual Agent nodes delegate to AbstractAgent's canonical ReAct workflow
|
|
825
|
+
via `START_SUBWORKFLOW` (runtime-owned execution and persistence).
|
|
826
|
+
|
|
827
|
+
This handler:
|
|
828
|
+
- resolves `task` / `context` via data edges
|
|
829
|
+
- starts the configured ReAct subworkflow (sync; may wait)
|
|
830
|
+
- exposes the final agent result and trace ("scratchpad") via output pins
|
|
831
|
+
- optionally performs a final structured-output LLM_CALL (format-only pass)
|
|
832
|
+
"""
|
|
833
|
+
import json
|
|
834
|
+
|
|
835
|
+
from abstractruntime.core.models import Effect, EffectType, StepPlan
|
|
836
|
+
|
|
837
|
+
from .visual.agent_ids import visual_react_workflow_id
|
|
838
|
+
|
|
839
|
+
def _ensure_temp_dict(run: Any) -> Dict[str, Any]:
|
|
840
|
+
temp = run.vars.get("_temp")
|
|
841
|
+
if not isinstance(temp, dict):
|
|
842
|
+
temp = {}
|
|
843
|
+
run.vars["_temp"] = temp
|
|
844
|
+
return temp
|
|
845
|
+
|
|
846
|
+
def _get_agent_bucket(run: Any) -> Dict[str, Any]:
|
|
847
|
+
temp = _ensure_temp_dict(run)
|
|
848
|
+
agent = temp.get("agent")
|
|
849
|
+
if not isinstance(agent, dict):
|
|
850
|
+
agent = {}
|
|
851
|
+
temp["agent"] = agent
|
|
852
|
+
bucket = agent.get(node_id)
|
|
853
|
+
if not isinstance(bucket, dict):
|
|
854
|
+
bucket = {}
|
|
855
|
+
agent[node_id] = bucket
|
|
856
|
+
return bucket
|
|
857
|
+
|
|
858
|
+
def _get_memory_sources_bucket(run: Any) -> Dict[str, Any]:
|
|
859
|
+
temp = _ensure_temp_dict(run)
|
|
860
|
+
mem = temp.get("memory_sources")
|
|
861
|
+
if not isinstance(mem, dict):
|
|
862
|
+
mem = {}
|
|
863
|
+
temp["memory_sources"] = mem
|
|
864
|
+
bucket = mem.get(node_id)
|
|
865
|
+
if not isinstance(bucket, dict):
|
|
866
|
+
bucket = {}
|
|
867
|
+
mem[node_id] = bucket
|
|
868
|
+
return bucket
|
|
869
|
+
|
|
870
|
+
def _resolve_inputs(run: Any) -> Dict[str, Any]:
|
|
871
|
+
if hasattr(flow, "_node_outputs") and hasattr(flow, "_data_edge_map"):
|
|
872
|
+
_sync_effect_results_to_node_outputs(run, flow)
|
|
873
|
+
|
|
874
|
+
if not callable(data_aware_handler):
|
|
875
|
+
return {}
|
|
876
|
+
last_output = run.vars.get("_last_output", {})
|
|
877
|
+
try:
|
|
878
|
+
resolved = data_aware_handler(last_output)
|
|
879
|
+
except Exception:
|
|
880
|
+
resolved = {}
|
|
881
|
+
return resolved if isinstance(resolved, dict) else {}
|
|
882
|
+
|
|
883
|
+
def _flatten_node_traces(node_traces: Any) -> list[Dict[str, Any]]:
|
|
884
|
+
if not isinstance(node_traces, dict):
|
|
885
|
+
return []
|
|
886
|
+
out: list[Dict[str, Any]] = []
|
|
887
|
+
for trace in node_traces.values():
|
|
888
|
+
if not isinstance(trace, dict):
|
|
889
|
+
continue
|
|
890
|
+
steps = trace.get("steps")
|
|
891
|
+
if not isinstance(steps, list):
|
|
892
|
+
continue
|
|
893
|
+
for s in steps:
|
|
894
|
+
if isinstance(s, dict):
|
|
895
|
+
out.append(dict(s))
|
|
896
|
+
out.sort(key=lambda s: str(s.get("ts") or ""))
|
|
897
|
+
return out
|
|
898
|
+
|
|
899
|
+
def _as_dict_list(value: Any) -> list[Dict[str, Any]]:
|
|
900
|
+
if value is None:
|
|
901
|
+
return []
|
|
902
|
+
if isinstance(value, dict):
|
|
903
|
+
return [dict(value)]
|
|
904
|
+
if isinstance(value, list):
|
|
905
|
+
out: list[Dict[str, Any]] = []
|
|
906
|
+
for x in value:
|
|
907
|
+
if isinstance(x, dict):
|
|
908
|
+
out.append(dict(x))
|
|
909
|
+
return out
|
|
910
|
+
return []
|
|
911
|
+
|
|
912
|
+
def _extract_tool_activity_from_steps(steps: Any) -> tuple[list[Dict[str, Any]], list[Dict[str, Any]]]:
|
|
913
|
+
"""Best-effort tool call/result extraction from flattened scratchpad steps."""
|
|
914
|
+
if not isinstance(steps, list):
|
|
915
|
+
return [], []
|
|
916
|
+
tool_calls: list[Dict[str, Any]] = []
|
|
917
|
+
tool_results: list[Dict[str, Any]] = []
|
|
918
|
+
for entry_any in steps:
|
|
919
|
+
entry = entry_any if isinstance(entry_any, dict) else None
|
|
920
|
+
if entry is None:
|
|
921
|
+
continue
|
|
922
|
+
effect = entry.get("effect")
|
|
923
|
+
if not isinstance(effect, dict) or str(effect.get("type") or "") != "tool_calls":
|
|
924
|
+
continue
|
|
925
|
+
payload = effect.get("payload")
|
|
926
|
+
payload_d = payload if isinstance(payload, dict) else {}
|
|
927
|
+
tool_calls.extend(_as_dict_list(payload_d.get("tool_calls")))
|
|
928
|
+
|
|
929
|
+
result = entry.get("result")
|
|
930
|
+
if not isinstance(result, dict):
|
|
931
|
+
continue
|
|
932
|
+
tool_results.extend(_as_dict_list(result.get("results")))
|
|
933
|
+
return tool_calls, tool_results
|
|
934
|
+
|
|
935
|
+
def _build_sub_vars(
|
|
936
|
+
run: Any,
|
|
937
|
+
*,
|
|
938
|
+
task: str,
|
|
939
|
+
context: Dict[str, Any],
|
|
940
|
+
provider: str,
|
|
941
|
+
model: str,
|
|
942
|
+
system_prompt: str,
|
|
943
|
+
allowed_tools: list[str],
|
|
944
|
+
temperature: float = 0.7,
|
|
945
|
+
seed: int = -1,
|
|
946
|
+
include_context: bool = False,
|
|
947
|
+
max_iterations: Optional[int] = None,
|
|
948
|
+
max_input_tokens: Optional[int] = None,
|
|
949
|
+
max_output_tokens: Optional[int] = None,
|
|
950
|
+
extra_messages: Optional[list[Dict[str, Any]]] = None,
|
|
951
|
+
include_session_attachments_index: Optional[bool] = None,
|
|
952
|
+
) -> Dict[str, Any]:
|
|
953
|
+
parent_limits = run.vars.get("_limits")
|
|
954
|
+
limits = dict(parent_limits) if isinstance(parent_limits, dict) else {}
|
|
955
|
+
limits.setdefault("max_iterations", 25)
|
|
956
|
+
limits.setdefault("current_iteration", 0)
|
|
957
|
+
from abstractruntime.core.vars import DEFAULT_MAX_TOKENS
|
|
958
|
+
|
|
959
|
+
limits.setdefault("max_tokens", DEFAULT_MAX_TOKENS)
|
|
960
|
+
limits.setdefault("max_output_tokens", None)
|
|
961
|
+
limits.setdefault("max_input_tokens", None)
|
|
962
|
+
limits.setdefault("max_history_messages", -1)
|
|
963
|
+
limits.setdefault("estimated_tokens_used", 0)
|
|
964
|
+
limits.setdefault("warn_iterations_pct", 80)
|
|
965
|
+
limits.setdefault("warn_tokens_pct", 80)
|
|
966
|
+
|
|
967
|
+
if isinstance(max_iterations, int) and max_iterations > 0:
|
|
968
|
+
limits["max_iterations"] = int(max_iterations)
|
|
969
|
+
|
|
970
|
+
if isinstance(max_input_tokens, int) and max_input_tokens > 0:
|
|
971
|
+
limits["max_input_tokens"] = int(max_input_tokens)
|
|
972
|
+
|
|
973
|
+
if isinstance(max_output_tokens, int) and max_output_tokens > 0:
|
|
974
|
+
limits["max_output_tokens"] = int(max_output_tokens)
|
|
975
|
+
|
|
976
|
+
ctx_ns: Dict[str, Any] = {"task": str(task or ""), "messages": []}
|
|
977
|
+
|
|
978
|
+
# Optional: inherit the parent's active context as agent history (including Recall into context inserts).
|
|
979
|
+
# This is a visual-editor UX feature; it is disabled by default and can be enabled via
|
|
980
|
+
# agentConfig.include_context or the include_context input pin.
|
|
981
|
+
if bool(include_context):
|
|
982
|
+
try:
|
|
983
|
+
from abstractruntime.memory.active_context import ActiveContextPolicy
|
|
984
|
+
|
|
985
|
+
base = ActiveContextPolicy.select_active_messages_for_llm_from_run(run)
|
|
986
|
+
if isinstance(base, list):
|
|
987
|
+
ctx_ns["messages"] = [dict(m) for m in base if isinstance(m, dict)]
|
|
988
|
+
except Exception:
|
|
989
|
+
pass
|
|
990
|
+
# Best-effort: inherit parent attachments as part of context when "use context" is enabled.
|
|
991
|
+
try:
|
|
992
|
+
parent_ctx = run.vars.get("context") if isinstance(run.vars, dict) else None
|
|
993
|
+
raw_attachments = parent_ctx.get("attachments") if isinstance(parent_ctx, dict) else None
|
|
994
|
+
if isinstance(raw_attachments, list) and raw_attachments:
|
|
995
|
+
cleaned_att: list[Any] = []
|
|
996
|
+
for a in raw_attachments:
|
|
997
|
+
if isinstance(a, dict):
|
|
998
|
+
cleaned_att.append(dict(a))
|
|
999
|
+
elif isinstance(a, str) and a.strip():
|
|
1000
|
+
cleaned_att.append(a.strip())
|
|
1001
|
+
if cleaned_att:
|
|
1002
|
+
ctx_ns["attachments"] = cleaned_att
|
|
1003
|
+
except Exception:
|
|
1004
|
+
pass
|
|
1005
|
+
|
|
1006
|
+
# Explicit context.messages from a pin overrides the inherited run context.
|
|
1007
|
+
raw_msgs = context.get("messages") if isinstance(context, dict) else None
|
|
1008
|
+
if isinstance(raw_msgs, list):
|
|
1009
|
+
msgs = [dict(m) for m in raw_msgs if isinstance(m, dict)]
|
|
1010
|
+
if msgs:
|
|
1011
|
+
ctx_ns["messages"] = msgs
|
|
1012
|
+
|
|
1013
|
+
# Optional: inject additional memory/system messages (467).
|
|
1014
|
+
#
|
|
1015
|
+
# This is used by the Visual Agent node to include span/KG recall even when
|
|
1016
|
+
# include_context is false (without pulling in the full conversation history).
|
|
1017
|
+
if isinstance(extra_messages, list) and extra_messages:
|
|
1018
|
+
base_msgs = ctx_ns.get("messages")
|
|
1019
|
+
base_msgs = [dict(m) for m in base_msgs if isinstance(m, dict)] if isinstance(base_msgs, list) else []
|
|
1020
|
+
extra_msgs = [dict(m) for m in extra_messages if isinstance(m, dict)]
|
|
1021
|
+
|
|
1022
|
+
seen_ids: set[str] = set()
|
|
1023
|
+
|
|
1024
|
+
def _msg_id(m: Dict[str, Any]) -> str:
|
|
1025
|
+
meta = m.get("metadata")
|
|
1026
|
+
if isinstance(meta, dict):
|
|
1027
|
+
mid = meta.get("message_id")
|
|
1028
|
+
if isinstance(mid, str) and mid.strip():
|
|
1029
|
+
return mid.strip()
|
|
1030
|
+
return ""
|
|
1031
|
+
|
|
1032
|
+
def _append(dst: list[Dict[str, Any]], m: Dict[str, Any]) -> None:
|
|
1033
|
+
mid = _msg_id(m)
|
|
1034
|
+
if mid:
|
|
1035
|
+
if mid in seen_ids:
|
|
1036
|
+
return
|
|
1037
|
+
seen_ids.add(mid)
|
|
1038
|
+
dst.append(m)
|
|
1039
|
+
|
|
1040
|
+
merged: list[Dict[str, Any]] = []
|
|
1041
|
+
# Preserve base system messages at the very top.
|
|
1042
|
+
for m in base_msgs:
|
|
1043
|
+
if m.get("role") == "system":
|
|
1044
|
+
_append(merged, m)
|
|
1045
|
+
# Then injected system messages.
|
|
1046
|
+
for m in extra_msgs:
|
|
1047
|
+
if m.get("role") == "system":
|
|
1048
|
+
_append(merged, m)
|
|
1049
|
+
# Then base non-system (history/task context).
|
|
1050
|
+
for m in base_msgs:
|
|
1051
|
+
if m.get("role") != "system":
|
|
1052
|
+
_append(merged, m)
|
|
1053
|
+
# Finally injected non-system messages.
|
|
1054
|
+
for m in extra_msgs:
|
|
1055
|
+
if m.get("role") != "system":
|
|
1056
|
+
_append(merged, m)
|
|
1057
|
+
|
|
1058
|
+
ctx_ns["messages"] = merged
|
|
1059
|
+
|
|
1060
|
+
if isinstance(context, dict) and context:
|
|
1061
|
+
for k, v in context.items():
|
|
1062
|
+
if k in ("task", "messages"):
|
|
1063
|
+
continue
|
|
1064
|
+
ctx_ns[str(k)] = v
|
|
1065
|
+
|
|
1066
|
+
runtime_ns: Dict[str, Any] = {
|
|
1067
|
+
"inbox": [],
|
|
1068
|
+
"provider": provider,
|
|
1069
|
+
"model": model,
|
|
1070
|
+
"allowed_tools": list(allowed_tools),
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
# Media input policy defaults (run-scoped):
|
|
1074
|
+
# If the parent run configured an explicit audio policy (e.g. AbstractCode sets
|
|
1075
|
+
# `_runtime.audio_policy="auto"` when audio attachments are present), inherit it
|
|
1076
|
+
# into the Agent subworkflow so all nested LLM calls behave consistently.
|
|
1077
|
+
try:
|
|
1078
|
+
parent_runtime = run.vars.get("_runtime") if isinstance(run.vars, dict) else None
|
|
1079
|
+
except Exception:
|
|
1080
|
+
parent_runtime = None
|
|
1081
|
+
if isinstance(parent_runtime, dict):
|
|
1082
|
+
ap = parent_runtime.get("audio_policy")
|
|
1083
|
+
if isinstance(ap, str) and ap.strip():
|
|
1084
|
+
runtime_ns.setdefault("audio_policy", ap.strip())
|
|
1085
|
+
|
|
1086
|
+
stt_lang = parent_runtime.get("stt_language")
|
|
1087
|
+
if stt_lang is None:
|
|
1088
|
+
stt_lang = parent_runtime.get("audio_language")
|
|
1089
|
+
if isinstance(stt_lang, str) and stt_lang.strip():
|
|
1090
|
+
runtime_ns.setdefault("stt_language", stt_lang.strip())
|
|
1091
|
+
|
|
1092
|
+
# Sampling controls:
|
|
1093
|
+
# - Do NOT force default values into the child runtime vars.
|
|
1094
|
+
# - When unset, step-level agent defaults (e.g. lower temperature for tool-followthrough)
|
|
1095
|
+
# must be able to take effect.
|
|
1096
|
+
#
|
|
1097
|
+
# Policy:
|
|
1098
|
+
# - temperature: only include when it differs from the framework default (0.7)
|
|
1099
|
+
# - seed: only include when explicitly set (>= 0)
|
|
1100
|
+
if abs(float(temperature) - 0.7) > 1e-9:
|
|
1101
|
+
runtime_ns["temperature"] = float(temperature)
|
|
1102
|
+
if int(seed) >= 0:
|
|
1103
|
+
runtime_ns["seed"] = int(seed)
|
|
1104
|
+
if include_session_attachments_index is not None:
|
|
1105
|
+
control = runtime_ns.get("control")
|
|
1106
|
+
if not isinstance(control, dict):
|
|
1107
|
+
control = {}
|
|
1108
|
+
runtime_ns["control"] = control
|
|
1109
|
+
control["include_session_attachments_index"] = bool(include_session_attachments_index)
|
|
1110
|
+
if isinstance(system_prompt, str) and system_prompt.strip():
|
|
1111
|
+
# IMPORTANT: Visual Agent `system` pin provides *additional* high-priority instructions.
|
|
1112
|
+
# We keep the canonical ReAct system prompt (iteration framing + evidence/tool-use rules)
|
|
1113
|
+
# and append this extra guidance so tool-followthrough remains robust.
|
|
1114
|
+
runtime_ns["system_prompt_extra"] = system_prompt
|
|
1115
|
+
|
|
1116
|
+
return {
|
|
1117
|
+
"context": ctx_ns,
|
|
1118
|
+
"scratchpad": {"iteration": 0, "max_iterations": int(limits.get("max_iterations") or 25)},
|
|
1119
|
+
# `_runtime` is durable; we store provider/model here so the ReAct subworkflow
|
|
1120
|
+
# can inject them into LLM_CALL payloads (and remain resumable).
|
|
1121
|
+
"_runtime": runtime_ns,
|
|
1122
|
+
"_temp": {},
|
|
1123
|
+
"_limits": limits,
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
def _coerce_max_iterations(value: Any) -> Optional[int]:
|
|
1127
|
+
try:
|
|
1128
|
+
if value is None:
|
|
1129
|
+
return None
|
|
1130
|
+
if isinstance(value, bool):
|
|
1131
|
+
return None
|
|
1132
|
+
if isinstance(value, (int, float)):
|
|
1133
|
+
iv = int(float(value))
|
|
1134
|
+
return iv if iv > 0 else None
|
|
1135
|
+
if isinstance(value, str) and value.strip():
|
|
1136
|
+
iv = int(float(value.strip()))
|
|
1137
|
+
return iv if iv > 0 else None
|
|
1138
|
+
except Exception:
|
|
1139
|
+
return None
|
|
1140
|
+
return None
|
|
1141
|
+
|
|
1142
|
+
def _coerce_max_input_tokens(value: Any) -> Optional[int]:
|
|
1143
|
+
try:
|
|
1144
|
+
if value is None or isinstance(value, bool):
|
|
1145
|
+
return None
|
|
1146
|
+
if isinstance(value, (int, float)):
|
|
1147
|
+
iv = int(float(value))
|
|
1148
|
+
return iv if iv > 0 else None
|
|
1149
|
+
if isinstance(value, str) and value.strip():
|
|
1150
|
+
iv = int(float(value.strip()))
|
|
1151
|
+
return iv if iv > 0 else None
|
|
1152
|
+
except Exception:
|
|
1153
|
+
return None
|
|
1154
|
+
return None
|
|
1155
|
+
|
|
1156
|
+
def _coerce_max_output_tokens(value: Any) -> Optional[int]:
|
|
1157
|
+
# Same coercion rules as max_input_tokens: accept int/float/string, reject <=0.
|
|
1158
|
+
try:
|
|
1159
|
+
if value is None or isinstance(value, bool):
|
|
1160
|
+
return None
|
|
1161
|
+
if isinstance(value, (int, float)):
|
|
1162
|
+
iv = int(float(value))
|
|
1163
|
+
return iv if iv > 0 else None
|
|
1164
|
+
if isinstance(value, str) and value.strip():
|
|
1165
|
+
iv = int(float(value.strip()))
|
|
1166
|
+
return iv if iv > 0 else None
|
|
1167
|
+
except Exception:
|
|
1168
|
+
return None
|
|
1169
|
+
return None
|
|
1170
|
+
|
|
1171
|
+
def _coerce_temperature(value: Any, default: float = 0.7) -> float:
|
|
1172
|
+
try:
|
|
1173
|
+
if value is None or isinstance(value, bool):
|
|
1174
|
+
return float(default)
|
|
1175
|
+
return float(value)
|
|
1176
|
+
except Exception:
|
|
1177
|
+
return float(default)
|
|
1178
|
+
|
|
1179
|
+
def _coerce_seed(value: Any, default: int = -1) -> int:
|
|
1180
|
+
try:
|
|
1181
|
+
if value is None or isinstance(value, bool):
|
|
1182
|
+
return int(default)
|
|
1183
|
+
return int(value)
|
|
1184
|
+
except Exception:
|
|
1185
|
+
return int(default)
|
|
1186
|
+
|
|
1187
|
+
def handler(run: Any, ctx: Any) -> "StepPlan":
|
|
1188
|
+
del ctx
|
|
1189
|
+
|
|
1190
|
+
output_schema_cfg = agent_config.get("outputSchema") if isinstance(agent_config.get("outputSchema"), dict) else {}
|
|
1191
|
+
schema_enabled = bool(output_schema_cfg.get("enabled"))
|
|
1192
|
+
schema = output_schema_cfg.get("jsonSchema") if isinstance(output_schema_cfg.get("jsonSchema"), dict) else None
|
|
1193
|
+
|
|
1194
|
+
bucket = _get_agent_bucket(run)
|
|
1195
|
+
phase = str(bucket.get("phase") or "init")
|
|
1196
|
+
|
|
1197
|
+
# IMPORTANT: This visual Agent node can be executed multiple times within a single run
|
|
1198
|
+
# (e.g. inside a Loop/While/Sequence). The per-node bucket is durable and would otherwise
|
|
1199
|
+
# keep `phase="done"` and `resolved_inputs` from the first invocation, causing subsequent
|
|
1200
|
+
# invocations to skip work and reuse stale inputs/results.
|
|
1201
|
+
#
|
|
1202
|
+
# When we re-enter the node after it previously completed, reset the bucket to start a
|
|
1203
|
+
# fresh subworkflow invocation with the current upstream inputs.
|
|
1204
|
+
if phase == "done":
|
|
1205
|
+
try:
|
|
1206
|
+
bucket.clear()
|
|
1207
|
+
except Exception:
|
|
1208
|
+
# Best-effort; if clear fails, overwrite key fields below.
|
|
1209
|
+
pass
|
|
1210
|
+
# Clear any cached per-node memory-source recall outputs (467) so re-entry
|
|
1211
|
+
# (e.g. inside loops) re-computes recall for the new inputs.
|
|
1212
|
+
try:
|
|
1213
|
+
ms_bucket = _get_memory_sources_bucket(run)
|
|
1214
|
+
ms_bucket.clear()
|
|
1215
|
+
except Exception:
|
|
1216
|
+
pass
|
|
1217
|
+
phase = "init"
|
|
1218
|
+
bucket["phase"] = "init"
|
|
1219
|
+
|
|
1220
|
+
resolved_inputs = bucket.get("resolved_inputs")
|
|
1221
|
+
if not isinstance(resolved_inputs, dict) or phase == "init":
|
|
1222
|
+
resolved_inputs = _resolve_inputs(run)
|
|
1223
|
+
bucket["resolved_inputs"] = resolved_inputs if isinstance(resolved_inputs, dict) else {}
|
|
1224
|
+
|
|
1225
|
+
# Pin-driven structured output:
|
|
1226
|
+
# If `response_schema` is provided via an input pin (data edge), it overrides the node config
|
|
1227
|
+
# and enables the structured-output post-pass (durable LLM_CALL).
|
|
1228
|
+
pin_schema = resolved_inputs.get("response_schema") if isinstance(resolved_inputs, dict) else None
|
|
1229
|
+
if isinstance(pin_schema, dict) and pin_schema:
|
|
1230
|
+
schema = dict(pin_schema)
|
|
1231
|
+
schema_enabled = True
|
|
1232
|
+
|
|
1233
|
+
# Provider/model can come from Agent node config or from data-edge inputs (pins).
|
|
1234
|
+
provider_raw = resolved_inputs.get("provider") if isinstance(resolved_inputs, dict) else None
|
|
1235
|
+
model_raw = resolved_inputs.get("model") if isinstance(resolved_inputs, dict) else None
|
|
1236
|
+
if not isinstance(provider_raw, str) or not provider_raw.strip():
|
|
1237
|
+
provider_raw = agent_config.get("provider")
|
|
1238
|
+
if not isinstance(model_raw, str) or not model_raw.strip():
|
|
1239
|
+
model_raw = agent_config.get("model")
|
|
1240
|
+
|
|
1241
|
+
provider = str(provider_raw or "").strip().lower() if isinstance(provider_raw, str) else ""
|
|
1242
|
+
model = str(model_raw or "").strip() if isinstance(model_raw, str) else ""
|
|
1243
|
+
|
|
1244
|
+
task = str(resolved_inputs.get("task") or "")
|
|
1245
|
+
context_raw = resolved_inputs.get("context")
|
|
1246
|
+
context = context_raw if isinstance(context_raw, dict) else {}
|
|
1247
|
+
system_raw = resolved_inputs.get("system") if isinstance(resolved_inputs, dict) else None
|
|
1248
|
+
system_prompt = system_raw if isinstance(system_raw, str) else str(system_raw or "")
|
|
1249
|
+
|
|
1250
|
+
# Include parent run context (as agent history):
|
|
1251
|
+
# - Pin override wins when connected (resolved_inputs contains include_context)
|
|
1252
|
+
# - Otherwise fall back to node config (checkbox)
|
|
1253
|
+
# - Default: false
|
|
1254
|
+
include_context: bool
|
|
1255
|
+
if isinstance(resolved_inputs, dict) and "include_context" in resolved_inputs:
|
|
1256
|
+
include_context = bool(resolved_inputs.get("include_context"))
|
|
1257
|
+
else:
|
|
1258
|
+
include_context_cfg = agent_config.get("include_context")
|
|
1259
|
+
include_context = bool(include_context_cfg) if include_context_cfg is not None else False
|
|
1260
|
+
|
|
1261
|
+
# Agent loop budget (max_iterations) can come from a data-edge pin or from config.
|
|
1262
|
+
max_iterations_raw = resolved_inputs.get("max_iterations") if isinstance(resolved_inputs, dict) else None
|
|
1263
|
+
max_iterations_override = _coerce_max_iterations(max_iterations_raw)
|
|
1264
|
+
if max_iterations_override is None:
|
|
1265
|
+
max_iterations_override = _coerce_max_iterations(agent_config.get("max_iterations"))
|
|
1266
|
+
|
|
1267
|
+
# Token budget (max_input_tokens) can come from a data-edge pin or from config.
|
|
1268
|
+
max_input_tokens_raw = resolved_inputs.get("max_input_tokens") if isinstance(resolved_inputs, dict) else None
|
|
1269
|
+
max_input_tokens_override = _coerce_max_input_tokens(max_input_tokens_raw)
|
|
1270
|
+
if max_input_tokens_override is None:
|
|
1271
|
+
max_input_tokens_override = _coerce_max_input_tokens(agent_config.get("max_input_tokens"))
|
|
1272
|
+
|
|
1273
|
+
# Token budget (max_output_tokens) can come from a data-edge pin or from config.
|
|
1274
|
+
max_output_tokens_raw: Any = None
|
|
1275
|
+
if isinstance(resolved_inputs, dict):
|
|
1276
|
+
if "max_output_tokens" in resolved_inputs:
|
|
1277
|
+
max_output_tokens_raw = resolved_inputs.get("max_output_tokens")
|
|
1278
|
+
elif "max_out_tokens" in resolved_inputs:
|
|
1279
|
+
max_output_tokens_raw = resolved_inputs.get("max_out_tokens")
|
|
1280
|
+
max_output_tokens_override = _coerce_max_output_tokens(max_output_tokens_raw)
|
|
1281
|
+
if max_output_tokens_override is None:
|
|
1282
|
+
max_output_tokens_override = _coerce_max_output_tokens(agent_config.get("max_output_tokens"))
|
|
1283
|
+
|
|
1284
|
+
# Sampling controls can come from pins or from config.
|
|
1285
|
+
if isinstance(resolved_inputs, dict) and "temperature" in resolved_inputs:
|
|
1286
|
+
temperature_raw = resolved_inputs.get("temperature")
|
|
1287
|
+
else:
|
|
1288
|
+
temperature_raw = agent_config.get("temperature", 0.7)
|
|
1289
|
+
temperature = _coerce_temperature(temperature_raw, default=0.7)
|
|
1290
|
+
|
|
1291
|
+
if isinstance(resolved_inputs, dict) and "seed" in resolved_inputs:
|
|
1292
|
+
seed_raw = resolved_inputs.get("seed")
|
|
1293
|
+
else:
|
|
1294
|
+
seed_raw = agent_config.get("seed", -1)
|
|
1295
|
+
seed = _coerce_seed(seed_raw, default=-1)
|
|
1296
|
+
|
|
1297
|
+
# Tools selection:
|
|
1298
|
+
# - If the resolved inputs explicitly include `tools` (e.g. tools pin connected),
|
|
1299
|
+
# respect it even if it's an empty list (disables tools).
|
|
1300
|
+
# - Otherwise fall back to the Agent node's configuration.
|
|
1301
|
+
if isinstance(resolved_inputs, dict) and "tools" in resolved_inputs:
|
|
1302
|
+
tools_raw = resolved_inputs.get("tools")
|
|
1303
|
+
else:
|
|
1304
|
+
tools_raw = agent_config.get("tools")
|
|
1305
|
+
allowed_tools: list[str] = []
|
|
1306
|
+
if isinstance(tools_raw, list):
|
|
1307
|
+
for t in tools_raw:
|
|
1308
|
+
if isinstance(t, str) and t.strip():
|
|
1309
|
+
allowed_tools.append(t.strip())
|
|
1310
|
+
elif isinstance(tools_raw, tuple):
|
|
1311
|
+
for t in tools_raw:
|
|
1312
|
+
if isinstance(t, str) and t.strip():
|
|
1313
|
+
allowed_tools.append(t.strip())
|
|
1314
|
+
elif isinstance(tools_raw, str) and tools_raw.strip():
|
|
1315
|
+
allowed_tools.append(tools_raw.strip())
|
|
1316
|
+
|
|
1317
|
+
# De-dup while preserving order.
|
|
1318
|
+
seen_tools: set[str] = set()
|
|
1319
|
+
allowed_tools = [t for t in allowed_tools if not (t in seen_tools or seen_tools.add(t))]
|
|
1320
|
+
|
|
1321
|
+
workflow_id_raw = agent_config.get("_react_workflow_id")
|
|
1322
|
+
react_workflow_id = (
|
|
1323
|
+
workflow_id_raw.strip()
|
|
1324
|
+
if isinstance(workflow_id_raw, str) and workflow_id_raw.strip()
|
|
1325
|
+
else visual_react_workflow_id(flow_id=flow.flow_id, node_id=node_id)
|
|
1326
|
+
)
|
|
1327
|
+
|
|
1328
|
+
if phase == "init":
|
|
1329
|
+
if not provider or not model:
|
|
1330
|
+
run.vars["_flow_error"] = "Agent node missing provider/model configuration"
|
|
1331
|
+
run.vars["_flow_error_node"] = node_id
|
|
1332
|
+
out = {
|
|
1333
|
+
"response": "Agent configuration error: missing provider/model",
|
|
1334
|
+
"task": task,
|
|
1335
|
+
"context": context,
|
|
1336
|
+
"success": False,
|
|
1337
|
+
"error": "missing provider/model",
|
|
1338
|
+
"provider": provider or "unknown",
|
|
1339
|
+
"model": model or "unknown",
|
|
1340
|
+
}
|
|
1341
|
+
_set_nested(run.vars, f"_temp.effects.{node_id}", out)
|
|
1342
|
+
bucket["phase"] = "done"
|
|
1343
|
+
|
|
1344
|
+
scratchpad = {"node_id": node_id, "steps": [], "tool_calls": [], "tool_results": []}
|
|
1345
|
+
meta: Dict[str, Any] = {
|
|
1346
|
+
"schema": "abstractcode.agent.v1.meta",
|
|
1347
|
+
"version": 1,
|
|
1348
|
+
"provider": provider or "unknown",
|
|
1349
|
+
"model": model or "unknown",
|
|
1350
|
+
"tool_calls": 0,
|
|
1351
|
+
"tool_results": 0,
|
|
1352
|
+
}
|
|
1353
|
+
flow._node_outputs[node_id] = {
|
|
1354
|
+
"response": str(out.get("response") or ""),
|
|
1355
|
+
"success": False,
|
|
1356
|
+
"meta": meta,
|
|
1357
|
+
"scratchpad": scratchpad,
|
|
1358
|
+
# Backward-compat / convenience:
|
|
1359
|
+
"tool_calls": [],
|
|
1360
|
+
"tool_results": [],
|
|
1361
|
+
}
|
|
1362
|
+
run.vars["_last_output"] = dict(flow._node_outputs.get(node_id) or {})
|
|
1363
|
+
if next_node:
|
|
1364
|
+
return StepPlan(node_id=node_id, next_node=next_node)
|
|
1365
|
+
return StepPlan(
|
|
1366
|
+
node_id=node_id,
|
|
1367
|
+
complete_output={
|
|
1368
|
+
"response": str(out.get("response") or ""),
|
|
1369
|
+
"success": False,
|
|
1370
|
+
"meta": meta,
|
|
1371
|
+
"scratchpad": scratchpad,
|
|
1372
|
+
},
|
|
1373
|
+
)
|
|
1374
|
+
|
|
1375
|
+
# ------------------------------------------------------------
|
|
1376
|
+
# 467: Memory-source access pins (pre-agent recall)
|
|
1377
|
+
# ------------------------------------------------------------
|
|
1378
|
+
#
|
|
1379
|
+
# Before we start the durable Agent subworkflow, optionally run runtime-owned
|
|
1380
|
+
# MEMORY_* effects to recall from spans and/or the KG. Results are stored under
|
|
1381
|
+
# `_temp.memory_sources.{node_id}.*` and injected into the child run context.
|
|
1382
|
+
ms_bucket = _get_memory_sources_bucket(run)
|
|
1383
|
+
ms_meta = ms_bucket.get("_meta") if isinstance(ms_bucket.get("_meta"), dict) else {}
|
|
1384
|
+
if not isinstance(ms_meta, dict):
|
|
1385
|
+
ms_meta = {}
|
|
1386
|
+
ms_bucket["_meta"] = ms_meta
|
|
1387
|
+
|
|
1388
|
+
def _pin_bool_opt(name: str) -> Optional[bool]:
|
|
1389
|
+
if not isinstance(resolved_inputs, dict):
|
|
1390
|
+
return None
|
|
1391
|
+
if name not in resolved_inputs:
|
|
1392
|
+
return None
|
|
1393
|
+
return bool(resolved_inputs.get(name))
|
|
1394
|
+
|
|
1395
|
+
use_span_memory = _pin_bool_opt("use_span_memory")
|
|
1396
|
+
use_kg_memory = _pin_bool_opt("use_kg_memory")
|
|
1397
|
+
use_semantic_search = _pin_bool_opt("use_semantic_search")
|
|
1398
|
+
use_session_attachments = _pin_bool_opt("use_session_attachments")
|
|
1399
|
+
|
|
1400
|
+
memory_scope = (
|
|
1401
|
+
str(resolved_inputs.get("memory_scope") or "run").strip().lower()
|
|
1402
|
+
if isinstance(resolved_inputs, dict)
|
|
1403
|
+
else "run"
|
|
1404
|
+
) or "run"
|
|
1405
|
+
recall_level = (
|
|
1406
|
+
str(resolved_inputs.get("recall_level") or "").strip().lower()
|
|
1407
|
+
if isinstance(resolved_inputs, dict)
|
|
1408
|
+
else ""
|
|
1409
|
+
)
|
|
1410
|
+
|
|
1411
|
+
mem_query = (
|
|
1412
|
+
str(resolved_inputs.get("memory_query") or "").strip()
|
|
1413
|
+
if isinstance(resolved_inputs, dict)
|
|
1414
|
+
else ""
|
|
1415
|
+
)
|
|
1416
|
+
query_text = mem_query or str(task or "").strip()
|
|
1417
|
+
|
|
1418
|
+
base_key = f"_temp.memory_sources.{node_id}"
|
|
1419
|
+
|
|
1420
|
+
def _coerce_int(value: Any) -> Optional[int]:
|
|
1421
|
+
if value is None or isinstance(value, bool):
|
|
1422
|
+
return None
|
|
1423
|
+
try:
|
|
1424
|
+
return int(float(value))
|
|
1425
|
+
except Exception:
|
|
1426
|
+
return None
|
|
1427
|
+
|
|
1428
|
+
def _coerce_float(value: Any) -> Optional[float]:
|
|
1429
|
+
if value is None or isinstance(value, bool):
|
|
1430
|
+
return None
|
|
1431
|
+
try:
|
|
1432
|
+
v = float(value)
|
|
1433
|
+
except Exception:
|
|
1434
|
+
return None
|
|
1435
|
+
return v if (v == v) else None
|
|
1436
|
+
|
|
1437
|
+
if use_semantic_search is True:
|
|
1438
|
+
warnings = ms_meta.get("warnings")
|
|
1439
|
+
if not isinstance(warnings, list):
|
|
1440
|
+
warnings = []
|
|
1441
|
+
ms_meta["warnings"] = warnings
|
|
1442
|
+
msg = "use_semantic_search is not implemented yet (planned: 464); ignoring."
|
|
1443
|
+
if msg not in warnings:
|
|
1444
|
+
warnings.append(msg)
|
|
1445
|
+
|
|
1446
|
+
# Span recall (metadata)
|
|
1447
|
+
if use_span_memory is True and "span_query" not in ms_bucket and query_text:
|
|
1448
|
+
mq_payload: Dict[str, Any] = {"query": query_text, "scope": memory_scope, "return": "meta"}
|
|
1449
|
+
if recall_level:
|
|
1450
|
+
mq_payload["recall_level"] = recall_level
|
|
1451
|
+
return StepPlan(
|
|
1452
|
+
node_id=node_id,
|
|
1453
|
+
effect=Effect(
|
|
1454
|
+
type=EffectType.MEMORY_QUERY,
|
|
1455
|
+
payload=mq_payload,
|
|
1456
|
+
result_key=f"{base_key}.span_query",
|
|
1457
|
+
),
|
|
1458
|
+
next_node=node_id,
|
|
1459
|
+
)
|
|
1460
|
+
|
|
1461
|
+
# Span rehydrate into parent context (so we can forward a subset into the child).
|
|
1462
|
+
if use_span_memory is True and "span_rehydrate" not in ms_bucket:
|
|
1463
|
+
span_ids: list[str] = []
|
|
1464
|
+
span_query = ms_bucket.get("span_query")
|
|
1465
|
+
if isinstance(span_query, dict):
|
|
1466
|
+
results = span_query.get("results")
|
|
1467
|
+
if isinstance(results, list) and results:
|
|
1468
|
+
first = results[0] if isinstance(results[0], dict) else None
|
|
1469
|
+
meta2 = first.get("meta") if isinstance(first, dict) else None
|
|
1470
|
+
if isinstance(meta2, dict):
|
|
1471
|
+
raw_ids = meta2.get("span_ids")
|
|
1472
|
+
if isinstance(raw_ids, list):
|
|
1473
|
+
span_ids = [str(x).strip() for x in raw_ids if str(x).strip()]
|
|
1474
|
+
ms_meta["span_ids"] = span_ids
|
|
1475
|
+
|
|
1476
|
+
mr_payload: Dict[str, Any] = {"span_ids": span_ids, "placement": "after_summary"}
|
|
1477
|
+
if recall_level:
|
|
1478
|
+
mr_payload["recall_level"] = recall_level
|
|
1479
|
+
max_span_messages = (
|
|
1480
|
+
_coerce_int(resolved_inputs.get("max_span_messages"))
|
|
1481
|
+
if isinstance(resolved_inputs, dict)
|
|
1482
|
+
else None
|
|
1483
|
+
)
|
|
1484
|
+
if max_span_messages is None and not recall_level:
|
|
1485
|
+
# Safe default (explicit warning): without recall_level, MEMORY_REHYDRATE would
|
|
1486
|
+
# otherwise insert all messages from selected spans.
|
|
1487
|
+
max_span_messages = 80
|
|
1488
|
+
warnings = ms_meta.get("warnings")
|
|
1489
|
+
if not isinstance(warnings, list):
|
|
1490
|
+
warnings = []
|
|
1491
|
+
ms_meta["warnings"] = warnings
|
|
1492
|
+
msg = "use_span_memory enabled without recall_level/max_span_messages; defaulting max_span_messages=80."
|
|
1493
|
+
if msg not in warnings:
|
|
1494
|
+
warnings.append(msg)
|
|
1495
|
+
if isinstance(max_span_messages, int):
|
|
1496
|
+
mr_payload["max_messages"] = max_span_messages
|
|
1497
|
+
|
|
1498
|
+
return StepPlan(
|
|
1499
|
+
node_id=node_id,
|
|
1500
|
+
effect=Effect(
|
|
1501
|
+
type=EffectType.MEMORY_REHYDRATE,
|
|
1502
|
+
payload=mr_payload,
|
|
1503
|
+
result_key=f"{base_key}.span_rehydrate",
|
|
1504
|
+
),
|
|
1505
|
+
next_node=node_id,
|
|
1506
|
+
)
|
|
1507
|
+
|
|
1508
|
+
# KG recall (packetized active memory)
|
|
1509
|
+
if use_kg_memory is True and "kg_query" not in ms_bucket and query_text:
|
|
1510
|
+
kg_payload: Dict[str, Any] = {"query_text": query_text, "scope": memory_scope}
|
|
1511
|
+
if recall_level:
|
|
1512
|
+
kg_payload["recall_level"] = recall_level
|
|
1513
|
+
|
|
1514
|
+
kg_min_score = (
|
|
1515
|
+
_coerce_float(resolved_inputs.get("kg_min_score")) if isinstance(resolved_inputs, dict) else None
|
|
1516
|
+
)
|
|
1517
|
+
if isinstance(kg_min_score, float):
|
|
1518
|
+
kg_payload["min_score"] = kg_min_score
|
|
1519
|
+
kg_limit = _coerce_int(resolved_inputs.get("kg_limit")) if isinstance(resolved_inputs, dict) else None
|
|
1520
|
+
if isinstance(kg_limit, int):
|
|
1521
|
+
kg_payload["limit"] = kg_limit
|
|
1522
|
+
kg_budget = (
|
|
1523
|
+
_coerce_int(resolved_inputs.get("kg_max_input_tokens"))
|
|
1524
|
+
if isinstance(resolved_inputs, dict)
|
|
1525
|
+
else None
|
|
1526
|
+
)
|
|
1527
|
+
if kg_budget is None and not recall_level:
|
|
1528
|
+
# Safe default (explicit warning): ensure we actually get `active_memory_text`
|
|
1529
|
+
# even when recall_level is not set.
|
|
1530
|
+
kg_budget = 1200
|
|
1531
|
+
warnings = ms_meta.get("warnings")
|
|
1532
|
+
if not isinstance(warnings, list):
|
|
1533
|
+
warnings = []
|
|
1534
|
+
ms_meta["warnings"] = warnings
|
|
1535
|
+
msg = "use_kg_memory enabled without recall_level/kg_max_input_tokens; defaulting kg_max_input_tokens=1200."
|
|
1536
|
+
if msg not in warnings:
|
|
1537
|
+
warnings.append(msg)
|
|
1538
|
+
if isinstance(kg_budget, int):
|
|
1539
|
+
kg_payload["max_input_tokens"] = kg_budget
|
|
1540
|
+
if model:
|
|
1541
|
+
kg_payload["model"] = model
|
|
1542
|
+
|
|
1543
|
+
return StepPlan(
|
|
1544
|
+
node_id=node_id,
|
|
1545
|
+
effect=Effect(
|
|
1546
|
+
type=EffectType.MEMORY_KG_QUERY,
|
|
1547
|
+
payload=kg_payload,
|
|
1548
|
+
result_key=f"{base_key}.kg_query",
|
|
1549
|
+
),
|
|
1550
|
+
next_node=node_id,
|
|
1551
|
+
)
|
|
1552
|
+
|
|
1553
|
+
bucket["phase"] = "subworkflow"
|
|
1554
|
+
flow._node_outputs[node_id] = {
|
|
1555
|
+
"status": "running",
|
|
1556
|
+
"task": task,
|
|
1557
|
+
"context": context,
|
|
1558
|
+
"response": "",
|
|
1559
|
+
"success": None,
|
|
1560
|
+
"meta": None,
|
|
1561
|
+
"scratchpad": None,
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
# Memory injection into the Agent subworkflow context (467).
|
|
1565
|
+
extra_messages: list[Dict[str, Any]] = []
|
|
1566
|
+
if use_kg_memory is True:
|
|
1567
|
+
kg_query = ms_bucket.get("kg_query")
|
|
1568
|
+
active_text = kg_query.get("active_memory_text") if isinstance(kg_query, dict) else None
|
|
1569
|
+
if isinstance(active_text, str) and active_text.strip():
|
|
1570
|
+
extra_messages.append(
|
|
1571
|
+
{
|
|
1572
|
+
"role": "system",
|
|
1573
|
+
"content": active_text.strip(),
|
|
1574
|
+
"metadata": {
|
|
1575
|
+
"kind": "kg_active_memory",
|
|
1576
|
+
"message_id": f"kg_active_memory:{node_id}",
|
|
1577
|
+
},
|
|
1578
|
+
}
|
|
1579
|
+
)
|
|
1580
|
+
|
|
1581
|
+
if use_span_memory is True:
|
|
1582
|
+
span_ids_any = ms_meta.get("span_ids")
|
|
1583
|
+
span_ids_set = (
|
|
1584
|
+
{str(x).strip() for x in span_ids_any if isinstance(x, str) and str(x).strip()}
|
|
1585
|
+
if isinstance(span_ids_any, list)
|
|
1586
|
+
else set()
|
|
1587
|
+
)
|
|
1588
|
+
ctx_ns = run.vars.get("context") if isinstance(run.vars, dict) else None
|
|
1589
|
+
raw_msgs = ctx_ns.get("messages") if isinstance(ctx_ns, dict) else None
|
|
1590
|
+
if isinstance(raw_msgs, list) and span_ids_set:
|
|
1591
|
+
for m_any in raw_msgs:
|
|
1592
|
+
m = m_any if isinstance(m_any, dict) else None
|
|
1593
|
+
if m is None:
|
|
1594
|
+
continue
|
|
1595
|
+
meta_m = m.get("metadata")
|
|
1596
|
+
if not isinstance(meta_m, dict) or meta_m.get("rehydrated") is not True:
|
|
1597
|
+
continue
|
|
1598
|
+
src = meta_m.get("source_artifact_id")
|
|
1599
|
+
src_id = str(src).strip() if isinstance(src, str) else ""
|
|
1600
|
+
if src_id and src_id in span_ids_set:
|
|
1601
|
+
extra_messages.append(dict(m))
|
|
1602
|
+
|
|
1603
|
+
return StepPlan(
|
|
1604
|
+
node_id=node_id,
|
|
1605
|
+
effect=Effect(
|
|
1606
|
+
type=EffectType.START_SUBWORKFLOW,
|
|
1607
|
+
payload={
|
|
1608
|
+
"workflow_id": react_workflow_id,
|
|
1609
|
+
"vars": _build_sub_vars(
|
|
1610
|
+
run,
|
|
1611
|
+
task=task,
|
|
1612
|
+
context=context,
|
|
1613
|
+
provider=provider,
|
|
1614
|
+
model=model,
|
|
1615
|
+
system_prompt=system_prompt,
|
|
1616
|
+
allowed_tools=allowed_tools,
|
|
1617
|
+
temperature=temperature,
|
|
1618
|
+
seed=seed,
|
|
1619
|
+
include_context=include_context,
|
|
1620
|
+
max_iterations=max_iterations_override,
|
|
1621
|
+
max_input_tokens=max_input_tokens_override,
|
|
1622
|
+
max_output_tokens=max_output_tokens_override,
|
|
1623
|
+
extra_messages=extra_messages or None,
|
|
1624
|
+
include_session_attachments_index=use_session_attachments,
|
|
1625
|
+
),
|
|
1626
|
+
# Run Agent as a durable async subworkflow so the host can:
|
|
1627
|
+
# - tick the child incrementally (real-time observability of each effect)
|
|
1628
|
+
# - resume the parent once the child completes (async+wait mode)
|
|
1629
|
+
"async": True,
|
|
1630
|
+
"wait": True,
|
|
1631
|
+
"include_traces": True,
|
|
1632
|
+
},
|
|
1633
|
+
result_key=f"_temp.agent.{node_id}.sub",
|
|
1634
|
+
),
|
|
1635
|
+
next_node=node_id,
|
|
1636
|
+
)
|
|
1637
|
+
|
|
1638
|
+
if phase == "subworkflow":
|
|
1639
|
+
sub = bucket.get("sub")
|
|
1640
|
+
if sub is None:
|
|
1641
|
+
temp = _ensure_temp_dict(run)
|
|
1642
|
+
agent_ns = temp.get("agent")
|
|
1643
|
+
if isinstance(agent_ns, dict):
|
|
1644
|
+
node_bucket = agent_ns.get(node_id)
|
|
1645
|
+
if isinstance(node_bucket, dict):
|
|
1646
|
+
sub = node_bucket.get("sub")
|
|
1647
|
+
|
|
1648
|
+
if not isinstance(sub, dict):
|
|
1649
|
+
return StepPlan(node_id=node_id, next_node=node_id)
|
|
1650
|
+
|
|
1651
|
+
sub_run_id = sub.get("sub_run_id") if isinstance(sub.get("sub_run_id"), str) else None
|
|
1652
|
+
output = sub.get("output")
|
|
1653
|
+
output_dict = output if isinstance(output, dict) else {}
|
|
1654
|
+
answer = str(output_dict.get("answer") or "")
|
|
1655
|
+
iterations = output_dict.get("iterations")
|
|
1656
|
+
|
|
1657
|
+
node_traces = sub.get("node_traces")
|
|
1658
|
+
raw_messages = output_dict.get("messages")
|
|
1659
|
+
|
|
1660
|
+
def _normalize_messages(raw: Any) -> list[Dict[str, Any]]:
|
|
1661
|
+
if isinstance(raw, list):
|
|
1662
|
+
out: list[Dict[str, Any]] = []
|
|
1663
|
+
for m in raw:
|
|
1664
|
+
if isinstance(m, dict):
|
|
1665
|
+
out.append(dict(m))
|
|
1666
|
+
else:
|
|
1667
|
+
out.append({"role": "assistant", "content": str(m)})
|
|
1668
|
+
return out
|
|
1669
|
+
msgs: list[Dict[str, Any]] = []
|
|
1670
|
+
if str(task or "").strip():
|
|
1671
|
+
msgs.append({"role": "user", "content": str(task)})
|
|
1672
|
+
if answer.strip():
|
|
1673
|
+
msgs.append({"role": "assistant", "content": answer})
|
|
1674
|
+
return msgs
|
|
1675
|
+
|
|
1676
|
+
messages_out = _normalize_messages(raw_messages)
|
|
1677
|
+
context_extra: Dict[str, Any] = {}
|
|
1678
|
+
if isinstance(context, dict) and context:
|
|
1679
|
+
for k, v in context.items():
|
|
1680
|
+
if k in ("task", "messages"):
|
|
1681
|
+
continue
|
|
1682
|
+
context_extra[str(k)] = v
|
|
1683
|
+
scratchpad = {
|
|
1684
|
+
"sub_run_id": sub_run_id,
|
|
1685
|
+
"workflow_id": react_workflow_id,
|
|
1686
|
+
"task": str(task or ""),
|
|
1687
|
+
"messages": messages_out,
|
|
1688
|
+
"node_traces": node_traces if isinstance(node_traces, dict) else {},
|
|
1689
|
+
"steps": _flatten_node_traces(node_traces),
|
|
1690
|
+
}
|
|
1691
|
+
if context_extra:
|
|
1692
|
+
scratchpad["context_extra"] = context_extra
|
|
1693
|
+
bucket["scratchpad"] = scratchpad
|
|
1694
|
+
bucket["messages"] = messages_out
|
|
1695
|
+
tc, tr = _extract_tool_activity_from_steps(scratchpad.get("steps"))
|
|
1696
|
+
bucket["answer"] = answer
|
|
1697
|
+
bucket["success"] = True
|
|
1698
|
+
bucket["provider"] = provider
|
|
1699
|
+
bucket["model"] = model
|
|
1700
|
+
bucket["output_mode"] = "unstructured"
|
|
1701
|
+
if iterations is not None:
|
|
1702
|
+
bucket["iterations"] = iterations
|
|
1703
|
+
if sub_run_id:
|
|
1704
|
+
bucket["sub_run_id"] = sub_run_id
|
|
1705
|
+
|
|
1706
|
+
# Agent `result` is intentionally minimal in unstructured mode.
|
|
1707
|
+
# - The user-facing answer string is available on the `response` output pin.
|
|
1708
|
+
# - Execution metadata belongs in `meta`.
|
|
1709
|
+
# - The agent-internal transcript lives in `scratchpad.messages`.
|
|
1710
|
+
result_obj: Dict[str, Any] = {"success": True}
|
|
1711
|
+
|
|
1712
|
+
scratchpad_out = dict(scratchpad)
|
|
1713
|
+
scratchpad_out["tool_calls"] = tc
|
|
1714
|
+
scratchpad_out["tool_results"] = tr
|
|
1715
|
+
|
|
1716
|
+
meta: Dict[str, Any] = {
|
|
1717
|
+
"schema": "abstractcode.agent.v1.meta",
|
|
1718
|
+
"version": 1,
|
|
1719
|
+
"output_mode": "unstructured",
|
|
1720
|
+
"provider": provider,
|
|
1721
|
+
"model": model,
|
|
1722
|
+
"tool_calls": len(tc),
|
|
1723
|
+
"tool_results": len(tr),
|
|
1724
|
+
}
|
|
1725
|
+
if sub_run_id:
|
|
1726
|
+
meta["sub_run_id"] = sub_run_id
|
|
1727
|
+
if iterations is not None:
|
|
1728
|
+
meta["iterations"] = iterations
|
|
1729
|
+
|
|
1730
|
+
# When "Include/Use context" is enabled on the Agent node, persist the turn
|
|
1731
|
+
# into the parent run's active context so subsequent Agent/Subflow/LLM_CALL
|
|
1732
|
+
# nodes can see prior interactions (critical for recursive subflows).
|
|
1733
|
+
try:
|
|
1734
|
+
if include_context:
|
|
1735
|
+
already = bucket.get("context_appended_sub_run_id")
|
|
1736
|
+
if already != sub_run_id:
|
|
1737
|
+
ctx_ns = run.vars.get("context")
|
|
1738
|
+
if not isinstance(ctx_ns, dict):
|
|
1739
|
+
ctx_ns = {}
|
|
1740
|
+
run.vars["context"] = ctx_ns
|
|
1741
|
+
msgs = ctx_ns.get("messages")
|
|
1742
|
+
if not isinstance(msgs, list):
|
|
1743
|
+
msgs = []
|
|
1744
|
+
ctx_ns["messages"] = msgs
|
|
1745
|
+
|
|
1746
|
+
def _has_message_id(message_id: str) -> bool:
|
|
1747
|
+
for m in msgs:
|
|
1748
|
+
if not isinstance(m, dict):
|
|
1749
|
+
continue
|
|
1750
|
+
meta = m.get("metadata")
|
|
1751
|
+
if not isinstance(meta, dict):
|
|
1752
|
+
continue
|
|
1753
|
+
if meta.get("message_id") == message_id:
|
|
1754
|
+
return True
|
|
1755
|
+
return False
|
|
1756
|
+
|
|
1757
|
+
def _append(role: str, content: str, *, suffix: str) -> None:
|
|
1758
|
+
text = str(content or "").strip()
|
|
1759
|
+
if not text:
|
|
1760
|
+
return
|
|
1761
|
+
mid = f"agent:{sub_run_id or run.run_id}:{node_id}:{suffix}"
|
|
1762
|
+
if _has_message_id(mid):
|
|
1763
|
+
return
|
|
1764
|
+
meta: Dict[str, Any] = {"kind": "agent_turn", "node_id": node_id, "message_id": mid}
|
|
1765
|
+
if sub_run_id:
|
|
1766
|
+
meta["sub_run_id"] = sub_run_id
|
|
1767
|
+
msgs.append({"role": role, "content": text, "metadata": meta})
|
|
1768
|
+
|
|
1769
|
+
def _truncate(text: str, *, max_chars: int) -> str:
|
|
1770
|
+
if max_chars <= 0:
|
|
1771
|
+
return text
|
|
1772
|
+
if len(text) <= max_chars:
|
|
1773
|
+
return text
|
|
1774
|
+
suffix = f"\n… (truncated, {len(text):,} chars total)"
|
|
1775
|
+
keep = max_chars - len(suffix)
|
|
1776
|
+
if keep < 200:
|
|
1777
|
+
keep = max_chars
|
|
1778
|
+
suffix = ""
|
|
1779
|
+
return text[:keep].rstrip() + suffix
|
|
1780
|
+
|
|
1781
|
+
def _append_tool_observations() -> None:
|
|
1782
|
+
# Persist a compact transcript of tool results executed by this Agent sub-run.
|
|
1783
|
+
# This is critical for outer loops (e.g. RALPH) that re-invoke the agent and
|
|
1784
|
+
# need evidence continuity to avoid repeating the same tool calls forever.
|
|
1785
|
+
if not isinstance(tr, list) or not tr:
|
|
1786
|
+
return
|
|
1787
|
+
|
|
1788
|
+
# Optional: attach arguments when available (from tool_calls).
|
|
1789
|
+
call_by_id: Dict[str, Dict[str, Any]] = {}
|
|
1790
|
+
if isinstance(tc, list):
|
|
1791
|
+
for c in tc:
|
|
1792
|
+
if not isinstance(c, dict):
|
|
1793
|
+
continue
|
|
1794
|
+
cid = str(c.get("call_id") or "").strip()
|
|
1795
|
+
if cid:
|
|
1796
|
+
call_by_id[cid] = dict(c)
|
|
1797
|
+
|
|
1798
|
+
max_results = 20
|
|
1799
|
+
for i, r in enumerate(tr[:max_results]):
|
|
1800
|
+
if not isinstance(r, dict):
|
|
1801
|
+
continue
|
|
1802
|
+
name = str(r.get("name") or "tool").strip() or "tool"
|
|
1803
|
+
call_id = str(r.get("call_id") or "").strip()
|
|
1804
|
+
success = bool(r.get("success"))
|
|
1805
|
+
|
|
1806
|
+
args = None
|
|
1807
|
+
if call_id and call_id in call_by_id:
|
|
1808
|
+
raw_args = call_by_id[call_id].get("arguments")
|
|
1809
|
+
if isinstance(raw_args, dict) and raw_args:
|
|
1810
|
+
args = raw_args
|
|
1811
|
+
|
|
1812
|
+
output = r.get("output")
|
|
1813
|
+
error = r.get("error")
|
|
1814
|
+
|
|
1815
|
+
try:
|
|
1816
|
+
rendered_out = json.dumps(output, ensure_ascii=False, indent=2) if isinstance(output, (dict, list)) else str(output or "")
|
|
1817
|
+
except Exception:
|
|
1818
|
+
rendered_out = "" if output is None else str(output)
|
|
1819
|
+
rendered_out = rendered_out.strip()
|
|
1820
|
+
|
|
1821
|
+
details = str(error or rendered_out).strip() if not success else rendered_out
|
|
1822
|
+
if args is not None:
|
|
1823
|
+
try:
|
|
1824
|
+
args_txt = json.dumps(args, ensure_ascii=False, sort_keys=True)
|
|
1825
|
+
except Exception:
|
|
1826
|
+
args_txt = str(args)
|
|
1827
|
+
details = f"args={args_txt}\n{details}".strip()
|
|
1828
|
+
|
|
1829
|
+
details = _truncate(details, max_chars=2000)
|
|
1830
|
+
content = f"[{name}]: {details}" if success else f"[{name}]: Error: {details}"
|
|
1831
|
+
|
|
1832
|
+
# Store as an assistant message for broad provider compatibility.
|
|
1833
|
+
# (Some OpenAI-compatible servers are strict about role='tool' message shape.)
|
|
1834
|
+
suffix = f"tool:{call_id or i}"
|
|
1835
|
+
mid = f"agent:{sub_run_id or run.run_id}:{node_id}:{suffix}"
|
|
1836
|
+
if _has_message_id(mid):
|
|
1837
|
+
continue
|
|
1838
|
+
meta: Dict[str, Any] = {
|
|
1839
|
+
"kind": "tool_observation",
|
|
1840
|
+
"node_id": node_id,
|
|
1841
|
+
"message_id": mid,
|
|
1842
|
+
"tool_name": name,
|
|
1843
|
+
"success": success,
|
|
1844
|
+
}
|
|
1845
|
+
if call_id:
|
|
1846
|
+
meta["call_id"] = call_id
|
|
1847
|
+
if sub_run_id:
|
|
1848
|
+
meta["sub_run_id"] = sub_run_id
|
|
1849
|
+
msgs.append({"role": "assistant", "content": content, "metadata": meta})
|
|
1850
|
+
|
|
1851
|
+
if len(tr) > max_results:
|
|
1852
|
+
omitted = len(tr) - max_results
|
|
1853
|
+
mid = f"agent:{sub_run_id or run.run_id}:{node_id}:tool:omitted"
|
|
1854
|
+
if not _has_message_id(mid):
|
|
1855
|
+
msgs.append(
|
|
1856
|
+
{
|
|
1857
|
+
"role": "assistant",
|
|
1858
|
+
"content": f"[tools]: (omitted {omitted} additional tool results for brevity; see sub_run_id={sub_run_id})",
|
|
1859
|
+
"metadata": {
|
|
1860
|
+
"kind": "tool_observation_summary",
|
|
1861
|
+
"node_id": node_id,
|
|
1862
|
+
"message_id": mid,
|
|
1863
|
+
"sub_run_id": sub_run_id,
|
|
1864
|
+
},
|
|
1865
|
+
}
|
|
1866
|
+
)
|
|
1867
|
+
|
|
1868
|
+
_append("user", task, suffix="task")
|
|
1869
|
+
_append_tool_observations()
|
|
1870
|
+
_append("assistant", answer, suffix="answer")
|
|
1871
|
+
bucket["context_appended_sub_run_id"] = sub_run_id
|
|
1872
|
+
except Exception:
|
|
1873
|
+
# Context persistence must never break Agent execution.
|
|
1874
|
+
pass
|
|
1875
|
+
|
|
1876
|
+
if schema_enabled and schema:
|
|
1877
|
+
bucket["phase"] = "structured"
|
|
1878
|
+
messages = [
|
|
1879
|
+
{
|
|
1880
|
+
"role": "user",
|
|
1881
|
+
"content": (
|
|
1882
|
+
"Convert the Agent answer into a JSON object matching the required schema. Return JSON only.\n\n"
|
|
1883
|
+
f"Task:\n{task}\n\n"
|
|
1884
|
+
f"Answer:\n{answer}"
|
|
1885
|
+
),
|
|
1886
|
+
}
|
|
1887
|
+
]
|
|
1888
|
+
return StepPlan(
|
|
1889
|
+
node_id=node_id,
|
|
1890
|
+
effect=Effect(
|
|
1891
|
+
type=EffectType.LLM_CALL,
|
|
1892
|
+
payload={
|
|
1893
|
+
"messages": messages,
|
|
1894
|
+
"system_prompt": system_prompt,
|
|
1895
|
+
"provider": provider,
|
|
1896
|
+
"model": model,
|
|
1897
|
+
"response_schema": schema,
|
|
1898
|
+
"response_schema_name": f"Agent_{node_id}",
|
|
1899
|
+
"params": {"temperature": temperature, "seed": seed} if seed >= 0 else {"temperature": temperature},
|
|
1900
|
+
},
|
|
1901
|
+
result_key=f"_temp.agent.{node_id}.structured",
|
|
1902
|
+
),
|
|
1903
|
+
next_node=node_id,
|
|
1904
|
+
)
|
|
1905
|
+
|
|
1906
|
+
_set_nested(run.vars, f"_temp.effects.{node_id}", result_obj)
|
|
1907
|
+
bucket["phase"] = "done"
|
|
1908
|
+
flow._node_outputs[node_id] = {
|
|
1909
|
+
"response": answer,
|
|
1910
|
+
"success": True,
|
|
1911
|
+
"meta": meta,
|
|
1912
|
+
"scratchpad": scratchpad_out,
|
|
1913
|
+
# Backward-compat / convenience:
|
|
1914
|
+
"tool_calls": tc,
|
|
1915
|
+
"tool_results": tr,
|
|
1916
|
+
}
|
|
1917
|
+
run.vars["_last_output"] = dict(flow._node_outputs.get(node_id) or {})
|
|
1918
|
+
if next_node:
|
|
1919
|
+
return StepPlan(node_id=node_id, next_node=next_node)
|
|
1920
|
+
return StepPlan(
|
|
1921
|
+
node_id=node_id,
|
|
1922
|
+
complete_output={
|
|
1923
|
+
"response": answer,
|
|
1924
|
+
"success": True,
|
|
1925
|
+
"meta": meta,
|
|
1926
|
+
"scratchpad": scratchpad_out,
|
|
1927
|
+
},
|
|
1928
|
+
)
|
|
1929
|
+
|
|
1930
|
+
if phase == "structured":
|
|
1931
|
+
structured_resp = bucket.get("structured")
|
|
1932
|
+
if structured_resp is None:
|
|
1933
|
+
temp = _ensure_temp_dict(run)
|
|
1934
|
+
agent_bucket = temp.get("agent", {}).get(node_id, {}) if isinstance(temp.get("agent"), dict) else {}
|
|
1935
|
+
structured_resp = agent_bucket.get("structured") if isinstance(agent_bucket, dict) else None
|
|
1936
|
+
|
|
1937
|
+
data = structured_resp.get("data") if isinstance(structured_resp, dict) else None
|
|
1938
|
+
if data is None and isinstance(structured_resp, dict):
|
|
1939
|
+
content = structured_resp.get("content")
|
|
1940
|
+
if isinstance(content, str) and content.strip():
|
|
1941
|
+
try:
|
|
1942
|
+
data = json.loads(content)
|
|
1943
|
+
except Exception:
|
|
1944
|
+
data = None
|
|
1945
|
+
|
|
1946
|
+
if not isinstance(data, dict):
|
|
1947
|
+
data = {}
|
|
1948
|
+
|
|
1949
|
+
_set_nested(run.vars, f"_temp.effects.{node_id}", data)
|
|
1950
|
+
bucket["phase"] = "done"
|
|
1951
|
+
scratchpad = bucket.get("scratchpad")
|
|
1952
|
+
if not isinstance(scratchpad, dict):
|
|
1953
|
+
scratchpad = {"node_id": node_id, "steps": []}
|
|
1954
|
+
tc, tr = _extract_tool_activity_from_steps(scratchpad.get("steps"))
|
|
1955
|
+
answer = bucket.get("answer") if isinstance(bucket.get("answer"), str) else ""
|
|
1956
|
+
scratchpad_out = dict(scratchpad)
|
|
1957
|
+
if "messages" not in scratchpad_out:
|
|
1958
|
+
msgs = bucket.get("messages")
|
|
1959
|
+
if isinstance(msgs, list):
|
|
1960
|
+
scratchpad_out["messages"] = list(msgs)
|
|
1961
|
+
scratchpad_out["tool_calls"] = tc
|
|
1962
|
+
scratchpad_out["tool_results"] = tr
|
|
1963
|
+
|
|
1964
|
+
meta: Dict[str, Any] = {
|
|
1965
|
+
"schema": "abstractcode.agent.v1.meta",
|
|
1966
|
+
"version": 1,
|
|
1967
|
+
"output_mode": "structured",
|
|
1968
|
+
"provider": provider,
|
|
1969
|
+
"model": model,
|
|
1970
|
+
"tool_calls": len(tc),
|
|
1971
|
+
"tool_results": len(tr),
|
|
1972
|
+
}
|
|
1973
|
+
sr = scratchpad_out.get("sub_run_id")
|
|
1974
|
+
if isinstance(sr, str) and sr.strip():
|
|
1975
|
+
meta["sub_run_id"] = sr.strip()
|
|
1976
|
+
|
|
1977
|
+
try:
|
|
1978
|
+
response_text = json.dumps(data, ensure_ascii=False, separators=(",", ":"))
|
|
1979
|
+
except Exception:
|
|
1980
|
+
response_text = str(data)
|
|
1981
|
+
|
|
1982
|
+
flow._node_outputs[node_id] = {
|
|
1983
|
+
"response": response_text,
|
|
1984
|
+
"success": True,
|
|
1985
|
+
"meta": meta,
|
|
1986
|
+
"scratchpad": scratchpad_out,
|
|
1987
|
+
# Backward-compat / convenience:
|
|
1988
|
+
"tool_calls": tc,
|
|
1989
|
+
"tool_results": tr,
|
|
1990
|
+
}
|
|
1991
|
+
bucket["output_mode"] = "structured"
|
|
1992
|
+
run.vars["_last_output"] = dict(flow._node_outputs.get(node_id) or {})
|
|
1993
|
+
if next_node:
|
|
1994
|
+
return StepPlan(node_id=node_id, next_node=next_node)
|
|
1995
|
+
return StepPlan(
|
|
1996
|
+
node_id=node_id,
|
|
1997
|
+
complete_output={
|
|
1998
|
+
"response": response_text,
|
|
1999
|
+
"success": True,
|
|
2000
|
+
"meta": meta,
|
|
2001
|
+
"scratchpad": scratchpad_out,
|
|
2002
|
+
},
|
|
2003
|
+
)
|
|
2004
|
+
|
|
2005
|
+
if next_node:
|
|
2006
|
+
return StepPlan(node_id=node_id, next_node=next_node)
|
|
2007
|
+
last = run.vars.get("_last_output")
|
|
2008
|
+
if isinstance(last, dict):
|
|
2009
|
+
return StepPlan(
|
|
2010
|
+
node_id=node_id,
|
|
2011
|
+
complete_output={
|
|
2012
|
+
"response": str(last.get("response") or ""),
|
|
2013
|
+
"success": bool(last.get("success")) if "success" in last else True,
|
|
2014
|
+
"meta": last.get("meta") if isinstance(last.get("meta"), dict) else {},
|
|
2015
|
+
"scratchpad": last.get("scratchpad"),
|
|
2016
|
+
},
|
|
2017
|
+
)
|
|
2018
|
+
return StepPlan(
|
|
2019
|
+
node_id=node_id,
|
|
2020
|
+
complete_output={"response": str(last) if last is not None else "", "success": True, "meta": {}, "scratchpad": None},
|
|
2021
|
+
)
|
|
2022
|
+
|
|
2023
|
+
return handler
|
|
2024
|
+
|
|
2025
|
+
|
|
2026
|
+
def _create_visual_function_handler(
|
|
2027
|
+
node_id: str,
|
|
2028
|
+
func: Callable,
|
|
2029
|
+
next_node: Optional[str],
|
|
2030
|
+
input_key: Optional[str],
|
|
2031
|
+
output_key: Optional[str],
|
|
2032
|
+
flow: Flow,
|
|
2033
|
+
branch_map: Optional[Dict[str, str]] = None,
|
|
2034
|
+
) -> Callable:
|
|
2035
|
+
"""Create a handler for visual flow function nodes.
|
|
2036
|
+
|
|
2037
|
+
Visual flows use data edges for passing values between nodes. This handler:
|
|
2038
|
+
1. Syncs effect results from run.vars to flow._node_outputs
|
|
2039
|
+
2. Calls the wrapped function with proper input
|
|
2040
|
+
3. Updates _last_output for downstream nodes
|
|
2041
|
+
"""
|
|
2042
|
+
from abstractruntime.core.models import StepPlan
|
|
2043
|
+
|
|
2044
|
+
def handler(run: Any, ctx: Any) -> "StepPlan":
|
|
2045
|
+
"""Execute the function and transition to next node."""
|
|
2046
|
+
def _to_complete_output(value: Any) -> Dict[str, Any]:
|
|
2047
|
+
if isinstance(value, dict):
|
|
2048
|
+
out = dict(value)
|
|
2049
|
+
out.setdefault("success", True)
|
|
2050
|
+
return out
|
|
2051
|
+
return {"response": value, "success": True}
|
|
2052
|
+
|
|
2053
|
+
# Sync effect results from run.vars to flow._node_outputs
|
|
2054
|
+
# This allows data edges from effect nodes to resolve correctly
|
|
2055
|
+
if hasattr(flow, '_node_outputs') and hasattr(flow, '_data_edge_map'):
|
|
2056
|
+
_sync_effect_results_to_node_outputs(run, flow)
|
|
2057
|
+
|
|
2058
|
+
# Get input from _last_output (visual flow pattern)
|
|
2059
|
+
# or from input_key if specified
|
|
2060
|
+
if input_key:
|
|
2061
|
+
input_data = run.vars.get(input_key)
|
|
2062
|
+
else:
|
|
2063
|
+
input_data = run.vars.get("_last_output") if "_last_output" in run.vars else run.vars
|
|
2064
|
+
|
|
2065
|
+
# Execute function (which is the data-aware wrapped handler)
|
|
2066
|
+
try:
|
|
2067
|
+
result = func(input_data)
|
|
2068
|
+
except Exception as e:
|
|
2069
|
+
run.vars["_flow_error"] = str(e)
|
|
2070
|
+
run.vars["_flow_error_node"] = node_id
|
|
2071
|
+
return StepPlan(
|
|
2072
|
+
node_id=node_id,
|
|
2073
|
+
complete_output={"error": str(e), "success": False, "node": node_id},
|
|
2074
|
+
)
|
|
2075
|
+
|
|
2076
|
+
# Store result in _last_output for downstream nodes
|
|
2077
|
+
run.vars["_last_output"] = result
|
|
2078
|
+
|
|
2079
|
+
# Persist per-node outputs for data-edge rehydration across pause/resume.
|
|
2080
|
+
#
|
|
2081
|
+
# Visual data edges read from `flow._node_outputs`, which is an in-memory
|
|
2082
|
+
# cache. When a run pauses (ASK_USER / TOOL passthrough) and is resumed
|
|
2083
|
+
# in a different process, we must be able to reconstruct upstream node
|
|
2084
|
+
# outputs from persisted `RunState.vars`.
|
|
2085
|
+
temp = run.vars.get("_temp")
|
|
2086
|
+
if not isinstance(temp, dict):
|
|
2087
|
+
temp = {}
|
|
2088
|
+
run.vars["_temp"] = temp
|
|
2089
|
+
persisted_outputs = temp.get("node_outputs")
|
|
2090
|
+
if not isinstance(persisted_outputs, dict):
|
|
2091
|
+
persisted_outputs = {}
|
|
2092
|
+
temp["node_outputs"] = persisted_outputs
|
|
2093
|
+
persisted_outputs[node_id] = result
|
|
2094
|
+
|
|
2095
|
+
# Also store in output_key if specified
|
|
2096
|
+
if output_key:
|
|
2097
|
+
_set_nested(run.vars, output_key, result)
|
|
2098
|
+
|
|
2099
|
+
if branch_map is not None:
|
|
2100
|
+
branch = result.get("branch") if isinstance(result, dict) else None
|
|
2101
|
+
if not isinstance(branch, str) or not branch:
|
|
2102
|
+
run.vars["_flow_error"] = "Branching node did not return a string 'branch' value"
|
|
2103
|
+
run.vars["_flow_error_node"] = node_id
|
|
2104
|
+
return StepPlan(
|
|
2105
|
+
node_id=node_id,
|
|
2106
|
+
complete_output={
|
|
2107
|
+
"error": "Branching node did not return a string 'branch' value",
|
|
2108
|
+
"success": False,
|
|
2109
|
+
"node": node_id,
|
|
2110
|
+
},
|
|
2111
|
+
)
|
|
2112
|
+
chosen = branch_map.get(branch)
|
|
2113
|
+
if not isinstance(chosen, str) or not chosen:
|
|
2114
|
+
# Blueprint-style behavior: if the chosen execution pin isn't connected,
|
|
2115
|
+
# treat it as a clean completion instead of an error.
|
|
2116
|
+
if branch in {"true", "false", "default"} or branch.startswith("case:"):
|
|
2117
|
+
return StepPlan(
|
|
2118
|
+
node_id=node_id,
|
|
2119
|
+
complete_output=_to_complete_output(result),
|
|
2120
|
+
)
|
|
2121
|
+
|
|
2122
|
+
run.vars["_flow_error"] = f"Unknown branch '{branch}'"
|
|
2123
|
+
run.vars["_flow_error_node"] = node_id
|
|
2124
|
+
return StepPlan(
|
|
2125
|
+
node_id=node_id,
|
|
2126
|
+
complete_output={
|
|
2127
|
+
"error": f"Unknown branch '{branch}'",
|
|
2128
|
+
"success": False,
|
|
2129
|
+
"node": node_id,
|
|
2130
|
+
},
|
|
2131
|
+
)
|
|
2132
|
+
return StepPlan(node_id=node_id, next_node=chosen)
|
|
2133
|
+
|
|
2134
|
+
# Continue to next node or complete
|
|
2135
|
+
if next_node:
|
|
2136
|
+
return StepPlan(node_id=node_id, next_node=next_node)
|
|
2137
|
+
return StepPlan(
|
|
2138
|
+
node_id=node_id,
|
|
2139
|
+
complete_output=_to_complete_output(result),
|
|
2140
|
+
)
|
|
2141
|
+
|
|
2142
|
+
return handler
|
|
2143
|
+
|
|
2144
|
+
|
|
2145
|
+
def _sync_effect_results_to_node_outputs(run: Any, flow: Flow) -> None:
|
|
2146
|
+
"""Sync effect results from run.vars to flow._node_outputs.
|
|
2147
|
+
|
|
2148
|
+
When an effect (like ask_user) completes, its result is stored in run.vars
|
|
2149
|
+
at the result_key. But visual flow data edges read from flow._node_outputs.
|
|
2150
|
+
This function syncs those results so data edges resolve correctly.
|
|
2151
|
+
"""
|
|
2152
|
+
# Attach a live reference to the current run vars so pure nodes (e.g. Get Variable)
|
|
2153
|
+
# can read the up-to-date workflow state during data-edge resolution.
|
|
2154
|
+
try:
|
|
2155
|
+
if hasattr(run, "vars") and isinstance(run.vars, dict):
|
|
2156
|
+
flow._run_vars = run.vars # type: ignore[attr-defined]
|
|
2157
|
+
except Exception:
|
|
2158
|
+
pass
|
|
2159
|
+
|
|
2160
|
+
# IMPORTANT: `flow._node_outputs` is an in-memory cache used by the visual executor
|
|
2161
|
+
# to resolve data edges (including lazy "pure" nodes like compare/subtract/concat).
|
|
2162
|
+
#
|
|
2163
|
+
# A single compiled `Flow` instance can be executed by multiple `RunState`s in the
|
|
2164
|
+
# same process when using subworkflows (START_SUBWORKFLOW) — especially with
|
|
2165
|
+
# self-recursion or mutual recursion. In that situation, stale cached outputs from
|
|
2166
|
+
# another run can break correctness (e.g. a cached `compare` result keeps a base-case
|
|
2167
|
+
# from ever becoming false), leading to infinite recursion.
|
|
2168
|
+
#
|
|
2169
|
+
# We isolate caches per run_id by resetting the dict *in-place* when the active run
|
|
2170
|
+
# changes, then rehydrating persisted outputs from `run.vars["_temp"]`.
|
|
2171
|
+
node_outputs = flow._node_outputs
|
|
2172
|
+
try:
|
|
2173
|
+
rid = getattr(run, "run_id", None)
|
|
2174
|
+
if isinstance(rid, str) and rid:
|
|
2175
|
+
active = getattr(flow, "_active_run_id", None)
|
|
2176
|
+
if active != rid:
|
|
2177
|
+
base = getattr(flow, "_static_node_outputs", None)
|
|
2178
|
+
# Backward-compat: if the baseline wasn't set (older flows), infer it
|
|
2179
|
+
# on first use — at this point it should contain only literal nodes.
|
|
2180
|
+
if not isinstance(base, dict):
|
|
2181
|
+
base = dict(node_outputs) if isinstance(node_outputs, dict) else {}
|
|
2182
|
+
try:
|
|
2183
|
+
flow._static_node_outputs = dict(base) # type: ignore[attr-defined]
|
|
2184
|
+
except Exception:
|
|
2185
|
+
pass
|
|
2186
|
+
if isinstance(node_outputs, dict):
|
|
2187
|
+
node_outputs.clear()
|
|
2188
|
+
if isinstance(base, dict):
|
|
2189
|
+
node_outputs.update(base)
|
|
2190
|
+
try:
|
|
2191
|
+
flow._active_run_id = rid # type: ignore[attr-defined]
|
|
2192
|
+
except Exception:
|
|
2193
|
+
pass
|
|
2194
|
+
except Exception:
|
|
2195
|
+
# Best-effort; never let cache isolation break execution.
|
|
2196
|
+
pass
|
|
2197
|
+
|
|
2198
|
+
temp_data = run.vars.get("_temp", {})
|
|
2199
|
+
if not isinstance(temp_data, dict):
|
|
2200
|
+
return
|
|
2201
|
+
|
|
2202
|
+
# Restore persisted outputs for executed (non-effect) nodes.
|
|
2203
|
+
persisted = temp_data.get("node_outputs")
|
|
2204
|
+
if isinstance(persisted, dict):
|
|
2205
|
+
for nid, out in persisted.items():
|
|
2206
|
+
if isinstance(nid, str) and nid:
|
|
2207
|
+
node_outputs[nid] = out
|
|
2208
|
+
|
|
2209
|
+
effects = temp_data.get("effects")
|
|
2210
|
+
if not isinstance(effects, dict):
|
|
2211
|
+
effects = {}
|
|
2212
|
+
|
|
2213
|
+
# Determine which output pins are actually referenced by data edges.
|
|
2214
|
+
# This lets us keep legacy pins (e.g. `result`) working for older saved flows,
|
|
2215
|
+
# without emitting them for new flows that don't wire them.
|
|
2216
|
+
referenced_source_pins: set[tuple[str, str]] = set()
|
|
2217
|
+
try:
|
|
2218
|
+
data_edge_map = getattr(flow, "_data_edge_map", None)
|
|
2219
|
+
if isinstance(data_edge_map, dict):
|
|
2220
|
+
for target_pins in data_edge_map.values():
|
|
2221
|
+
if not isinstance(target_pins, dict):
|
|
2222
|
+
continue
|
|
2223
|
+
for src_any in target_pins.values():
|
|
2224
|
+
if not (isinstance(src_any, tuple) and len(src_any) == 2):
|
|
2225
|
+
continue
|
|
2226
|
+
src_node_id, src_pin_id = src_any
|
|
2227
|
+
if isinstance(src_node_id, str) and isinstance(src_pin_id, str):
|
|
2228
|
+
referenced_source_pins.add((src_node_id, src_pin_id))
|
|
2229
|
+
except Exception:
|
|
2230
|
+
referenced_source_pins = set()
|
|
2231
|
+
|
|
2232
|
+
def _get_span_id(raw: Any) -> Optional[str]:
|
|
2233
|
+
if not isinstance(raw, dict):
|
|
2234
|
+
return None
|
|
2235
|
+
results = raw.get("results")
|
|
2236
|
+
if not isinstance(results, list) or not results:
|
|
2237
|
+
return None
|
|
2238
|
+
first = results[0]
|
|
2239
|
+
if not isinstance(first, dict):
|
|
2240
|
+
return None
|
|
2241
|
+
meta = first.get("meta")
|
|
2242
|
+
if not isinstance(meta, dict):
|
|
2243
|
+
return None
|
|
2244
|
+
span_id = meta.get("span_id")
|
|
2245
|
+
if isinstance(span_id, str) and span_id.strip():
|
|
2246
|
+
return span_id.strip()
|
|
2247
|
+
return None
|
|
2248
|
+
|
|
2249
|
+
def _as_dict_list(value: Any) -> List[Dict[str, Any]]:
|
|
2250
|
+
"""Normalize a value into a list of dicts (best-effort, JSON-safe)."""
|
|
2251
|
+
if value is None:
|
|
2252
|
+
return []
|
|
2253
|
+
if isinstance(value, dict):
|
|
2254
|
+
return [dict(value)]
|
|
2255
|
+
if isinstance(value, list):
|
|
2256
|
+
out: List[Dict[str, Any]] = []
|
|
2257
|
+
for x in value:
|
|
2258
|
+
if isinstance(x, dict):
|
|
2259
|
+
out.append(dict(x))
|
|
2260
|
+
return out
|
|
2261
|
+
return []
|
|
2262
|
+
|
|
2263
|
+
def _extract_agent_tool_activity(scratchpad: Any) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
|
|
2264
|
+
"""Extract tool call requests and tool results from an agent scratchpad.
|
|
2265
|
+
|
|
2266
|
+
This is *post-run* ergonomics: it does not provide real-time streaming while the agent runs.
|
|
2267
|
+
For real-time tool observability, hosts should subscribe to the ledger and/or node_traces.
|
|
2268
|
+
"""
|
|
2269
|
+
sp = scratchpad if isinstance(scratchpad, dict) else None
|
|
2270
|
+
if sp is None:
|
|
2271
|
+
return [], []
|
|
2272
|
+
|
|
2273
|
+
node_traces = sp.get("node_traces")
|
|
2274
|
+
if not isinstance(node_traces, dict):
|
|
2275
|
+
# Allow passing a single node trace directly.
|
|
2276
|
+
if isinstance(sp.get("steps"), list) and sp.get("node_id") is not None:
|
|
2277
|
+
node_traces = {str(sp.get("node_id")): sp}
|
|
2278
|
+
else:
|
|
2279
|
+
return [], []
|
|
2280
|
+
|
|
2281
|
+
# Flatten steps across nodes and sort by timestamp (ISO strings are lexicographically sortable).
|
|
2282
|
+
steps: List[Tuple[str, Dict[str, Any]]] = []
|
|
2283
|
+
for _nid, trace_any in node_traces.items():
|
|
2284
|
+
trace = trace_any if isinstance(trace_any, dict) else None
|
|
2285
|
+
if trace is None:
|
|
2286
|
+
continue
|
|
2287
|
+
entries = trace.get("steps")
|
|
2288
|
+
if not isinstance(entries, list):
|
|
2289
|
+
continue
|
|
2290
|
+
for entry_any in entries:
|
|
2291
|
+
entry = entry_any if isinstance(entry_any, dict) else None
|
|
2292
|
+
if entry is None:
|
|
2293
|
+
continue
|
|
2294
|
+
ts = entry.get("ts")
|
|
2295
|
+
ts_s = ts if isinstance(ts, str) else ""
|
|
2296
|
+
steps.append((ts_s, entry))
|
|
2297
|
+
steps.sort(key=lambda x: x[0])
|
|
2298
|
+
|
|
2299
|
+
tool_calls: List[Dict[str, Any]] = []
|
|
2300
|
+
tool_results: List[Dict[str, Any]] = []
|
|
2301
|
+
for _ts, entry in steps:
|
|
2302
|
+
effect = entry.get("effect")
|
|
2303
|
+
if not isinstance(effect, dict):
|
|
2304
|
+
continue
|
|
2305
|
+
if str(effect.get("type") or "") != "tool_calls":
|
|
2306
|
+
continue
|
|
2307
|
+
payload = effect.get("payload")
|
|
2308
|
+
payload_d = payload if isinstance(payload, dict) else {}
|
|
2309
|
+
tool_calls.extend(_as_dict_list(payload_d.get("tool_calls")))
|
|
2310
|
+
|
|
2311
|
+
result = entry.get("result")
|
|
2312
|
+
if not isinstance(result, dict):
|
|
2313
|
+
continue
|
|
2314
|
+
results = result.get("results")
|
|
2315
|
+
tool_results.extend(_as_dict_list(results))
|
|
2316
|
+
|
|
2317
|
+
return tool_calls, tool_results
|
|
2318
|
+
|
|
2319
|
+
for node_id, flow_node in flow.nodes.items():
|
|
2320
|
+
effect_type = flow_node.effect_type
|
|
2321
|
+
if not effect_type:
|
|
2322
|
+
continue
|
|
2323
|
+
|
|
2324
|
+
raw = effects.get(node_id)
|
|
2325
|
+
if raw is None:
|
|
2326
|
+
# Backward-compat for older runs/tests that stored by effect type.
|
|
2327
|
+
legacy_key = f"{effect_type}_response"
|
|
2328
|
+
raw = temp_data.get(legacy_key)
|
|
2329
|
+
if raw is None:
|
|
2330
|
+
continue
|
|
2331
|
+
|
|
2332
|
+
current = node_outputs.get(node_id)
|
|
2333
|
+
if not isinstance(current, dict):
|
|
2334
|
+
current = {}
|
|
2335
|
+
node_outputs[node_id] = current
|
|
2336
|
+
else:
|
|
2337
|
+
# If this node previously produced a pre-effect placeholder from the
|
|
2338
|
+
# visual executor (e.g. `_pending_effect`), remove it now that we have
|
|
2339
|
+
# the durable effect outcome in `run.vars["_temp"]["effects"]`.
|
|
2340
|
+
current.pop("_pending_effect", None)
|
|
2341
|
+
|
|
2342
|
+
mapped_value: Any = None
|
|
2343
|
+
|
|
2344
|
+
if effect_type == "ask_user":
|
|
2345
|
+
if isinstance(raw, dict):
|
|
2346
|
+
# raw is usually {"response": "..."} (resume payload)
|
|
2347
|
+
current.update(raw)
|
|
2348
|
+
mapped_value = raw.get("response")
|
|
2349
|
+
elif effect_type == "answer_user":
|
|
2350
|
+
if isinstance(raw, dict):
|
|
2351
|
+
current.update(raw)
|
|
2352
|
+
mapped_value = raw.get("message")
|
|
2353
|
+
else:
|
|
2354
|
+
current["message"] = raw
|
|
2355
|
+
mapped_value = raw
|
|
2356
|
+
elif effect_type == "llm_call":
|
|
2357
|
+
if isinstance(raw, dict):
|
|
2358
|
+
tool_calls = _as_dict_list(raw.get("tool_calls"))
|
|
2359
|
+
data = raw.get("data")
|
|
2360
|
+
response_text = ""
|
|
2361
|
+
if isinstance(data, (dict, list)):
|
|
2362
|
+
try:
|
|
2363
|
+
import json as _json
|
|
2364
|
+
|
|
2365
|
+
response_text = _json.dumps(data, ensure_ascii=False, separators=(",", ":"))
|
|
2366
|
+
except Exception:
|
|
2367
|
+
response_text = ""
|
|
2368
|
+
if not response_text:
|
|
2369
|
+
content = raw.get("content")
|
|
2370
|
+
response_text = str(content) if content is not None else ""
|
|
2371
|
+
current["response"] = response_text
|
|
2372
|
+
# Convenience pin: expose tool_calls directly, instead of forcing consumers
|
|
2373
|
+
# to drill into `result.tool_calls` via a Break Object node.
|
|
2374
|
+
current["tool_calls"] = tool_calls
|
|
2375
|
+
current["success"] = True
|
|
2376
|
+
|
|
2377
|
+
meta: Dict[str, Any] = {
|
|
2378
|
+
"schema": "abstractflow.llm_call.v1.meta",
|
|
2379
|
+
"version": 1,
|
|
2380
|
+
"output_mode": "structured" if isinstance(data, (dict, list)) else "unstructured",
|
|
2381
|
+
"tool_calls": len(tool_calls),
|
|
2382
|
+
}
|
|
2383
|
+
provider = raw.get("provider")
|
|
2384
|
+
if isinstance(provider, str) and provider.strip():
|
|
2385
|
+
meta["provider"] = provider.strip()
|
|
2386
|
+
model = raw.get("model")
|
|
2387
|
+
if isinstance(model, str) and model.strip():
|
|
2388
|
+
meta["model"] = model.strip()
|
|
2389
|
+
finish_reason = raw.get("finish_reason")
|
|
2390
|
+
if isinstance(finish_reason, str) and finish_reason.strip():
|
|
2391
|
+
meta["finish_reason"] = finish_reason.strip()
|
|
2392
|
+
usage = raw.get("usage")
|
|
2393
|
+
if isinstance(usage, dict):
|
|
2394
|
+
meta["usage"] = dict(usage)
|
|
2395
|
+
trace_id = raw.get("trace_id")
|
|
2396
|
+
if not (isinstance(trace_id, str) and trace_id.strip()):
|
|
2397
|
+
md = raw.get("metadata")
|
|
2398
|
+
if isinstance(md, dict):
|
|
2399
|
+
trace_id = md.get("trace_id")
|
|
2400
|
+
if isinstance(trace_id, str) and trace_id.strip():
|
|
2401
|
+
meta["trace"] = {"trace_id": trace_id.strip()}
|
|
2402
|
+
gen_time = raw.get("gen_time")
|
|
2403
|
+
if gen_time is not None and not isinstance(gen_time, bool):
|
|
2404
|
+
meta["gen_time"] = gen_time
|
|
2405
|
+
ttft_ms = raw.get("ttft_ms")
|
|
2406
|
+
if ttft_ms is not None and not isinstance(ttft_ms, bool):
|
|
2407
|
+
meta["ttft_ms"] = ttft_ms
|
|
2408
|
+
|
|
2409
|
+
current["meta"] = meta
|
|
2410
|
+
# Legacy pins for older saved flows.
|
|
2411
|
+
# Only populate if the flow actually references these handles.
|
|
2412
|
+
if (node_id, "result") in referenced_source_pins:
|
|
2413
|
+
current["result"] = raw
|
|
2414
|
+
if (node_id, "raw") in referenced_source_pins:
|
|
2415
|
+
current["raw"] = raw
|
|
2416
|
+
if (node_id, "gen_time") in referenced_source_pins:
|
|
2417
|
+
current["gen_time"] = gen_time
|
|
2418
|
+
if (node_id, "ttft_ms") in referenced_source_pins:
|
|
2419
|
+
current["ttft_ms"] = ttft_ms
|
|
2420
|
+
mapped_value = response_text
|
|
2421
|
+
elif effect_type == "tool_calls":
|
|
2422
|
+
# Effect outcome is produced by AbstractRuntime TOOL_CALLS handler:
|
|
2423
|
+
# - executed: {"mode":"executed","results":[{call_id,name,success,output,error}, ...]}
|
|
2424
|
+
# - passthrough/untrusted: {"mode": "...", "tool_calls": [...]}
|
|
2425
|
+
if isinstance(raw, dict):
|
|
2426
|
+
mode = raw.get("mode")
|
|
2427
|
+
results = raw.get("results")
|
|
2428
|
+
if not isinstance(results, list):
|
|
2429
|
+
results = []
|
|
2430
|
+
current["results"] = results
|
|
2431
|
+
# Only treat non-executed modes as failure (results are unavailable).
|
|
2432
|
+
if isinstance(mode, str) and mode.strip() and mode != "executed":
|
|
2433
|
+
current["success"] = False
|
|
2434
|
+
else:
|
|
2435
|
+
current["success"] = all(isinstance(r, dict) and r.get("success") is True for r in results)
|
|
2436
|
+
current["raw"] = raw
|
|
2437
|
+
mapped_value = current["results"]
|
|
2438
|
+
elif effect_type == "call_tool":
|
|
2439
|
+
# Convenience wrapper over TOOL_CALLS: expose a single tool's outcome as
|
|
2440
|
+
# (result, success) instead of an array of per-call results.
|
|
2441
|
+
if isinstance(raw, dict):
|
|
2442
|
+
mode = raw.get("mode")
|
|
2443
|
+
results = raw.get("results")
|
|
2444
|
+
if not isinstance(results, list):
|
|
2445
|
+
results = []
|
|
2446
|
+
first = results[0] if results else None
|
|
2447
|
+
|
|
2448
|
+
if isinstance(mode, str) and mode.strip() and mode != "executed":
|
|
2449
|
+
current["success"] = False
|
|
2450
|
+
current["result"] = f"Tool execution not completed (mode={mode})"
|
|
2451
|
+
elif isinstance(first, dict):
|
|
2452
|
+
ok = first.get("success") is True
|
|
2453
|
+
current["success"] = ok
|
|
2454
|
+
current["result"] = first.get("output") if ok else (first.get("error") or "Tool execution failed")
|
|
2455
|
+
else:
|
|
2456
|
+
current["success"] = False
|
|
2457
|
+
current["result"] = "Missing tool result"
|
|
2458
|
+
|
|
2459
|
+
current["raw"] = raw
|
|
2460
|
+
mapped_value = current["result"]
|
|
2461
|
+
elif effect_type == "agent":
|
|
2462
|
+
scratchpad = None
|
|
2463
|
+
answer: Optional[str] = None
|
|
2464
|
+
messages: Any = None
|
|
2465
|
+
bucket_success: Optional[bool] = None
|
|
2466
|
+
bucket_provider: Optional[str] = None
|
|
2467
|
+
bucket_model: Optional[str] = None
|
|
2468
|
+
bucket_iterations: Optional[int] = None
|
|
2469
|
+
bucket_sub_run_id: Optional[str] = None
|
|
2470
|
+
bucket_output_mode: Optional[str] = None
|
|
2471
|
+
agent_ns = temp_data.get("agent")
|
|
2472
|
+
if isinstance(agent_ns, dict):
|
|
2473
|
+
bucket = agent_ns.get(node_id)
|
|
2474
|
+
if isinstance(bucket, dict):
|
|
2475
|
+
scratchpad = bucket.get("scratchpad")
|
|
2476
|
+
ans = bucket.get("answer")
|
|
2477
|
+
if isinstance(ans, str):
|
|
2478
|
+
answer = ans
|
|
2479
|
+
messages = bucket.get("messages")
|
|
2480
|
+
if "success" in bucket:
|
|
2481
|
+
bucket_success = bool(bucket.get("success"))
|
|
2482
|
+
bp = bucket.get("provider")
|
|
2483
|
+
bm = bucket.get("model")
|
|
2484
|
+
if isinstance(bp, str) and bp.strip():
|
|
2485
|
+
bucket_provider = bp.strip()
|
|
2486
|
+
if isinstance(bm, str) and bm.strip():
|
|
2487
|
+
bucket_model = bm.strip()
|
|
2488
|
+
bi = bucket.get("iterations")
|
|
2489
|
+
try:
|
|
2490
|
+
if isinstance(bi, bool):
|
|
2491
|
+
bucket_iterations = None
|
|
2492
|
+
elif isinstance(bi, (int, float)):
|
|
2493
|
+
bucket_iterations = int(bi)
|
|
2494
|
+
elif isinstance(bi, str) and bi.strip():
|
|
2495
|
+
bucket_iterations = int(float(bi.strip()))
|
|
2496
|
+
except Exception:
|
|
2497
|
+
bucket_iterations = None
|
|
2498
|
+
bsr = bucket.get("sub_run_id")
|
|
2499
|
+
if isinstance(bsr, str) and bsr.strip():
|
|
2500
|
+
bucket_sub_run_id = bsr.strip()
|
|
2501
|
+
bom = bucket.get("output_mode")
|
|
2502
|
+
if isinstance(bom, str) and bom.strip():
|
|
2503
|
+
bucket_output_mode = bom.strip()
|
|
2504
|
+
|
|
2505
|
+
if scratchpad is None:
|
|
2506
|
+
# Fallback: use this node's own trace if present.
|
|
2507
|
+
try:
|
|
2508
|
+
from abstractruntime.core.vars import get_node_trace as _get_node_trace
|
|
2509
|
+
except Exception: # pragma: no cover
|
|
2510
|
+
_get_node_trace = None # type: ignore[assignment]
|
|
2511
|
+
if callable(_get_node_trace):
|
|
2512
|
+
scratchpad = _get_node_trace(run.vars, node_id)
|
|
2513
|
+
|
|
2514
|
+
scratchpad_obj = scratchpad if isinstance(scratchpad, dict) else None
|
|
2515
|
+
if scratchpad_obj is None:
|
|
2516
|
+
scratchpad_obj = {"node_id": node_id, "steps": []}
|
|
2517
|
+
|
|
2518
|
+
# Convenience pins: expose tool activity extracted from the scratchpad trace.
|
|
2519
|
+
# This is intentionally best-effort and does not change agent execution behavior.
|
|
2520
|
+
tc, tr = _extract_agent_tool_activity(scratchpad_obj)
|
|
2521
|
+
|
|
2522
|
+
# Embed tool activity inside the scratchpad for easy Break Object access.
|
|
2523
|
+
scratchpad_out = dict(scratchpad_obj)
|
|
2524
|
+
scratchpad_out["tool_calls"] = tc
|
|
2525
|
+
scratchpad_out["tool_results"] = tr
|
|
2526
|
+
|
|
2527
|
+
# Best-effort response string:
|
|
2528
|
+
# - prefer preserved unstructured answer (before structured-output post-pass)
|
|
2529
|
+
# - fall back to common keys inside the raw result
|
|
2530
|
+
response = ""
|
|
2531
|
+
if bucket_output_mode == "structured" and isinstance(raw, dict):
|
|
2532
|
+
try:
|
|
2533
|
+
import json as _json
|
|
2534
|
+
|
|
2535
|
+
response = _json.dumps(raw, ensure_ascii=False, separators=(",", ":"))
|
|
2536
|
+
except Exception:
|
|
2537
|
+
response = ""
|
|
2538
|
+
if not response:
|
|
2539
|
+
if isinstance(answer, str) and answer.strip():
|
|
2540
|
+
response = answer.strip()
|
|
2541
|
+
elif isinstance(raw, dict):
|
|
2542
|
+
r1 = raw.get("result")
|
|
2543
|
+
r2 = raw.get("response")
|
|
2544
|
+
if isinstance(r1, str) and r1.strip():
|
|
2545
|
+
response = r1.strip()
|
|
2546
|
+
elif isinstance(r2, str) and r2.strip():
|
|
2547
|
+
response = r2.strip()
|
|
2548
|
+
elif isinstance(raw, str) and raw.strip():
|
|
2549
|
+
response = raw.strip()
|
|
2550
|
+
|
|
2551
|
+
def _normalize_message_list(raw_msgs: Any) -> list[Dict[str, Any]]:
|
|
2552
|
+
if not isinstance(raw_msgs, list):
|
|
2553
|
+
return []
|
|
2554
|
+
out: list[Dict[str, Any]] = []
|
|
2555
|
+
for m in raw_msgs:
|
|
2556
|
+
if isinstance(m, dict):
|
|
2557
|
+
out.append(dict(m))
|
|
2558
|
+
else:
|
|
2559
|
+
out.append({"role": "assistant", "content": str(m)})
|
|
2560
|
+
return out
|
|
2561
|
+
|
|
2562
|
+
def _minimal_transcript() -> list[Dict[str, Any]]:
|
|
2563
|
+
task_s = ""
|
|
2564
|
+
if isinstance(raw, dict):
|
|
2565
|
+
t = raw.get("task")
|
|
2566
|
+
if isinstance(t, str) and t.strip():
|
|
2567
|
+
task_s = t.strip()
|
|
2568
|
+
msgs: list[Dict[str, Any]] = []
|
|
2569
|
+
if task_s:
|
|
2570
|
+
msgs.append({"role": "user", "content": task_s})
|
|
2571
|
+
if response:
|
|
2572
|
+
msgs.append({"role": "assistant", "content": response})
|
|
2573
|
+
return msgs
|
|
2574
|
+
|
|
2575
|
+
if not isinstance(scratchpad_out.get("messages"), list):
|
|
2576
|
+
msgs_out = _normalize_message_list(messages)
|
|
2577
|
+
if not msgs_out and isinstance(raw, dict):
|
|
2578
|
+
ctx = raw.get("context")
|
|
2579
|
+
ctx_d = ctx if isinstance(ctx, dict) else {}
|
|
2580
|
+
msgs_out = _normalize_message_list(ctx_d.get("messages"))
|
|
2581
|
+
if not msgs_out:
|
|
2582
|
+
msgs_out = _minimal_transcript()
|
|
2583
|
+
scratchpad_out["messages"] = msgs_out
|
|
2584
|
+
|
|
2585
|
+
meta: Dict[str, Any] = {"schema": "abstractcode.agent.v1.meta", "version": 1}
|
|
2586
|
+
provider: Optional[str] = None
|
|
2587
|
+
model: Optional[str] = None
|
|
2588
|
+
iterations: Optional[int] = None
|
|
2589
|
+
sub_run_id: Optional[str] = None
|
|
2590
|
+
success: Optional[bool] = bucket_success
|
|
2591
|
+
|
|
2592
|
+
if isinstance(raw, dict):
|
|
2593
|
+
p = raw.get("provider")
|
|
2594
|
+
m = raw.get("model")
|
|
2595
|
+
if isinstance(p, str) and p.strip():
|
|
2596
|
+
provider = p.strip()
|
|
2597
|
+
if isinstance(m, str) and m.strip():
|
|
2598
|
+
model = m.strip()
|
|
2599
|
+
|
|
2600
|
+
it = raw.get("iterations")
|
|
2601
|
+
try:
|
|
2602
|
+
if isinstance(it, bool):
|
|
2603
|
+
iterations = None
|
|
2604
|
+
elif isinstance(it, (int, float)):
|
|
2605
|
+
iterations = int(it)
|
|
2606
|
+
elif isinstance(it, str) and it.strip():
|
|
2607
|
+
iterations = int(float(it.strip()))
|
|
2608
|
+
except Exception:
|
|
2609
|
+
iterations = None
|
|
2610
|
+
|
|
2611
|
+
sr = raw.get("sub_run_id")
|
|
2612
|
+
if isinstance(sr, str) and sr.strip():
|
|
2613
|
+
sub_run_id = sr.strip()
|
|
2614
|
+
if "success" in raw:
|
|
2615
|
+
success = bool(raw.get("success"))
|
|
2616
|
+
|
|
2617
|
+
if sub_run_id is None:
|
|
2618
|
+
sr = bucket_sub_run_id or scratchpad_out.get("sub_run_id")
|
|
2619
|
+
if isinstance(sr, str) and sr.strip():
|
|
2620
|
+
sub_run_id = sr.strip()
|
|
2621
|
+
|
|
2622
|
+
if provider is None:
|
|
2623
|
+
provider = bucket_provider
|
|
2624
|
+
if model is None:
|
|
2625
|
+
model = bucket_model
|
|
2626
|
+
if iterations is None:
|
|
2627
|
+
iterations = bucket_iterations
|
|
2628
|
+
|
|
2629
|
+
# Structured-output mode stores the final output object under `raw` without
|
|
2630
|
+
# provider/model metadata. When resuming from persisted state, infer these
|
|
2631
|
+
# fields from the last LLM_CALL step in the scratchpad trace (best-effort).
|
|
2632
|
+
if (provider is None or model is None) and isinstance(scratchpad_out.get("steps"), list):
|
|
2633
|
+
steps_any = scratchpad_out.get("steps")
|
|
2634
|
+
steps = steps_any if isinstance(steps_any, list) else []
|
|
2635
|
+
for entry_any in reversed(steps):
|
|
2636
|
+
entry = entry_any if isinstance(entry_any, dict) else None
|
|
2637
|
+
if entry is None:
|
|
2638
|
+
continue
|
|
2639
|
+
eff = entry.get("effect")
|
|
2640
|
+
if not isinstance(eff, dict) or str(eff.get("type") or "") != "llm_call":
|
|
2641
|
+
continue
|
|
2642
|
+
payload = eff.get("payload")
|
|
2643
|
+
if isinstance(payload, dict):
|
|
2644
|
+
if provider is None:
|
|
2645
|
+
p = payload.get("provider")
|
|
2646
|
+
if isinstance(p, str) and p.strip():
|
|
2647
|
+
provider = p.strip()
|
|
2648
|
+
if model is None:
|
|
2649
|
+
m = payload.get("model")
|
|
2650
|
+
if isinstance(m, str) and m.strip():
|
|
2651
|
+
model = m.strip()
|
|
2652
|
+
|
|
2653
|
+
res = entry.get("result")
|
|
2654
|
+
if isinstance(res, dict):
|
|
2655
|
+
if provider is None:
|
|
2656
|
+
p = res.get("provider")
|
|
2657
|
+
if isinstance(p, str) and p.strip():
|
|
2658
|
+
provider = p.strip()
|
|
2659
|
+
if model is None:
|
|
2660
|
+
m = res.get("model")
|
|
2661
|
+
if isinstance(m, str) and m.strip():
|
|
2662
|
+
model = m.strip()
|
|
2663
|
+
|
|
2664
|
+
if provider is not None or model is not None:
|
|
2665
|
+
break
|
|
2666
|
+
|
|
2667
|
+
if provider:
|
|
2668
|
+
meta["provider"] = provider
|
|
2669
|
+
if model:
|
|
2670
|
+
meta["model"] = model
|
|
2671
|
+
if sub_run_id:
|
|
2672
|
+
meta["sub_run_id"] = sub_run_id
|
|
2673
|
+
if iterations is not None:
|
|
2674
|
+
meta["iterations"] = iterations
|
|
2675
|
+
meta["tool_calls"] = len(tc)
|
|
2676
|
+
meta["tool_results"] = len(tr)
|
|
2677
|
+
if bucket_output_mode:
|
|
2678
|
+
meta["output_mode"] = bucket_output_mode
|
|
2679
|
+
|
|
2680
|
+
current["response"] = response
|
|
2681
|
+
current["success"] = bool(success) if success is not None else True
|
|
2682
|
+
current["meta"] = meta
|
|
2683
|
+
current["scratchpad"] = scratchpad_out
|
|
2684
|
+
# Legacy pin: older flows may still wire Agent.result into Break Object.
|
|
2685
|
+
if (node_id, "result") in referenced_source_pins:
|
|
2686
|
+
if isinstance(raw, dict) and (set(raw.keys()) - {"success"}):
|
|
2687
|
+
current["result"] = raw
|
|
2688
|
+
else:
|
|
2689
|
+
current["result"] = {
|
|
2690
|
+
"response": response,
|
|
2691
|
+
"success": current["success"],
|
|
2692
|
+
"meta": meta,
|
|
2693
|
+
"scratchpad": scratchpad_out,
|
|
2694
|
+
}
|
|
2695
|
+
|
|
2696
|
+
# Backward-compat / convenience: keep top-level tool activity too.
|
|
2697
|
+
current["tool_calls"] = tc
|
|
2698
|
+
current["tool_results"] = tr
|
|
2699
|
+
mapped_value = raw
|
|
2700
|
+
elif effect_type == "wait_event":
|
|
2701
|
+
current["event_data"] = raw
|
|
2702
|
+
mapped_value = raw
|
|
2703
|
+
elif effect_type == "on_event":
|
|
2704
|
+
# Custom event listener: the resume payload is a structured envelope.
|
|
2705
|
+
if isinstance(raw, dict):
|
|
2706
|
+
current["event"] = raw
|
|
2707
|
+
current["payload"] = raw.get("payload")
|
|
2708
|
+
current["event_id"] = raw.get("event_id")
|
|
2709
|
+
current["name"] = raw.get("name")
|
|
2710
|
+
mapped_value = raw
|
|
2711
|
+
else:
|
|
2712
|
+
current["event"] = raw
|
|
2713
|
+
mapped_value = raw
|
|
2714
|
+
elif effect_type == "on_schedule":
|
|
2715
|
+
cfg = flow_node.effect_config if isinstance(flow_node.effect_config, dict) else {}
|
|
2716
|
+
schedule_cfg = cfg.get("schedule")
|
|
2717
|
+
schedule_str = str(schedule_cfg or "").strip() if schedule_cfg is not None else ""
|
|
2718
|
+
recurrent_cfg = cfg.get("recurrent")
|
|
2719
|
+
recurrent_flag = True if recurrent_cfg is None else bool(recurrent_cfg)
|
|
2720
|
+
# ISO timestamps are treated as one-shot; recurrence is disabled.
|
|
2721
|
+
if schedule_str and not isinstance(schedule_cfg, (int, float)) and schedule_str:
|
|
2722
|
+
import re
|
|
2723
|
+
|
|
2724
|
+
if not re.match(r"^\\s*\\d+(?:\\.\\d+)?\\s*(ms|s|m|h|d)\\s*$", schedule_str, re.IGNORECASE):
|
|
2725
|
+
recurrent_flag = False
|
|
2726
|
+
|
|
2727
|
+
if isinstance(raw, dict):
|
|
2728
|
+
current.update(raw)
|
|
2729
|
+
ts = raw.get("timestamp")
|
|
2730
|
+
if ts is None:
|
|
2731
|
+
ts = raw.get("scheduled_for")
|
|
2732
|
+
current["timestamp"] = ts
|
|
2733
|
+
current["recurrent"] = recurrent_flag
|
|
2734
|
+
mapped_value = ts if ts is not None else raw
|
|
2735
|
+
else:
|
|
2736
|
+
current["timestamp"] = raw
|
|
2737
|
+
current["recurrent"] = recurrent_flag
|
|
2738
|
+
mapped_value = raw
|
|
2739
|
+
elif effect_type == "wait_until":
|
|
2740
|
+
if isinstance(raw, dict):
|
|
2741
|
+
current.update(raw)
|
|
2742
|
+
else:
|
|
2743
|
+
current["result"] = raw
|
|
2744
|
+
mapped_value = raw
|
|
2745
|
+
elif effect_type == "emit_event":
|
|
2746
|
+
# Custom event emission result (dispatch summary).
|
|
2747
|
+
if isinstance(raw, dict):
|
|
2748
|
+
current.update(raw)
|
|
2749
|
+
mapped_value = raw
|
|
2750
|
+
else:
|
|
2751
|
+
current["result"] = raw
|
|
2752
|
+
mapped_value = raw
|
|
2753
|
+
elif effect_type == "memory_note":
|
|
2754
|
+
span_id = _get_span_id(raw)
|
|
2755
|
+
current["note_id"] = span_id
|
|
2756
|
+
current["raw"] = raw
|
|
2757
|
+
mapped_value = span_id
|
|
2758
|
+
elif effect_type == "memory_compact":
|
|
2759
|
+
span_id = _get_span_id(raw)
|
|
2760
|
+
current["span_id"] = span_id
|
|
2761
|
+
current["raw"] = raw
|
|
2762
|
+
mapped_value = span_id
|
|
2763
|
+
elif effect_type == "memory_query":
|
|
2764
|
+
# Runtime returns a tool-results envelope:
|
|
2765
|
+
# {"mode":"executed","results":[{call_id,name,success,output,error,meta?}, ...]}
|
|
2766
|
+
rendered = ""
|
|
2767
|
+
matches: list[Any] = []
|
|
2768
|
+
span_ids: list[Any] = []
|
|
2769
|
+
if isinstance(raw, dict):
|
|
2770
|
+
results_list = raw.get("results")
|
|
2771
|
+
if isinstance(results_list, list) and results_list:
|
|
2772
|
+
first = results_list[0]
|
|
2773
|
+
if isinstance(first, dict):
|
|
2774
|
+
out = first.get("output")
|
|
2775
|
+
if isinstance(out, str):
|
|
2776
|
+
rendered = out
|
|
2777
|
+
meta = first.get("meta")
|
|
2778
|
+
if isinstance(meta, dict):
|
|
2779
|
+
m = meta.get("matches")
|
|
2780
|
+
if isinstance(m, list):
|
|
2781
|
+
matches = m
|
|
2782
|
+
sids = meta.get("span_ids")
|
|
2783
|
+
if isinstance(sids, list):
|
|
2784
|
+
span_ids = sids
|
|
2785
|
+
|
|
2786
|
+
current["rendered"] = rendered
|
|
2787
|
+
current["results"] = matches
|
|
2788
|
+
current["span_ids"] = span_ids
|
|
2789
|
+
current["raw"] = raw
|
|
2790
|
+
mapped_value = current["results"]
|
|
2791
|
+
elif effect_type == "memory_tag":
|
|
2792
|
+
rendered = ""
|
|
2793
|
+
success = False
|
|
2794
|
+
results_list: list[Any] = []
|
|
2795
|
+
if isinstance(raw, dict):
|
|
2796
|
+
rl = raw.get("results")
|
|
2797
|
+
if isinstance(rl, list):
|
|
2798
|
+
results_list = rl
|
|
2799
|
+
if results_list:
|
|
2800
|
+
first = results_list[0]
|
|
2801
|
+
if isinstance(first, dict):
|
|
2802
|
+
out = first.get("output")
|
|
2803
|
+
if isinstance(out, str):
|
|
2804
|
+
rendered = out
|
|
2805
|
+
success = first.get("success") is True
|
|
2806
|
+
|
|
2807
|
+
current["rendered"] = rendered
|
|
2808
|
+
current["success"] = bool(success)
|
|
2809
|
+
current["results"] = results_list
|
|
2810
|
+
current["raw"] = raw
|
|
2811
|
+
mapped_value = rendered
|
|
2812
|
+
elif effect_type == "memory_rehydrate":
|
|
2813
|
+
if isinstance(raw, dict):
|
|
2814
|
+
current["inserted"] = raw.get("inserted")
|
|
2815
|
+
current["skipped"] = raw.get("skipped")
|
|
2816
|
+
current["artifacts"] = raw.get("artifacts")
|
|
2817
|
+
else:
|
|
2818
|
+
current["inserted"] = 0
|
|
2819
|
+
current["skipped"] = 0
|
|
2820
|
+
current["artifacts"] = []
|
|
2821
|
+
current["raw"] = raw
|
|
2822
|
+
mapped_value = raw
|
|
2823
|
+
elif effect_type == "memory_kg_assert":
|
|
2824
|
+
if isinstance(raw, dict):
|
|
2825
|
+
ids = raw.get("assertion_ids")
|
|
2826
|
+
current["assertion_ids"] = ids if isinstance(ids, list) else []
|
|
2827
|
+
current["count"] = raw.get("count")
|
|
2828
|
+
current["ok"] = raw.get("ok")
|
|
2829
|
+
else:
|
|
2830
|
+
current["assertion_ids"] = []
|
|
2831
|
+
current["count"] = 0
|
|
2832
|
+
current["ok"] = False
|
|
2833
|
+
current["raw"] = raw
|
|
2834
|
+
mapped_value = current.get("assertion_ids")
|
|
2835
|
+
elif effect_type == "memory_kg_query":
|
|
2836
|
+
if isinstance(raw, dict):
|
|
2837
|
+
items = raw.get("items")
|
|
2838
|
+
current["items"] = items if isinstance(items, list) else []
|
|
2839
|
+
current["count"] = raw.get("count")
|
|
2840
|
+
current["ok"] = raw.get("ok")
|
|
2841
|
+
# Active Memory packing (optional; only present when max_input_tokens is set).
|
|
2842
|
+
if "packets_version" in raw:
|
|
2843
|
+
current["packets_version"] = raw.get("packets_version")
|
|
2844
|
+
if "packets" in raw:
|
|
2845
|
+
current["packets"] = raw.get("packets")
|
|
2846
|
+
if "packed_count" in raw:
|
|
2847
|
+
current["packed_count"] = raw.get("packed_count")
|
|
2848
|
+
if "active_memory_text" in raw:
|
|
2849
|
+
current["active_memory_text"] = raw.get("active_memory_text")
|
|
2850
|
+
if "estimated_tokens" in raw:
|
|
2851
|
+
current["estimated_tokens"] = raw.get("estimated_tokens")
|
|
2852
|
+
if "dropped" in raw:
|
|
2853
|
+
current["dropped"] = raw.get("dropped")
|
|
2854
|
+
if "warnings" in raw:
|
|
2855
|
+
current["warnings"] = raw.get("warnings")
|
|
2856
|
+
else:
|
|
2857
|
+
current["items"] = []
|
|
2858
|
+
current["count"] = 0
|
|
2859
|
+
current["ok"] = False
|
|
2860
|
+
current["packets"] = []
|
|
2861
|
+
current["packed_count"] = 0
|
|
2862
|
+
current["active_memory_text"] = ""
|
|
2863
|
+
current["estimated_tokens"] = 0
|
|
2864
|
+
current["dropped"] = 0
|
|
2865
|
+
current["raw"] = raw
|
|
2866
|
+
mapped_value = current.get("items")
|
|
2867
|
+
elif effect_type == "memory_kg_resolve":
|
|
2868
|
+
if isinstance(raw, dict):
|
|
2869
|
+
candidates = raw.get("candidates")
|
|
2870
|
+
current["candidates"] = candidates if isinstance(candidates, list) else []
|
|
2871
|
+
current["count"] = raw.get("count")
|
|
2872
|
+
current["ok"] = raw.get("ok")
|
|
2873
|
+
if "warnings" in raw:
|
|
2874
|
+
current["warnings"] = raw.get("warnings")
|
|
2875
|
+
else:
|
|
2876
|
+
current["candidates"] = []
|
|
2877
|
+
current["count"] = 0
|
|
2878
|
+
current["ok"] = False
|
|
2879
|
+
current["raw"] = raw
|
|
2880
|
+
mapped_value = current.get("candidates")
|
|
2881
|
+
elif effect_type == "start_subworkflow":
|
|
2882
|
+
if isinstance(raw, dict):
|
|
2883
|
+
current["sub_run_id"] = raw.get("sub_run_id")
|
|
2884
|
+
out = raw.get("output")
|
|
2885
|
+
if isinstance(out, dict) and "result" in out:
|
|
2886
|
+
result_value = out.get("result")
|
|
2887
|
+
current["output"] = result_value
|
|
2888
|
+
current["child_output"] = out
|
|
2889
|
+
else:
|
|
2890
|
+
result_value = out
|
|
2891
|
+
current["output"] = result_value
|
|
2892
|
+
if isinstance(out, dict):
|
|
2893
|
+
current["child_output"] = out
|
|
2894
|
+
mapped_value = current.get("output")
|
|
2895
|
+
|
|
2896
|
+
cfg = flow_node.effect_config or {}
|
|
2897
|
+
out_pins = cfg.get("output_pins")
|
|
2898
|
+
if isinstance(out_pins, list) and out_pins:
|
|
2899
|
+
if isinstance(result_value, dict):
|
|
2900
|
+
for pid in out_pins:
|
|
2901
|
+
if isinstance(pid, str) and pid:
|
|
2902
|
+
if pid == "output":
|
|
2903
|
+
continue
|
|
2904
|
+
current[pid] = result_value.get(pid)
|
|
2905
|
+
elif len(out_pins) == 1 and isinstance(out_pins[0], str) and out_pins[0]:
|
|
2906
|
+
current[out_pins[0]] = result_value
|
|
2907
|
+
else:
|
|
2908
|
+
current["output"] = raw
|
|
2909
|
+
mapped_value = raw
|
|
2910
|
+
|
|
2911
|
+
# Optional: also write the mapped output to run.vars if configured.
|
|
2912
|
+
if flow_node.output_key and mapped_value is not None:
|
|
2913
|
+
_set_nested(run.vars, flow_node.output_key, mapped_value)
|
|
2914
|
+
|
|
2915
|
+
|
|
2916
|
+
def _set_nested(target: Dict[str, Any], dotted_key: str, value: Any) -> None:
|
|
2917
|
+
"""Set nested dict value using dot notation."""
|
|
2918
|
+
parts = dotted_key.split(".")
|
|
2919
|
+
cur = target
|
|
2920
|
+
for p in parts[:-1]:
|
|
2921
|
+
nxt = cur.get(p)
|
|
2922
|
+
if not isinstance(nxt, dict):
|
|
2923
|
+
nxt = {}
|
|
2924
|
+
cur[p] = nxt
|
|
2925
|
+
cur = nxt
|
|
2926
|
+
cur[parts[-1]] = value
|
|
2927
|
+
|
|
2928
|
+
|
|
2929
|
+
def compile_flow(flow: Flow) -> "WorkflowSpec":
|
|
2930
|
+
"""Compile a Flow definition into an AbstractRuntime WorkflowSpec.
|
|
2931
|
+
|
|
2932
|
+
This function transforms a declarative Flow definition into an executable
|
|
2933
|
+
WorkflowSpec that can be run by AbstractRuntime. Each flow node is converted
|
|
2934
|
+
to a workflow node handler based on its type:
|
|
2935
|
+
|
|
2936
|
+
- Functions: Executed directly within the workflow
|
|
2937
|
+
- Agents: Run as subworkflows using START_SUBWORKFLOW effect
|
|
2938
|
+
- Nested Flows: Compiled recursively and run as subworkflows
|
|
2939
|
+
|
|
2940
|
+
Args:
|
|
2941
|
+
flow: The Flow definition to compile
|
|
2942
|
+
|
|
2943
|
+
Returns:
|
|
2944
|
+
A WorkflowSpec that can be executed by AbstractRuntime
|
|
2945
|
+
|
|
2946
|
+
Raises:
|
|
2947
|
+
ValueError: If the flow is invalid (no entry node, missing nodes, etc.)
|
|
2948
|
+
TypeError: If a node handler is of unknown type
|
|
2949
|
+
|
|
2950
|
+
Example:
|
|
2951
|
+
>>> flow = Flow("my_flow")
|
|
2952
|
+
>>> flow.add_node("start", my_func)
|
|
2953
|
+
>>> flow.set_entry("start")
|
|
2954
|
+
>>> spec = compile_flow(flow)
|
|
2955
|
+
>>> runtime.start(workflow=spec)
|
|
2956
|
+
"""
|
|
2957
|
+
from abstractruntime.core.spec import WorkflowSpec
|
|
2958
|
+
|
|
2959
|
+
# Validate flow
|
|
2960
|
+
errors = flow.validate()
|
|
2961
|
+
if errors:
|
|
2962
|
+
raise ValueError(f"Invalid flow: {'; '.join(errors)}")
|
|
2963
|
+
|
|
2964
|
+
outgoing: Dict[str, list] = {}
|
|
2965
|
+
for edge in flow.edges:
|
|
2966
|
+
outgoing.setdefault(edge.source, []).append(edge)
|
|
2967
|
+
|
|
2968
|
+
# Build next-node map (linear) and branch maps (If/Else).
|
|
2969
|
+
next_node_map: Dict[str, Optional[str]] = {}
|
|
2970
|
+
branch_maps: Dict[str, Dict[str, str]] = {}
|
|
2971
|
+
control_specs: Dict[str, Dict[str, Any]] = {}
|
|
2972
|
+
|
|
2973
|
+
def _is_supported_branch_handle(handle: str) -> bool:
|
|
2974
|
+
return handle in {"true", "false", "default"} or handle.startswith("case:")
|
|
2975
|
+
|
|
2976
|
+
for node_id in flow.nodes:
|
|
2977
|
+
outs = outgoing.get(node_id, [])
|
|
2978
|
+
if not outs:
|
|
2979
|
+
next_node_map[node_id] = None
|
|
2980
|
+
continue
|
|
2981
|
+
|
|
2982
|
+
flow_node = flow.nodes.get(node_id)
|
|
2983
|
+
node_effect_type = getattr(flow_node, "effect_type", None) if flow_node else None
|
|
2984
|
+
|
|
2985
|
+
# Sequence / Parallel: deterministic fan-out scheduling (Blueprint-style).
|
|
2986
|
+
#
|
|
2987
|
+
# Important: these nodes may have 0..N connected outputs; even a single
|
|
2988
|
+
# `then:0` edge should compile (it is not "branching" based on data).
|
|
2989
|
+
if node_effect_type in {"sequence", "parallel"}:
|
|
2990
|
+
# Validate all execution edges have a handle
|
|
2991
|
+
handles: list[str] = []
|
|
2992
|
+
targets_by_handle: Dict[str, str] = {}
|
|
2993
|
+
for e in outs:
|
|
2994
|
+
h = getattr(e, "source_handle", None)
|
|
2995
|
+
if not isinstance(h, str) or not h:
|
|
2996
|
+
raise ValueError(
|
|
2997
|
+
f"Control node '{node_id}' has an execution edge with no source_handle."
|
|
2998
|
+
)
|
|
2999
|
+
handles.append(h)
|
|
3000
|
+
targets_by_handle[h] = e.target
|
|
3001
|
+
|
|
3002
|
+
cfg = getattr(flow_node, "effect_config", None) if flow_node else None
|
|
3003
|
+
cfg_dict = cfg if isinstance(cfg, dict) else {}
|
|
3004
|
+
then_handles = cfg_dict.get("then_handles")
|
|
3005
|
+
if not isinstance(then_handles, list):
|
|
3006
|
+
then_handles = [h for h in handles if h.startswith("then:")]
|
|
3007
|
+
|
|
3008
|
+
def _then_key(h: str) -> int:
|
|
3009
|
+
try:
|
|
3010
|
+
if h.startswith("then:"):
|
|
3011
|
+
return int(h.split(":", 1)[1])
|
|
3012
|
+
except Exception:
|
|
3013
|
+
pass
|
|
3014
|
+
return 10**9
|
|
3015
|
+
|
|
3016
|
+
then_handles = sorted(then_handles, key=_then_key)
|
|
3017
|
+
else:
|
|
3018
|
+
then_handles = [str(h) for h in then_handles if isinstance(h, str) and h]
|
|
3019
|
+
|
|
3020
|
+
allowed = set(then_handles)
|
|
3021
|
+
completed_target: Optional[str] = None
|
|
3022
|
+
if node_effect_type == "parallel":
|
|
3023
|
+
allowed.add("completed")
|
|
3024
|
+
completed_target = targets_by_handle.get("completed")
|
|
3025
|
+
|
|
3026
|
+
unknown = [h for h in handles if h not in allowed]
|
|
3027
|
+
if unknown:
|
|
3028
|
+
raise ValueError(
|
|
3029
|
+
f"Control node '{node_id}' has unsupported execution outputs: {unknown}"
|
|
3030
|
+
)
|
|
3031
|
+
|
|
3032
|
+
control_specs[node_id] = {
|
|
3033
|
+
"kind": node_effect_type,
|
|
3034
|
+
"then_handles": then_handles,
|
|
3035
|
+
"targets_by_handle": targets_by_handle,
|
|
3036
|
+
"completed_target": completed_target,
|
|
3037
|
+
}
|
|
3038
|
+
next_node_map[node_id] = None
|
|
3039
|
+
continue
|
|
3040
|
+
|
|
3041
|
+
# Loop (Foreach): structured scheduling via `loop` (body) and `done` (completed).
|
|
3042
|
+
#
|
|
3043
|
+
# This is a scheduler node (like Sequence/Parallel), not data-driven branching.
|
|
3044
|
+
if node_effect_type == "loop":
|
|
3045
|
+
handles: list[str] = []
|
|
3046
|
+
targets_by_handle: Dict[str, str] = {}
|
|
3047
|
+
for e in outs:
|
|
3048
|
+
h = getattr(e, "source_handle", None)
|
|
3049
|
+
if not isinstance(h, str) or not h:
|
|
3050
|
+
raise ValueError(
|
|
3051
|
+
f"Control node '{node_id}' has an execution edge with no source_handle."
|
|
3052
|
+
)
|
|
3053
|
+
handles.append(h)
|
|
3054
|
+
targets_by_handle[h] = e.target
|
|
3055
|
+
|
|
3056
|
+
allowed = {"loop", "done"}
|
|
3057
|
+
unknown = [h for h in handles if h not in allowed]
|
|
3058
|
+
if unknown:
|
|
3059
|
+
raise ValueError(
|
|
3060
|
+
f"Control node '{node_id}' has unsupported execution outputs: {unknown}"
|
|
3061
|
+
)
|
|
3062
|
+
|
|
3063
|
+
control_specs[node_id] = {
|
|
3064
|
+
"kind": "loop",
|
|
3065
|
+
"loop_target": targets_by_handle.get("loop"),
|
|
3066
|
+
"done_target": targets_by_handle.get("done"),
|
|
3067
|
+
}
|
|
3068
|
+
next_node_map[node_id] = None
|
|
3069
|
+
continue
|
|
3070
|
+
|
|
3071
|
+
# While: structured scheduling via `loop` (body) and `done` (completed),
|
|
3072
|
+
# gated by a boolean condition pin resolved via data edges.
|
|
3073
|
+
if node_effect_type == "while":
|
|
3074
|
+
handles = []
|
|
3075
|
+
targets_by_handle = {}
|
|
3076
|
+
for e in outs:
|
|
3077
|
+
h = getattr(e, "source_handle", None)
|
|
3078
|
+
if not isinstance(h, str) or not h:
|
|
3079
|
+
raise ValueError(
|
|
3080
|
+
f"Control node '{node_id}' has an execution edge with no source_handle."
|
|
3081
|
+
)
|
|
3082
|
+
handles.append(h)
|
|
3083
|
+
targets_by_handle[h] = e.target
|
|
3084
|
+
|
|
3085
|
+
allowed = {"loop", "done"}
|
|
3086
|
+
unknown = [h for h in handles if h not in allowed]
|
|
3087
|
+
if unknown:
|
|
3088
|
+
raise ValueError(
|
|
3089
|
+
f"Control node '{node_id}' has unsupported execution outputs: {unknown}"
|
|
3090
|
+
)
|
|
3091
|
+
|
|
3092
|
+
control_specs[node_id] = {
|
|
3093
|
+
"kind": "while",
|
|
3094
|
+
"loop_target": targets_by_handle.get("loop"),
|
|
3095
|
+
"done_target": targets_by_handle.get("done"),
|
|
3096
|
+
}
|
|
3097
|
+
next_node_map[node_id] = None
|
|
3098
|
+
continue
|
|
3099
|
+
|
|
3100
|
+
# For: structured scheduling via `loop` (body) and `done` (completed),
|
|
3101
|
+
# over a numeric range resolved via data edges (start/end/step).
|
|
3102
|
+
if node_effect_type == "for":
|
|
3103
|
+
handles = []
|
|
3104
|
+
targets_by_handle = {}
|
|
3105
|
+
for e in outs:
|
|
3106
|
+
h = getattr(e, "source_handle", None)
|
|
3107
|
+
if not isinstance(h, str) or not h:
|
|
3108
|
+
raise ValueError(
|
|
3109
|
+
f"Control node '{node_id}' has an execution edge with no source_handle."
|
|
3110
|
+
)
|
|
3111
|
+
handles.append(h)
|
|
3112
|
+
targets_by_handle[h] = e.target
|
|
3113
|
+
|
|
3114
|
+
allowed = {"loop", "done"}
|
|
3115
|
+
unknown = [h for h in handles if h not in allowed]
|
|
3116
|
+
if unknown:
|
|
3117
|
+
raise ValueError(
|
|
3118
|
+
f"Control node '{node_id}' has unsupported execution outputs: {unknown}"
|
|
3119
|
+
)
|
|
3120
|
+
|
|
3121
|
+
control_specs[node_id] = {
|
|
3122
|
+
"kind": "for",
|
|
3123
|
+
"loop_target": targets_by_handle.get("loop"),
|
|
3124
|
+
"done_target": targets_by_handle.get("done"),
|
|
3125
|
+
}
|
|
3126
|
+
next_node_map[node_id] = None
|
|
3127
|
+
continue
|
|
3128
|
+
|
|
3129
|
+
if len(outs) == 1:
|
|
3130
|
+
h = getattr(outs[0], "source_handle", None)
|
|
3131
|
+
if isinstance(h, str) and h and h != "exec-out":
|
|
3132
|
+
if not _is_supported_branch_handle(h):
|
|
3133
|
+
raise ValueError(
|
|
3134
|
+
f"Node '{node_id}' has unsupported branching output '{h}'. "
|
|
3135
|
+
"Branching is not yet supported."
|
|
3136
|
+
)
|
|
3137
|
+
branch_maps[node_id] = {h: outs[0].target} # type: ignore[arg-type]
|
|
3138
|
+
next_node_map[node_id] = None
|
|
3139
|
+
else:
|
|
3140
|
+
next_node_map[node_id] = outs[0].target
|
|
3141
|
+
continue
|
|
3142
|
+
|
|
3143
|
+
handles: list[str] = []
|
|
3144
|
+
for e in outs:
|
|
3145
|
+
h = getattr(e, "source_handle", None)
|
|
3146
|
+
if not isinstance(h, str) or not h:
|
|
3147
|
+
handles = []
|
|
3148
|
+
break
|
|
3149
|
+
handles.append(h)
|
|
3150
|
+
|
|
3151
|
+
if len(handles) != len(outs) or len(set(handles)) != len(handles):
|
|
3152
|
+
raise ValueError(
|
|
3153
|
+
f"Node '{node_id}' has multiple outgoing edges. "
|
|
3154
|
+
"Branching is not yet supported."
|
|
3155
|
+
)
|
|
3156
|
+
|
|
3157
|
+
# Minimal branching support: If/Else uses `true` / `false` execution outputs.
|
|
3158
|
+
if set(handles) <= {"true", "false"}:
|
|
3159
|
+
branch_maps[node_id] = {e.source_handle: e.target for e in outs} # type: ignore[arg-type]
|
|
3160
|
+
next_node_map[node_id] = None
|
|
3161
|
+
continue
|
|
3162
|
+
|
|
3163
|
+
# Switch branching: stable case handles + optional default.
|
|
3164
|
+
if all(h == "default" or h.startswith("case:") for h in handles):
|
|
3165
|
+
branch_maps[node_id] = {e.source_handle: e.target for e in outs} # type: ignore[arg-type]
|
|
3166
|
+
next_node_map[node_id] = None
|
|
3167
|
+
continue
|
|
3168
|
+
|
|
3169
|
+
raise ValueError(
|
|
3170
|
+
f"Node '{node_id}' has multiple outgoing edges. "
|
|
3171
|
+
"Branching is not yet supported."
|
|
3172
|
+
)
|
|
3173
|
+
|
|
3174
|
+
# Determine exit node if not set
|
|
3175
|
+
exit_node = flow.exit_node
|
|
3176
|
+
if not exit_node:
|
|
3177
|
+
terminal_nodes = flow.get_terminal_nodes()
|
|
3178
|
+
if len(terminal_nodes) == 1:
|
|
3179
|
+
exit_node = terminal_nodes[0]
|
|
3180
|
+
elif len(terminal_nodes) > 1:
|
|
3181
|
+
# Multiple terminals - each will complete the flow when reached
|
|
3182
|
+
pass
|
|
3183
|
+
|
|
3184
|
+
# Create node handlers
|
|
3185
|
+
handlers: Dict[str, Callable] = {}
|
|
3186
|
+
|
|
3187
|
+
def _wrap_return_to_active_control(
|
|
3188
|
+
handler: Callable,
|
|
3189
|
+
*,
|
|
3190
|
+
node_id: str,
|
|
3191
|
+
visual_type: Optional[str],
|
|
3192
|
+
) -> Callable:
|
|
3193
|
+
"""If a node tries to complete the run inside an active control block, return to the scheduler.
|
|
3194
|
+
|
|
3195
|
+
This is crucial for Blueprint-style nodes:
|
|
3196
|
+
- branch chains can end (no outgoing exec edges) without ending the whole flow
|
|
3197
|
+
- Sequence/Parallel can then continue scheduling other branches
|
|
3198
|
+
"""
|
|
3199
|
+
from abstractruntime.core.models import StepPlan
|
|
3200
|
+
|
|
3201
|
+
try:
|
|
3202
|
+
from .adapters.control_adapter import get_active_control_node_id
|
|
3203
|
+
except Exception: # pragma: no cover
|
|
3204
|
+
get_active_control_node_id = None # type: ignore[assignment]
|
|
3205
|
+
|
|
3206
|
+
def wrapped(run: Any, ctx: Any) -> "StepPlan":
|
|
3207
|
+
plan: StepPlan = handler(run, ctx)
|
|
3208
|
+
if not callable(get_active_control_node_id):
|
|
3209
|
+
return plan
|
|
3210
|
+
|
|
3211
|
+
active = get_active_control_node_id(run.vars)
|
|
3212
|
+
if not isinstance(active, str) or not active:
|
|
3213
|
+
return plan
|
|
3214
|
+
if active == node_id:
|
|
3215
|
+
return plan
|
|
3216
|
+
|
|
3217
|
+
# Explicit end node should always terminate the run, even inside a control block.
|
|
3218
|
+
if visual_type == "on_flow_end":
|
|
3219
|
+
return plan
|
|
3220
|
+
|
|
3221
|
+
# If the node is about to complete the run, treat it as "branch complete" instead.
|
|
3222
|
+
if plan.complete_output is not None:
|
|
3223
|
+
return StepPlan(node_id=plan.node_id, next_node=active)
|
|
3224
|
+
|
|
3225
|
+
# Terminal effect node: runtime would auto-complete if next_node is missing.
|
|
3226
|
+
if plan.effect is not None and not plan.next_node:
|
|
3227
|
+
return StepPlan(node_id=plan.node_id, effect=plan.effect, next_node=active)
|
|
3228
|
+
|
|
3229
|
+
# Defensive fallback.
|
|
3230
|
+
if plan.effect is None and not plan.next_node and plan.complete_output is None:
|
|
3231
|
+
return StepPlan(node_id=plan.node_id, next_node=active)
|
|
3232
|
+
|
|
3233
|
+
return plan
|
|
3234
|
+
|
|
3235
|
+
return wrapped
|
|
3236
|
+
|
|
3237
|
+
for node_id, flow_node in flow.nodes.items():
|
|
3238
|
+
next_node = next_node_map.get(node_id)
|
|
3239
|
+
branch_map = branch_maps.get(node_id)
|
|
3240
|
+
handler_obj = getattr(flow_node, "handler", None)
|
|
3241
|
+
effect_type = getattr(flow_node, "effect_type", None)
|
|
3242
|
+
effect_config = getattr(flow_node, "effect_config", None) or {}
|
|
3243
|
+
visual_type = effect_config.get("_visual_type") if isinstance(effect_config, dict) else None
|
|
3244
|
+
|
|
3245
|
+
# Check for effect/control nodes first
|
|
3246
|
+
if effect_type == "sequence":
|
|
3247
|
+
from .adapters.control_adapter import create_sequence_node_handler
|
|
3248
|
+
|
|
3249
|
+
spec = control_specs.get(node_id) or {}
|
|
3250
|
+
handlers[node_id] = create_sequence_node_handler(
|
|
3251
|
+
node_id=node_id,
|
|
3252
|
+
ordered_then_handles=list(spec.get("then_handles") or []),
|
|
3253
|
+
targets_by_handle=dict(spec.get("targets_by_handle") or {}),
|
|
3254
|
+
)
|
|
3255
|
+
elif effect_type == "parallel":
|
|
3256
|
+
from .adapters.control_adapter import create_parallel_node_handler
|
|
3257
|
+
|
|
3258
|
+
spec = control_specs.get(node_id) or {}
|
|
3259
|
+
handlers[node_id] = create_parallel_node_handler(
|
|
3260
|
+
node_id=node_id,
|
|
3261
|
+
ordered_then_handles=list(spec.get("then_handles") or []),
|
|
3262
|
+
targets_by_handle=dict(spec.get("targets_by_handle") or {}),
|
|
3263
|
+
completed_target=spec.get("completed_target"),
|
|
3264
|
+
)
|
|
3265
|
+
elif effect_type == "loop":
|
|
3266
|
+
from .adapters.control_adapter import create_loop_node_handler
|
|
3267
|
+
|
|
3268
|
+
spec = control_specs.get(node_id) or {}
|
|
3269
|
+
loop_data_handler = handler_obj if callable(handler_obj) else None
|
|
3270
|
+
|
|
3271
|
+
# Precompute upstream pure-node ids for cache invalidation (best-effort).
|
|
3272
|
+
#
|
|
3273
|
+
# Pure nodes (e.g. concat/split/break_object) are cached in `flow._node_outputs`.
|
|
3274
|
+
# Inside a Loop, the inputs to those pure nodes often change per-iteration
|
|
3275
|
+
# (index/item, evolving scratchpad vars, etc.). If we don't invalidate, the
|
|
3276
|
+
# loop body may reuse stale values from iteration 0.
|
|
3277
|
+
pure_ids = getattr(flow, "_pure_node_ids", None) if flow is not None else None
|
|
3278
|
+
pure_ids = set(pure_ids) if isinstance(pure_ids, (set, list, tuple)) else set()
|
|
3279
|
+
|
|
3280
|
+
def _resolve_items(
|
|
3281
|
+
run: Any,
|
|
3282
|
+
_handler: Any = loop_data_handler,
|
|
3283
|
+
_node_id: str = node_id,
|
|
3284
|
+
) -> list[Any]:
|
|
3285
|
+
if flow is not None and hasattr(flow, "_node_outputs") and hasattr(flow, "_data_edge_map"):
|
|
3286
|
+
_sync_effect_results_to_node_outputs(run, flow)
|
|
3287
|
+
if not callable(_handler):
|
|
3288
|
+
return []
|
|
3289
|
+
last_output = run.vars.get("_last_output", {})
|
|
3290
|
+
try:
|
|
3291
|
+
resolved = _handler(last_output)
|
|
3292
|
+
except Exception as e:
|
|
3293
|
+
# Surface this as a workflow error (don't silently treat as empty).
|
|
3294
|
+
try:
|
|
3295
|
+
run.vars["_flow_error"] = f"Loop items resolution failed: {e}"
|
|
3296
|
+
run.vars["_flow_error_node"] = _node_id
|
|
3297
|
+
except Exception:
|
|
3298
|
+
pass
|
|
3299
|
+
raise
|
|
3300
|
+
if not isinstance(resolved, dict):
|
|
3301
|
+
return []
|
|
3302
|
+
raw = resolved.get("items")
|
|
3303
|
+
if isinstance(raw, list):
|
|
3304
|
+
return raw
|
|
3305
|
+
if isinstance(raw, tuple):
|
|
3306
|
+
return list(raw)
|
|
3307
|
+
if raw is None:
|
|
3308
|
+
return []
|
|
3309
|
+
return [raw]
|
|
3310
|
+
|
|
3311
|
+
base_loop = create_loop_node_handler(
|
|
3312
|
+
node_id=node_id,
|
|
3313
|
+
loop_target=spec.get("loop_target"),
|
|
3314
|
+
done_target=spec.get("done_target"),
|
|
3315
|
+
resolve_items=_resolve_items,
|
|
3316
|
+
)
|
|
3317
|
+
|
|
3318
|
+
def _wrapped_loop(
|
|
3319
|
+
run: Any,
|
|
3320
|
+
ctx: Any,
|
|
3321
|
+
*,
|
|
3322
|
+
_base: Any = base_loop,
|
|
3323
|
+
_pure_ids: set[str] = pure_ids,
|
|
3324
|
+
) -> StepPlan:
|
|
3325
|
+
# Ensure pure nodes feeding the loop body are re-evaluated per iteration.
|
|
3326
|
+
if flow is not None and _pure_ids and hasattr(flow, "_node_outputs") and hasattr(flow, "_data_edge_map"):
|
|
3327
|
+
_sync_effect_results_to_node_outputs(run, flow)
|
|
3328
|
+
node_outputs = getattr(flow, "_node_outputs", None)
|
|
3329
|
+
if isinstance(node_outputs, dict):
|
|
3330
|
+
for nid in _pure_ids:
|
|
3331
|
+
node_outputs.pop(nid, None)
|
|
3332
|
+
plan = _base(run, ctx)
|
|
3333
|
+
# The loop scheduler persists `{item,index,total}` into run.vars, but
|
|
3334
|
+
# UI node_complete events read from `flow._node_outputs`. Sync after
|
|
3335
|
+
# scheduling so observability reflects the current iteration.
|
|
3336
|
+
if flow is not None and hasattr(flow, "_node_outputs") and hasattr(flow, "_data_edge_map"):
|
|
3337
|
+
_sync_effect_results_to_node_outputs(run, flow)
|
|
3338
|
+
return plan
|
|
3339
|
+
|
|
3340
|
+
handlers[node_id] = _wrapped_loop
|
|
3341
|
+
elif effect_type == "agent":
|
|
3342
|
+
data_aware_handler = handler_obj if callable(handler_obj) else None
|
|
3343
|
+
handlers[node_id] = _create_visual_agent_effect_handler(
|
|
3344
|
+
node_id=node_id,
|
|
3345
|
+
next_node=next_node,
|
|
3346
|
+
agent_config=effect_config if isinstance(effect_config, dict) else {},
|
|
3347
|
+
data_aware_handler=data_aware_handler,
|
|
3348
|
+
flow=flow,
|
|
3349
|
+
)
|
|
3350
|
+
elif effect_type == "while":
|
|
3351
|
+
from .adapters.control_adapter import create_while_node_handler
|
|
3352
|
+
|
|
3353
|
+
spec = control_specs.get(node_id) or {}
|
|
3354
|
+
while_data_handler = handler_obj if callable(handler_obj) else None
|
|
3355
|
+
|
|
3356
|
+
# Precompute upstream pure-node ids for cache invalidation (best-effort).
|
|
3357
|
+
pure_ids = getattr(flow, "_pure_node_ids", None) if flow is not None else None
|
|
3358
|
+
pure_ids = set(pure_ids) if isinstance(pure_ids, (set, list, tuple)) else set()
|
|
3359
|
+
|
|
3360
|
+
data_edge_map = getattr(flow, "_data_edge_map", None) if flow is not None else None
|
|
3361
|
+
data_edge_map = data_edge_map if isinstance(data_edge_map, dict) else {}
|
|
3362
|
+
|
|
3363
|
+
upstream_pure: set[str] = set()
|
|
3364
|
+
if pure_ids:
|
|
3365
|
+
stack2 = [node_id]
|
|
3366
|
+
seen2: set[str] = set()
|
|
3367
|
+
while stack2:
|
|
3368
|
+
cur = stack2.pop()
|
|
3369
|
+
if cur in seen2:
|
|
3370
|
+
continue
|
|
3371
|
+
seen2.add(cur)
|
|
3372
|
+
deps = data_edge_map.get(cur)
|
|
3373
|
+
if not isinstance(deps, dict):
|
|
3374
|
+
continue
|
|
3375
|
+
for _pin, src in deps.items():
|
|
3376
|
+
if not isinstance(src, tuple) or len(src) != 2:
|
|
3377
|
+
continue
|
|
3378
|
+
src_node = src[0]
|
|
3379
|
+
if not isinstance(src_node, str) or not src_node:
|
|
3380
|
+
continue
|
|
3381
|
+
stack2.append(src_node)
|
|
3382
|
+
if src_node in pure_ids:
|
|
3383
|
+
upstream_pure.add(src_node)
|
|
3384
|
+
|
|
3385
|
+
def _resolve_condition(
|
|
3386
|
+
run: Any,
|
|
3387
|
+
_handler: Any = while_data_handler,
|
|
3388
|
+
_node_id: str = node_id,
|
|
3389
|
+
_upstream_pure: set[str] = upstream_pure,
|
|
3390
|
+
) -> bool:
|
|
3391
|
+
if flow is not None and hasattr(flow, "_node_outputs") and hasattr(flow, "_data_edge_map"):
|
|
3392
|
+
_sync_effect_results_to_node_outputs(run, flow)
|
|
3393
|
+
# Ensure pure nodes feeding the condition are re-evaluated per iteration.
|
|
3394
|
+
if _upstream_pure and hasattr(flow, "_node_outputs"):
|
|
3395
|
+
node_outputs = getattr(flow, "_node_outputs", None)
|
|
3396
|
+
if isinstance(node_outputs, dict):
|
|
3397
|
+
for nid in _upstream_pure:
|
|
3398
|
+
node_outputs.pop(nid, None)
|
|
3399
|
+
|
|
3400
|
+
if not callable(_handler):
|
|
3401
|
+
return False
|
|
3402
|
+
|
|
3403
|
+
last_output = run.vars.get("_last_output", {})
|
|
3404
|
+
try:
|
|
3405
|
+
resolved = _handler(last_output)
|
|
3406
|
+
except Exception as e:
|
|
3407
|
+
try:
|
|
3408
|
+
run.vars["_flow_error"] = f"While condition resolution failed: {e}"
|
|
3409
|
+
run.vars["_flow_error_node"] = _node_id
|
|
3410
|
+
except Exception:
|
|
3411
|
+
pass
|
|
3412
|
+
raise
|
|
3413
|
+
|
|
3414
|
+
if isinstance(resolved, dict) and "condition" in resolved:
|
|
3415
|
+
return bool(resolved.get("condition"))
|
|
3416
|
+
return bool(resolved)
|
|
3417
|
+
|
|
3418
|
+
base_while = create_while_node_handler(
|
|
3419
|
+
node_id=node_id,
|
|
3420
|
+
loop_target=spec.get("loop_target"),
|
|
3421
|
+
done_target=spec.get("done_target"),
|
|
3422
|
+
resolve_condition=_resolve_condition,
|
|
3423
|
+
)
|
|
3424
|
+
|
|
3425
|
+
def _wrapped_while(
|
|
3426
|
+
run: Any,
|
|
3427
|
+
ctx: Any,
|
|
3428
|
+
*,
|
|
3429
|
+
_base: Any = base_while,
|
|
3430
|
+
_pure_ids: set[str] = pure_ids,
|
|
3431
|
+
) -> StepPlan:
|
|
3432
|
+
# Ensure pure nodes feeding the loop body are re-evaluated per iteration.
|
|
3433
|
+
if flow is not None and _pure_ids and hasattr(flow, "_node_outputs") and hasattr(flow, "_data_edge_map"):
|
|
3434
|
+
_sync_effect_results_to_node_outputs(run, flow)
|
|
3435
|
+
node_outputs = getattr(flow, "_node_outputs", None)
|
|
3436
|
+
if isinstance(node_outputs, dict):
|
|
3437
|
+
for nid in _pure_ids:
|
|
3438
|
+
node_outputs.pop(nid, None)
|
|
3439
|
+
plan = _base(run, ctx)
|
|
3440
|
+
# While scheduler persists `index` into run.vars; sync so WS/UI
|
|
3441
|
+
# node_complete events show the latest iteration count.
|
|
3442
|
+
if flow is not None and hasattr(flow, "_node_outputs") and hasattr(flow, "_data_edge_map"):
|
|
3443
|
+
_sync_effect_results_to_node_outputs(run, flow)
|
|
3444
|
+
return plan
|
|
3445
|
+
|
|
3446
|
+
handlers[node_id] = _wrapped_while
|
|
3447
|
+
elif effect_type == "for":
|
|
3448
|
+
from .adapters.control_adapter import create_for_node_handler
|
|
3449
|
+
|
|
3450
|
+
spec = control_specs.get(node_id) or {}
|
|
3451
|
+
for_data_handler = handler_obj if callable(handler_obj) else None
|
|
3452
|
+
|
|
3453
|
+
# Precompute upstream pure-node ids for cache invalidation (best-effort).
|
|
3454
|
+
pure_ids = getattr(flow, "_pure_node_ids", None) if flow is not None else None
|
|
3455
|
+
pure_ids = set(pure_ids) if isinstance(pure_ids, (set, list, tuple)) else set()
|
|
3456
|
+
|
|
3457
|
+
def _resolve_range(
|
|
3458
|
+
run: Any,
|
|
3459
|
+
_handler: Any = for_data_handler,
|
|
3460
|
+
_node_id: str = node_id,
|
|
3461
|
+
) -> Dict[str, Any]:
|
|
3462
|
+
if flow is not None and hasattr(flow, "_node_outputs") and hasattr(flow, "_data_edge_map"):
|
|
3463
|
+
_sync_effect_results_to_node_outputs(run, flow)
|
|
3464
|
+
if not callable(_handler):
|
|
3465
|
+
return {}
|
|
3466
|
+
last_output = run.vars.get("_last_output", {})
|
|
3467
|
+
try:
|
|
3468
|
+
resolved = _handler(last_output)
|
|
3469
|
+
except Exception as e:
|
|
3470
|
+
try:
|
|
3471
|
+
run.vars["_flow_error"] = f"For range resolution failed: {e}"
|
|
3472
|
+
run.vars["_flow_error_node"] = _node_id
|
|
3473
|
+
except Exception:
|
|
3474
|
+
pass
|
|
3475
|
+
raise
|
|
3476
|
+
return resolved if isinstance(resolved, dict) else {}
|
|
3477
|
+
|
|
3478
|
+
base_for = create_for_node_handler(
|
|
3479
|
+
node_id=node_id,
|
|
3480
|
+
loop_target=spec.get("loop_target"),
|
|
3481
|
+
done_target=spec.get("done_target"),
|
|
3482
|
+
resolve_range=_resolve_range,
|
|
3483
|
+
)
|
|
3484
|
+
|
|
3485
|
+
def _wrapped_for(
|
|
3486
|
+
run: Any,
|
|
3487
|
+
ctx: Any,
|
|
3488
|
+
*,
|
|
3489
|
+
_base: Any = base_for,
|
|
3490
|
+
_pure_ids: set[str] = pure_ids,
|
|
3491
|
+
) -> StepPlan:
|
|
3492
|
+
# Ensure pure nodes feeding the loop body are re-evaluated per iteration.
|
|
3493
|
+
if flow is not None and _pure_ids and hasattr(flow, "_node_outputs") and hasattr(flow, "_data_edge_map"):
|
|
3494
|
+
_sync_effect_results_to_node_outputs(run, flow)
|
|
3495
|
+
node_outputs = getattr(flow, "_node_outputs", None)
|
|
3496
|
+
if isinstance(node_outputs, dict):
|
|
3497
|
+
for nid in _pure_ids:
|
|
3498
|
+
node_outputs.pop(nid, None)
|
|
3499
|
+
plan = _base(run, ctx)
|
|
3500
|
+
# For scheduler persists `{i,index,total}` into run.vars; sync so
|
|
3501
|
+
# WS/UI node_complete events show the current iteration.
|
|
3502
|
+
if flow is not None and hasattr(flow, "_node_outputs") and hasattr(flow, "_data_edge_map"):
|
|
3503
|
+
_sync_effect_results_to_node_outputs(run, flow)
|
|
3504
|
+
return plan
|
|
3505
|
+
|
|
3506
|
+
handlers[node_id] = _wrapped_for
|
|
3507
|
+
elif effect_type == "on_event":
|
|
3508
|
+
from .adapters.event_adapter import create_on_event_node_handler
|
|
3509
|
+
|
|
3510
|
+
on_event_data_handler = handler_obj if callable(handler_obj) else None
|
|
3511
|
+
|
|
3512
|
+
def _resolve_inputs(
|
|
3513
|
+
run: Any,
|
|
3514
|
+
_handler: Any = on_event_data_handler,
|
|
3515
|
+
) -> Dict[str, Any]:
|
|
3516
|
+
if flow is not None and hasattr(flow, "_node_outputs") and hasattr(flow, "_data_edge_map"):
|
|
3517
|
+
_sync_effect_results_to_node_outputs(run, flow)
|
|
3518
|
+
if not callable(_handler):
|
|
3519
|
+
return {}
|
|
3520
|
+
last_output = run.vars.get("_last_output", {})
|
|
3521
|
+
try:
|
|
3522
|
+
resolved = _handler(last_output)
|
|
3523
|
+
except Exception:
|
|
3524
|
+
resolved = {}
|
|
3525
|
+
return resolved if isinstance(resolved, dict) else {}
|
|
3526
|
+
|
|
3527
|
+
# Blank/unspecified name is treated as "listen to any event" (wildcard).
|
|
3528
|
+
default_name = ""
|
|
3529
|
+
scope = "session"
|
|
3530
|
+
if isinstance(effect_config, dict):
|
|
3531
|
+
raw_name = effect_config.get("name") or effect_config.get("event_name")
|
|
3532
|
+
if isinstance(raw_name, str) and raw_name.strip():
|
|
3533
|
+
default_name = raw_name
|
|
3534
|
+
raw_scope = effect_config.get("scope")
|
|
3535
|
+
if isinstance(raw_scope, str) and raw_scope.strip():
|
|
3536
|
+
scope = raw_scope
|
|
3537
|
+
|
|
3538
|
+
handlers[node_id] = create_on_event_node_handler(
|
|
3539
|
+
node_id=node_id,
|
|
3540
|
+
next_node=next_node,
|
|
3541
|
+
resolve_inputs=_resolve_inputs if callable(on_event_data_handler) else None,
|
|
3542
|
+
default_name=default_name,
|
|
3543
|
+
scope=scope,
|
|
3544
|
+
flow=flow,
|
|
3545
|
+
)
|
|
3546
|
+
elif effect_type == "on_schedule":
|
|
3547
|
+
from .adapters.event_adapter import create_on_schedule_node_handler
|
|
3548
|
+
|
|
3549
|
+
on_schedule_data_handler = handler_obj if callable(handler_obj) else None
|
|
3550
|
+
|
|
3551
|
+
def _resolve_inputs(
|
|
3552
|
+
run: Any,
|
|
3553
|
+
_handler: Any = on_schedule_data_handler,
|
|
3554
|
+
) -> Dict[str, Any]:
|
|
3555
|
+
if flow is not None and hasattr(flow, "_node_outputs") and hasattr(flow, "_data_edge_map"):
|
|
3556
|
+
_sync_effect_results_to_node_outputs(run, flow)
|
|
3557
|
+
if not callable(_handler):
|
|
3558
|
+
return {}
|
|
3559
|
+
last_output = run.vars.get("_last_output", {})
|
|
3560
|
+
try:
|
|
3561
|
+
resolved = _handler(last_output)
|
|
3562
|
+
except Exception:
|
|
3563
|
+
resolved = {}
|
|
3564
|
+
return resolved if isinstance(resolved, dict) else {}
|
|
3565
|
+
|
|
3566
|
+
schedule = "15s"
|
|
3567
|
+
recurrent = True
|
|
3568
|
+
if isinstance(effect_config, dict):
|
|
3569
|
+
raw_schedule = effect_config.get("schedule")
|
|
3570
|
+
if isinstance(raw_schedule, str) and raw_schedule.strip():
|
|
3571
|
+
schedule = raw_schedule.strip()
|
|
3572
|
+
raw_recurrent = effect_config.get("recurrent")
|
|
3573
|
+
if isinstance(raw_recurrent, bool):
|
|
3574
|
+
recurrent = raw_recurrent
|
|
3575
|
+
|
|
3576
|
+
handlers[node_id] = create_on_schedule_node_handler(
|
|
3577
|
+
node_id=node_id,
|
|
3578
|
+
next_node=next_node,
|
|
3579
|
+
resolve_inputs=_resolve_inputs if callable(on_schedule_data_handler) else None,
|
|
3580
|
+
schedule=schedule,
|
|
3581
|
+
recurrent=recurrent,
|
|
3582
|
+
flow=flow,
|
|
3583
|
+
)
|
|
3584
|
+
elif effect_type == "emit_event":
|
|
3585
|
+
from .adapters.event_adapter import create_emit_event_node_handler
|
|
3586
|
+
|
|
3587
|
+
emit_data_handler = handler_obj if callable(handler_obj) else None
|
|
3588
|
+
|
|
3589
|
+
def _resolve_inputs(
|
|
3590
|
+
run: Any,
|
|
3591
|
+
_handler: Any = emit_data_handler,
|
|
3592
|
+
) -> Dict[str, Any]:
|
|
3593
|
+
if flow is not None and hasattr(flow, "_node_outputs") and hasattr(flow, "_data_edge_map"):
|
|
3594
|
+
_sync_effect_results_to_node_outputs(run, flow)
|
|
3595
|
+
if not callable(_handler):
|
|
3596
|
+
return {}
|
|
3597
|
+
last_output = run.vars.get("_last_output", {})
|
|
3598
|
+
try:
|
|
3599
|
+
resolved = _handler(last_output)
|
|
3600
|
+
except Exception:
|
|
3601
|
+
resolved = {}
|
|
3602
|
+
return resolved if isinstance(resolved, dict) else {}
|
|
3603
|
+
|
|
3604
|
+
default_name = ""
|
|
3605
|
+
default_session_id: Optional[str] = None
|
|
3606
|
+
scope = "session"
|
|
3607
|
+
if isinstance(effect_config, dict):
|
|
3608
|
+
raw_name = effect_config.get("name") or effect_config.get("event_name")
|
|
3609
|
+
if isinstance(raw_name, str) and raw_name.strip():
|
|
3610
|
+
default_name = raw_name
|
|
3611
|
+
raw_session = effect_config.get("session_id")
|
|
3612
|
+
if raw_session is None:
|
|
3613
|
+
raw_session = effect_config.get("sessionId")
|
|
3614
|
+
if isinstance(raw_session, str) and raw_session.strip():
|
|
3615
|
+
default_session_id = raw_session.strip()
|
|
3616
|
+
raw_scope = effect_config.get("scope")
|
|
3617
|
+
if isinstance(raw_scope, str) and raw_scope.strip():
|
|
3618
|
+
scope = raw_scope
|
|
3619
|
+
|
|
3620
|
+
handlers[node_id] = create_emit_event_node_handler(
|
|
3621
|
+
node_id=node_id,
|
|
3622
|
+
next_node=next_node,
|
|
3623
|
+
resolve_inputs=_resolve_inputs,
|
|
3624
|
+
default_name=default_name,
|
|
3625
|
+
default_session_id=default_session_id,
|
|
3626
|
+
scope=scope,
|
|
3627
|
+
)
|
|
3628
|
+
elif effect_type:
|
|
3629
|
+
# Pass the handler_obj as data_aware_handler if it's callable
|
|
3630
|
+
# This allows visual flows to resolve data edges before creating effects
|
|
3631
|
+
data_aware_handler = handler_obj if callable(handler_obj) else None
|
|
3632
|
+
handlers[node_id] = _create_effect_node_handler(
|
|
3633
|
+
node_id=node_id,
|
|
3634
|
+
effect_type=effect_type,
|
|
3635
|
+
effect_config=effect_config,
|
|
3636
|
+
next_node=next_node,
|
|
3637
|
+
input_key=getattr(flow_node, "input_key", None),
|
|
3638
|
+
output_key=getattr(flow_node, "output_key", None),
|
|
3639
|
+
data_aware_handler=data_aware_handler,
|
|
3640
|
+
flow=flow,
|
|
3641
|
+
)
|
|
3642
|
+
elif _is_agent(handler_obj):
|
|
3643
|
+
handlers[node_id] = create_agent_node_handler(
|
|
3644
|
+
node_id=node_id,
|
|
3645
|
+
agent=handler_obj,
|
|
3646
|
+
next_node=next_node,
|
|
3647
|
+
input_key=getattr(flow_node, "input_key", None),
|
|
3648
|
+
output_key=getattr(flow_node, "output_key", None),
|
|
3649
|
+
)
|
|
3650
|
+
elif _is_flow(handler_obj):
|
|
3651
|
+
# Nested flow - compile recursively
|
|
3652
|
+
nested_spec = compile_flow(handler_obj)
|
|
3653
|
+
handlers[node_id] = create_subflow_node_handler(
|
|
3654
|
+
node_id=node_id,
|
|
3655
|
+
nested_workflow=nested_spec,
|
|
3656
|
+
next_node=next_node,
|
|
3657
|
+
input_key=getattr(flow_node, "input_key", None),
|
|
3658
|
+
output_key=getattr(flow_node, "output_key", None),
|
|
3659
|
+
)
|
|
3660
|
+
elif visual_type == "set_var":
|
|
3661
|
+
from .adapters.variable_adapter import create_set_var_node_handler
|
|
3662
|
+
|
|
3663
|
+
data_aware_handler = handler_obj if callable(handler_obj) else None
|
|
3664
|
+
handlers[node_id] = create_set_var_node_handler(
|
|
3665
|
+
node_id=node_id,
|
|
3666
|
+
next_node=next_node,
|
|
3667
|
+
data_aware_handler=data_aware_handler,
|
|
3668
|
+
flow=flow,
|
|
3669
|
+
)
|
|
3670
|
+
elif visual_type == "add_message":
|
|
3671
|
+
from .adapters.context_adapter import create_add_message_node_handler
|
|
3672
|
+
|
|
3673
|
+
data_aware_handler = handler_obj if callable(handler_obj) else None
|
|
3674
|
+
handlers[node_id] = create_add_message_node_handler(
|
|
3675
|
+
node_id=node_id,
|
|
3676
|
+
next_node=next_node,
|
|
3677
|
+
data_aware_handler=data_aware_handler,
|
|
3678
|
+
flow=flow,
|
|
3679
|
+
)
|
|
3680
|
+
elif visual_type == "memact_compose":
|
|
3681
|
+
from .adapters.memact_adapter import create_memact_compose_node_handler
|
|
3682
|
+
|
|
3683
|
+
data_aware_handler = handler_obj if callable(handler_obj) else None
|
|
3684
|
+
handlers[node_id] = create_memact_compose_node_handler(
|
|
3685
|
+
node_id=node_id,
|
|
3686
|
+
next_node=next_node,
|
|
3687
|
+
data_aware_handler=data_aware_handler,
|
|
3688
|
+
flow=flow,
|
|
3689
|
+
)
|
|
3690
|
+
elif visual_type == "set_var_property":
|
|
3691
|
+
from .adapters.variable_adapter import create_set_var_property_node_handler
|
|
3692
|
+
|
|
3693
|
+
data_aware_handler = handler_obj if callable(handler_obj) else None
|
|
3694
|
+
handlers[node_id] = create_set_var_property_node_handler(
|
|
3695
|
+
node_id=node_id,
|
|
3696
|
+
next_node=next_node,
|
|
3697
|
+
data_aware_handler=data_aware_handler,
|
|
3698
|
+
flow=flow,
|
|
3699
|
+
)
|
|
3700
|
+
elif visual_type == "set_vars":
|
|
3701
|
+
from .adapters.variable_adapter import create_set_vars_node_handler
|
|
3702
|
+
|
|
3703
|
+
data_aware_handler = handler_obj if callable(handler_obj) else None
|
|
3704
|
+
handlers[node_id] = create_set_vars_node_handler(
|
|
3705
|
+
node_id=node_id,
|
|
3706
|
+
next_node=next_node,
|
|
3707
|
+
data_aware_handler=data_aware_handler,
|
|
3708
|
+
flow=flow,
|
|
3709
|
+
)
|
|
3710
|
+
elif callable(handler_obj):
|
|
3711
|
+
# Plain Flow callables should behave like standard function nodes (input defaults to run.vars).
|
|
3712
|
+
#
|
|
3713
|
+
# VisualFlow callables (builtins/code/function/etc.) need data-edge awareness, `_last_output`
|
|
3714
|
+
# pipelining, and persisted node outputs for pause/resume, so they use the visual handler.
|
|
3715
|
+
is_visual_flow = bool(
|
|
3716
|
+
hasattr(flow, "_data_edge_map") and hasattr(flow, "_node_outputs")
|
|
3717
|
+
) or isinstance(visual_type, str)
|
|
3718
|
+
|
|
3719
|
+
if not is_visual_flow:
|
|
3720
|
+
handlers[node_id] = create_function_node_handler(
|
|
3721
|
+
node_id=node_id,
|
|
3722
|
+
func=handler_obj,
|
|
3723
|
+
next_node=next_node,
|
|
3724
|
+
input_key=getattr(flow_node, "input_key", None),
|
|
3725
|
+
output_key=getattr(flow_node, "output_key", None),
|
|
3726
|
+
)
|
|
3727
|
+
else:
|
|
3728
|
+
handlers[node_id] = _create_visual_function_handler(
|
|
3729
|
+
node_id=node_id,
|
|
3730
|
+
func=handler_obj,
|
|
3731
|
+
next_node=next_node,
|
|
3732
|
+
input_key=getattr(flow_node, "input_key", None),
|
|
3733
|
+
output_key=getattr(flow_node, "output_key", None),
|
|
3734
|
+
branch_map=branch_map,
|
|
3735
|
+
flow=flow,
|
|
3736
|
+
)
|
|
3737
|
+
else:
|
|
3738
|
+
raise TypeError(
|
|
3739
|
+
f"Unknown handler type for node '{node_id}': {type(handler_obj)}. "
|
|
3740
|
+
"Expected agent, function, or Flow."
|
|
3741
|
+
)
|
|
3742
|
+
|
|
3743
|
+
# Blueprint-style control flow: terminal nodes inside Sequence/Parallel should
|
|
3744
|
+
# return to the active scheduler instead of completing the whole run.
|
|
3745
|
+
handlers[node_id] = _wrap_return_to_active_control(
|
|
3746
|
+
handlers[node_id],
|
|
3747
|
+
node_id=node_id,
|
|
3748
|
+
visual_type=visual_type if isinstance(visual_type, str) else None,
|
|
3749
|
+
)
|
|
3750
|
+
|
|
3751
|
+
return WorkflowSpec(
|
|
3752
|
+
workflow_id=flow.flow_id,
|
|
3753
|
+
entry_node=flow.entry_node,
|
|
3754
|
+
nodes=handlers,
|
|
3755
|
+
)
|
|
3756
|
+
|
|
3757
|
+
|
|
3758
|
+
def compile_visualflow(raw_or_model: Any) -> "WorkflowSpec":
|
|
3759
|
+
"""Compile a VisualFlow JSON object (or Pydantic-like model) into a WorkflowSpec."""
|
|
3760
|
+
from .visual.models import load_visualflow_json
|
|
3761
|
+
from .visual.executor import visual_to_flow
|
|
3762
|
+
|
|
3763
|
+
vf = load_visualflow_json(raw_or_model)
|
|
3764
|
+
flow = visual_to_flow(vf)
|
|
3765
|
+
return compile_flow(flow)
|
|
3766
|
+
|
|
3767
|
+
|
|
3768
|
+
def compile_visualflow_tree(
|
|
3769
|
+
*,
|
|
3770
|
+
root_id: Optional[str],
|
|
3771
|
+
flows_by_id: Dict[str, Any],
|
|
3772
|
+
) -> Dict[str, "WorkflowSpec"]:
|
|
3773
|
+
"""Compile a flow tree (root + referenced subflows) to WorkflowSpecs.
|
|
3774
|
+
|
|
3775
|
+
Notes:
|
|
3776
|
+
- This compiles VisualFlow JSON into callable WorkflowSpecs (not portable).
|
|
3777
|
+
- Subflow references are detected from `subflow` nodes (`subflowId`/`flowId`).
|
|
3778
|
+
- Cycles are allowed; each flow id is compiled once.
|
|
3779
|
+
"""
|
|
3780
|
+
from .visual.models import load_visualflow_json
|
|
3781
|
+
from .visual.executor import visual_to_flow
|
|
3782
|
+
|
|
3783
|
+
parsed: Dict[str, Any] = {}
|
|
3784
|
+
for fid, raw in dict(flows_by_id or {}).items():
|
|
3785
|
+
fid2 = str(fid or "").strip()
|
|
3786
|
+
if not fid2:
|
|
3787
|
+
continue
|
|
3788
|
+
parsed[fid2] = load_visualflow_json(raw)
|
|
3789
|
+
|
|
3790
|
+
if not parsed:
|
|
3791
|
+
return {}
|
|
3792
|
+
|
|
3793
|
+
start = str(root_id or "").strip() or next(iter(parsed.keys()))
|
|
3794
|
+
|
|
3795
|
+
def _subflow_ref(node: Any) -> Optional[str]:
|
|
3796
|
+
data = getattr(node, "data", None)
|
|
3797
|
+
if not isinstance(data, dict):
|
|
3798
|
+
return None
|
|
3799
|
+
sid = data.get("subflowId") or data.get("flowId") or data.get("workflowId") or data.get("workflow_id")
|
|
3800
|
+
if isinstance(sid, str) and sid.strip():
|
|
3801
|
+
return sid.strip()
|
|
3802
|
+
return None
|
|
3803
|
+
|
|
3804
|
+
visited: set[str] = set()
|
|
3805
|
+
queue: list[str] = [start]
|
|
3806
|
+
while queue:
|
|
3807
|
+
fid = queue.pop(0)
|
|
3808
|
+
if not fid or fid in visited:
|
|
3809
|
+
continue
|
|
3810
|
+
vf = parsed.get(fid)
|
|
3811
|
+
if vf is None:
|
|
3812
|
+
# Missing subflow: skip (hosts may choose to error earlier).
|
|
3813
|
+
continue
|
|
3814
|
+
visited.add(fid)
|
|
3815
|
+
for n in getattr(vf, "nodes", []) or []:
|
|
3816
|
+
t = getattr(n, "type", None)
|
|
3817
|
+
type_str = t.value if hasattr(t, "value") else str(t or "")
|
|
3818
|
+
if type_str != "subflow":
|
|
3819
|
+
continue
|
|
3820
|
+
sid = _subflow_ref(n)
|
|
3821
|
+
if sid:
|
|
3822
|
+
queue.append(sid)
|
|
3823
|
+
|
|
3824
|
+
specs: Dict[str, "WorkflowSpec"] = {}
|
|
3825
|
+
for fid in visited:
|
|
3826
|
+
vf = parsed.get(fid)
|
|
3827
|
+
if vf is None:
|
|
3828
|
+
continue
|
|
3829
|
+
f = visual_to_flow(vf)
|
|
3830
|
+
spec = compile_flow(f)
|
|
3831
|
+
specs[str(spec.workflow_id)] = spec
|
|
3832
|
+
return specs
|