power-loop 6.46.4__tar.gz → 6.47.0__tar.gz

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 (113) hide show
  1. {power_loop-6.46.4 → power_loop-6.47.0}/PKG-INFO +1 -1
  2. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/__init__.py +1 -1
  3. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/agent/stateful_loop.py +6 -2
  4. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/agent/types.py +3 -7
  5. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/core/pipeline.py +62 -192
  6. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/representation.py +7 -0
  7. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/workflow/engine.py +147 -3
  8. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/workflow/resume.py +98 -20
  9. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/workflow/runner.py +15 -0
  10. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop.egg-info/PKG-INFO +1 -1
  11. {power_loop-6.46.4 → power_loop-6.47.0}/LICENSE +0 -0
  12. {power_loop-6.46.4 → power_loop-6.47.0}/README.md +0 -0
  13. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/_vendor/__init__.py +0 -0
  14. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/_vendor/llm_client/__init__.py +0 -0
  15. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/_vendor/llm_client/anthropic_factory.py +0 -0
  16. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/_vendor/llm_client/capabilities.py +0 -0
  17. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/_vendor/llm_client/interface.py +0 -0
  18. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/_vendor/llm_client/llm_factory.py +0 -0
  19. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/_vendor/llm_client/llm_tooling.py +0 -0
  20. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/_vendor/llm_client/llm_utils.py +0 -0
  21. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/_vendor/llm_client/multimodal.py +0 -0
  22. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/_vendor/llm_client/proxy_fallback.py +0 -0
  23. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/agent/__init__.py +0 -0
  24. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/agent/follow_up.py +0 -0
  25. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/agent/sink.py +0 -0
  26. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/agent/system_prompt.py +0 -0
  27. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/contracts/__init__.py +0 -0
  28. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/contracts/errors.py +0 -0
  29. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/contracts/event_payloads.py +0 -0
  30. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/contracts/events.py +0 -0
  31. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/contracts/handlers.py +0 -0
  32. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/contracts/hook_contexts.py +0 -0
  33. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/contracts/hooks.py +0 -0
  34. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/contracts/messages.py +0 -0
  35. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/contracts/protocols.py +0 -0
  36. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/contracts/tools.py +0 -0
  37. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/contrib/__init__.py +0 -0
  38. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/contrib/_redact.py +0 -0
  39. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/contrib/jsonl_sink.py +0 -0
  40. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/contrib/logging_sink.py +0 -0
  41. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/contrib/mcp.py +0 -0
  42. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/contrib/metrics_sink.py +0 -0
  43. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/contrib/otel_sink.py +0 -0
  44. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/core/agent_context.py +0 -0
  45. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/core/events.py +0 -0
  46. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/core/hooks.py +0 -0
  47. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/core/phase.py +0 -0
  48. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/core/runner.py +0 -0
  49. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/core/state.py +0 -0
  50. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/py.typed +0 -0
  51. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/blackboard.py +0 -0
  52. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/budget.py +0 -0
  53. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/cancellation.py +0 -0
  54. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/capability_probe.py +0 -0
  55. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/compact.py +0 -0
  56. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/env.py +0 -0
  57. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/exec_backend.py +0 -0
  58. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/fold.py +0 -0
  59. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/fold_adapter.py +0 -0
  60. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/history_projector.py +0 -0
  61. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/history_sanitize.py +0 -0
  62. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/human_input.py +0 -0
  63. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/image_recall.py +0 -0
  64. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/memory.py +0 -0
  65. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/notes.py +0 -0
  66. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/provider.py +0 -0
  67. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/retry.py +0 -0
  68. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/runtime_state.py +0 -0
  69. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/session_store.py +0 -0
  70. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/skills.py +0 -0
  71. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/spec.py +0 -0
  72. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/steer.py +0 -0
  73. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/stop_policy.py +0 -0
  74. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/store/__init__.py +0 -0
  75. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/store/backends/__init__.py +0 -0
  76. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/store/backends/mysql.py +0 -0
  77. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/store/backends/postgres.py +0 -0
  78. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/store/backends/sqlite.py +0 -0
  79. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/store/capabilities.py +0 -0
  80. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/store/db.py +0 -0
  81. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/store/dialect.py +0 -0
  82. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/store/factory.py +0 -0
  83. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/store/schema.py +0 -0
  84. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/store/store.py +0 -0
  85. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/store/types.py +0 -0
  86. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/structured.py +0 -0
  87. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/stub_provider.py +0 -0
  88. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/timers.py +0 -0
  89. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/todos.py +0 -0
  90. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/usage_estimate.py +0 -0
  91. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/tools/__init__.py +0 -0
  92. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/tools/blackboard.py +0 -0
  93. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/tools/command_policy.py +0 -0
  94. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/tools/command_rules.py +0 -0
  95. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/tools/default_manifest.py +0 -0
  96. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/tools/default_tools.py +0 -0
  97. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/tools/registry.py +0 -0
  98. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/tools/spawn_agent.py +0 -0
  99. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/workflow/__init__.py +0 -0
  100. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/workflow/api.py +0 -0
  101. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/workflow/introspect.py +0 -0
  102. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/workflow/journal.py +0 -0
  103. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/workflow/result.py +0 -0
  104. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/workflow/spec.py +0 -0
  105. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/workflow/subprocess_executor.py +0 -0
  106. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/workflow/tool.py +0 -0
  107. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/workflow/worker.py +0 -0
  108. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop.egg-info/SOURCES.txt +0 -0
  109. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop.egg-info/dependency_links.txt +0 -0
  110. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop.egg-info/requires.txt +0 -0
  111. {power_loop-6.46.4 → power_loop-6.47.0}/power_loop.egg-info/top_level.txt +0 -0
  112. {power_loop-6.46.4 → power_loop-6.47.0}/pyproject.toml +0 -0
  113. {power_loop-6.46.4 → power_loop-6.47.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: power-loop
3
- Version: 6.46.4
3
+ Version: 6.47.0
4
4
  Summary: Embeddable agent execution kernel — LLM loop, hooks, events, tools, dynamic sub-agents.
5
5
  Author-email: zhangran <zhangran24@126.com>
6
6
  License: MIT
@@ -15,7 +15,7 @@ Stability tiers
15
15
  无版本承诺,可随时变更或删除。
16
16
  """
17
17
 
18
- __version__ = "6.46.4"
18
+ __version__ = "6.47.0"
19
19
 
20
20
  # Public LLM contract (SDK-free) re-exported so callers (e.g. writing llm.* hooks or
21
21
  # a custom LLMService) don't reach into the internal vendored transport package (H3.4).
@@ -785,6 +785,7 @@ class StatefulAgentLoop:
785
785
  tools: Sequence[str] | ToolRegistry | None = None,
786
786
  system_prompt: str | None = None,
787
787
  max_rounds: int | None = None,
788
+ enqueue_only: bool = False,
788
789
  ) -> StatefulResult | FollowUpQueued:
789
790
  """Put ``items`` in front of the session's model — now, or as soon as it can take them.
790
791
 
@@ -798,6 +799,8 @@ class StatefulAgentLoop:
798
799
  them. Returns :class:`FollowUpQueued`.
799
800
  * idle → a fresh send runs with the waiting items as its input (everything pending —
800
801
  including items a cancelled run left behind — oldest first). Returns its result.
802
+ * ``enqueue_only=True`` → persist and return without starting an idle send;
803
+ the orchestration owner must drain it, including the terminal window.
801
804
  * nothing new (all duplicates) → :class:`FollowUpQueued` with ``accepted=0``.
802
805
 
803
806
  ``stop_event`` / ``tools`` / ``system_prompt`` / ``max_rounds`` apply only to a run this
@@ -824,7 +827,7 @@ class StatefulAgentLoop:
824
827
  duplicates=len(rows) - len(accepted), accepted_ids=tuple(accepted),
825
828
  )
826
829
 
827
- if not accepted or self._lock_for(sid).locked():
830
+ if enqueue_only or not accepted or self._lock_for(sid).locked():
828
831
  return await _queued()
829
832
  if self.config.distributed_sessions:
830
833
  # Idle HERE, but another process may be driving this session: its run claims the
@@ -847,6 +850,7 @@ class StatefulAgentLoop:
847
850
  tools: Sequence[str] | ToolRegistry | None = None,
848
851
  system_prompt: str | None = None,
849
852
  max_rounds: int | None = None,
853
+ enqueue_only: bool = False,
850
854
  kind: str = INBOX_KIND_USER,
851
855
  mode: str = "queue",
852
856
  item_id: str | None = None,
@@ -865,7 +869,7 @@ class StatefulAgentLoop:
865
869
  return await self.deliver(
866
870
  InboxItem(content=user_input, kind=kind, mode=mode, item_id=item_id),
867
871
  session_id, stop_event=stop_event, tools=tools, system_prompt=system_prompt,
868
- max_rounds=max_rounds,
872
+ max_rounds=max_rounds, enqueue_only=enqueue_only,
869
873
  )
870
874
 
871
875
  def pending_follow_up_count(self, session_id: str) -> int:
@@ -106,14 +106,10 @@ class AgentLoopConfig:
106
106
  #: 真实大小)≥ 它 → 以 ``context_checkpoint`` 优雅收尾本 send(COMPLETE_DECIDE 先给宿主收尾窗口)
107
107
  #: → 正常投影 → 宿主的续接机制在新 send 里接着干。与 ``max_tokens_per_run``(累计费用上限)正交。
108
108
  context_checkpoint_tokens: int | None = None
109
- #: ``insend_distill_tokens``:send 内保险丝——**本 send 内新增**的上下文 ≥ 它时(6.27.0 起量的是
110
- #: 增量:上一轮真实 prompt_tokens 减去本 send 第一轮的 prompt_tokens;拿不到真实用量就估算本 send
111
- #: 新增的行),把当前 send 里热尾之外的冷工具结果**在内存里替换成它的投影行**(同一套
112
- #: ToolDefinition.project 蒸馏 + ``recall_send`` 坐标),不落盘、不改 pl_messages。None → 关。
113
- #: 6.27.0 之前量的是整个上下文(系统提示词 + 历史 + 本 send),一开局就可能超阈值、每轮都烧。
109
+ #: Total prompt threshold for in-send semantic distillation. When enabled,
110
+ #: context_checkpoint_tokens is ignored: fuse and cut are exclusive policies.
114
111
  insend_distill_tokens: int | None = None
115
- #: 每次触发只蒸馏**最早**的 n 条尚未蒸馏的工具结果(逐轮递进:下一轮仍超阈值就再蒸馏下 n 条),
116
- #: 且永远不动最近 ``hot_tail`` 条。触发依据优先用上一轮供应商返回的真实 prompt_tokens。
112
+ #: Deprecated compatibility field; does not limit the number of distilled calls.
117
113
  insend_distill_batch: int = 10
118
114
  insend_distill_hot_tail: int = 8
119
115
  #: 6.27.0 这些工具的**调用参数永不瘦身**(结果照旧)。给「模型自己说的话」用:send_message /
@@ -882,76 +882,6 @@ class AgentPipeline:
882
882
 
883
883
  _DISTILL_MARK = "[distilled #"
884
884
 
885
- #: 参数被瘦身后留在原位的标记(值本身仍是合法 JSON 字符串,工具早已执行完,
886
- #: 这里只影响模型回看时读到什么)。
887
- _ARGS_MARK = "⟨已移出上下文"
888
- #: 单个参数字段超过它才瘦身:短字段(路径、开关、行号)留着才看得出这次调用干了什么。
889
- _ARGS_KEEP_CHARS = 200
890
-
891
- def _has_heavy_args(self, idx: int) -> bool:
892
- """这一行是 assistant,且它的某个 tool_calls 参数里有还没瘦过的大字段。"""
893
- m = self.history[idx]
894
- if m.get("role") != "assistant":
895
- return False
896
- keep = set(getattr(self.config, "insend_distill_keep_tools", ()) or ())
897
- for tc in m.get("tool_calls") or []:
898
- fn = (tc or {}).get("function") or {}
899
- if str(fn.get("name") or "") in keep:
900
- continue
901
- raw = fn.get("arguments")
902
- if (
903
- isinstance(raw, str)
904
- and len(raw) > 300
905
- and self._ARGS_MARK not in raw
906
- ):
907
- return True
908
- return False
909
-
910
- def _distill_tool_call_args(self, idx: int, seqs: list[Any]) -> int:
911
- """把一行 assistant 的 tool_calls 参数里的大字段换成指针,**保持合法 JSON**。
912
-
913
- 为什么不套用投影那套逐工具规则:那边产出的是给人读的一行散文,而这里改的是
914
- 供应商要按 JSON 解析的 ``function.arguments``。所以规则通用——短字段原样留下
915
- (路径、模式、行号这些才看得出这次调用干了什么),只有超过 ``_ARGS_KEEP_CHARS``
916
- 的长字符串换成一句带 ``recall_send`` 坐标的说明。工具早已执行完,改它不影响任何
917
- 副作用,只影响模型回看时读到什么。返回释放的估算 token。"""
918
- m = self.history[idx]
919
- seq = seqs[idx] if idx < len(seqs) else None
920
- coord = (
921
- f"recall_send(send_index={self.send_index}, seq={seq})"
922
- if (self.send_index is not None and seq is not None)
923
- else "recall_send"
924
- )
925
- freed = 0
926
- keep = set(getattr(self.config, "insend_distill_keep_tools", ()) or ())
927
- for tc in m.get("tool_calls") or []:
928
- fn = (tc or {}).get("function") or {}
929
- if str(fn.get("name") or "") in keep:
930
- continue # 模型自己的发言(send_message 之类):瘦成占位符它会照样再发一遍
931
- raw = fn.get("arguments")
932
- if not isinstance(raw, str) or len(raw) <= 300 or self._ARGS_MARK in raw:
933
- continue
934
- try:
935
- args = json.loads(raw)
936
- except (TypeError, ValueError):
937
- continue
938
- if not isinstance(args, dict):
939
- continue
940
- slim = {
941
- k: (
942
- f"{self._ARGS_MARK} {len(v)} 字符,需要时 {coord} 回取⟩"
943
- if isinstance(v, str) and len(v) > self._ARGS_KEEP_CHARS
944
- else v
945
- )
946
- for k, v in args.items()
947
- }
948
- new_raw = json.dumps(slim, ensure_ascii=False)
949
- if len(new_raw) >= len(raw):
950
- continue # 没省下就别动(改了反而打断前缀缓存)
951
- fn["arguments"] = new_raw
952
- freed += (len(raw) - len(new_raw)) // 4
953
- return freed
954
-
955
885
  def _context_profile(self, start: int, limit: int = 4) -> str:
956
886
  """本 send 的上下文被谁占着——`工具名:来源=字符数`,最大的几个。
957
887
 
@@ -978,106 +908,65 @@ class AgentPipeline:
978
908
  top = sorted(buckets.items(), key=lambda kv: -kv[1])[:limit]
979
909
  return " ".join(f"{k}={v // 1000}K" for k, v in top if v >= 1000) or "(无大项)"
980
910
 
981
- def _distill_oldest_tool_rows(self, batch: int, hot_tail: int) -> int:
982
- """把当前 send 里**最早的 batch 条**工具占用在内存里换成指针(6.10.0;6.25.0 起含调用参数)。
911
+ def _distill_oldest_tool_rows(self, batch: int, hot_tail: int, *, target_tokens: int | None = None) -> int:
912
+ """Distill complete calls oldest first; ``batch`` is ignored for compatibility.
983
913
 
984
- 「工具占用」有两块:结果在 tool 行、**参数在 assistant 行的 tool_calls 里**。两块按位置
985
- 统一排队、一起算进 batch,最早的先回收。
986
-
987
- 投影行 = 与 end-of-send 投影**同一套** ``ToolDefinition.project`` 蒸馏(找不到钩子就截断),
988
- 带 ``recall_send(send_index, seq)`` 坐标——原文永远在 pl_messages,要用就回取。不落盘、不改
989
- 数据库、不改行数(sink 的 index↔seq 对齐不受影响)。最近 ``hot_tail`` 条永不动。verbatim
990
- 模式不做(那边有就地压缩器)。返回本批释放的估算 token(≈字符/4)。"""
914
+ Protect invocation IDs, not result rows: parallel results may arrive out of order.
915
+ Only replace in-memory copies, keeping persisted audit records and seq alignment intact.
916
+ """
991
917
  rep = self.config.representation
992
- if getattr(rep, "kind", "projection") == "verbatim":
918
+ distill = getattr(rep, "distill_tool_call", None)
919
+ if getattr(rep, "kind", "projection") == "verbatim" or distill is None:
993
920
  return 0
994
- project_tool = getattr(rep, "_project_tool", None)
995
921
  seqs = getattr(self.sink, "_history_seqs", None) or []
996
- # 6.27.0 只动**本 send** 追加的行:run 之前就在历史里的(上一个 send 留下的逐字行)归跨 send
997
- # 投影管,保险丝碰它们等于两套机制抢同一段历史。
998
922
  start = min(int(getattr(self, "_send_start_len", 0) or 0), len(self.history))
999
- tool_idx = [
1000
- i for i, m in enumerate(self.history)
1001
- if i >= start and m.get("role") == "tool" and isinstance(m.get("content"), str)
1002
- ]
1003
- # max(0, …):本 send 的工具行少于 hot_tail 时,切片起点会是负数,Python 把它当成
1004
- # 「倒数第 |x| 条」绕回去——5 条工具行配 hot_tail=8 时只保住最后 3 条,最早 2 条
1005
- # 照样被蒸馏,而那 2 条正是模型手边的东西。本意是「不足 hot_tail 条就全保住」:
1006
- # 保险丝的前提是有旧结果可回收,没有旧的就什么都不该动,让它涨到切 send 阈值
1007
- # 优雅收尾即可(断片比切 send 贵得多)。
1008
- protected = (
1009
- set(tool_idx[max(0, len(tool_idx) - hot_tail):]) if hot_tail > 0 else set()
1010
- )
1011
- # 热尾的起点:它之后的任何一行(工具结果、assistant 的调用参数)都算「手边的东西」。
1012
- protect_from = min(protected) if protected else len(self.history)
1013
- result_ok = {
1014
- i for i in tool_idx
1015
- if i not in protected
1016
- and not str(self.history[i].get("content")).startswith(self._DISTILL_MARK)
1017
- and len(self.history[i].get("content") or "") > 300
1018
- }
1019
- # 工具在上下文里占两块:结果在 tool 行,**参数在 assistant 行的 tool_calls 里**。
1020
- # 参数从来没被保险丝碰过,而实测它占全库上下文的 29%——write_file 一个人 12.6MB,
1021
- # 单次最大 5.4 万字符:文件已经落盘了,正文却还在 prompt 里一轮轮重发。
1022
- # 跨 send 的投影早就把两块一起收掉了,缺的只是 send 内这一层。
1023
- candidates: list[tuple[str, int]] = sorted(
1024
- [("result", i) for i in result_ok]
1025
- + [("args", i) for i in range(start, protect_from) if self._has_heavy_args(i)],
1026
- key=lambda x: x[1],
1027
- )
1028
- picked_all = candidates[: max(1, batch)]
1029
- picked = [i for kind, i in picked_all if kind == "result"]
1030
- picked_args = [i for kind, i in picked_all if kind == "args"]
1031
- if not picked_all:
1032
- return 0
1033
- # tool_call_id → (name, args) 从前面的 assistant(tool_calls) 行反查。
1034
- # 只解析**本批选中的**那几个 id:以前是每次触发都把全历史所有 assistant 行的所有
1035
- # tool_calls 的 arguments 反序列化一遍建全表,只为查其中 batch 条(默认 10)。
1036
- # 保险丝一旦启动几乎每轮都触发,于是每轮做一次 O(历史) 的 json.loads。
1037
- wanted = {str(self.history[i].get("tool_call_id") or "") for i in picked}
1038
- wanted.discard("")
1039
- call_meta: dict[str, tuple[str, Any]] = {}
1040
- for m in self.history if wanted else ():
1041
- if m.get("role") != "assistant":
923
+ calls = []
924
+ results = {}
925
+ for i in range(start, len(self.history)):
926
+ row = self.history[i]
927
+ if row.get("role") == "assistant":
928
+ for j, tc in enumerate(row.get("tool_calls") or []):
929
+ if tc.get("id"):
930
+ calls.append((str(tc["id"]), i, j))
931
+ elif row.get("role") == "tool":
932
+ results[str(row.get("tool_call_id") or "")] = i
933
+ protected = {cid for cid, _, _ in calls[-hot_tail:]} if hot_tail > 0 else set()
934
+ keep = set(self.config.insend_distill_keep_tools or ())
935
+ freed_chars = 0
936
+ for cid, i, j in calls:
937
+ if cid in protected or cid not in results:
938
+ continue
939
+ ri = results[cid]
940
+ content = self.history[ri].get("content")
941
+ tc = self.history[i]["tool_calls"][j]
942
+ fn = tc.get("function") or {}
943
+ raw = fn.get("arguments")
944
+ name = str(fn.get("name") or "tool")
945
+ if (name in keep or not isinstance(content, str) or not isinstance(raw, str)
946
+ or content.startswith(self._DISTILL_MARK)):
947
+ continue
948
+ try:
949
+ args = json.loads(raw)
950
+ line = distill(name, args, content, getattr(self, "tool_registry", None))
951
+ except Exception: # a broken projector must not stop the run or starve later calls
952
+ logger.warning("in-send distillation failed for %s", name, exc_info=True)
1042
953
  continue
1043
- for tc in m.get("tool_calls") or []:
1044
- cid = str((tc or {}).get("id") or "")
1045
- if cid not in wanted or cid in call_meta:
1046
- continue
1047
- fn = (tc or {}).get("function") or {}
1048
- args = fn.get("arguments")
1049
- if isinstance(args, str):
1050
- try:
1051
- args = json.loads(args)
1052
- except (TypeError, ValueError):
1053
- args = {"raw": args[:200]}
1054
- call_meta[cid] = (str(fn.get("name") or "tool"), args)
1055
- if len(call_meta) == len(wanted):
1056
- break
1057
- freed = 0
1058
- for i in picked:
1059
- m = self.history[i]
1060
- name, args = call_meta.get(str(m.get("tool_call_id") or ""), ("tool", {}))
1061
- content = str(m.get("content") or "")
1062
- if project_tool is not None:
1063
- try:
1064
- d = project_tool(name, args, content, getattr(self, "tool_registry", None))
1065
- parts = [f"{k}={v}" for k, v in (d or {}).items() if k != "name" and v not in (None, "")]
1066
- line = f"{name}(" + ", ".join(str(x)[:200] for x in parts) + ")"
1067
- except Exception: # noqa: BLE001 — 蒸馏钩子出错就退回截断
1068
- line = f"{name}: {content[:200]}…"
1069
- else:
1070
- line = f"{name}: {content[:200]}…"
1071
954
  seq = seqs[i] if i < len(seqs) else None
1072
955
  coord = (f"recall_send(send_index={self.send_index}, seq={seq})"
1073
- if (self.send_index is not None and seq is not None) else "recall_send")
1074
- new_content = (f"{self._DISTILL_MARK}{self.send_index} seq={seq} — 原文已从上下文移出,"
1075
- f"需要时 {coord} 回取] {line}")
1076
- freed += max(0, (len(content) - len(new_content)) // 4)
1077
- m["content"] = new_content
1078
- for i in picked_args:
1079
- freed += self._distill_tool_call_args(i, seqs)
1080
- return freed
956
+ if self.send_index is not None and seq is not None else "recall_send")
957
+ new_content = f"{self._DISTILL_MARK}{self.send_index} seq={seq}; {coord}] {line}"
958
+ new_raw = json.dumps({"_distilled": coord}, ensure_ascii=False)
959
+ saved = len(raw) + len(content) - len(new_raw) - len(new_content)
960
+ if saved <= 0:
961
+ continue
962
+ new_calls = list(self.history[i]["tool_calls"])
963
+ new_calls[j] = {**tc, "function": {**fn, "arguments": new_raw}}
964
+ self.history[i] = {**self.history[i], "tool_calls": new_calls}
965
+ self.history[ri] = {**self.history[ri], "content": new_content}
966
+ freed_chars += saved
967
+ if target_tokens is not None and freed_chars // 4 >= target_tokens:
968
+ break
969
+ return freed_chars // 4
1081
970
 
1082
971
  def _estimate_history_tokens(self) -> int:
1083
972
  """Current history token estimate, maintained incrementally (SCALE-4).
@@ -1131,42 +1020,22 @@ class AgentPipeline:
1131
1020
  if irr is not None and int(irr) > 0 and self._retire_stale_images(round_index, int(irr)):
1132
1021
  self._tok_len = -1
1133
1022
 
1134
- # ── 6.10.0 send 内保险丝:最早的 n 条工具结果 → 投影行(内存替换,pl_messages 不动)──
1135
- # 触发依据 = 上一轮供应商返回的真实 prompt_tokens(就是当前上下文的真实大小;第一轮
1136
- # 还没有就用估算)。每轮最多蒸馏一批(最早的 n 条、跳过已蒸馏的、不动最近 hot_tail 条);
1137
- # 下一轮真实 prompt 仍超阈值就再蒸馏下一批——逐轮递进,只蒸馏到够用为止。
1023
+ # Fuse uses total prompt size, including the fixed prompt and history prefix.
1138
1024
  dt = self.config.insend_distill_tokens
1139
1025
  if dt is not None and int(dt) > 0 and round_index > 0:
1140
- # 6.27.0:量的是**本 send 内的增长**,不是整个上下文。系统提示词 + 技能 + 工具目录 + 历史
1141
- # 一开局就可能有几万 token,按整体判的话第一轮就超阈值、之后每轮都烧(真实事故:一个
1142
- # 会话 44 次触发,模型自己的发言参数被瘦成占位符,再被照样发进聊天)。保险丝的本意是
1143
- # 「这一个 send 跑得太长」才出手:第一轮的真实 prompt_tokens 是基线,之后每轮减它。
1144
1026
  real = int((self.ctx.token_usage or {}).get("prompt_tokens", 0) or 0)
1145
- if real > 0:
1146
- if self._send_baseline_prompt is None:
1147
- self._send_baseline_prompt = real
1148
- growth = real - int(self._send_baseline_prompt)
1149
- else:
1150
- start = min(int(getattr(self, "_send_start_len", 0) or 0), len(self.history))
1151
- growth = estimate_tokens(self.history[start:])
1152
- if growth >= int(dt):
1027
+ if real and self._send_baseline_prompt is None:
1028
+ self._send_baseline_prompt = real # diagnostics only; never subtracted from trigger
1029
+ total = real or self._estimate_history_tokens()
1030
+ if total >= int(dt):
1153
1031
  freed = self._distill_oldest_tool_rows(
1154
- int(self.config.insend_distill_batch or 10),
1155
- int(self.config.insend_distill_hot_tail or 0),
1032
+ 0, int(self.config.insend_distill_hot_tail or 0),
1033
+ target_tokens=max(1, total - int(dt) + 1),
1156
1034
  )
1157
1035
  if freed:
1158
- self._tok_len = -1 # content shrank in place; force a fresh estimate
1159
- start_i = min(int(getattr(self, "_send_start_len", 0) or 0), len(self.history))
1160
- # freed==0 也要记:那是「保险丝烧完了、再触发也没用」的静默状态,
1161
- # 而它的表现和「一切正常」一模一样——上下文继续涨,日志里一个字都没有。
1162
- logger.info(
1163
- "insend distill: send grew %d >= %d tokens; freed ~%d this round "
1164
- "(send %s, round %d, prompt %d, rows %d) | 大头: %s%s",
1165
- growth, int(dt), freed, self.send_index, round_index, real,
1166
- len(self.history) - start_i, self._context_profile(start_i),
1167
- "" if freed else " ← 没有可回收的了:剩下的要么在热尾、要么已蒸馏、要么是"
1168
- "模型自己的正文(保险丝只动工具结果与调用参数)",
1169
- )
1036
+ self._tok_len = -1
1037
+ logger.info("insend distill: total prompt %d >= %d; freed ~%d tokens",
1038
+ total, int(dt), freed)
1170
1039
  if self.config.microcompact_enabled:
1171
1040
  self.ctx.microcompact(
1172
1041
  self.history,
@@ -1665,6 +1534,7 @@ class AgentPipeline:
1665
1534
  ckpt = self.config.context_checkpoint_tokens
1666
1535
  if (
1667
1536
  ckpt is not None
1537
+ and (self.config.insend_distill_tokens or 0) <= 0
1668
1538
  and int(ckpt) > 0
1669
1539
  and round_idx > 0
1670
1540
  and not self._terminal_grace_active
@@ -420,6 +420,13 @@ class ProjectedRepresentation:
420
420
  source_seq_hi=max(seqs) if seqs else None,
421
421
  )
422
422
 
423
+ def distill_tool_call(
424
+ self, name: str, args: Any, result: Any, tool_registry: ToolRegistry | None
425
+ ) -> str:
426
+ """Public single-call projection seam shared with the in-send context fuse."""
427
+ entry = self._project_tool(name, args, result, tool_registry)
428
+ return json.dumps(entry, ensure_ascii=False, default=str)
429
+
423
430
  def _project_tool(
424
431
  self, name: str | None, args: Any, result: Any, tool_registry: ToolRegistry | None
425
432
  ) -> dict[str, Any]:
@@ -386,8 +386,14 @@ class WorkflowEngine:
386
386
  # journaling (those leaves share one id and aren't individually
387
387
  # replayable). The foreach's aggregate is journaled instead.
388
388
  self._emit_suppressed = 0
389
+ self._frames: dict[int, dict[str, Any]] = {}
390
+ self._repairs: dict[int, asyncio.Task[Any]] = {}
391
+ self._resume_instructions: dict[str, tuple[str, int]] = {}
392
+ self._replay_no_charge: set[str] = set()
393
+ self._steering: dict[str, list[asyncio.Task[Any]]] = {}
389
394
 
390
395
  async def run(self, spec: WorkflowSpec) -> WorkflowResult:
396
+ self._spec = spec
391
397
  if self._budget is None and spec.budget is not None:
392
398
  self._budget = SharedBudget(
393
399
  spec.budget.max_tokens, stop_at_remaining_pct=spec.budget.stop_at_remaining_pct
@@ -477,7 +483,113 @@ class WorkflowEngine:
477
483
 
478
484
  # ── node dispatch ──────────────────────────────────────────────────────
479
485
 
486
+ @staticmethod
487
+ def _children(node: WorkflowNode) -> list[WorkflowNode]:
488
+ if isinstance(node, SequenceNode):
489
+ return list(node.steps)
490
+ if isinstance(node, ParallelNode):
491
+ return list(node.branches)
492
+ if isinstance(node, (LoopNode, ForeachNode)):
493
+ return [node.body]
494
+ if isinstance(node, BranchNode):
495
+ return list(node.cases.values()) + ([node.default] if node.default else [])
496
+ return []
497
+
498
+ async def steer_node(self, node_id: str, sid: str, message: str):
499
+ """Accept steering only while this engine owns execution, never launch an idle send."""
500
+ import uuid
501
+ pending = self._steering.get(node_id)
502
+ if pending is None:
503
+ raise WorkflowRunError("node is settling; retry nudge after its journal step settles")
504
+ task = asyncio.create_task(self._loop.follow_up(
505
+ message, session_id=sid, mode="steer", enqueue_only=True,
506
+ item_id=f"workflow-steer-{uuid.uuid4().hex}"))
507
+ pending.append(task) # registered before any await; the leaf joins delivery before settling
508
+ return await asyncio.shield(task)
509
+
510
+ async def continue_node(self, node_id: str, message: str, rounds: int) -> AgentResult | None:
511
+ """Recover a settled branch while siblings run, using the original leaf session.
512
+
513
+ Active dependent scopes are rejected before executing anything. Foreach IDs are
514
+ ambiguous and require iteration-aware orchestration instead of guessing a session.
515
+ """
516
+ def path_to(node):
517
+ if isinstance(node, AgentNode) and node.id == node_id:
518
+ return [node]
519
+ for child in self._children(node):
520
+ path = path_to(child)
521
+ if path:
522
+ return [node, *path]
523
+ return None
524
+
525
+ path = path_to(self._spec.root)
526
+ if not path or any(isinstance(n, ForeachNode) for n in path):
527
+ raise WorkflowRunError("node is missing or requires an explicit foreach iteration")
528
+ leaf = path[-1]
529
+ frame = self._frames.get(id(leaf))
530
+ if not frame or frame["active"]:
531
+ raise WorkflowRunError("node is active or has not settled; deliver a steering message to its sid")
532
+ scope = leaf
533
+ for parent in reversed(path[:-1]):
534
+ pf = self._frames.get(id(parent))
535
+ if not pf or pf["active"]:
536
+ if not isinstance(parent, ParallelNode):
537
+ raise WorkflowRunError("dependent scope is still active; wait for its current step to settle")
538
+ break
539
+ scope = parent
540
+ key = id(scope)
541
+ if key in self._repairs:
542
+ raise WorkflowRunError("this branch already has a continuation in progress")
543
+ prior = self._results.get(node_id)
544
+ if prior is None or not prior.session_id:
545
+ raise WorkflowRunError("node has no resumable session")
546
+ sf = self._frames[key]
547
+
548
+ async def repair():
549
+ from .resume import continuation_replay
550
+ repaired_spec = replace(self._spec, root=scope)
551
+ for key in _agent_ids(scope):
552
+ self._replay.pop(key, None)
553
+ invalidated: set[str] = set()
554
+ replay = continuation_replay(repaired_spec, node_id, dict(self._results), invalidated=invalidated)
555
+ self._replay.update(replay)
556
+ self._replay_no_charge.update(replay)
557
+ for invalid_id in invalidated:
558
+ self._replay.pop(invalid_id, None)
559
+ old = self._results.pop(invalid_id, None)
560
+ await self._emit_step(AgentResult(
561
+ node_id=invalid_id, status="pending", text="",
562
+ session_id=old.session_id if old else None,
563
+ error="invalidated by node continuation"))
564
+ self._resume_sessions[node_id] = prior.session_id
565
+ self._resume_instructions[node_id] = (message, rounds)
566
+ guard = _IN_WORKFLOW.set(True)
567
+ try:
568
+ return await self._exec(scope, sf["env"], sf["driver"])
569
+ finally:
570
+ _IN_WORKFLOW.reset(guard)
571
+
572
+ task = asyncio.create_task(repair(), name=f"workflow-continue-{node_id}")
573
+ self._repairs[key] = task
574
+ try:
575
+ return await asyncio.shield(task)
576
+ finally:
577
+ # Keep the task registered until completed, even if the caller disconnects.
578
+ if task.done():
579
+ self._repairs.pop(key, None)
580
+
480
581
  async def _exec(self, node: WorkflowNode, env: dict[str, Any], driver_sid: str) -> AgentResult | None:
582
+ if ITERATION_ENV_KEY in env:
583
+ return await self._exec_dispatch(node, env, driver_sid)
584
+ frame = {"active": True, "env": dict(env), "driver": driver_sid, "result": None}
585
+ self._frames[id(node)] = frame
586
+ try:
587
+ frame["result"] = await self._exec_dispatch(node, env, driver_sid)
588
+ return frame["result"]
589
+ finally:
590
+ frame["active"] = False
591
+
592
+ async def _exec_dispatch(self, node: WorkflowNode, env: dict[str, Any], driver_sid: str) -> AgentResult | None:
481
593
  if isinstance(node, AgentNode):
482
594
  return await self._exec_agent(node, env, driver_sid)
483
595
  if isinstance(node, SequenceNode):
@@ -502,7 +614,9 @@ class WorkflowEngine:
502
614
  text=str(r.get("text") or ""), payload=r.get("payload"),
503
615
  session_id=r.get("session_id"))
504
616
  self._results[node.id] = replayed
505
- self._add_usage(replayed.usage)
617
+ if node.id not in self._replay_no_charge:
618
+ self._add_usage(replayed.usage)
619
+ self._replay_no_charge.discard(node.id)
506
620
  return replayed
507
621
  body_ids = _agent_ids(node.body)
508
622
  feedback_node = str(node.until.get("ref") or "").split(".", 1)[0]
@@ -557,7 +671,9 @@ class WorkflowEngine:
557
671
  if replayed is not None:
558
672
  self._results[node.id] = replayed
559
673
  self._last = replayed
560
- self._add_usage(replayed.usage)
674
+ if node.id not in self._replay_no_charge:
675
+ self._add_usage(replayed.usage)
676
+ self._replay_no_charge.discard(node.id)
561
677
  return replayed
562
678
 
563
679
  if self._cancel.is_cancelled():
@@ -663,6 +779,7 @@ class WorkflowEngine:
663
779
  except Exception: # noqa: BLE001 — observability must not fail the run
664
780
  pass
665
781
 
782
+ self._steering[node.id] = []
666
783
  node_tok = self._cancel.child()
667
784
  self._node_tokens.setdefault(node.id, []).append(node_tok)
668
785
  # 重启后接着跑(6.46):这个节点上次有叶子会话、没做完 → 先在原会话补轮;补不了再照常开新会话。
@@ -670,8 +787,10 @@ class WorkflowEngine:
670
787
  rs = self._resume_sessions.pop(node.id, None) if not self._emit_suppressed else None
671
788
  if rs:
672
789
  try:
790
+ prompt, rounds = self._resume_instructions.pop(
791
+ node.id, (_RESUME_PROMPT, max(int(node.spec.max_rounds or 30), 10)))
673
792
  resumed_raw = await self._continue_leaf(
674
- rs, _RESUME_PROMPT, extra_rounds=max(int(node.spec.max_rounds or 30), 10), stop=node_tok)
793
+ rs, prompt, extra_rounds=rounds, stop=node_tok)
675
794
  resumed_raw.setdefault("session_id", rs)
676
795
  logger.info("workflow leaf %s resumed in its original session %s", node.id, rs)
677
796
  except Exception as exc: # noqa: BLE001 — 会话丢了就照常重跑
@@ -781,6 +900,22 @@ class WorkflowEngine:
781
900
  raw = {**raw, "error": raw.get("error") or f"节点被单独取消:{node_tok.reason}"}
782
901
  else:
783
902
  self._cancelled = True
903
+ # Close intake, then join every accepted delivery. Any input arriving after the
904
+ # final round drain is flushed before schema validation, journaling or review.
905
+ accepted_steering = self._steering.pop(node.id, [])
906
+ if accepted_steering:
907
+ await asyncio.gather(*accepted_steering)
908
+ sid = raw.get("session_id")
909
+ pending = await self._loop.inbox_pending(sid) if sid else {}
910
+ if pending.get("pending", 0):
911
+ more = await self._continue_leaf(
912
+ str(sid), "继续处理收件箱中的补充要求,完成后按原任务格式交付。",
913
+ extra_rounds=max(int(node.spec.max_rounds or 30), 10), stop=node_tok)
914
+ combined_usage = dict(raw.get("usage") or {})
915
+ for key, value in (more.get("usage") or {}).items():
916
+ combined_usage[key] = combined_usage.get(key, 0) + value
917
+ raw = {**more, "session_id": sid, "usage": combined_usage}
918
+
784
919
  payload: dict[str, Any] | None = None
785
920
  err: str | None = None
786
921
  if node.output_schema is not None and raw.get("status") == "completed":
@@ -1018,6 +1153,15 @@ class WorkflowEngine:
1018
1153
  gathered = await self._gather_branches(
1019
1154
  [one(b) for b in node.branches], on_error=node.on_error
1020
1155
  )
1156
+ # A closed branch may have been repaired while siblings were executing.
1157
+ # Join accepted repairs before releasing dependent sequence/assembly steps.
1158
+ for branch in node.branches:
1159
+ task = self._repairs.get(id(branch))
1160
+ if task is not None:
1161
+ await asyncio.shield(task)
1162
+ gathered = [self._frames.get(id(b), {}).get("result", r)
1163
+ if not isinstance(r, BaseException) and ITERATION_ENV_KEY not in env else r
1164
+ for b, r in zip(node.branches, gathered, strict=True)]
1021
1165
  for r in gathered:
1022
1166
  if isinstance(r, BaseException):
1023
1167
  self._errors.append(f"parallel branch error: {r}")
@@ -260,6 +260,60 @@ async def resume_run(
260
260
  return result
261
261
 
262
262
 
263
+ def continuation_replay(spec: WorkflowSpec, node_id: str, results: dict[str, AgentResult],
264
+ *, invalidated: set[str] | None = None) -> dict[str, AgentResult]:
265
+ """Invalidate the target, its downstream sequence, and enclosing aggregates.
266
+
267
+ Parallel siblings retain their completed results. Foreach body IDs cannot identify
268
+ an iteration and are rejected. Old reviews downstream of changed work never replay.
269
+ """
270
+ invalid: set[str] = set()
271
+ found = False
272
+
273
+ def all_ids(node):
274
+ own = {node.id} if getattr(node, "id", None) else set()
275
+ for c in WorkflowEngine._children(node):
276
+ own.update(all_ids(c))
277
+ return own
278
+
279
+ def visit(node, in_foreach=False):
280
+ nonlocal found
281
+ if isinstance(node, AgentNode):
282
+ if node.id == node_id:
283
+ if in_foreach:
284
+ raise WorkflowRunError("foreach continuation needs an iteration-specific target")
285
+ found = True
286
+ invalid.add(node.id)
287
+ return True
288
+ return False
289
+ affected = False
290
+ for child in WorkflowEngine._children(node):
291
+ if affected and isinstance(node, SequenceNode):
292
+ invalid.update(all_ids(child))
293
+ affected = visit(child, in_foreach or isinstance(node, ForeachNode)) or affected
294
+ if affected and getattr(node, "id", None):
295
+ invalid.add(node.id)
296
+ return affected
297
+
298
+ visit(spec.root)
299
+ if not found:
300
+ raise WorkflowRunError(f"unknown continuation node {node_id}")
301
+ if invalidated is not None:
302
+ invalidated.update(invalid)
303
+ scope_ids = all_ids(spec.root)
304
+ replay = {k: v for k, v in results.items()
305
+ if k in scope_ids and k not in invalid and v.status not in ("pending", "running")}
306
+ def prune(node):
307
+ if isinstance(node, (LoopNode, ForeachNode)) and node.id in replay:
308
+ for key in all_ids(node.body):
309
+ replay.pop(key, None)
310
+ else:
311
+ for c in WorkflowEngine._children(node):
312
+ prune(c)
313
+ prune(spec.root)
314
+ return replay
315
+
316
+
263
317
  async def resume_detached(
264
318
  loop: Any,
265
319
  parent_sid: str,
@@ -271,6 +325,7 @@ async def resume_detached(
271
325
  force: bool = False,
272
326
  on_complete: OnComplete | None = None,
273
327
  file_io: Any | None = None,
328
+ continuation: tuple[str, str, int] | None = None,
274
329
  ) -> WorkflowRunHandle:
275
330
  """Resume a run on a background task; wake the parent on completion.
276
331
 
@@ -285,25 +340,48 @@ async def resume_detached(
285
340
  if await store.get_session(parent_sid) is None:
286
341
  raise WorkflowRunError("parent session not found; cannot resume detached run")
287
342
  spec, replay, j = await rehydrate(store, parent_sid, run_id)
343
+ invalidated: set[str] = set()
344
+ if continuation is not None:
345
+ nid, message, rounds = continuation
346
+ all_results = {str(st["node_id"]): AgentResult(
347
+ node_id=str(st["node_id"]), status=str(st.get("status") or "failed"),
348
+ text=st.get("text") or "", payload=st.get("payload"), usage=st.get("usage") or {},
349
+ session_id=st.get("session_id"),
350
+ ) for st in j.get("steps", [])}
351
+ replay = continuation_replay(spec, nid, all_results, invalidated=invalidated)
352
+ if not (j.get("live_sessions") or {}).get(nid):
353
+ raise WorkflowRunError("continuation node has no recorded session")
288
354
  _guard_resumable(run_id, force)
289
- await _mark_resuming(store, parent_sid, run_id, j)
290
- cancel_token = CancellationToken()
291
-
292
- def _build_engine() -> WorkflowEngine:
293
- return WorkflowEngine(
294
- loop, executor=executor, budget=budget,
295
- on_step=make_on_step(store, parent_sid, run_id),
296
- on_node_start=make_on_node_start(store, parent_sid, run_id),
297
- on_node_session=make_on_node_session(store, parent_sid, run_id),
298
- resume_sessions=resume_sessions(spec, replay, j),
299
- replay=replay, run_id=run_id, stop_event=cancel_token,
300
- allowed_tools=_journaled_clamp(j),
301
- file_io=file_io,
355
+ _LIVE_RUN_IDS.add(run_id) # reserve before the first journal-write await
356
+ try:
357
+ await _mark_resuming(store, parent_sid, run_id, j)
358
+ for invalid_id in invalidated:
359
+ await journal.record_step(store, parent_sid, run_id, node_id=invalid_id,
360
+ status="pending", error="invalidated by node continuation",
361
+ session_id=(j.get("live_sessions") or {}).get(invalid_id))
362
+ cancel_token = CancellationToken()
363
+
364
+ def _build_engine() -> WorkflowEngine:
365
+ engine = WorkflowEngine(
366
+ loop, executor=executor, budget=budget,
367
+ on_step=make_on_step(store, parent_sid, run_id),
368
+ on_node_start=make_on_node_start(store, parent_sid, run_id),
369
+ on_node_session=make_on_node_session(store, parent_sid, run_id),
370
+ resume_sessions=resume_sessions(spec, replay, j),
371
+ replay=replay, run_id=run_id, stop_event=cancel_token,
372
+ allowed_tools=_journaled_clamp(j),
373
+ file_io=file_io,
374
+ )
375
+ if continuation is not None:
376
+ engine._resume_instructions[continuation[0]] = (continuation[1], continuation[2])
377
+ return engine
378
+
379
+ return spawn_background(
380
+ loop, parent_sid, run_id, store=store,
381
+ build_engine=_build_engine, run_spec=spec,
382
+ cancel_token=cancel_token, eager_wake=eager_wake, task_set=_RESUME_TASKS,
383
+ on_complete=on_complete,
302
384
  )
303
-
304
- return spawn_background(
305
- loop, parent_sid, run_id, store=store,
306
- build_engine=_build_engine, run_spec=spec,
307
- cancel_token=cancel_token, eager_wake=eager_wake, task_set=_RESUME_TASKS,
308
- on_complete=on_complete,
309
- )
385
+ except BaseException:
386
+ _LIVE_RUN_IDS.discard(run_id)
387
+ raise
@@ -145,6 +145,21 @@ def make_on_node_session(store: Any, parent_sid: str, run_id: str):
145
145
  _ENGINES: dict[str, Any] = {}
146
146
 
147
147
 
148
+ async def steer_node(run_id: str, node_id: str, sid: str, message: str):
149
+ engine = _ENGINES.get(run_id)
150
+ if engine is None:
151
+ raise RuntimeError("run is settling; retry nudge to resume the persisted workflow")
152
+ return await engine.steer_node(node_id, sid, message)
153
+
154
+
155
+ async def continue_node(run_id: str, node_id: str, message: str, rounds: int = 8):
156
+ """Continue one settled branch in its live engine; active leaves use follow_up."""
157
+ engine = _ENGINES.get(run_id)
158
+ if engine is None:
159
+ raise RuntimeError("run has no live engine; resume its persisted journal first")
160
+ return await engine.continue_node(node_id, message, rounds)
161
+
162
+
148
163
  def cancel_node(run_id: str, node_id: str, reason: str = "cancelled by host") -> int | None:
149
164
  """只取消某个 run 里的一个节点(6.46)。返回停了几个在跑的叶子;None = 这个 run 不在本进程跑。"""
150
165
  eng = _ENGINES.get(run_id)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: power-loop
3
- Version: 6.46.4
3
+ Version: 6.47.0
4
4
  Summary: Embeddable agent execution kernel — LLM loop, hooks, events, tools, dynamic sub-agents.
5
5
  Author-email: zhangran <zhangran24@126.com>
6
6
  License: MIT
File without changes
File without changes
File without changes
File without changes