AbstractRuntime 0.4.0__py3-none-any.whl → 0.4.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. abstractruntime/__init__.py +76 -1
  2. abstractruntime/core/config.py +68 -1
  3. abstractruntime/core/models.py +5 -0
  4. abstractruntime/core/policy.py +74 -3
  5. abstractruntime/core/runtime.py +1002 -126
  6. abstractruntime/core/vars.py +8 -2
  7. abstractruntime/evidence/recorder.py +1 -1
  8. abstractruntime/history_bundle.py +772 -0
  9. abstractruntime/integrations/abstractcore/__init__.py +3 -0
  10. abstractruntime/integrations/abstractcore/default_tools.py +127 -3
  11. abstractruntime/integrations/abstractcore/effect_handlers.py +2440 -99
  12. abstractruntime/integrations/abstractcore/embeddings_client.py +69 -0
  13. abstractruntime/integrations/abstractcore/factory.py +68 -20
  14. abstractruntime/integrations/abstractcore/llm_client.py +447 -15
  15. abstractruntime/integrations/abstractcore/mcp_worker.py +1 -0
  16. abstractruntime/integrations/abstractcore/session_attachments.py +946 -0
  17. abstractruntime/integrations/abstractcore/tool_executor.py +31 -10
  18. abstractruntime/integrations/abstractcore/workspace_scoped_tools.py +561 -0
  19. abstractruntime/integrations/abstractmemory/__init__.py +3 -0
  20. abstractruntime/integrations/abstractmemory/effect_handlers.py +946 -0
  21. abstractruntime/memory/active_context.py +6 -1
  22. abstractruntime/memory/kg_packets.py +164 -0
  23. abstractruntime/memory/memact_composer.py +175 -0
  24. abstractruntime/memory/recall_levels.py +163 -0
  25. abstractruntime/memory/token_budget.py +86 -0
  26. abstractruntime/storage/__init__.py +4 -1
  27. abstractruntime/storage/artifacts.py +158 -30
  28. abstractruntime/storage/base.py +17 -1
  29. abstractruntime/storage/commands.py +339 -0
  30. abstractruntime/storage/in_memory.py +41 -1
  31. abstractruntime/storage/json_files.py +195 -12
  32. abstractruntime/storage/observable.py +38 -1
  33. abstractruntime/storage/offloading.py +433 -0
  34. abstractruntime/storage/sqlite.py +836 -0
  35. abstractruntime/visualflow_compiler/__init__.py +29 -0
  36. abstractruntime/visualflow_compiler/adapters/__init__.py +11 -0
  37. abstractruntime/visualflow_compiler/adapters/agent_adapter.py +126 -0
  38. abstractruntime/visualflow_compiler/adapters/context_adapter.py +109 -0
  39. abstractruntime/visualflow_compiler/adapters/control_adapter.py +615 -0
  40. abstractruntime/visualflow_compiler/adapters/effect_adapter.py +1051 -0
  41. abstractruntime/visualflow_compiler/adapters/event_adapter.py +307 -0
  42. abstractruntime/visualflow_compiler/adapters/function_adapter.py +97 -0
  43. abstractruntime/visualflow_compiler/adapters/memact_adapter.py +114 -0
  44. abstractruntime/visualflow_compiler/adapters/subflow_adapter.py +74 -0
  45. abstractruntime/visualflow_compiler/adapters/variable_adapter.py +316 -0
  46. abstractruntime/visualflow_compiler/compiler.py +3832 -0
  47. abstractruntime/visualflow_compiler/flow.py +247 -0
  48. abstractruntime/visualflow_compiler/visual/__init__.py +13 -0
  49. abstractruntime/visualflow_compiler/visual/agent_ids.py +29 -0
  50. abstractruntime/visualflow_compiler/visual/builtins.py +1376 -0
  51. abstractruntime/visualflow_compiler/visual/code_executor.py +214 -0
  52. abstractruntime/visualflow_compiler/visual/executor.py +2804 -0
  53. abstractruntime/visualflow_compiler/visual/models.py +211 -0
  54. abstractruntime/workflow_bundle/__init__.py +52 -0
  55. abstractruntime/workflow_bundle/models.py +236 -0
  56. abstractruntime/workflow_bundle/packer.py +317 -0
  57. abstractruntime/workflow_bundle/reader.py +87 -0
  58. abstractruntime/workflow_bundle/registry.py +587 -0
  59. abstractruntime-0.4.1.dist-info/METADATA +177 -0
  60. abstractruntime-0.4.1.dist-info/RECORD +86 -0
  61. abstractruntime-0.4.0.dist-info/METADATA +0 -167
  62. abstractruntime-0.4.0.dist-info/RECORD +0 -49
  63. {abstractruntime-0.4.0.dist-info → abstractruntime-0.4.1.dist-info}/WHEEL +0 -0
  64. {abstractruntime-0.4.0.dist-info → abstractruntime-0.4.1.dist-info}/entry_points.txt +0 -0
  65. {abstractruntime-0.4.0.dist-info → abstractruntime-0.4.1.dist-info}/licenses/LICENSE +0 -0
@@ -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", 32768) or 32768)
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
- max_tokens = int(limits.get("max_tokens", 32768) or 32768)
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=plan.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=plan.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 plan.effect.type == EffectType.TOOL_CALLS
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=plan.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=plan.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 plan.effect.result_key and outcome.result is not None:
849
- _set_nested(run.vars, plan.effect.result_key, outcome.result)
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
- # Tool waits may carry blocked-by-allowlist metadata. External hosts typically only execute
946
- # the filtered subset of tool calls and resume with results for those calls. To keep agent
947
- # semantics correct (and evidence indices aligned), merge blocked entries back into the
948
- # resumed payload deterministically.
949
- merged_payload: Dict[str, Any] = payload
950
- try:
951
- details = run.waiting.details if run.waiting is not None else None
952
- if isinstance(details, dict):
953
- blocked = details.get("blocked_by_index")
954
- original_count = details.get("original_call_count")
955
- results = payload.get("results") if isinstance(payload, dict) else None
956
- if (
957
- isinstance(blocked, dict)
958
- and isinstance(original_count, int)
959
- and original_count > 0
960
- and isinstance(results, list)
961
- and len(results) != original_count
962
- ):
963
- merged_results: list[Any] = []
964
- executed_iter = iter(results)
965
-
966
- for idx in range(original_count):
967
- blocked_entry = blocked.get(str(idx))
968
- if isinstance(blocked_entry, dict):
969
- merged_results.append(blocked_entry)
970
- continue
971
- try:
972
- merged_results.append(next(executed_iter))
973
- except StopIteration:
974
- merged_results.append(
975
- {
976
- "call_id": "",
977
- "name": "",
978
- "success": False,
979
- "output": None,
980
- "error": "Missing tool result",
981
- }
982
- )
983
-
984
- merged_payload = dict(payload)
985
- merged_payload["results"] = merged_results
986
- merged_payload.setdefault("mode", "executed")
987
- except Exception:
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 _resolve_session_root_run(self, run: RunState) -> RunState:
1099
- """Resolve the root run of the current run-tree (walk `parent_run_id`)."""
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
- return self._resolve_session_root_run(base_run)
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
- return EffectOutcome.completed({"message": str(message)})
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
- sub_vars = effect.payload.get("vars") or {}
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=getattr(run, "session_id", None), # Inherit session from parent
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
- return EffectOutcome.failed(
1613
- f"Subworkflow '{workflow_id}' failed: {sub_state.error}"
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
- # Scope routing (run-tree/global). Scope affects which run owns the span index queried.
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 in-run span index (`_runtime.memory_spans`) only; it does not change artifacts.
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
- target_index: Optional[int] = None
2906
+ index_hint: Optional[int] = None
2154
2907
 
2155
2908
  if isinstance(span_id, int):
2156
- idx = span_id - 1
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
- idx = int(s) - 1
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 artifact_id:
2183
- return EffectOutcome.failed("Could not resolve span_id to an artifact_id")
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
- if target_index is None:
2186
- for i, span in enumerate(spans):
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 "") == artifact_id:
2190
- target_index = i
2191
- break
2948
+ if str(span.get("artifact_id") or "") == artifact_id_value:
2949
+ return i
2950
+ return None
2192
2951
 
2193
- if target_index is None:
2194
- return EffectOutcome.failed(f"Unknown span_id: {artifact_id}")
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
- target = spans[target_index]
2197
- if not isinstance(target, dict):
2198
- return EffectOutcome.failed(f"Invalid span record at index {target_index + 1}")
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
- existing_tags = target.get("tags")
2201
- if not isinstance(existing_tags, dict):
2202
- existing_tags = {}
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
- if merge:
2205
- merged_tags = dict(existing_tags)
2206
- merged_tags.update(clean_tags)
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
- target["tags"] = merged_tags
2211
- target["tagged_at"] = utc_now_iso()
2212
- if run.actor_id:
2213
- target["tagged_by"] = str(run.actor_id)
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
- rendered_tags = json.dumps(merged_tags, ensure_ascii=False, sort_keys=True)
2216
- text = f"Tagged span_id={artifact_id} tags={rendered_tags}"
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
- preview = preview[:157] + "…"
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
- return EffectOutcome.failed("MEMORY_REHYDRATE requires payload.span_ids (non-empty list)")
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