AbstractRuntime 0.4.0__py3-none-any.whl → 0.4.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- abstractruntime/__init__.py +76 -1
- abstractruntime/core/config.py +68 -1
- abstractruntime/core/models.py +5 -0
- abstractruntime/core/policy.py +74 -3
- abstractruntime/core/runtime.py +1002 -126
- abstractruntime/core/vars.py +8 -2
- abstractruntime/evidence/recorder.py +1 -1
- abstractruntime/history_bundle.py +772 -0
- abstractruntime/integrations/abstractcore/__init__.py +3 -0
- abstractruntime/integrations/abstractcore/default_tools.py +127 -3
- abstractruntime/integrations/abstractcore/effect_handlers.py +2440 -99
- abstractruntime/integrations/abstractcore/embeddings_client.py +69 -0
- abstractruntime/integrations/abstractcore/factory.py +68 -20
- abstractruntime/integrations/abstractcore/llm_client.py +447 -15
- abstractruntime/integrations/abstractcore/mcp_worker.py +1 -0
- abstractruntime/integrations/abstractcore/session_attachments.py +946 -0
- abstractruntime/integrations/abstractcore/tool_executor.py +31 -10
- 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/active_context.py +6 -1
- 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/storage/__init__.py +4 -1
- abstractruntime/storage/artifacts.py +158 -30
- abstractruntime/storage/base.py +17 -1
- abstractruntime/storage/commands.py +339 -0
- abstractruntime/storage/in_memory.py +41 -1
- abstractruntime/storage/json_files.py +195 -12
- abstractruntime/storage/observable.py +38 -1
- 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.0.dist-info/METADATA +0 -167
- abstractruntime-0.4.0.dist-info/RECORD +0 -49
- {abstractruntime-0.4.0.dist-info → abstractruntime-0.4.1.dist-info}/WHEEL +0 -0
- {abstractruntime-0.4.0.dist-info → abstractruntime-0.4.1.dist-info}/entry_points.txt +0 -0
- {abstractruntime-0.4.0.dist-info → abstractruntime-0.4.1.dist-info}/licenses/LICENSE +0 -0
abstractruntime/core/runtime.py
CHANGED
|
@@ -18,10 +18,11 @@ We keep the design explicitly modular:
|
|
|
18
18
|
|
|
19
19
|
from __future__ import annotations
|
|
20
20
|
|
|
21
|
-
from dataclasses import dataclass
|
|
21
|
+
from dataclasses import dataclass, asdict, is_dataclass
|
|
22
22
|
from datetime import datetime, timezone
|
|
23
23
|
from typing import Any, Callable, Dict, Optional, List
|
|
24
24
|
import copy
|
|
25
|
+
import hashlib
|
|
25
26
|
import inspect
|
|
26
27
|
import json
|
|
27
28
|
import os
|
|
@@ -50,9 +51,201 @@ def utc_now_iso() -> str:
|
|
|
50
51
|
return datetime.now(timezone.utc).isoformat()
|
|
51
52
|
|
|
52
53
|
|
|
54
|
+
def _jsonable(value: Any, *, _path: Optional[set[int]] = None, _depth: int = 0) -> Any:
|
|
55
|
+
"""Best-effort conversion to JSON-safe objects.
|
|
56
|
+
|
|
57
|
+
The ledger is persisted as JSON. Any value stored in StepRecord.result must be JSON-safe.
|
|
58
|
+
"""
|
|
59
|
+
if _path is None:
|
|
60
|
+
_path = set()
|
|
61
|
+
# Avoid pathological recursion and cyclic structures.
|
|
62
|
+
if _depth > 200:
|
|
63
|
+
return "<max_depth>"
|
|
64
|
+
if value is None:
|
|
65
|
+
return None
|
|
66
|
+
if isinstance(value, (str, int, float, bool)):
|
|
67
|
+
return value
|
|
68
|
+
if isinstance(value, dict):
|
|
69
|
+
vid = id(value)
|
|
70
|
+
if vid in _path:
|
|
71
|
+
return "<cycle>"
|
|
72
|
+
_path.add(vid)
|
|
73
|
+
try:
|
|
74
|
+
return {str(k): _jsonable(v, _path=_path, _depth=_depth + 1) for k, v in value.items()}
|
|
75
|
+
finally:
|
|
76
|
+
_path.discard(vid)
|
|
77
|
+
if isinstance(value, list):
|
|
78
|
+
vid = id(value)
|
|
79
|
+
if vid in _path:
|
|
80
|
+
return "<cycle>"
|
|
81
|
+
_path.add(vid)
|
|
82
|
+
try:
|
|
83
|
+
return [_jsonable(v, _path=_path, _depth=_depth + 1) for v in value]
|
|
84
|
+
finally:
|
|
85
|
+
_path.discard(vid)
|
|
86
|
+
try:
|
|
87
|
+
if is_dataclass(value):
|
|
88
|
+
vid = id(value)
|
|
89
|
+
if vid in _path:
|
|
90
|
+
return "<cycle>"
|
|
91
|
+
_path.add(vid)
|
|
92
|
+
try:
|
|
93
|
+
return _jsonable(asdict(value), _path=_path, _depth=_depth + 1)
|
|
94
|
+
finally:
|
|
95
|
+
_path.discard(vid)
|
|
96
|
+
except Exception:
|
|
97
|
+
pass
|
|
98
|
+
try:
|
|
99
|
+
md = getattr(value, "model_dump", None)
|
|
100
|
+
if callable(md):
|
|
101
|
+
vid = id(value)
|
|
102
|
+
if vid in _path:
|
|
103
|
+
return "<cycle>"
|
|
104
|
+
_path.add(vid)
|
|
105
|
+
try:
|
|
106
|
+
return _jsonable(md(), _path=_path, _depth=_depth + 1)
|
|
107
|
+
finally:
|
|
108
|
+
_path.discard(vid)
|
|
109
|
+
except Exception:
|
|
110
|
+
pass
|
|
111
|
+
try:
|
|
112
|
+
td = getattr(value, "to_dict", None)
|
|
113
|
+
if callable(td):
|
|
114
|
+
vid = id(value)
|
|
115
|
+
if vid in _path:
|
|
116
|
+
return "<cycle>"
|
|
117
|
+
_path.add(vid)
|
|
118
|
+
try:
|
|
119
|
+
return _jsonable(td(), _path=_path, _depth=_depth + 1)
|
|
120
|
+
finally:
|
|
121
|
+
_path.discard(vid)
|
|
122
|
+
except Exception:
|
|
123
|
+
pass
|
|
124
|
+
try:
|
|
125
|
+
json.dumps(value)
|
|
126
|
+
return value
|
|
127
|
+
except Exception:
|
|
128
|
+
return str(value)
|
|
129
|
+
|
|
130
|
+
|
|
53
131
|
_DEFAULT_GLOBAL_MEMORY_RUN_ID = "global_memory"
|
|
132
|
+
_DEFAULT_SESSION_MEMORY_RUN_PREFIX = "session_memory_"
|
|
54
133
|
_SAFE_RUN_ID_PATTERN = re.compile(r"^[a-zA-Z0-9_-]+$")
|
|
55
134
|
|
|
135
|
+
_RUNTIME_TOOL_CALL_ID_PREFIX = "rtcall_"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _ensure_tool_calls_have_runtime_ids(
|
|
139
|
+
*,
|
|
140
|
+
effect: Effect,
|
|
141
|
+
idempotency_key: str,
|
|
142
|
+
) -> Effect:
|
|
143
|
+
"""Attach stable runtime-owned IDs to tool calls without mutating semantics.
|
|
144
|
+
|
|
145
|
+
- Preserves provider/model `call_id` when present (used for OpenAI transcripts).
|
|
146
|
+
- Adds `runtime_call_id` derived from the effect idempotency key + call index.
|
|
147
|
+
- Ensures each tool call has a non-empty `call_id` (falls back to runtime id).
|
|
148
|
+
- Canonicalizes allowlist ordering (`allowed_tools`) for deterministic payloads.
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
if effect.type != EffectType.TOOL_CALLS:
|
|
152
|
+
return effect
|
|
153
|
+
if not isinstance(effect.payload, dict):
|
|
154
|
+
return effect
|
|
155
|
+
|
|
156
|
+
payload = dict(effect.payload)
|
|
157
|
+
raw_tool_calls = payload.get("tool_calls")
|
|
158
|
+
if not isinstance(raw_tool_calls, list):
|
|
159
|
+
return effect
|
|
160
|
+
|
|
161
|
+
tool_calls: list[Any] = []
|
|
162
|
+
for idx, tc in enumerate(raw_tool_calls):
|
|
163
|
+
if not isinstance(tc, dict):
|
|
164
|
+
tool_calls.append(tc)
|
|
165
|
+
continue
|
|
166
|
+
|
|
167
|
+
tc2 = dict(tc)
|
|
168
|
+
runtime_call_id = tc2.get("runtime_call_id")
|
|
169
|
+
runtime_call_id_str = str(runtime_call_id).strip() if runtime_call_id is not None else ""
|
|
170
|
+
if not runtime_call_id_str:
|
|
171
|
+
runtime_call_id_str = f"{_RUNTIME_TOOL_CALL_ID_PREFIX}{idempotency_key}_{idx+1}"
|
|
172
|
+
tc2["runtime_call_id"] = runtime_call_id_str
|
|
173
|
+
|
|
174
|
+
call_id = tc2.get("call_id")
|
|
175
|
+
if call_id is None:
|
|
176
|
+
call_id = tc2.get("id")
|
|
177
|
+
call_id_str = str(call_id).strip() if call_id is not None else ""
|
|
178
|
+
if call_id_str:
|
|
179
|
+
tc2["call_id"] = call_id_str
|
|
180
|
+
else:
|
|
181
|
+
# When the model/provider didn't emit a call id (or the caller omitted it),
|
|
182
|
+
# fall back to a runtime-owned stable id so result correlation still works.
|
|
183
|
+
tc2["call_id"] = runtime_call_id_str
|
|
184
|
+
|
|
185
|
+
name = tc2.get("name")
|
|
186
|
+
if isinstance(name, str):
|
|
187
|
+
tc2["name"] = name.strip()
|
|
188
|
+
|
|
189
|
+
tool_calls.append(tc2)
|
|
190
|
+
|
|
191
|
+
payload["tool_calls"] = tool_calls
|
|
192
|
+
|
|
193
|
+
allowed_tools = payload.get("allowed_tools")
|
|
194
|
+
if isinstance(allowed_tools, list):
|
|
195
|
+
uniq = {
|
|
196
|
+
str(t).strip()
|
|
197
|
+
for t in allowed_tools
|
|
198
|
+
if isinstance(t, str) and t.strip()
|
|
199
|
+
}
|
|
200
|
+
payload["allowed_tools"] = sorted(uniq)
|
|
201
|
+
|
|
202
|
+
return Effect(type=effect.type, payload=payload, result_key=effect.result_key)
|
|
203
|
+
|
|
204
|
+
def _maybe_inject_llm_call_grounding_for_ledger(*, effect: Effect) -> Effect:
|
|
205
|
+
"""Inject per-call time/location grounding into LLM_CALL payloads for auditability.
|
|
206
|
+
|
|
207
|
+
Why:
|
|
208
|
+
- The ledger is the replay/source-of-truth for thin clients.
|
|
209
|
+
- Grounding is injected at the integration boundary (AbstractCore LLM client) so the
|
|
210
|
+
model always knows "when/where" it is.
|
|
211
|
+
- But that injection historically happened *after* the runtime recorded the LLM_CALL
|
|
212
|
+
payload, making it appear missing in ledger UIs.
|
|
213
|
+
|
|
214
|
+
Contract:
|
|
215
|
+
- Only mutates the effect payload (never the durable run context/messages).
|
|
216
|
+
- Must not influence idempotency keys; callers should compute idempotency before calling this.
|
|
217
|
+
"""
|
|
218
|
+
|
|
219
|
+
if effect.type != EffectType.LLM_CALL:
|
|
220
|
+
return effect
|
|
221
|
+
if not isinstance(effect.payload, dict):
|
|
222
|
+
return effect
|
|
223
|
+
|
|
224
|
+
payload = dict(effect.payload)
|
|
225
|
+
prompt = payload.get("prompt")
|
|
226
|
+
messages = payload.get("messages")
|
|
227
|
+
prompt_str = str(prompt or "")
|
|
228
|
+
messages_list = messages if isinstance(messages, list) else None
|
|
229
|
+
|
|
230
|
+
try:
|
|
231
|
+
from abstractruntime.integrations.abstractcore.llm_client import _inject_turn_grounding
|
|
232
|
+
except Exception:
|
|
233
|
+
return effect
|
|
234
|
+
|
|
235
|
+
updated_prompt, updated_messages = _inject_turn_grounding(prompt=prompt_str, messages=messages_list)
|
|
236
|
+
|
|
237
|
+
changed = False
|
|
238
|
+
if updated_prompt != prompt_str:
|
|
239
|
+
payload["prompt"] = updated_prompt
|
|
240
|
+
changed = True
|
|
241
|
+
|
|
242
|
+
if messages_list is not None:
|
|
243
|
+
if updated_messages != messages_list:
|
|
244
|
+
payload["messages"] = updated_messages
|
|
245
|
+
changed = True
|
|
246
|
+
|
|
247
|
+
return Effect(type=effect.type, payload=payload, result_key=effect.result_key) if changed else effect
|
|
248
|
+
|
|
56
249
|
|
|
57
250
|
def _ensure_runtime_namespace(vars: Dict[str, Any]) -> Dict[str, Any]:
|
|
58
251
|
runtime_ns = vars.get("_runtime")
|
|
@@ -406,6 +599,7 @@ class Runtime:
|
|
|
406
599
|
pass
|
|
407
600
|
run.updated_at = utc_now_iso()
|
|
408
601
|
self._run_store.save(run)
|
|
602
|
+
self._append_terminal_status_event(run)
|
|
409
603
|
return run
|
|
410
604
|
|
|
411
605
|
def pause_run(self, run_id: str, *, reason: Optional[str] = None) -> RunState:
|
|
@@ -595,10 +789,14 @@ class Runtime:
|
|
|
595
789
|
def pct(current: int, maximum: int) -> float:
|
|
596
790
|
return round(current / maximum * 100, 1) if maximum > 0 else 0
|
|
597
791
|
|
|
792
|
+
from .vars import DEFAULT_MAX_TOKENS
|
|
793
|
+
|
|
598
794
|
current_iter = int(limits.get("current_iteration", 0) or 0)
|
|
599
795
|
max_iter = int(limits.get("max_iterations", 25) or 25)
|
|
600
796
|
tokens_used = int(limits.get("estimated_tokens_used", 0) or 0)
|
|
601
|
-
max_tokens = int(limits.get("max_tokens",
|
|
797
|
+
max_tokens = int(limits.get("max_tokens", DEFAULT_MAX_TOKENS) or DEFAULT_MAX_TOKENS)
|
|
798
|
+
max_input_tokens = limits.get("max_input_tokens")
|
|
799
|
+
max_output_tokens = limits.get("max_output_tokens")
|
|
602
800
|
|
|
603
801
|
return {
|
|
604
802
|
"iterations": {
|
|
@@ -610,6 +808,8 @@ class Runtime:
|
|
|
610
808
|
"tokens": {
|
|
611
809
|
"estimated_used": tokens_used,
|
|
612
810
|
"max": max_tokens,
|
|
811
|
+
"max_input_tokens": max_input_tokens,
|
|
812
|
+
"max_output_tokens": max_output_tokens,
|
|
613
813
|
"pct": pct(tokens_used, max_tokens),
|
|
614
814
|
"warning": pct(tokens_used, max_tokens) >= limits.get("warn_tokens_pct", 80),
|
|
615
815
|
},
|
|
@@ -646,7 +846,9 @@ class Runtime:
|
|
|
646
846
|
|
|
647
847
|
# Check tokens
|
|
648
848
|
tokens_used = int(limits.get("estimated_tokens_used", 0) or 0)
|
|
649
|
-
|
|
849
|
+
from .vars import DEFAULT_MAX_TOKENS
|
|
850
|
+
|
|
851
|
+
max_tokens = int(limits.get("max_tokens", DEFAULT_MAX_TOKENS) or DEFAULT_MAX_TOKENS)
|
|
650
852
|
warn_tokens_pct = int(limits.get("warn_tokens_pct", 80) or 80)
|
|
651
853
|
|
|
652
854
|
if max_tokens > 0 and tokens_used > 0:
|
|
@@ -677,6 +879,7 @@ class Runtime:
|
|
|
677
879
|
"max_iterations",
|
|
678
880
|
"max_tokens",
|
|
679
881
|
"max_output_tokens",
|
|
882
|
+
"max_input_tokens",
|
|
680
883
|
"max_history_messages",
|
|
681
884
|
"warn_iterations_pct",
|
|
682
885
|
"warn_tokens_pct",
|
|
@@ -690,6 +893,35 @@ class Runtime:
|
|
|
690
893
|
|
|
691
894
|
self._run_store.save(run)
|
|
692
895
|
|
|
896
|
+
def _append_terminal_status_event(self, run: RunState) -> None:
|
|
897
|
+
"""Best-effort: append a durable `abstract.status` event on terminal runs.
|
|
898
|
+
|
|
899
|
+
This exists for UI clients that rely on `emit_event` records (e.g. status bars)
|
|
900
|
+
and should not be required for correctness. Failures must be non-fatal.
|
|
901
|
+
"""
|
|
902
|
+
try:
|
|
903
|
+
status = getattr(getattr(run, "status", None), "value", None) or str(getattr(run, "status", "") or "")
|
|
904
|
+
status_str = str(status or "").strip().lower()
|
|
905
|
+
if status_str not in {RunStatus.COMPLETED.value, RunStatus.FAILED.value, RunStatus.CANCELLED.value}:
|
|
906
|
+
return
|
|
907
|
+
|
|
908
|
+
node_id = str(getattr(run, "current_node", None) or "").strip() or "runtime"
|
|
909
|
+
eff = Effect(
|
|
910
|
+
type=EffectType.EMIT_EVENT,
|
|
911
|
+
payload={"name": "abstract.status", "scope": "session", "payload": {"text": status_str}},
|
|
912
|
+
)
|
|
913
|
+
rec = StepRecord.start(
|
|
914
|
+
run=run,
|
|
915
|
+
node_id=node_id,
|
|
916
|
+
effect=eff,
|
|
917
|
+
idempotency_key=f"system:terminal_status:{status_str}",
|
|
918
|
+
)
|
|
919
|
+
rec.finish_success({"emitted": True, "name": "abstract.status", "payload": {"text": status_str}})
|
|
920
|
+
self._ledger_store.append(rec)
|
|
921
|
+
except Exception:
|
|
922
|
+
# Observability must never compromise durability/execution.
|
|
923
|
+
return
|
|
924
|
+
|
|
693
925
|
def tick(self, *, workflow: WorkflowSpec, run_id: str, max_steps: int = 100) -> RunState:
|
|
694
926
|
run = self.get_state(run_id)
|
|
695
927
|
# Terminal runs never progress.
|
|
@@ -748,9 +980,10 @@ class Runtime:
|
|
|
748
980
|
# ledger: completion record (no effect)
|
|
749
981
|
rec = StepRecord.start(run=run, node_id=plan.node_id, effect=None)
|
|
750
982
|
rec.status = StepStatus.COMPLETED
|
|
751
|
-
rec.result = {"completed": True}
|
|
983
|
+
rec.result = {"completed": True, "output": _jsonable(run.output)}
|
|
752
984
|
rec.ended_at = utc_now_iso()
|
|
753
985
|
self._ledger_store.append(rec)
|
|
986
|
+
self._append_terminal_status_event(run)
|
|
754
987
|
return run
|
|
755
988
|
|
|
756
989
|
# Pure transition
|
|
@@ -766,9 +999,11 @@ class Runtime:
|
|
|
766
999
|
continue
|
|
767
1000
|
|
|
768
1001
|
# Effectful step - check for prior completed result (idempotency)
|
|
1002
|
+
effect = plan.effect
|
|
769
1003
|
idempotency_key = self._effect_policy.idempotency_key(
|
|
770
|
-
run=run, node_id=plan.node_id, effect=
|
|
1004
|
+
run=run, node_id=plan.node_id, effect=effect
|
|
771
1005
|
)
|
|
1006
|
+
effect = _ensure_tool_calls_have_runtime_ids(effect=effect, idempotency_key=idempotency_key)
|
|
772
1007
|
prior_result = self._find_prior_completed_result(run.run_id, idempotency_key)
|
|
773
1008
|
reused_prior_result = prior_result is not None
|
|
774
1009
|
|
|
@@ -782,11 +1017,15 @@ class Runtime:
|
|
|
782
1017
|
# Reuse prior result - skip re-execution
|
|
783
1018
|
outcome = EffectOutcome.completed(prior_result)
|
|
784
1019
|
else:
|
|
1020
|
+
# For LLM calls, inject runtime grounding into the effect payload so ledger consumers
|
|
1021
|
+
# can see exactly what the model was sent (timestamp + country), without mutating the
|
|
1022
|
+
# durable run context.
|
|
1023
|
+
effect = _maybe_inject_llm_call_grounding_for_ledger(effect=effect)
|
|
785
1024
|
# Execute with retry logic
|
|
786
1025
|
outcome = self._execute_effect_with_retry(
|
|
787
1026
|
run=run,
|
|
788
1027
|
node_id=plan.node_id,
|
|
789
|
-
effect=
|
|
1028
|
+
effect=effect,
|
|
790
1029
|
idempotency_key=idempotency_key,
|
|
791
1030
|
default_next_node=plan.next_node,
|
|
792
1031
|
)
|
|
@@ -800,13 +1039,13 @@ class Runtime:
|
|
|
800
1039
|
try:
|
|
801
1040
|
if (
|
|
802
1041
|
not reused_prior_result
|
|
803
|
-
and
|
|
1042
|
+
and effect.type == EffectType.TOOL_CALLS
|
|
804
1043
|
and outcome.status == "completed"
|
|
805
1044
|
):
|
|
806
1045
|
self._maybe_record_tool_evidence(
|
|
807
1046
|
run=run,
|
|
808
1047
|
node_id=plan.node_id,
|
|
809
|
-
effect=
|
|
1048
|
+
effect=effect,
|
|
810
1049
|
tool_results=outcome.result,
|
|
811
1050
|
)
|
|
812
1051
|
except Exception:
|
|
@@ -816,13 +1055,36 @@ class Runtime:
|
|
|
816
1055
|
_record_node_trace(
|
|
817
1056
|
run=run,
|
|
818
1057
|
node_id=plan.node_id,
|
|
819
|
-
effect=
|
|
1058
|
+
effect=effect,
|
|
820
1059
|
outcome=outcome,
|
|
821
1060
|
idempotency_key=idempotency_key,
|
|
822
1061
|
reused_prior_result=reused_prior_result,
|
|
823
1062
|
duration_ms=duration_ms,
|
|
824
1063
|
)
|
|
825
1064
|
|
|
1065
|
+
# Best-effort token observability: surface last-known input token usage in `_limits`.
|
|
1066
|
+
#
|
|
1067
|
+
# AbstractCore responses generally populate `usage` (prompt/input/output/total tokens).
|
|
1068
|
+
# We store the input-side usage as `estimated_tokens_used` so host UIs and workflows
|
|
1069
|
+
# can reason about compaction budgets without re-tokenizing.
|
|
1070
|
+
try:
|
|
1071
|
+
if effect.type == EffectType.LLM_CALL and outcome.status == "completed" and isinstance(outcome.result, dict):
|
|
1072
|
+
usage = outcome.result.get("usage")
|
|
1073
|
+
if isinstance(usage, dict):
|
|
1074
|
+
raw_in = usage.get("input_tokens")
|
|
1075
|
+
if raw_in is None:
|
|
1076
|
+
raw_in = usage.get("prompt_tokens")
|
|
1077
|
+
if raw_in is None:
|
|
1078
|
+
raw_in = usage.get("total_tokens")
|
|
1079
|
+
if raw_in is not None and not isinstance(raw_in, bool):
|
|
1080
|
+
limits = run.vars.get("_limits")
|
|
1081
|
+
if not isinstance(limits, dict):
|
|
1082
|
+
limits = {}
|
|
1083
|
+
run.vars["_limits"] = limits
|
|
1084
|
+
limits["estimated_tokens_used"] = int(raw_in)
|
|
1085
|
+
except Exception:
|
|
1086
|
+
pass
|
|
1087
|
+
|
|
826
1088
|
if outcome.status == "failed":
|
|
827
1089
|
controlled = _abort_if_externally_controlled()
|
|
828
1090
|
if controlled is not None:
|
|
@@ -831,6 +1093,7 @@ class Runtime:
|
|
|
831
1093
|
run.error = outcome.error or "unknown error"
|
|
832
1094
|
run.updated_at = utc_now_iso()
|
|
833
1095
|
self._run_store.save(run)
|
|
1096
|
+
self._append_terminal_status_event(run)
|
|
834
1097
|
return run
|
|
835
1098
|
|
|
836
1099
|
if outcome.status == "waiting":
|
|
@@ -845,8 +1108,8 @@ class Runtime:
|
|
|
845
1108
|
return run
|
|
846
1109
|
|
|
847
1110
|
# completed
|
|
848
|
-
if
|
|
849
|
-
_set_nested(run.vars,
|
|
1111
|
+
if effect.result_key and outcome.result is not None:
|
|
1112
|
+
_set_nested(run.vars, effect.result_key, outcome.result)
|
|
850
1113
|
|
|
851
1114
|
# Terminal effect node: treat missing next_node as completion.
|
|
852
1115
|
#
|
|
@@ -862,6 +1125,7 @@ class Runtime:
|
|
|
862
1125
|
run.output = {"success": True, "result": outcome.result}
|
|
863
1126
|
run.updated_at = utc_now_iso()
|
|
864
1127
|
self._run_store.save(run)
|
|
1128
|
+
self._append_terminal_status_event(run)
|
|
865
1129
|
return run
|
|
866
1130
|
controlled = _abort_if_externally_controlled()
|
|
867
1131
|
if controlled is not None:
|
|
@@ -942,50 +1206,113 @@ class Runtime:
|
|
|
942
1206
|
stored_payload: Dict[str, Any] = payload
|
|
943
1207
|
|
|
944
1208
|
if result_key:
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
#
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
1209
|
+
details = run.waiting.details if run.waiting is not None else None
|
|
1210
|
+
|
|
1211
|
+
# Special case: subworkflow completion resumed as a tool-style observation.
|
|
1212
|
+
if (
|
|
1213
|
+
run.waiting.reason == WaitReason.SUBWORKFLOW
|
|
1214
|
+
and isinstance(details, dict)
|
|
1215
|
+
and bool(details.get("wrap_as_tool_result", False))
|
|
1216
|
+
and isinstance(payload, dict)
|
|
1217
|
+
and not ("mode" in payload and "results" in payload)
|
|
1218
|
+
):
|
|
1219
|
+
tool_name = str(details.get("tool_name") or "start_subworkflow").strip() or "start_subworkflow"
|
|
1220
|
+
call_id = str(details.get("call_id") or "subworkflow").strip() or "subworkflow"
|
|
1221
|
+
sub_run_id = str(payload.get("sub_run_id") or details.get("sub_run_id") or "").strip()
|
|
1222
|
+
child_output = payload.get("output")
|
|
1223
|
+
|
|
1224
|
+
answer = ""
|
|
1225
|
+
report = ""
|
|
1226
|
+
err = None
|
|
1227
|
+
success = True
|
|
1228
|
+
if isinstance(child_output, dict):
|
|
1229
|
+
# Generic failure envelope support (VisualFlow style).
|
|
1230
|
+
if child_output.get("success") is False:
|
|
1231
|
+
success = False
|
|
1232
|
+
err = str(child_output.get("error") or "Subworkflow failed")
|
|
1233
|
+
a = child_output.get("answer")
|
|
1234
|
+
if isinstance(a, str) and a.strip():
|
|
1235
|
+
answer = a.strip()
|
|
1236
|
+
r = child_output.get("report")
|
|
1237
|
+
if isinstance(r, str) and r.strip():
|
|
1238
|
+
report = r.strip()
|
|
1239
|
+
|
|
1240
|
+
if not answer:
|
|
1241
|
+
if isinstance(child_output, str) and child_output.strip():
|
|
1242
|
+
answer = child_output.strip()
|
|
1243
|
+
else:
|
|
1244
|
+
try:
|
|
1245
|
+
answer = json.dumps(child_output, ensure_ascii=False)
|
|
1246
|
+
except Exception:
|
|
1247
|
+
answer = "" if child_output is None else str(child_output)
|
|
1248
|
+
|
|
1249
|
+
tool_output: Dict[str, Any] = {"rendered": answer, "answer": answer, "sub_run_id": sub_run_id}
|
|
1250
|
+
if report and len(report) <= 4000:
|
|
1251
|
+
tool_output["report"] = report
|
|
1252
|
+
|
|
1253
|
+
merged_payload: Dict[str, Any] = {
|
|
1254
|
+
"mode": "executed",
|
|
1255
|
+
"results": [
|
|
1256
|
+
{
|
|
1257
|
+
"call_id": call_id,
|
|
1258
|
+
"name": tool_name,
|
|
1259
|
+
"success": bool(success),
|
|
1260
|
+
"output": tool_output if success else None,
|
|
1261
|
+
"error": None if success else err,
|
|
1262
|
+
}
|
|
1263
|
+
],
|
|
1264
|
+
}
|
|
1265
|
+
else:
|
|
1266
|
+
# Tool waits may carry blocked-by-allowlist metadata. External hosts typically only execute
|
|
1267
|
+
# the filtered subset of tool calls and resume with results for those calls. To keep agent
|
|
1268
|
+
# semantics correct (and evidence indices aligned), merge blocked entries back into the
|
|
1269
|
+
# resumed payload deterministically.
|
|
988
1270
|
merged_payload = payload
|
|
1271
|
+
try:
|
|
1272
|
+
if isinstance(details, dict):
|
|
1273
|
+
blocked = details.get("blocked_by_index")
|
|
1274
|
+
pre_results = details.get("pre_results_by_index")
|
|
1275
|
+
original_count = details.get("original_call_count")
|
|
1276
|
+
results = payload.get("results") if isinstance(payload, dict) else None
|
|
1277
|
+
fixed_by_index: Dict[str, Any] = {}
|
|
1278
|
+
if isinstance(blocked, dict):
|
|
1279
|
+
fixed_by_index.update(blocked)
|
|
1280
|
+
if isinstance(pre_results, dict):
|
|
1281
|
+
fixed_by_index.update(pre_results)
|
|
1282
|
+
if (
|
|
1283
|
+
fixed_by_index
|
|
1284
|
+
and isinstance(original_count, int)
|
|
1285
|
+
and original_count > 0
|
|
1286
|
+
and isinstance(results, list)
|
|
1287
|
+
and len(results) != original_count
|
|
1288
|
+
):
|
|
1289
|
+
merged_results: list[Any] = []
|
|
1290
|
+
executed_iter = iter(results)
|
|
1291
|
+
|
|
1292
|
+
for idx in range(original_count):
|
|
1293
|
+
fixed_entry = fixed_by_index.get(str(idx))
|
|
1294
|
+
if isinstance(fixed_entry, dict):
|
|
1295
|
+
merged_results.append(fixed_entry)
|
|
1296
|
+
continue
|
|
1297
|
+
try:
|
|
1298
|
+
merged_results.append(next(executed_iter))
|
|
1299
|
+
except StopIteration:
|
|
1300
|
+
merged_results.append(
|
|
1301
|
+
{
|
|
1302
|
+
"call_id": "",
|
|
1303
|
+
"runtime_call_id": None,
|
|
1304
|
+
"name": "",
|
|
1305
|
+
"success": False,
|
|
1306
|
+
"output": None,
|
|
1307
|
+
"error": "Missing tool result",
|
|
1308
|
+
}
|
|
1309
|
+
)
|
|
1310
|
+
|
|
1311
|
+
merged_payload = dict(payload)
|
|
1312
|
+
merged_payload["results"] = merged_results
|
|
1313
|
+
merged_payload.setdefault("mode", "executed")
|
|
1314
|
+
except Exception:
|
|
1315
|
+
merged_payload = payload
|
|
989
1316
|
|
|
990
1317
|
_set_nested(run.vars, result_key, merged_payload)
|
|
991
1318
|
stored_payload = merged_payload
|
|
@@ -1014,15 +1341,107 @@ class Runtime:
|
|
|
1014
1341
|
except Exception:
|
|
1015
1342
|
pass
|
|
1016
1343
|
|
|
1344
|
+
# Append a durable "resume" record to the ledger for replay-first clients.
|
|
1345
|
+
#
|
|
1346
|
+
# Why:
|
|
1347
|
+
# - The ledger is the source-of-truth for replay/streaming (ADR-0011/0018).
|
|
1348
|
+
# - Without a resume record, user input payloads (ASK_USER / abstract.ask / tool approvals)
|
|
1349
|
+
# only live in RunState.vars and are not visible during ledger-only replay.
|
|
1350
|
+
#
|
|
1351
|
+
# This is best-effort: failure to append must not compromise correctness.
|
|
1352
|
+
try:
|
|
1353
|
+
wait_before = run.waiting
|
|
1354
|
+
wait_reason_value = None
|
|
1355
|
+
wait_key_value = None
|
|
1356
|
+
try:
|
|
1357
|
+
if wait_before is not None:
|
|
1358
|
+
r0 = getattr(wait_before, "reason", None)
|
|
1359
|
+
wait_reason_value = r0.value if hasattr(r0, "value") else str(r0) if r0 is not None else None
|
|
1360
|
+
wait_key_value = getattr(wait_before, "wait_key", None)
|
|
1361
|
+
except Exception:
|
|
1362
|
+
wait_reason_value = None
|
|
1363
|
+
wait_key_value = None
|
|
1364
|
+
|
|
1365
|
+
payload_for_ledger: Any = stored_payload
|
|
1366
|
+
try:
|
|
1367
|
+
from ..storage.offloading import _default_max_inline_bytes, offload_large_values
|
|
1368
|
+
|
|
1369
|
+
if self._artifact_store is not None:
|
|
1370
|
+
payload_for_ledger = offload_large_values(
|
|
1371
|
+
stored_payload,
|
|
1372
|
+
artifact_store=self._artifact_store,
|
|
1373
|
+
run_id=str(run.run_id or ""),
|
|
1374
|
+
max_inline_bytes=_default_max_inline_bytes(),
|
|
1375
|
+
base_tags={"source": "resume", "kind": "resume_payload"},
|
|
1376
|
+
root_path="resume.payload",
|
|
1377
|
+
allow_root_replace=False,
|
|
1378
|
+
)
|
|
1379
|
+
except Exception:
|
|
1380
|
+
payload_for_ledger = stored_payload
|
|
1381
|
+
|
|
1382
|
+
node_id0 = str(getattr(run, "current_node", None) or "")
|
|
1383
|
+
rec = StepRecord.start(run=run, node_id=node_id0 or "unknown", effect=None)
|
|
1384
|
+
rec.status = StepStatus.COMPLETED
|
|
1385
|
+
rec.effect = {
|
|
1386
|
+
"type": "resume",
|
|
1387
|
+
"payload": {
|
|
1388
|
+
"wait_reason": wait_reason_value,
|
|
1389
|
+
"wait_key": wait_key_value,
|
|
1390
|
+
"resume_to_node": resume_to,
|
|
1391
|
+
"result_key": result_key,
|
|
1392
|
+
"payload": payload_for_ledger,
|
|
1393
|
+
},
|
|
1394
|
+
"result_key": None,
|
|
1395
|
+
}
|
|
1396
|
+
rec.result = {"resumed": True}
|
|
1397
|
+
rec.ended_at = utc_now_iso()
|
|
1398
|
+
self._ledger_store.append(rec)
|
|
1399
|
+
except Exception:
|
|
1400
|
+
pass
|
|
1401
|
+
|
|
1017
1402
|
# Terminal waiting node: if there is no resume target, treat the resume payload as
|
|
1018
1403
|
# the final output instead of re-executing the waiting node again (which would
|
|
1019
1404
|
# otherwise create an infinite wait/resume loop).
|
|
1020
1405
|
if resume_to is None:
|
|
1406
|
+
# Capture the wait context for observability before clearing it.
|
|
1407
|
+
wait_before = run.waiting
|
|
1408
|
+
wait_reason = None
|
|
1409
|
+
wait_key0 = None
|
|
1410
|
+
try:
|
|
1411
|
+
if wait_before is not None:
|
|
1412
|
+
r0 = getattr(wait_before, "reason", None)
|
|
1413
|
+
wait_reason = r0.value if hasattr(r0, "value") else str(r0) if r0 is not None else None
|
|
1414
|
+
wait_key0 = getattr(wait_before, "wait_key", None)
|
|
1415
|
+
except Exception:
|
|
1416
|
+
wait_reason = None
|
|
1417
|
+
wait_key0 = None
|
|
1418
|
+
|
|
1021
1419
|
run.status = RunStatus.COMPLETED
|
|
1022
1420
|
run.waiting = None
|
|
1023
1421
|
run.output = {"success": True, "result": stored_payload}
|
|
1024
1422
|
run.updated_at = utc_now_iso()
|
|
1025
1423
|
self._run_store.save(run)
|
|
1424
|
+
|
|
1425
|
+
# Ledger must remain the source-of-truth for replay/streaming.
|
|
1426
|
+
# When a terminal wait is resumed, there is no follow-up `tick()` to append a
|
|
1427
|
+
# completion record, so we append one here.
|
|
1428
|
+
try:
|
|
1429
|
+
node_id0 = str(getattr(run, "current_node", None) or "")
|
|
1430
|
+
rec = StepRecord.start(run=run, node_id=node_id0 or "unknown", effect=None)
|
|
1431
|
+
rec.status = StepStatus.COMPLETED
|
|
1432
|
+
rec.result = {
|
|
1433
|
+
"completed": True,
|
|
1434
|
+
"via": "resume",
|
|
1435
|
+
"wait_reason": wait_reason,
|
|
1436
|
+
"wait_key": wait_key0,
|
|
1437
|
+
"output": _jsonable(run.output),
|
|
1438
|
+
}
|
|
1439
|
+
rec.ended_at = utc_now_iso()
|
|
1440
|
+
self._ledger_store.append(rec)
|
|
1441
|
+
except Exception:
|
|
1442
|
+
# Observability must never compromise durability/execution.
|
|
1443
|
+
pass
|
|
1444
|
+
self._append_terminal_status_event(run)
|
|
1026
1445
|
return run
|
|
1027
1446
|
|
|
1028
1447
|
self._apply_resume_payload(run, payload=payload, override_node=resume_to)
|
|
@@ -1095,8 +1514,57 @@ class Runtime:
|
|
|
1095
1514
|
self._run_store.save(run)
|
|
1096
1515
|
return run
|
|
1097
1516
|
|
|
1098
|
-
def
|
|
1099
|
-
"""
|
|
1517
|
+
def _session_memory_run_id(self, session_id: str) -> str:
|
|
1518
|
+
"""Return a stable session memory run id for a durable `session_id`.
|
|
1519
|
+
|
|
1520
|
+
This run is internal and is used only as the owner for `scope="session"` span indices.
|
|
1521
|
+
"""
|
|
1522
|
+
sid = str(session_id or "").strip()
|
|
1523
|
+
if not sid:
|
|
1524
|
+
raise ValueError("session_id is required")
|
|
1525
|
+
if _SAFE_RUN_ID_PATTERN.match(sid):
|
|
1526
|
+
rid = f"{_DEFAULT_SESSION_MEMORY_RUN_PREFIX}{sid}"
|
|
1527
|
+
if _SAFE_RUN_ID_PATTERN.match(rid):
|
|
1528
|
+
return rid
|
|
1529
|
+
digest = hashlib.sha256(sid.encode("utf-8")).hexdigest()[:32]
|
|
1530
|
+
return f"{_DEFAULT_SESSION_MEMORY_RUN_PREFIX}sha_{digest}"
|
|
1531
|
+
|
|
1532
|
+
def _ensure_session_memory_run(self, session_id: str) -> RunState:
|
|
1533
|
+
"""Load or create the session memory run used as the owner for `scope=\"session\"` spans."""
|
|
1534
|
+
rid = self._session_memory_run_id(session_id)
|
|
1535
|
+
existing = self._run_store.load(rid)
|
|
1536
|
+
if existing is not None:
|
|
1537
|
+
return existing
|
|
1538
|
+
|
|
1539
|
+
run = RunState(
|
|
1540
|
+
run_id=rid,
|
|
1541
|
+
workflow_id="__session_memory__",
|
|
1542
|
+
status=RunStatus.COMPLETED,
|
|
1543
|
+
current_node="done",
|
|
1544
|
+
vars={
|
|
1545
|
+
"context": {"task": "", "messages": []},
|
|
1546
|
+
"scratchpad": {},
|
|
1547
|
+
"_runtime": {"memory_spans": []},
|
|
1548
|
+
"_temp": {},
|
|
1549
|
+
"_limits": {},
|
|
1550
|
+
},
|
|
1551
|
+
waiting=None,
|
|
1552
|
+
output={"messages": []},
|
|
1553
|
+
error=None,
|
|
1554
|
+
created_at=utc_now_iso(),
|
|
1555
|
+
updated_at=utc_now_iso(),
|
|
1556
|
+
actor_id=None,
|
|
1557
|
+
session_id=str(session_id or "").strip() or None,
|
|
1558
|
+
parent_run_id=None,
|
|
1559
|
+
)
|
|
1560
|
+
self._run_store.save(run)
|
|
1561
|
+
return run
|
|
1562
|
+
|
|
1563
|
+
def _resolve_run_tree_root_run(self, run: RunState) -> RunState:
|
|
1564
|
+
"""Resolve the root run of the current run-tree (walk `parent_run_id`).
|
|
1565
|
+
|
|
1566
|
+
This is used as a backward-compatible fallback for legacy runs without `session_id`.
|
|
1567
|
+
"""
|
|
1100
1568
|
cur = run
|
|
1101
1569
|
seen: set[str] = set()
|
|
1102
1570
|
while True:
|
|
@@ -1118,7 +1586,10 @@ class Runtime:
|
|
|
1118
1586
|
if s == "run":
|
|
1119
1587
|
return base_run
|
|
1120
1588
|
if s == "session":
|
|
1121
|
-
|
|
1589
|
+
sid = getattr(base_run, "session_id", None)
|
|
1590
|
+
if isinstance(sid, str) and sid.strip():
|
|
1591
|
+
return self._ensure_session_memory_run(sid.strip())
|
|
1592
|
+
return self._resolve_run_tree_root_run(base_run)
|
|
1122
1593
|
if s == "global":
|
|
1123
1594
|
return self._ensure_global_memory_run()
|
|
1124
1595
|
raise ValueError(f"Unknown memory scope: {scope}")
|
|
@@ -1377,12 +1848,6 @@ class Runtime:
|
|
|
1377
1848
|
except Exception:
|
|
1378
1849
|
wildcard_wait_key = None
|
|
1379
1850
|
|
|
1380
|
-
if self._workflow_registry is None:
|
|
1381
|
-
return EffectOutcome.failed(
|
|
1382
|
-
"emit_event requires a workflow_registry to resume target runs. "
|
|
1383
|
-
"Set it via Runtime(workflow_registry=...) or runtime.set_workflow_registry(...)."
|
|
1384
|
-
)
|
|
1385
|
-
|
|
1386
1851
|
if not isinstance(self._run_store, QueryableRunStore):
|
|
1387
1852
|
return EffectOutcome.failed(
|
|
1388
1853
|
"emit_event requires a QueryableRunStore to find waiting runs. "
|
|
@@ -1415,6 +1880,11 @@ class Runtime:
|
|
|
1415
1880
|
available_in_session: list[str] = []
|
|
1416
1881
|
prefix = f"evt:session:{session_id}:"
|
|
1417
1882
|
|
|
1883
|
+
# First pass: find matching runs and compute best-effort diagnostics without
|
|
1884
|
+
# requiring a workflow_registry. This allows UI-only EMIT_EVENT usage
|
|
1885
|
+
# (e.g. AbstractCode notifications) in deployments that do not use
|
|
1886
|
+
# WAIT_EVENT listeners.
|
|
1887
|
+
matched: list[tuple[RunState, Optional[str]]] = []
|
|
1418
1888
|
for r in candidates:
|
|
1419
1889
|
if _is_paused_run_vars(getattr(r, "vars", None)):
|
|
1420
1890
|
continue
|
|
@@ -1430,6 +1900,31 @@ class Runtime:
|
|
|
1430
1900
|
if wk != wait_key and (wildcard_wait_key is None or wk != wildcard_wait_key):
|
|
1431
1901
|
continue
|
|
1432
1902
|
|
|
1903
|
+
matched.append((r, wk if isinstance(wk, str) else None))
|
|
1904
|
+
|
|
1905
|
+
# If there are no matching listeners, emitting is still a useful side effect
|
|
1906
|
+
# for hosts (ledger observability, UI events). In that case, do not require
|
|
1907
|
+
# a workflow_registry.
|
|
1908
|
+
if not matched:
|
|
1909
|
+
out0: Dict[str, Any] = {
|
|
1910
|
+
"wait_key": wait_key,
|
|
1911
|
+
"name": str(name),
|
|
1912
|
+
"scope": str(scope or "session"),
|
|
1913
|
+
"delivered": 0,
|
|
1914
|
+
"delivered_to": [],
|
|
1915
|
+
"resumed": [],
|
|
1916
|
+
}
|
|
1917
|
+
if available_in_session:
|
|
1918
|
+
out0["available_listeners_in_session"] = available_in_session
|
|
1919
|
+
return EffectOutcome.completed(out0)
|
|
1920
|
+
|
|
1921
|
+
if self._workflow_registry is None:
|
|
1922
|
+
return EffectOutcome.failed(
|
|
1923
|
+
"emit_event requires a workflow_registry to resume target runs. "
|
|
1924
|
+
"Set it via Runtime(workflow_registry=...) or runtime.set_workflow_registry(...)."
|
|
1925
|
+
)
|
|
1926
|
+
|
|
1927
|
+
for r, wk in matched:
|
|
1433
1928
|
wf = self._workflow_registry.get(r.workflow_id)
|
|
1434
1929
|
if wf is None:
|
|
1435
1930
|
# Can't resume without the spec; skip but include diagnostic in result.
|
|
@@ -1516,7 +2011,15 @@ class Runtime:
|
|
|
1516
2011
|
message = effect.payload.get("text") or effect.payload.get("content")
|
|
1517
2012
|
if message is None:
|
|
1518
2013
|
return EffectOutcome.failed("answer_user requires payload.message")
|
|
1519
|
-
|
|
2014
|
+
level_raw = effect.payload.get("level")
|
|
2015
|
+
level = str(level_raw).strip().lower() if isinstance(level_raw, str) else ""
|
|
2016
|
+
if level in {"warn"}:
|
|
2017
|
+
level = "warning"
|
|
2018
|
+
if level not in {"message", "warning", "error", "info"}:
|
|
2019
|
+
level = "message"
|
|
2020
|
+
if level == "info":
|
|
2021
|
+
level = "message"
|
|
2022
|
+
return EffectOutcome.completed({"message": str(message), "level": level})
|
|
1520
2023
|
|
|
1521
2024
|
def _handle_start_subworkflow(
|
|
1522
2025
|
self, run: RunState, effect: Effect, default_next_node: Optional[str]
|
|
@@ -1537,11 +2040,77 @@ class Runtime:
|
|
|
1537
2040
|
- Starts the subworkflow and returns immediately
|
|
1538
2041
|
- Returns {"sub_run_id": "..."} so parent can track it
|
|
1539
2042
|
"""
|
|
2043
|
+
payload0 = effect.payload if isinstance(effect.payload, dict) else {}
|
|
2044
|
+
wrap_as_tool_result = bool(payload0.get("wrap_as_tool_result", False))
|
|
2045
|
+
tool_name_raw = payload0.get("tool_name")
|
|
2046
|
+
if tool_name_raw is None:
|
|
2047
|
+
tool_name_raw = payload0.get("toolName")
|
|
2048
|
+
tool_name = str(tool_name_raw or "").strip()
|
|
2049
|
+
call_id_raw = payload0.get("call_id")
|
|
2050
|
+
if call_id_raw is None:
|
|
2051
|
+
call_id_raw = payload0.get("callId")
|
|
2052
|
+
call_id = str(call_id_raw or "").strip()
|
|
2053
|
+
|
|
2054
|
+
def _tool_result(*, success: bool, output: Any, error: Optional[str]) -> Dict[str, Any]:
|
|
2055
|
+
name = tool_name or "start_subworkflow"
|
|
2056
|
+
cid = call_id or "subworkflow"
|
|
2057
|
+
return {
|
|
2058
|
+
"mode": "executed",
|
|
2059
|
+
"results": [
|
|
2060
|
+
{
|
|
2061
|
+
"call_id": cid,
|
|
2062
|
+
"name": name,
|
|
2063
|
+
"success": bool(success),
|
|
2064
|
+
"output": output if success else None,
|
|
2065
|
+
"error": None if success else str(error or "Subworkflow failed"),
|
|
2066
|
+
}
|
|
2067
|
+
],
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
def _tool_output_for_subworkflow(*, sub_run_id: str, output: Any) -> Dict[str, Any]:
|
|
2071
|
+
rendered = ""
|
|
2072
|
+
answer = ""
|
|
2073
|
+
report = ""
|
|
2074
|
+
if isinstance(output, dict):
|
|
2075
|
+
a = output.get("answer")
|
|
2076
|
+
if isinstance(a, str) and a.strip():
|
|
2077
|
+
answer = a.strip()
|
|
2078
|
+
r = output.get("report")
|
|
2079
|
+
if isinstance(r, str) and r.strip():
|
|
2080
|
+
report = r.strip()
|
|
2081
|
+
if not answer:
|
|
2082
|
+
if isinstance(output, str) and output.strip():
|
|
2083
|
+
answer = output.strip()
|
|
2084
|
+
else:
|
|
2085
|
+
try:
|
|
2086
|
+
answer = json.dumps(output, ensure_ascii=False)
|
|
2087
|
+
except Exception:
|
|
2088
|
+
answer = str(output)
|
|
2089
|
+
rendered = answer
|
|
2090
|
+
out = {"rendered": rendered, "answer": answer, "sub_run_id": str(sub_run_id)}
|
|
2091
|
+
# Keep the tool observation bounded; the full child run can be inspected via run id if needed.
|
|
2092
|
+
if report and len(report) <= 4000:
|
|
2093
|
+
out["report"] = report
|
|
2094
|
+
return out
|
|
2095
|
+
|
|
1540
2096
|
workflow_id = effect.payload.get("workflow_id")
|
|
1541
2097
|
if not workflow_id:
|
|
2098
|
+
if wrap_as_tool_result:
|
|
2099
|
+
return EffectOutcome.completed(_tool_result(success=False, output=None, error="start_subworkflow requires payload.workflow_id"))
|
|
1542
2100
|
return EffectOutcome.failed("start_subworkflow requires payload.workflow_id")
|
|
1543
2101
|
|
|
1544
2102
|
if self._workflow_registry is None:
|
|
2103
|
+
if wrap_as_tool_result:
|
|
2104
|
+
return EffectOutcome.completed(
|
|
2105
|
+
_tool_result(
|
|
2106
|
+
success=False,
|
|
2107
|
+
output=None,
|
|
2108
|
+
error=(
|
|
2109
|
+
"start_subworkflow requires a workflow_registry. "
|
|
2110
|
+
"Set it via Runtime(workflow_registry=...) or runtime.set_workflow_registry(...)"
|
|
2111
|
+
),
|
|
2112
|
+
)
|
|
2113
|
+
)
|
|
1545
2114
|
return EffectOutcome.failed(
|
|
1546
2115
|
"start_subworkflow requires a workflow_registry. "
|
|
1547
2116
|
"Set it via Runtime(workflow_registry=...) or runtime.set_workflow_registry(...)"
|
|
@@ -1550,20 +2119,59 @@ class Runtime:
|
|
|
1550
2119
|
# Look up the subworkflow
|
|
1551
2120
|
sub_workflow = self._workflow_registry.get(workflow_id)
|
|
1552
2121
|
if sub_workflow is None:
|
|
2122
|
+
if wrap_as_tool_result:
|
|
2123
|
+
return EffectOutcome.completed(
|
|
2124
|
+
_tool_result(success=False, output=None, error=f"Workflow '{workflow_id}' not found in registry")
|
|
2125
|
+
)
|
|
1553
2126
|
return EffectOutcome.failed(f"Workflow '{workflow_id}' not found in registry")
|
|
1554
2127
|
|
|
1555
|
-
|
|
2128
|
+
sub_vars_raw = effect.payload.get("vars")
|
|
2129
|
+
sub_vars: Dict[str, Any] = dict(sub_vars_raw) if isinstance(sub_vars_raw, dict) else {}
|
|
2130
|
+
|
|
2131
|
+
# Inherit workspace policy into child runs by default.
|
|
2132
|
+
#
|
|
2133
|
+
# Why: in VisualFlow, agents/subflows run as START_SUBWORKFLOW runs. Tool execution inside the
|
|
2134
|
+
# child must respect the same workspace scope the user configured for the parent run.
|
|
2135
|
+
#
|
|
2136
|
+
# Policy: only inherit when the child did not explicitly override the keys.
|
|
2137
|
+
try:
|
|
2138
|
+
parent_vars = run.vars if isinstance(getattr(run, "vars", None), dict) else {}
|
|
2139
|
+
for k in ("workspace_root", "workspace_access_mode", "workspace_allowed_paths", "workspace_ignored_paths"):
|
|
2140
|
+
if k in sub_vars:
|
|
2141
|
+
continue
|
|
2142
|
+
v = parent_vars.get(k)
|
|
2143
|
+
if v is None:
|
|
2144
|
+
continue
|
|
2145
|
+
if isinstance(v, str):
|
|
2146
|
+
if not v.strip():
|
|
2147
|
+
continue
|
|
2148
|
+
sub_vars[k] = v
|
|
2149
|
+
continue
|
|
2150
|
+
sub_vars[k] = v
|
|
2151
|
+
except Exception:
|
|
2152
|
+
pass
|
|
1556
2153
|
is_async = bool(effect.payload.get("async", False))
|
|
1557
2154
|
wait_for_completion = bool(effect.payload.get("wait", False))
|
|
1558
2155
|
include_traces = bool(effect.payload.get("include_traces", False))
|
|
1559
2156
|
resume_to = effect.payload.get("resume_to_node") or default_next_node
|
|
1560
2157
|
|
|
2158
|
+
# Optional override: allow the caller (e.g. VisualFlow compiler) to pass an explicit
|
|
2159
|
+
# session_id for the child run. When omitted, children inherit the parent's session.
|
|
2160
|
+
session_override = effect.payload.get("session_id")
|
|
2161
|
+
if session_override is None:
|
|
2162
|
+
session_override = effect.payload.get("sessionId")
|
|
2163
|
+
session_id: Optional[str]
|
|
2164
|
+
if isinstance(session_override, str) and session_override.strip():
|
|
2165
|
+
session_id = session_override.strip()
|
|
2166
|
+
else:
|
|
2167
|
+
session_id = getattr(run, "session_id", None)
|
|
2168
|
+
|
|
1561
2169
|
# Start the subworkflow with parent tracking
|
|
1562
2170
|
sub_run_id = self.start(
|
|
1563
2171
|
workflow=sub_workflow,
|
|
1564
2172
|
vars=sub_vars,
|
|
1565
2173
|
actor_id=run.actor_id, # Inherit actor from parent
|
|
1566
|
-
session_id=
|
|
2174
|
+
session_id=session_id,
|
|
1567
2175
|
parent_run_id=run.run_id, # Track parent for hierarchy
|
|
1568
2176
|
)
|
|
1569
2177
|
|
|
@@ -1586,11 +2194,25 @@ class Runtime:
|
|
|
1586
2194
|
"sub_run_id": sub_run_id,
|
|
1587
2195
|
"sub_workflow_id": workflow_id,
|
|
1588
2196
|
"async": True,
|
|
2197
|
+
"include_traces": include_traces,
|
|
1589
2198
|
},
|
|
1590
2199
|
)
|
|
2200
|
+
if wrap_as_tool_result:
|
|
2201
|
+
if isinstance(wait.details, dict):
|
|
2202
|
+
wait.details["wrap_as_tool_result"] = True
|
|
2203
|
+
wait.details["tool_name"] = tool_name or "start_subworkflow"
|
|
2204
|
+
wait.details["call_id"] = call_id or "subworkflow"
|
|
1591
2205
|
return EffectOutcome.waiting(wait)
|
|
1592
2206
|
|
|
1593
2207
|
# Fire-and-forget: caller is responsible for driving/observing the child.
|
|
2208
|
+
if wrap_as_tool_result:
|
|
2209
|
+
return EffectOutcome.completed(
|
|
2210
|
+
_tool_result(
|
|
2211
|
+
success=True,
|
|
2212
|
+
output={"rendered": f"Started subworkflow {sub_run_id}", "sub_run_id": sub_run_id, "async": True},
|
|
2213
|
+
error=None,
|
|
2214
|
+
)
|
|
2215
|
+
)
|
|
1594
2216
|
return EffectOutcome.completed({"sub_run_id": sub_run_id, "async": True})
|
|
1595
2217
|
|
|
1596
2218
|
# Sync mode: run the subworkflow until completion or waiting
|
|
@@ -1602,6 +2224,14 @@ class Runtime:
|
|
|
1602
2224
|
|
|
1603
2225
|
if sub_state.status == RunStatus.COMPLETED:
|
|
1604
2226
|
# Subworkflow completed - return its output
|
|
2227
|
+
if wrap_as_tool_result:
|
|
2228
|
+
return EffectOutcome.completed(
|
|
2229
|
+
_tool_result(
|
|
2230
|
+
success=True,
|
|
2231
|
+
output=_tool_output_for_subworkflow(sub_run_id=sub_run_id, output=sub_state.output),
|
|
2232
|
+
error=None,
|
|
2233
|
+
)
|
|
2234
|
+
)
|
|
1605
2235
|
result: Dict[str, Any] = {"sub_run_id": sub_run_id, "output": sub_state.output}
|
|
1606
2236
|
if include_traces:
|
|
1607
2237
|
result["node_traces"] = self.get_node_traces(sub_run_id)
|
|
@@ -1609,9 +2239,15 @@ class Runtime:
|
|
|
1609
2239
|
|
|
1610
2240
|
if sub_state.status == RunStatus.FAILED:
|
|
1611
2241
|
# Subworkflow failed - propagate error
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
2242
|
+
if wrap_as_tool_result:
|
|
2243
|
+
return EffectOutcome.completed(
|
|
2244
|
+
_tool_result(
|
|
2245
|
+
success=False,
|
|
2246
|
+
output=None,
|
|
2247
|
+
error=f"Subworkflow '{workflow_id}' failed: {sub_state.error}",
|
|
2248
|
+
)
|
|
2249
|
+
)
|
|
2250
|
+
return EffectOutcome.failed(f"Subworkflow '{workflow_id}' failed: {sub_state.error}")
|
|
1615
2251
|
|
|
1616
2252
|
if sub_state.status == RunStatus.WAITING:
|
|
1617
2253
|
# Subworkflow is waiting - parent must also wait
|
|
@@ -1623,12 +2259,18 @@ class Runtime:
|
|
|
1623
2259
|
details={
|
|
1624
2260
|
"sub_run_id": sub_run_id,
|
|
1625
2261
|
"sub_workflow_id": workflow_id,
|
|
2262
|
+
"include_traces": include_traces,
|
|
1626
2263
|
"sub_waiting": {
|
|
1627
2264
|
"reason": sub_state.waiting.reason.value if sub_state.waiting else None,
|
|
1628
2265
|
"wait_key": sub_state.waiting.wait_key if sub_state.waiting else None,
|
|
1629
2266
|
},
|
|
1630
2267
|
},
|
|
1631
2268
|
)
|
|
2269
|
+
if wrap_as_tool_result:
|
|
2270
|
+
if isinstance(wait.details, dict):
|
|
2271
|
+
wait.details["wrap_as_tool_result"] = True
|
|
2272
|
+
wait.details["tool_name"] = tool_name or "start_subworkflow"
|
|
2273
|
+
wait.details["call_id"] = call_id or "subworkflow"
|
|
1632
2274
|
return EffectOutcome.waiting(wait)
|
|
1633
2275
|
|
|
1634
2276
|
# Unexpected status
|
|
@@ -1680,7 +2322,21 @@ class Runtime:
|
|
|
1680
2322
|
tool_name = str(payload.get("tool_name") or "recall_memory")
|
|
1681
2323
|
call_id = str(payload.get("call_id") or "memory")
|
|
1682
2324
|
|
|
1683
|
-
#
|
|
2325
|
+
# Recall effort policy (optional; no silent fallback).
|
|
2326
|
+
recall_level_raw = payload.get("recall_level")
|
|
2327
|
+
if recall_level_raw is None:
|
|
2328
|
+
recall_level_raw = payload.get("recallLevel")
|
|
2329
|
+
try:
|
|
2330
|
+
from ..memory.recall_levels import parse_recall_level, policy_for
|
|
2331
|
+
|
|
2332
|
+
recall_level = parse_recall_level(recall_level_raw)
|
|
2333
|
+
except Exception as e:
|
|
2334
|
+
return EffectOutcome.failed(str(e))
|
|
2335
|
+
|
|
2336
|
+
recall_warnings: list[str] = []
|
|
2337
|
+
recall_effort: dict[str, Any] = {}
|
|
2338
|
+
|
|
2339
|
+
# Scope routing (run/session/global). Scope affects which run owns the span index queried.
|
|
1684
2340
|
scope = str(payload.get("scope") or "run").strip().lower() or "run"
|
|
1685
2341
|
if scope not in {"run", "session", "global", "all"}:
|
|
1686
2342
|
return EffectOutcome.failed(f"Unknown memory_query scope: {scope}")
|
|
@@ -1753,6 +2409,7 @@ class Runtime:
|
|
|
1753
2409
|
authors = _norm_str_list(payload.get("users"))
|
|
1754
2410
|
locations = _norm_str_list(payload.get("locations") if "locations" in payload else payload.get("location"))
|
|
1755
2411
|
|
|
2412
|
+
limit_spans_provided = "limit_spans" in payload
|
|
1756
2413
|
try:
|
|
1757
2414
|
limit_spans = int(payload.get("limit_spans", 5) or 5)
|
|
1758
2415
|
except Exception:
|
|
@@ -1760,12 +2417,14 @@ class Runtime:
|
|
|
1760
2417
|
if limit_spans < 1:
|
|
1761
2418
|
limit_spans = 1
|
|
1762
2419
|
|
|
2420
|
+
deep_provided = "deep" in payload
|
|
1763
2421
|
deep = payload.get("deep")
|
|
1764
2422
|
if deep is None:
|
|
1765
2423
|
deep_enabled = bool(query_text)
|
|
1766
2424
|
else:
|
|
1767
2425
|
deep_enabled = bool(deep)
|
|
1768
2426
|
|
|
2427
|
+
deep_limit_spans_provided = "deep_limit_spans" in payload
|
|
1769
2428
|
try:
|
|
1770
2429
|
deep_limit_spans = int(payload.get("deep_limit_spans", 50) or 50)
|
|
1771
2430
|
except Exception:
|
|
@@ -1773,6 +2432,7 @@ class Runtime:
|
|
|
1773
2432
|
if deep_limit_spans < 1:
|
|
1774
2433
|
deep_limit_spans = 1
|
|
1775
2434
|
|
|
2435
|
+
deep_limit_messages_provided = "deep_limit_messages_per_span" in payload
|
|
1776
2436
|
try:
|
|
1777
2437
|
deep_limit_messages_per_span = int(payload.get("deep_limit_messages_per_span", 400) or 400)
|
|
1778
2438
|
except Exception:
|
|
@@ -1780,6 +2440,7 @@ class Runtime:
|
|
|
1780
2440
|
if deep_limit_messages_per_span < 1:
|
|
1781
2441
|
deep_limit_messages_per_span = 1
|
|
1782
2442
|
|
|
2443
|
+
connected_provided = "connected" in payload
|
|
1783
2444
|
connected = bool(payload.get("connected", False))
|
|
1784
2445
|
try:
|
|
1785
2446
|
neighbor_hops = int(payload.get("neighbor_hops", 1) or 1)
|
|
@@ -1794,6 +2455,7 @@ class Runtime:
|
|
|
1794
2455
|
else:
|
|
1795
2456
|
connect_keys = ["topic", "person"]
|
|
1796
2457
|
|
|
2458
|
+
max_messages_provided = "max_messages" in payload
|
|
1797
2459
|
try:
|
|
1798
2460
|
max_messages = int(payload.get("max_messages", -1) or -1)
|
|
1799
2461
|
except Exception:
|
|
@@ -1804,6 +2466,79 @@ class Runtime:
|
|
|
1804
2466
|
if max_messages != -1 and max_messages < 1:
|
|
1805
2467
|
max_messages = 1
|
|
1806
2468
|
|
|
2469
|
+
# Apply recall_level budgets when explicitly provided (no silent downgrade).
|
|
2470
|
+
if recall_level is not None:
|
|
2471
|
+
pol = policy_for(recall_level)
|
|
2472
|
+
|
|
2473
|
+
if not limit_spans_provided:
|
|
2474
|
+
limit_spans = pol.span.limit_spans_default
|
|
2475
|
+
if limit_spans > pol.span.limit_spans_max:
|
|
2476
|
+
recall_warnings.append(
|
|
2477
|
+
f"recall_level={recall_level.value}: clamped limit_spans from {limit_spans} to {pol.span.limit_spans_max}"
|
|
2478
|
+
)
|
|
2479
|
+
limit_spans = pol.span.limit_spans_max
|
|
2480
|
+
|
|
2481
|
+
if deep_enabled and not pol.span.deep_allowed:
|
|
2482
|
+
recall_warnings.append(
|
|
2483
|
+
f"recall_level={recall_level.value}: deep scan disabled (not allowed at this level)"
|
|
2484
|
+
)
|
|
2485
|
+
deep_enabled = False
|
|
2486
|
+
|
|
2487
|
+
if deep_enabled and not deep_limit_spans_provided:
|
|
2488
|
+
deep_limit_spans = min(deep_limit_spans, pol.span.deep_limit_spans_max)
|
|
2489
|
+
if deep_limit_spans > pol.span.deep_limit_spans_max:
|
|
2490
|
+
recall_warnings.append(
|
|
2491
|
+
f"recall_level={recall_level.value}: clamped deep_limit_spans from {deep_limit_spans} to {pol.span.deep_limit_spans_max}"
|
|
2492
|
+
)
|
|
2493
|
+
deep_limit_spans = pol.span.deep_limit_spans_max
|
|
2494
|
+
|
|
2495
|
+
if deep_enabled and not deep_limit_messages_provided:
|
|
2496
|
+
deep_limit_messages_per_span = min(deep_limit_messages_per_span, pol.span.deep_limit_messages_per_span_max)
|
|
2497
|
+
if deep_limit_messages_per_span > pol.span.deep_limit_messages_per_span_max:
|
|
2498
|
+
recall_warnings.append(
|
|
2499
|
+
f"recall_level={recall_level.value}: clamped deep_limit_messages_per_span from {deep_limit_messages_per_span} to {pol.span.deep_limit_messages_per_span_max}"
|
|
2500
|
+
)
|
|
2501
|
+
deep_limit_messages_per_span = pol.span.deep_limit_messages_per_span_max
|
|
2502
|
+
|
|
2503
|
+
if connected and not pol.span.connected_allowed:
|
|
2504
|
+
recall_warnings.append(
|
|
2505
|
+
f"recall_level={recall_level.value}: connected expansion disabled (not allowed at this level)"
|
|
2506
|
+
)
|
|
2507
|
+
connected = False
|
|
2508
|
+
|
|
2509
|
+
if neighbor_hops > pol.span.neighbor_hops_max:
|
|
2510
|
+
recall_warnings.append(
|
|
2511
|
+
f"recall_level={recall_level.value}: clamped neighbor_hops from {neighbor_hops} to {pol.span.neighbor_hops_max}"
|
|
2512
|
+
)
|
|
2513
|
+
neighbor_hops = pol.span.neighbor_hops_max
|
|
2514
|
+
|
|
2515
|
+
# Enforce bounded rendering budget (max_messages). -1 means "unbounded" and is not allowed when policy is active.
|
|
2516
|
+
if not max_messages_provided:
|
|
2517
|
+
max_messages = pol.span.max_messages_default
|
|
2518
|
+
elif max_messages == -1:
|
|
2519
|
+
recall_warnings.append(
|
|
2520
|
+
f"recall_level={recall_level.value}: max_messages=-1 (unbounded) is not allowed; clamped to {pol.span.max_messages_max}"
|
|
2521
|
+
)
|
|
2522
|
+
max_messages = pol.span.max_messages_max
|
|
2523
|
+
elif max_messages > pol.span.max_messages_max:
|
|
2524
|
+
recall_warnings.append(
|
|
2525
|
+
f"recall_level={recall_level.value}: clamped max_messages from {max_messages} to {pol.span.max_messages_max}"
|
|
2526
|
+
)
|
|
2527
|
+
max_messages = pol.span.max_messages_max
|
|
2528
|
+
|
|
2529
|
+
recall_effort = {
|
|
2530
|
+
"recall_level": recall_level.value,
|
|
2531
|
+
"applied": {
|
|
2532
|
+
"limit_spans": limit_spans,
|
|
2533
|
+
"deep": bool(deep_enabled),
|
|
2534
|
+
"deep_limit_spans": deep_limit_spans,
|
|
2535
|
+
"deep_limit_messages_per_span": deep_limit_messages_per_span,
|
|
2536
|
+
"connected": bool(connected),
|
|
2537
|
+
"neighbor_hops": neighbor_hops,
|
|
2538
|
+
"max_messages": max_messages,
|
|
2539
|
+
},
|
|
2540
|
+
}
|
|
2541
|
+
|
|
1807
2542
|
from ..memory.active_context import ActiveContextPolicy, TimeRange
|
|
1808
2543
|
|
|
1809
2544
|
# Select run(s) to query.
|
|
@@ -1984,6 +2719,17 @@ class Runtime:
|
|
|
1984
2719
|
|
|
1985
2720
|
meta = {"matches": matches, "span_ids": list(all_selected)}
|
|
1986
2721
|
|
|
2722
|
+
# Attach recall policy transparency (warnings + applied budgets).
|
|
2723
|
+
if recall_level is not None:
|
|
2724
|
+
if return_mode in {"meta", "both"}:
|
|
2725
|
+
if recall_effort:
|
|
2726
|
+
meta["effort"] = recall_effort
|
|
2727
|
+
if recall_warnings:
|
|
2728
|
+
meta["warnings"] = list(recall_warnings)
|
|
2729
|
+
if return_mode in {"rendered", "both"} and recall_warnings:
|
|
2730
|
+
warnings_block = "\n".join([f"- {w}" for w in recall_warnings if str(w).strip()])
|
|
2731
|
+
rendered_text = f"[recall warnings]\n{warnings_block}\n\n{rendered_text}".strip()
|
|
2732
|
+
|
|
1987
2733
|
result = {
|
|
1988
2734
|
"mode": "executed",
|
|
1989
2735
|
"results": [
|
|
@@ -2106,33 +2852,38 @@ class Runtime:
|
|
|
2106
2852
|
|
|
2107
2853
|
Payload (required unless stated):
|
|
2108
2854
|
- span_id: str | int (artifact_id or 1-based index into `_runtime.memory_spans`)
|
|
2855
|
+
- scope: str (optional, default "run") "run" | "session" | "global" | "all"
|
|
2109
2856
|
- tags: dict[str,str] (merged into span["tags"] by default)
|
|
2110
2857
|
- merge: bool (optional, default True; when False, replaces span["tags"])
|
|
2858
|
+
- target_run_id: str (optional; defaults to current run_id; used as the base run for scope routing)
|
|
2111
2859
|
- tool_name: str (optional; for tool-style output, default "remember")
|
|
2112
2860
|
- call_id: str (optional; passthrough for tool-style output)
|
|
2113
2861
|
|
|
2114
2862
|
Notes:
|
|
2115
|
-
- This mutates the
|
|
2863
|
+
- This mutates the owner run's span index (`_runtime.memory_spans`) only; it does not change artifacts.
|
|
2116
2864
|
- Tagging is intentionally JSON-safe (string->string).
|
|
2117
2865
|
"""
|
|
2118
2866
|
import json
|
|
2119
2867
|
|
|
2120
2868
|
from .vars import ensure_namespaces
|
|
2121
2869
|
|
|
2122
|
-
ensure_namespaces(run.vars)
|
|
2123
|
-
runtime_ns = run.vars.get("_runtime")
|
|
2124
|
-
if not isinstance(runtime_ns, dict):
|
|
2125
|
-
runtime_ns = {}
|
|
2126
|
-
run.vars["_runtime"] = runtime_ns
|
|
2127
|
-
|
|
2128
|
-
spans = runtime_ns.get("memory_spans")
|
|
2129
|
-
if not isinstance(spans, list):
|
|
2130
|
-
return EffectOutcome.failed("MEMORY_TAG requires _runtime.memory_spans to be a list")
|
|
2131
|
-
|
|
2132
2870
|
payload = dict(effect.payload or {})
|
|
2133
2871
|
tool_name = str(payload.get("tool_name") or "remember")
|
|
2134
2872
|
call_id = str(payload.get("call_id") or "memory")
|
|
2135
2873
|
|
|
2874
|
+
base_run_id = str(payload.get("target_run_id") or run.run_id).strip() or run.run_id
|
|
2875
|
+
base_run = run
|
|
2876
|
+
if base_run_id != run.run_id:
|
|
2877
|
+
loaded = self._run_store.load(base_run_id)
|
|
2878
|
+
if loaded is None:
|
|
2879
|
+
return EffectOutcome.failed(f"Unknown target_run_id: {base_run_id}")
|
|
2880
|
+
base_run = loaded
|
|
2881
|
+
ensure_namespaces(base_run.vars)
|
|
2882
|
+
|
|
2883
|
+
scope = str(payload.get("scope") or "run").strip().lower() or "run"
|
|
2884
|
+
if scope not in {"run", "session", "global", "all"}:
|
|
2885
|
+
return EffectOutcome.failed(f"Unknown memory_tag scope: {scope}")
|
|
2886
|
+
|
|
2136
2887
|
span_id = payload.get("span_id")
|
|
2137
2888
|
tags = payload.get("tags")
|
|
2138
2889
|
if span_id is None:
|
|
@@ -2145,75 +2896,145 @@ class Runtime:
|
|
|
2145
2896
|
clean_tags: Dict[str, str] = {}
|
|
2146
2897
|
for k, v in tags.items():
|
|
2147
2898
|
if isinstance(k, str) and isinstance(v, str) and k and v:
|
|
2899
|
+
if k == "kind":
|
|
2900
|
+
continue
|
|
2148
2901
|
clean_tags[k] = v
|
|
2149
2902
|
if not clean_tags:
|
|
2150
2903
|
return EffectOutcome.failed("MEMORY_TAG requires at least one non-empty string tag")
|
|
2151
2904
|
|
|
2152
2905
|
artifact_id: Optional[str] = None
|
|
2153
|
-
|
|
2906
|
+
index_hint: Optional[int] = None
|
|
2154
2907
|
|
|
2155
2908
|
if isinstance(span_id, int):
|
|
2156
|
-
|
|
2157
|
-
if idx < 0 or idx >= len(spans):
|
|
2158
|
-
return EffectOutcome.failed(f"Unknown span index: {span_id}")
|
|
2159
|
-
span = spans[idx]
|
|
2160
|
-
if not isinstance(span, dict):
|
|
2161
|
-
return EffectOutcome.failed(f"Invalid span record at index {span_id}")
|
|
2162
|
-
artifact_id = str(span.get("artifact_id") or "").strip() or None
|
|
2163
|
-
target_index = idx
|
|
2909
|
+
index_hint = span_id
|
|
2164
2910
|
elif isinstance(span_id, str):
|
|
2165
2911
|
s = span_id.strip()
|
|
2166
2912
|
if not s:
|
|
2167
2913
|
return EffectOutcome.failed("MEMORY_TAG requires a non-empty span_id")
|
|
2168
2914
|
if s.isdigit():
|
|
2169
|
-
|
|
2170
|
-
if idx < 0 or idx >= len(spans):
|
|
2171
|
-
return EffectOutcome.failed(f"Unknown span index: {s}")
|
|
2172
|
-
span = spans[idx]
|
|
2173
|
-
if not isinstance(span, dict):
|
|
2174
|
-
return EffectOutcome.failed(f"Invalid span record at index {s}")
|
|
2175
|
-
artifact_id = str(span.get("artifact_id") or "").strip() or None
|
|
2176
|
-
target_index = idx
|
|
2915
|
+
index_hint = int(s)
|
|
2177
2916
|
else:
|
|
2178
2917
|
artifact_id = s
|
|
2179
2918
|
else:
|
|
2180
2919
|
return EffectOutcome.failed("MEMORY_TAG requires span_id as str or int")
|
|
2181
2920
|
|
|
2182
|
-
if not
|
|
2183
|
-
return EffectOutcome.failed("
|
|
2921
|
+
if scope == "all" and index_hint is not None:
|
|
2922
|
+
return EffectOutcome.failed("memory_tag scope='all' requires span_id as artifact id (no indices)")
|
|
2184
2923
|
|
|
2185
|
-
|
|
2186
|
-
|
|
2924
|
+
def _ensure_spans(target_run: RunState) -> list[dict[str, Any]]:
|
|
2925
|
+
ensure_namespaces(target_run.vars)
|
|
2926
|
+
target_runtime_ns = target_run.vars.get("_runtime")
|
|
2927
|
+
if not isinstance(target_runtime_ns, dict):
|
|
2928
|
+
target_runtime_ns = {}
|
|
2929
|
+
target_run.vars["_runtime"] = target_runtime_ns
|
|
2930
|
+
spans_any = target_runtime_ns.get("memory_spans")
|
|
2931
|
+
if not isinstance(spans_any, list):
|
|
2932
|
+
spans_any = []
|
|
2933
|
+
target_runtime_ns["memory_spans"] = spans_any
|
|
2934
|
+
return spans_any # type: ignore[return-value]
|
|
2935
|
+
|
|
2936
|
+
def _resolve_target_index(spans_list: list[Any], *, artifact_id_value: str, index_value: Optional[int]) -> Optional[int]:
|
|
2937
|
+
if index_value is not None:
|
|
2938
|
+
idx = int(index_value) - 1
|
|
2939
|
+
if idx < 0 or idx >= len(spans_list):
|
|
2940
|
+
return None
|
|
2941
|
+
span = spans_list[idx]
|
|
2942
|
+
if not isinstance(span, dict):
|
|
2943
|
+
return None
|
|
2944
|
+
return idx
|
|
2945
|
+
for i, span in enumerate(spans_list):
|
|
2187
2946
|
if not isinstance(span, dict):
|
|
2188
2947
|
continue
|
|
2189
|
-
if str(span.get("artifact_id") or "") ==
|
|
2190
|
-
|
|
2191
|
-
|
|
2948
|
+
if str(span.get("artifact_id") or "") == artifact_id_value:
|
|
2949
|
+
return i
|
|
2950
|
+
return None
|
|
2192
2951
|
|
|
2193
|
-
|
|
2194
|
-
|
|
2952
|
+
def _apply_tags(target_run: RunState, spans_list: list[Any]) -> Optional[dict[str, Any]]:
|
|
2953
|
+
artifact_id_local = artifact_id
|
|
2954
|
+
target_index_local: Optional[int] = None
|
|
2195
2955
|
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2956
|
+
# Resolve index->artifact id when an index hint is used.
|
|
2957
|
+
if index_hint is not None:
|
|
2958
|
+
idx = int(index_hint) - 1
|
|
2959
|
+
if idx < 0 or idx >= len(spans_list):
|
|
2960
|
+
return None
|
|
2961
|
+
span = spans_list[idx]
|
|
2962
|
+
if not isinstance(span, dict):
|
|
2963
|
+
return None
|
|
2964
|
+
resolved = str(span.get("artifact_id") or "").strip()
|
|
2965
|
+
if not resolved:
|
|
2966
|
+
return None
|
|
2967
|
+
artifact_id_local = resolved
|
|
2968
|
+
target_index_local = idx
|
|
2969
|
+
|
|
2970
|
+
if not artifact_id_local:
|
|
2971
|
+
return None
|
|
2199
2972
|
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2973
|
+
if target_index_local is None:
|
|
2974
|
+
target_index_local = _resolve_target_index(
|
|
2975
|
+
spans_list, artifact_id_value=str(artifact_id_local), index_value=None
|
|
2976
|
+
)
|
|
2977
|
+
if target_index_local is None:
|
|
2978
|
+
return None
|
|
2203
2979
|
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
else:
|
|
2208
|
-
merged_tags = dict(clean_tags)
|
|
2980
|
+
target = spans_list[target_index_local]
|
|
2981
|
+
if not isinstance(target, dict):
|
|
2982
|
+
return None
|
|
2209
2983
|
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2984
|
+
existing_tags = target.get("tags")
|
|
2985
|
+
if not isinstance(existing_tags, dict):
|
|
2986
|
+
existing_tags = {}
|
|
2987
|
+
|
|
2988
|
+
if merge:
|
|
2989
|
+
merged_tags = dict(existing_tags)
|
|
2990
|
+
merged_tags.update(clean_tags)
|
|
2991
|
+
else:
|
|
2992
|
+
merged_tags = dict(clean_tags)
|
|
2993
|
+
|
|
2994
|
+
target["tags"] = merged_tags
|
|
2995
|
+
target["tagged_at"] = utc_now_iso()
|
|
2996
|
+
if run.actor_id:
|
|
2997
|
+
target["tagged_by"] = str(run.actor_id)
|
|
2998
|
+
return {"run_id": target_run.run_id, "artifact_id": str(artifact_id_local), "tags": merged_tags}
|
|
2999
|
+
|
|
3000
|
+
# Resolve which run(s) to tag.
|
|
3001
|
+
runs_to_tag: list[RunState] = []
|
|
3002
|
+
if scope == "all":
|
|
3003
|
+
root = self._resolve_scope_owner_run(base_run, scope="session")
|
|
3004
|
+
global_run = self._resolve_scope_owner_run(base_run, scope="global")
|
|
3005
|
+
seen_ids: set[str] = set()
|
|
3006
|
+
for r in (base_run, root, global_run):
|
|
3007
|
+
if r.run_id in seen_ids:
|
|
3008
|
+
continue
|
|
3009
|
+
seen_ids.add(r.run_id)
|
|
3010
|
+
runs_to_tag.append(r)
|
|
3011
|
+
else:
|
|
3012
|
+
try:
|
|
3013
|
+
runs_to_tag = [self._resolve_scope_owner_run(base_run, scope=scope)]
|
|
3014
|
+
except Exception as e:
|
|
3015
|
+
return EffectOutcome.failed(str(e))
|
|
2214
3016
|
|
|
2215
|
-
|
|
2216
|
-
|
|
3017
|
+
applied: list[dict[str, Any]] = []
|
|
3018
|
+
for target_run in runs_to_tag:
|
|
3019
|
+
spans_list = _ensure_spans(target_run)
|
|
3020
|
+
entry = _apply_tags(target_run, spans_list)
|
|
3021
|
+
if entry is None:
|
|
3022
|
+
continue
|
|
3023
|
+
applied.append(entry)
|
|
3024
|
+
if target_run is not run:
|
|
3025
|
+
target_run.updated_at = utc_now_iso()
|
|
3026
|
+
self._run_store.save(target_run)
|
|
3027
|
+
|
|
3028
|
+
if not applied:
|
|
3029
|
+
if artifact_id:
|
|
3030
|
+
return EffectOutcome.failed(f"Unknown span_id: {artifact_id}")
|
|
3031
|
+
if index_hint is not None:
|
|
3032
|
+
return EffectOutcome.failed(f"Unknown span index: {index_hint}")
|
|
3033
|
+
return EffectOutcome.failed("Could not resolve span_id")
|
|
3034
|
+
|
|
3035
|
+
rendered_tags = json.dumps(applied[0].get("tags") or {}, ensure_ascii=False, sort_keys=True)
|
|
3036
|
+
rendered_runs = ",".join([str(x.get("run_id") or "") for x in applied if x.get("run_id")])
|
|
3037
|
+
text = f"Tagged span_id={applied[0].get('artifact_id')} scope={scope} runs=[{rendered_runs}] tags={rendered_tags}"
|
|
2217
3038
|
|
|
2218
3039
|
result = {
|
|
2219
3040
|
"mode": "executed",
|
|
@@ -2224,6 +3045,7 @@ class Runtime:
|
|
|
2224
3045
|
"success": True,
|
|
2225
3046
|
"output": text,
|
|
2226
3047
|
"error": None,
|
|
3048
|
+
"meta": {"applied": applied},
|
|
2227
3049
|
}
|
|
2228
3050
|
],
|
|
2229
3051
|
}
|
|
@@ -2699,7 +3521,13 @@ class Runtime:
|
|
|
2699
3521
|
|
|
2700
3522
|
preview = note_text
|
|
2701
3523
|
if len(preview) > 160:
|
|
2702
|
-
|
|
3524
|
+
#[WARNING:TRUNCATION] bounded memory_note preview for spans listing
|
|
3525
|
+
marker = "… (truncated)"
|
|
3526
|
+
keep = max(0, 160 - len(marker))
|
|
3527
|
+
if keep <= 0:
|
|
3528
|
+
preview = marker[:160].rstrip()
|
|
3529
|
+
else:
|
|
3530
|
+
preview = preview[:keep].rstrip() + marker
|
|
2703
3531
|
|
|
2704
3532
|
span_record: Dict[str, Any] = {
|
|
2705
3533
|
"kind": "memory_note",
|
|
@@ -2820,21 +3648,67 @@ class Runtime:
|
|
|
2820
3648
|
payload = dict(effect.payload or {})
|
|
2821
3649
|
target_run_id = str(payload.get("target_run_id") or run.run_id).strip() or run.run_id
|
|
2822
3650
|
|
|
3651
|
+
# Recall effort policy (optional; no silent fallback).
|
|
3652
|
+
recall_level_raw = payload.get("recall_level")
|
|
3653
|
+
if recall_level_raw is None:
|
|
3654
|
+
recall_level_raw = payload.get("recallLevel")
|
|
3655
|
+
try:
|
|
3656
|
+
from ..memory.recall_levels import parse_recall_level, policy_for
|
|
3657
|
+
|
|
3658
|
+
recall_level = parse_recall_level(recall_level_raw)
|
|
3659
|
+
except Exception as e:
|
|
3660
|
+
return EffectOutcome.failed(str(e))
|
|
3661
|
+
|
|
3662
|
+
recall_warnings: list[str] = []
|
|
3663
|
+
recall_effort: dict[str, Any] = {}
|
|
3664
|
+
|
|
2823
3665
|
# Normalize span_ids (accept legacy `span_id` too).
|
|
2824
3666
|
raw_span_ids = payload.get("span_ids")
|
|
2825
3667
|
if raw_span_ids is None:
|
|
2826
3668
|
raw_span_ids = payload.get("span_id")
|
|
3669
|
+
if raw_span_ids is None:
|
|
3670
|
+
return EffectOutcome.failed("MEMORY_REHYDRATE requires payload.span_ids (or legacy span_id)")
|
|
2827
3671
|
span_ids: list[Any] = []
|
|
2828
3672
|
if isinstance(raw_span_ids, list):
|
|
2829
3673
|
span_ids = list(raw_span_ids)
|
|
2830
3674
|
elif raw_span_ids is not None:
|
|
2831
3675
|
span_ids = [raw_span_ids]
|
|
2832
3676
|
if not span_ids:
|
|
2833
|
-
|
|
3677
|
+
# Empty rehydrate is a valid no-op (common when recall returns no spans).
|
|
3678
|
+
return EffectOutcome.completed(result={"inserted": 0, "skipped": 0, "artifacts": []})
|
|
2834
3679
|
|
|
2835
3680
|
placement = str(payload.get("placement") or "after_summary").strip() or "after_summary"
|
|
2836
3681
|
dedup_by = str(payload.get("dedup_by") or "message_id").strip() or "message_id"
|
|
2837
3682
|
max_messages = payload.get("max_messages")
|
|
3683
|
+
max_messages_provided = "max_messages" in payload
|
|
3684
|
+
|
|
3685
|
+
if recall_level is not None:
|
|
3686
|
+
pol = policy_for(recall_level)
|
|
3687
|
+
raw_max = max_messages
|
|
3688
|
+
parsed: Optional[int] = None
|
|
3689
|
+
if raw_max is not None and not isinstance(raw_max, bool):
|
|
3690
|
+
try:
|
|
3691
|
+
parsed = int(float(raw_max))
|
|
3692
|
+
except Exception:
|
|
3693
|
+
parsed = None
|
|
3694
|
+
if not max_messages_provided or parsed is None:
|
|
3695
|
+
parsed = pol.rehydrate.max_messages_default
|
|
3696
|
+
if parsed < 1:
|
|
3697
|
+
recall_warnings.append(
|
|
3698
|
+
f"recall_level={recall_level.value}: max_messages must be >=1; using {pol.rehydrate.max_messages_default}"
|
|
3699
|
+
)
|
|
3700
|
+
parsed = pol.rehydrate.max_messages_default
|
|
3701
|
+
if parsed > pol.rehydrate.max_messages_max:
|
|
3702
|
+
recall_warnings.append(
|
|
3703
|
+
f"recall_level={recall_level.value}: clamped max_messages from {parsed} to {pol.rehydrate.max_messages_max}"
|
|
3704
|
+
)
|
|
3705
|
+
parsed = pol.rehydrate.max_messages_max
|
|
3706
|
+
|
|
3707
|
+
max_messages = parsed
|
|
3708
|
+
recall_effort = {
|
|
3709
|
+
"recall_level": recall_level.value,
|
|
3710
|
+
"applied": {"max_messages": int(parsed)},
|
|
3711
|
+
}
|
|
2838
3712
|
|
|
2839
3713
|
# Load the target run (may be different from current).
|
|
2840
3714
|
target_run = run
|
|
@@ -2922,6 +3796,8 @@ class Runtime:
|
|
|
2922
3796
|
"inserted": out.get("inserted", 0),
|
|
2923
3797
|
"skipped": out.get("skipped", 0),
|
|
2924
3798
|
"artifacts": artifacts_out,
|
|
3799
|
+
"effort": recall_effort if recall_effort else None,
|
|
3800
|
+
"warnings": list(recall_warnings) if recall_warnings else None,
|
|
2925
3801
|
}
|
|
2926
3802
|
)
|
|
2927
3803
|
|