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.
- {power_loop-6.46.4 → power_loop-6.47.0}/PKG-INFO +1 -1
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/__init__.py +1 -1
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/agent/stateful_loop.py +6 -2
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/agent/types.py +3 -7
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/core/pipeline.py +62 -192
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/representation.py +7 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/workflow/engine.py +147 -3
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/workflow/resume.py +98 -20
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/workflow/runner.py +15 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop.egg-info/PKG-INFO +1 -1
- {power_loop-6.46.4 → power_loop-6.47.0}/LICENSE +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/README.md +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/_vendor/__init__.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/_vendor/llm_client/__init__.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/_vendor/llm_client/anthropic_factory.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/_vendor/llm_client/capabilities.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/_vendor/llm_client/interface.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/_vendor/llm_client/llm_factory.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/_vendor/llm_client/llm_tooling.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/_vendor/llm_client/llm_utils.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/_vendor/llm_client/multimodal.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/_vendor/llm_client/proxy_fallback.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/agent/__init__.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/agent/follow_up.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/agent/sink.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/agent/system_prompt.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/contracts/__init__.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/contracts/errors.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/contracts/event_payloads.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/contracts/events.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/contracts/handlers.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/contracts/hook_contexts.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/contracts/hooks.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/contracts/messages.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/contracts/protocols.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/contracts/tools.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/contrib/__init__.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/contrib/_redact.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/contrib/jsonl_sink.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/contrib/logging_sink.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/contrib/mcp.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/contrib/metrics_sink.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/contrib/otel_sink.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/core/agent_context.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/core/events.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/core/hooks.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/core/phase.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/core/runner.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/core/state.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/py.typed +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/blackboard.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/budget.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/cancellation.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/capability_probe.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/compact.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/env.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/exec_backend.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/fold.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/fold_adapter.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/history_projector.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/history_sanitize.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/human_input.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/image_recall.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/memory.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/notes.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/provider.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/retry.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/runtime_state.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/session_store.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/skills.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/spec.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/steer.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/stop_policy.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/store/__init__.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/store/backends/__init__.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/store/backends/mysql.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/store/backends/postgres.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/store/backends/sqlite.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/store/capabilities.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/store/db.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/store/dialect.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/store/factory.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/store/schema.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/store/store.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/store/types.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/structured.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/stub_provider.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/timers.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/todos.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/runtime/usage_estimate.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/tools/__init__.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/tools/blackboard.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/tools/command_policy.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/tools/command_rules.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/tools/default_manifest.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/tools/default_tools.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/tools/registry.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/tools/spawn_agent.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/workflow/__init__.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/workflow/api.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/workflow/introspect.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/workflow/journal.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/workflow/result.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/workflow/spec.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/workflow/subprocess_executor.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/workflow/tool.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop/workflow/worker.py +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop.egg-info/SOURCES.txt +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop.egg-info/dependency_links.txt +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop.egg-info/requires.txt +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/power_loop.egg-info/top_level.txt +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/pyproject.toml +0 -0
- {power_loop-6.46.4 → power_loop-6.47.0}/setup.cfg +0 -0
|
@@ -15,7 +15,7 @@ Stability tiers
|
|
|
15
15
|
无版本承诺,可随时变更或删除。
|
|
16
16
|
"""
|
|
17
17
|
|
|
18
|
-
__version__ = "6.
|
|
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
|
-
#:
|
|
110
|
-
#:
|
|
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
|
-
#:
|
|
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
|
-
"""
|
|
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
|
-
|
|
985
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
)
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
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
|
|
1074
|
-
new_content =
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
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
|
-
#
|
|
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
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
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.
|
|
1155
|
-
int(
|
|
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
|
|
1159
|
-
|
|
1160
|
-
|
|
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.
|
|
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.
|
|
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,
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
305
|
-
|
|
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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|