power-loop 6.45.0__tar.gz → 6.46.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.45.0 → power_loop-6.46.0}/PKG-INFO +1 -1
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/__init__.py +1 -1
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/agent/stateful_loop.py +22 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/core/pipeline.py +4 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/spec.py +16 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/workflow/__init__.py +2 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/workflow/engine.py +230 -18
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/workflow/journal.py +17 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/workflow/resume.py +37 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/workflow/runner.py +30 -2
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/workflow/spec.py +159 -6
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/workflow/tool.py +10 -2
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop.egg-info/PKG-INFO +1 -1
- {power_loop-6.45.0 → power_loop-6.46.0}/LICENSE +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/README.md +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/_vendor/__init__.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/_vendor/llm_client/__init__.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/_vendor/llm_client/anthropic_factory.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/_vendor/llm_client/capabilities.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/_vendor/llm_client/interface.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/_vendor/llm_client/llm_factory.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/_vendor/llm_client/llm_tooling.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/_vendor/llm_client/llm_utils.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/_vendor/llm_client/multimodal.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/_vendor/llm_client/proxy_fallback.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/agent/__init__.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/agent/follow_up.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/agent/sink.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/agent/system_prompt.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/agent/types.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/contracts/__init__.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/contracts/errors.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/contracts/event_payloads.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/contracts/events.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/contracts/handlers.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/contracts/hook_contexts.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/contracts/hooks.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/contracts/messages.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/contracts/protocols.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/contracts/tools.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/contrib/__init__.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/contrib/_redact.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/contrib/jsonl_sink.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/contrib/logging_sink.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/contrib/mcp.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/contrib/metrics_sink.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/contrib/otel_sink.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/core/agent_context.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/core/events.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/core/hooks.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/core/phase.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/core/runner.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/core/state.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/py.typed +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/blackboard.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/budget.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/cancellation.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/capability_probe.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/compact.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/env.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/exec_backend.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/fold.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/fold_adapter.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/history_projector.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/history_sanitize.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/human_input.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/image_recall.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/memory.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/notes.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/provider.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/representation.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/retry.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/runtime_state.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/session_store.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/skills.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/steer.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/stop_policy.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/store/__init__.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/store/backends/__init__.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/store/backends/mysql.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/store/backends/postgres.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/store/backends/sqlite.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/store/capabilities.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/store/db.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/store/dialect.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/store/factory.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/store/schema.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/store/store.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/store/types.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/structured.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/stub_provider.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/timers.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/todos.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/usage_estimate.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/tools/__init__.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/tools/blackboard.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/tools/command_policy.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/tools/command_rules.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/tools/default_manifest.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/tools/default_tools.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/tools/registry.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/tools/spawn_agent.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/workflow/api.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/workflow/introspect.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/workflow/result.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/workflow/subprocess_executor.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/workflow/worker.py +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop.egg-info/SOURCES.txt +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop.egg-info/dependency_links.txt +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop.egg-info/requires.txt +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/power_loop.egg-info/top_level.txt +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/pyproject.toml +0 -0
- {power_loop-6.45.0 → power_loop-6.46.0}/setup.cfg +0 -0
|
@@ -15,7 +15,7 @@ Stability tiers
|
|
|
15
15
|
无版本承诺,可随时变更或删除。
|
|
16
16
|
"""
|
|
17
17
|
|
|
18
|
-
__version__ = "6.
|
|
18
|
+
__version__ = "6.46.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).
|
|
@@ -187,6 +187,8 @@ class _SessionSync:
|
|
|
187
187
|
# waiting tools await it); cleared when the inbox is claimed.
|
|
188
188
|
steer: asyncio.Event = field(default_factory=asyncio.Event)
|
|
189
189
|
refs: set[int] = field(default_factory=set)
|
|
190
|
+
# 运行中追加的轮数(6.46,extend_rounds):正在跑的这个 send 在下一轮开始时把它加到轮数上限上。
|
|
191
|
+
round_bonus: int = 0
|
|
190
192
|
|
|
191
193
|
|
|
192
194
|
# session_id -> _SessionSync. PROCESS-GLOBAL and deliberately NOT per-instance state.
|
|
@@ -208,6 +210,17 @@ class _SessionSync:
|
|
|
208
210
|
# session driven from two event loops (e.g. mixing the sync API's runner thread with direct async
|
|
209
211
|
# calls) is already broken today with per-instance locks. This changes nothing there.
|
|
210
212
|
_SESSION_SYNC: dict[str, _SessionSync] = {}
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def take_round_bonus(session_id: str | None) -> int:
|
|
216
|
+
"""取走这个会话运行中追加的轮数(6.46,extend_rounds);流水线每轮开始时调。"""
|
|
217
|
+
if not session_id:
|
|
218
|
+
return 0
|
|
219
|
+
entry = _SESSION_SYNC.get(session_id)
|
|
220
|
+
if entry is None or entry.round_bonus <= 0:
|
|
221
|
+
return 0
|
|
222
|
+
n, entry.round_bonus = entry.round_bonus, 0
|
|
223
|
+
return n
|
|
211
224
|
# Guards only registry mutation (dict insert/delete) — instances may be constructed from several
|
|
212
225
|
# threads. Never held across an await.
|
|
213
226
|
_SESSION_SYNC_GUARD = threading.Lock()
|
|
@@ -871,6 +884,15 @@ class StatefulAgentLoop:
|
|
|
871
884
|
store = await self._ensure_store()
|
|
872
885
|
return await store.inbox_counts(session_id)
|
|
873
886
|
|
|
887
|
+
def extend_rounds(self, session_id: str, rounds: int) -> bool:
|
|
888
|
+
"""给**正在跑**的会话加轮数(6.46,DeepTalk design/170):它在下一轮开始时把轮数上限加上 ``rounds``。
|
|
889
|
+
会话没在跑(锁没被占)返回 False——空闲的会话用 follow_up / send 的 max_rounds。"""
|
|
890
|
+
entry = _SESSION_SYNC.get(session_id)
|
|
891
|
+
if entry is None or not entry.lock.locked() or int(rounds) <= 0:
|
|
892
|
+
return False
|
|
893
|
+
entry.round_bonus += int(rounds)
|
|
894
|
+
return True
|
|
895
|
+
|
|
874
896
|
def steer_event(self, session_id: str) -> asyncio.Event:
|
|
875
897
|
"""The session's "steering is waiting" event (set while a ``mode="steer"`` item this
|
|
876
898
|
process accepted is undelivered). Interrupt points and waiting tools await it."""
|
|
@@ -1600,6 +1600,10 @@ class AgentPipeline:
|
|
|
1600
1600
|
round_idx = -1
|
|
1601
1601
|
while True:
|
|
1602
1602
|
round_idx += 1
|
|
1603
|
+
from power_loop.agent.stateful_loop import take_round_bonus
|
|
1604
|
+
_bonus = take_round_bonus(self.session_id)
|
|
1605
|
+
if _bonus:
|
|
1606
|
+
self._round_limit += _bonus # 宿主在运行中加了轮数(extend_rounds)
|
|
1603
1607
|
if round_idx >= self._round_limit:
|
|
1604
1608
|
# ── Hook: COMPLETE_DECIDE (round budget exhausted) ──
|
|
1605
1609
|
# A SHORT_CIRCUIT with inject extends the budget and keeps
|
|
@@ -238,6 +238,10 @@ def _publish_subagent_event(
|
|
|
238
238
|
_USAGE_SINK: ContextVar[dict[str, int] | None] = ContextVar("power_loop_usage_sink", default=None)
|
|
239
239
|
|
|
240
240
|
|
|
241
|
+
#: 子会话建好时的回调(6.46):``cb(child_session_id)``,可以是协程函数。workflow 引擎用它记「节点 → 叶子会话」。
|
|
242
|
+
_ON_CHILD_SESSION: ContextVar[Any] = ContextVar("power_loop_on_child_session", default=None)
|
|
243
|
+
|
|
244
|
+
|
|
241
245
|
def _add_usage(into: dict[str, int], usage: dict[str, Any] | None) -> None:
|
|
242
246
|
for k, v in (usage or {}).items():
|
|
243
247
|
if isinstance(v, (int, float)) and not isinstance(v, bool):
|
|
@@ -369,6 +373,16 @@ async def run_agent_spec(
|
|
|
369
373
|
# name, not the raw session UUID (subagent-coordination-3): blackboard._author reads metadata.
|
|
370
374
|
metadata={**dict(spec.metadata), "spec_name": spec.name},
|
|
371
375
|
)
|
|
376
|
+
# 子会话建好就告诉设了回调的那一层(6.46:workflow 引擎把「节点 → 叶子会话」记进 journal,重启后 resume 接着原会话跑)。
|
|
377
|
+
# 只告诉最外层:这次子运行期间把回调清掉,叶子自己再派的子 agent 不会被当成叶子会话。
|
|
378
|
+
_session_cb = _ON_CHILD_SESSION.get()
|
|
379
|
+
if _session_cb is not None:
|
|
380
|
+
try:
|
|
381
|
+
_r = _session_cb(child_sid)
|
|
382
|
+
if hasattr(_r, "__await__"):
|
|
383
|
+
await _r
|
|
384
|
+
except Exception: # noqa: BLE001 — 观测回调失败不毁子运行
|
|
385
|
+
pass
|
|
372
386
|
|
|
373
387
|
child_row = await store.get_session(child_sid)
|
|
374
388
|
depth = child_row.spawn_depth if child_row is not None else 1
|
|
@@ -424,11 +438,13 @@ async def run_agent_spec(
|
|
|
424
438
|
_outer_sink = _USAGE_SINK.get()
|
|
425
439
|
_my_sink: dict[str, int] = {}
|
|
426
440
|
_sink_tok = _USAGE_SINK.set(_my_sink)
|
|
441
|
+
_cb_tok = _ON_CHILD_SESSION.set(None)
|
|
427
442
|
try:
|
|
428
443
|
result = await child_loop.send(
|
|
429
444
|
user_input, session_id=child_sid, stop_event=stop_event
|
|
430
445
|
)
|
|
431
446
|
finally:
|
|
447
|
+
_ON_CHILD_SESSION.reset(_cb_tok)
|
|
432
448
|
_USAGE_SINK.reset(_sink_tok)
|
|
433
449
|
_total_usage: dict[str, int] = {}
|
|
434
450
|
_add_usage(_total_usage, dict(result.usage or {}))
|
|
@@ -65,6 +65,7 @@ from .spec import (
|
|
|
65
65
|
BranchNode,
|
|
66
66
|
ContinuationPolicy,
|
|
67
67
|
ForeachNode,
|
|
68
|
+
LoopNode,
|
|
68
69
|
ParallelNode,
|
|
69
70
|
RetryPolicy,
|
|
70
71
|
SequenceNode,
|
|
@@ -100,6 +101,7 @@ __all__ = [
|
|
|
100
101
|
"SequenceNode",
|
|
101
102
|
"ParallelNode",
|
|
102
103
|
"ForeachNode",
|
|
104
|
+
"LoopNode",
|
|
103
105
|
"BranchNode",
|
|
104
106
|
# engine
|
|
105
107
|
"WorkflowEngine",
|
|
@@ -38,7 +38,7 @@ from typing import Any, Protocol
|
|
|
38
38
|
|
|
39
39
|
from power_loop.core.agent_context import reset_session_id, set_session_id
|
|
40
40
|
from power_loop.runtime.cancellation import CancellationLike, CancellationToken
|
|
41
|
-
from power_loop.runtime.spec import AgentSpec, run_agent_spec
|
|
41
|
+
from power_loop.runtime.spec import _ON_CHILD_SESSION, AgentSpec, run_agent_spec
|
|
42
42
|
from power_loop.runtime.structured import StructuredOutputError, parse_structured
|
|
43
43
|
|
|
44
44
|
from .result import AgentResult, SharedBudget, WorkflowResult
|
|
@@ -48,12 +48,16 @@ from .spec import (
|
|
|
48
48
|
AgentNode,
|
|
49
49
|
BranchNode,
|
|
50
50
|
ForeachNode,
|
|
51
|
+
LoopNode,
|
|
51
52
|
ParallelNode,
|
|
52
53
|
SequenceNode,
|
|
53
54
|
WorkflowNode,
|
|
54
55
|
WorkflowSpec,
|
|
55
56
|
)
|
|
56
57
|
|
|
58
|
+
#: 循环信息在 env 里的键(模板变量正则匹配不到它,不会被当成 {{变量}})
|
|
59
|
+
_LOOP_KEY = "\x00loop"
|
|
60
|
+
|
|
57
61
|
__all__ = ["Executor", "InProcessExecutor", "WorkflowEngine", "WorkflowRunError", "in_workflow"]
|
|
58
62
|
|
|
59
63
|
logger = logging.getLogger(__name__)
|
|
@@ -65,6 +69,34 @@ MAX_TOTAL_LEAVES = 10_000
|
|
|
65
69
|
|
|
66
70
|
#: ``{{var}}``(foreach 变量等)和 ``{{node.key}}``(上游节点结构化输出里的一个键,6.45:之前不认带点的名字,
|
|
67
71
|
#: YAML 的 ``${{ needs.x.outputs.k }}`` 编成 ``{{x.k}}`` 后原样送给了模型)。节点 id 可以带短横线。
|
|
72
|
+
def _agent_ids(node: WorkflowNode) -> list[str]:
|
|
73
|
+
out: list[str] = []
|
|
74
|
+
if isinstance(node, AgentNode):
|
|
75
|
+
return [node.id]
|
|
76
|
+
for child in (getattr(node, "steps", None) or getattr(node, "branches", None) or ()):
|
|
77
|
+
out += _agent_ids(child)
|
|
78
|
+
if isinstance(node, BranchNode):
|
|
79
|
+
for c in node.cases.values():
|
|
80
|
+
out += _agent_ids(c)
|
|
81
|
+
if node.default is not None:
|
|
82
|
+
out += _agent_ids(node.default)
|
|
83
|
+
if isinstance(node, (ForeachNode, LoopNode)):
|
|
84
|
+
out += _agent_ids(node.body)
|
|
85
|
+
return out
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _loop_prompt(lc: dict[str, Any], user_input: str) -> str:
|
|
89
|
+
"""循环第 2 轮起给 continue_nodes 的话:上一轮为什么没过、意见是什么、在已有成果上改。"""
|
|
90
|
+
return (f"第 {lc['iteration']}/{lc['max']} 轮:上一轮没通过({lc.get('why') or ''})。\n"
|
|
91
|
+
f"上一轮的意见:\n{lc.get('feedback') or ''}\n\n"
|
|
92
|
+
"按意见改。你上一轮做的都还在(会话、文件),在已有成果上改,不要从头重做。改完照原来的要求交卷。\n\n"
|
|
93
|
+
f"(这一轮的任务输入照旧附在下面)\n{user_input}")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
#: 重启后在原会话接着跑时给叶子的话(6.46)
|
|
97
|
+
_RESUME_PROMPT = ("进程重启打断了你,你的会话、文件和半成品都还在。先看一眼自己做到哪了(todo、产出文件),"
|
|
98
|
+
"接着把原来的任务做完,然后照原来的要求给出最终回复。")
|
|
99
|
+
|
|
68
100
|
_VAR_RE = re.compile(r"\{\{\s*([a-zA-Z_][\w-]*(?:\.[a-zA-Z_]\w*)?)\s*\}\}")
|
|
69
101
|
|
|
70
102
|
# Set while a workflow run is in flight. Propagates into child sub-agent tasks
|
|
@@ -249,6 +281,8 @@ class WorkflowEngine:
|
|
|
249
281
|
close_driver: bool = True,
|
|
250
282
|
allowed_tools: frozenset[str] | None = None,
|
|
251
283
|
file_io: WorkflowFileIO | None = None,
|
|
284
|
+
on_node_session: Callable[[str, str], Awaitable[None]] | None = None,
|
|
285
|
+
resume_sessions: dict[str, str] | None = None,
|
|
252
286
|
) -> None:
|
|
253
287
|
self._loop = parent_loop
|
|
254
288
|
self._executor = executor or InProcessExecutor()
|
|
@@ -275,7 +309,17 @@ class WorkflowEngine:
|
|
|
275
309
|
# Cooperative cancellation: forwarded into every in-flight leaf so they
|
|
276
310
|
# stop at their next checkpoint, and checked before spawning new leaves
|
|
277
311
|
# so a cancelled run stops fanning out.
|
|
278
|
-
|
|
312
|
+
# 6.46:引擎自己也持有一个令牌(run 超时用它停整个 run),和外面传进来的「谁取消都算」。
|
|
313
|
+
self._own_cancel = CancellationToken()
|
|
314
|
+
self._cancel: CancellationToken = CancellationToken.any_of(
|
|
315
|
+
CancellationToken.from_any(stop_event), self._own_cancel)
|
|
316
|
+
self._timed_out = False
|
|
317
|
+
# 单节点取消(6.46):每个节点一个子令牌——取消它只停这个节点,run 和别的节点照跑。
|
|
318
|
+
self._node_tokens: dict[str, list[CancellationToken]] = {}
|
|
319
|
+
self._node_cancelled: set[str] = set()
|
|
320
|
+
# 叶子会话建好时的观察者(runner 把「节点 → 会话」记进 journal);resume 时有会话的节点在原会话接着跑。
|
|
321
|
+
self._on_node_session = on_node_session
|
|
322
|
+
self._resume_sessions: dict[str, str] = dict(resume_sessions or {})
|
|
279
323
|
# Resume support: results of already-completed nodes, keyed by node id,
|
|
280
324
|
# rehydrated from a run's journal. A node whose id is here is replayed
|
|
281
325
|
# (its cached result is returned) instead of re-executed. Foreach-body
|
|
@@ -325,6 +369,13 @@ class WorkflowEngine:
|
|
|
325
369
|
env: dict[str, Any] = {"input": spec.input}
|
|
326
370
|
status = "completed"
|
|
327
371
|
guard = _IN_WORKFLOW.set(True)
|
|
372
|
+
watchdog: asyncio.Task[None] | None = None
|
|
373
|
+
if spec.timeout_s:
|
|
374
|
+
async def _watchdog(limit: float = float(spec.timeout_s)) -> None:
|
|
375
|
+
await asyncio.sleep(limit)
|
|
376
|
+
self._timed_out = True
|
|
377
|
+
self._own_cancel.cancel(f"run timeout ({int(limit)}s)")
|
|
378
|
+
watchdog = asyncio.create_task(_watchdog(), name="workflow-timeout")
|
|
328
379
|
try:
|
|
329
380
|
await self._exec(spec.root, env, driver_sid)
|
|
330
381
|
except WorkflowRunError as exc:
|
|
@@ -332,7 +383,12 @@ class WorkflowEngine:
|
|
|
332
383
|
self._errors.append(str(exc))
|
|
333
384
|
finally:
|
|
334
385
|
_IN_WORKFLOW.reset(guard)
|
|
335
|
-
|
|
386
|
+
if watchdog is not None:
|
|
387
|
+
watchdog.cancel()
|
|
388
|
+
if self._timed_out:
|
|
389
|
+
status = "failed"
|
|
390
|
+
self._errors.append(f"run 超时({int(spec.timeout_s or 0)} 秒):没跑完的节点已停,没开始的不再启动")
|
|
391
|
+
elif self._cancelled or self._cancel.is_cancelled():
|
|
336
392
|
status = "cancelled"
|
|
337
393
|
elif self._budget_hit:
|
|
338
394
|
status = "budget_exceeded"
|
|
@@ -345,7 +401,7 @@ class WorkflowEngine:
|
|
|
345
401
|
# (只认 "failed" 的老判定漏掉它;continuation 救回来的已经是 completed 了)。
|
|
346
402
|
broken = [
|
|
347
403
|
nid for nid, r in self._results.items()
|
|
348
|
-
if r.status not in ("completed",) and nid not in self._tolerated
|
|
404
|
+
if r.status not in ("completed", "skipped") and nid not in self._tolerated
|
|
349
405
|
]
|
|
350
406
|
if broken:
|
|
351
407
|
status = "failed"
|
|
@@ -397,8 +453,63 @@ class WorkflowEngine:
|
|
|
397
453
|
return await self._exec_foreach(node, env, driver_sid)
|
|
398
454
|
if isinstance(node, BranchNode):
|
|
399
455
|
return await self._exec_branch(node, env, driver_sid)
|
|
456
|
+
if isinstance(node, LoopNode):
|
|
457
|
+
return await self._exec_loop(node, env, driver_sid)
|
|
400
458
|
raise WorkflowRunError(f"unknown node type: {type(node).__name__}")
|
|
401
459
|
|
|
460
|
+
async def _exec_loop(self, node: LoopNode, env: dict[str, Any], driver_sid: str) -> AgentResult:
|
|
461
|
+
"""反复跑循环体直到 ``until`` 成立(6.46,design/170 第三批)。循环信息随 env 往下传(几个循环可以并行跑)。"""
|
|
462
|
+
replayed = self._replay.pop(node.id, None)
|
|
463
|
+
if replayed is not None:
|
|
464
|
+
# 整体重放:把最后一轮各节点的结果还原,循环之后对它们的引用照样能用
|
|
465
|
+
for nid, r in ((replayed.payload or {}).get("last") or {}).items():
|
|
466
|
+
self._results[nid] = AgentResult(node_id=nid, status=str(r.get("status") or "completed"),
|
|
467
|
+
text=str(r.get("text") or ""), payload=r.get("payload"),
|
|
468
|
+
session_id=r.get("session_id"))
|
|
469
|
+
self._results[node.id] = replayed
|
|
470
|
+
self._add_usage(replayed.usage)
|
|
471
|
+
return replayed
|
|
472
|
+
body_ids = _agent_ids(node.body)
|
|
473
|
+
feedback_node = str(node.until.get("ref") or "").split(".", 1)[0]
|
|
474
|
+
prev: dict[str, str] = {}
|
|
475
|
+
converged, why, iteration = False, "", 0
|
|
476
|
+
for iteration in range(1, node.max_iterations + 1):
|
|
477
|
+
if self._cancel.is_cancelled():
|
|
478
|
+
break
|
|
479
|
+
lc = {"loop_id": node.id, "iteration": iteration, "max": node.max_iterations,
|
|
480
|
+
"continue": set(node.continue_nodes), "prev": dict(prev), "why": why,
|
|
481
|
+
"feedback": self._loop_feedback(feedback_node) if iteration > 1 else ""}
|
|
482
|
+
await self._exec(node.body, {**env, _LOOP_KEY: lc, "loop_iteration": iteration}, driver_sid)
|
|
483
|
+
for nid in node.continue_nodes:
|
|
484
|
+
r = self._results.get(nid)
|
|
485
|
+
if r is not None and r.session_id:
|
|
486
|
+
prev[nid] = r.session_id
|
|
487
|
+
converged, why = self._eval_when(node.until)
|
|
488
|
+
if converged:
|
|
489
|
+
break
|
|
490
|
+
logger.info("workflow loop %s: iteration %d/%d not done (%s)", node.id, iteration, node.max_iterations, why)
|
|
491
|
+
last = {nid: {"status": r.status, "text": (r.text or "")[:4000], "payload": r.payload, "session_id": r.session_id}
|
|
492
|
+
for nid in body_ids if (r := self._results.get(nid)) is not None}
|
|
493
|
+
payload = {"converged": converged, "iterations": iteration, "why": why, "last": last}
|
|
494
|
+
ok = converged or (node.on_exhausted == "continue" and not self._cancel.is_cancelled())
|
|
495
|
+
err = None if converged else (f"跑了 {iteration} 轮仍不满足 until:{why}" if not self._cancel.is_cancelled()
|
|
496
|
+
else "循环被取消")
|
|
497
|
+
res = AgentResult(node_id=node.id, status="completed" if ok else ("cancelled" if self._cancel.is_cancelled() else "failed"),
|
|
498
|
+
text=f"循环 {node.id}:{'满足' if converged else '没满足'}({iteration} 轮):{why}",
|
|
499
|
+
payload=payload, error=err)
|
|
500
|
+
self._results[node.id] = res
|
|
501
|
+
await self._emit_step(res)
|
|
502
|
+
return res
|
|
503
|
+
|
|
504
|
+
def _loop_feedback(self, node_id: str) -> str:
|
|
505
|
+
"""上一轮 until 引用的那个节点交回的意见(结构化结果优先)。"""
|
|
506
|
+
r = self._results.get(node_id)
|
|
507
|
+
if r is None:
|
|
508
|
+
return "(上一轮没有意见)"
|
|
509
|
+
if r.payload is not None:
|
|
510
|
+
return json.dumps(r.payload, ensure_ascii=False)[:4000]
|
|
511
|
+
return (r.text or r.error or "")[:4000]
|
|
512
|
+
|
|
402
513
|
async def _exec_agent(self, node: AgentNode, env: dict[str, Any], driver_sid: str) -> AgentResult:
|
|
403
514
|
# Resume: a completed node is replayed from the journal, not re-run. Pop
|
|
404
515
|
# so a body id that somehow leaked in can only ever replay once.
|
|
@@ -417,6 +528,14 @@ class WorkflowEngine:
|
|
|
417
528
|
await self._emit_step(res)
|
|
418
529
|
return res
|
|
419
530
|
|
|
531
|
+
if node.when is not None:
|
|
532
|
+
ok, why = self._eval_when(node.when)
|
|
533
|
+
if not ok:
|
|
534
|
+
res = AgentResult(node_id=node.id, status="skipped", text="", error=f"条件不成立,跳过:{why}")
|
|
535
|
+
self._results[node.id] = res
|
|
536
|
+
await self._emit_step(res)
|
|
537
|
+
return res
|
|
538
|
+
|
|
420
539
|
if self._budget is not None and not self._budget.can_spawn():
|
|
421
540
|
self._budget_hit = True
|
|
422
541
|
res = AgentResult(node_id=node.id, status="budget_exceeded", text="",
|
|
@@ -441,7 +560,9 @@ class WorkflowEngine:
|
|
|
441
560
|
up = self._results.get(ref)
|
|
442
561
|
if up is None:
|
|
443
562
|
continue
|
|
444
|
-
if up.status
|
|
563
|
+
if up.status == "skipped":
|
|
564
|
+
extras.append(f"[上游节点 '{ref}' 条件不成立,跳过了:{up.error or ''}]")
|
|
565
|
+
elif up.status != "completed":
|
|
445
566
|
# 🔴 上游失败时**绝不能**给空字符串:下游模型会把「没说话」读成「没意见」,
|
|
446
567
|
# 进而在缺证据的情况下下结论。给一个显式的失败说明。
|
|
447
568
|
extras.append(
|
|
@@ -502,6 +623,34 @@ class WorkflowEngine:
|
|
|
502
623
|
except Exception: # noqa: BLE001 — observability must not fail the run
|
|
503
624
|
pass
|
|
504
625
|
|
|
626
|
+
node_tok = self._cancel.child()
|
|
627
|
+
self._node_tokens.setdefault(node.id, []).append(node_tok)
|
|
628
|
+
# 重启后接着跑(6.46):这个节点上次有叶子会话、没做完 → 先在原会话补轮;补不了再照常开新会话。
|
|
629
|
+
resumed_raw: dict[str, Any] | None = None
|
|
630
|
+
rs = self._resume_sessions.pop(node.id, None) if not self._emit_suppressed else None
|
|
631
|
+
if rs:
|
|
632
|
+
try:
|
|
633
|
+
resumed_raw = await self._continue_leaf(
|
|
634
|
+
rs, _RESUME_PROMPT, extra_rounds=max(int(node.spec.max_rounds or 30), 10), stop=node_tok)
|
|
635
|
+
resumed_raw.setdefault("session_id", rs)
|
|
636
|
+
logger.info("workflow leaf %s resumed in its original session %s", node.id, rs)
|
|
637
|
+
except Exception as exc: # noqa: BLE001 — 会话丢了就照常重跑
|
|
638
|
+
logger.warning("workflow leaf %s: resume in session %s failed (%s); re-running", node.id, rs, exc)
|
|
639
|
+
resumed_raw = None
|
|
640
|
+
|
|
641
|
+
lc = env.get(_LOOP_KEY)
|
|
642
|
+
if (resumed_raw is None and lc and lc["iteration"] > 1 and node.id in lc["continue"]
|
|
643
|
+
and lc["prev"].get(node.id)):
|
|
644
|
+
prev_sid = lc["prev"][node.id]
|
|
645
|
+
try:
|
|
646
|
+
resumed_raw = await self._continue_leaf(
|
|
647
|
+
prev_sid, _loop_prompt(lc, user_input), extra_rounds=max(int(node.spec.max_rounds or 30), 10), stop=node_tok)
|
|
648
|
+
resumed_raw.setdefault("session_id", prev_sid)
|
|
649
|
+
except Exception as exc: # noqa: BLE001 — 接不上就照常新开会话
|
|
650
|
+
logger.warning("workflow loop %s: continuing %s in %s failed (%s); fresh run",
|
|
651
|
+
lc["loop_id"], node.id, prev_sid, exc)
|
|
652
|
+
resumed_raw = None
|
|
653
|
+
|
|
505
654
|
# ── 重试循环(design/64 §1)──────────────────────────────────────────────
|
|
506
655
|
# 每次 attempt 都起**新的叶子会话**(失败会话的上下文可能已被半截的工具调用污染)。
|
|
507
656
|
# idempotency_key 跨 attempt 不变(变了工具就没法去重),attempt 号单独进 metadata。
|
|
@@ -522,15 +671,24 @@ class WorkflowEngine:
|
|
|
522
671
|
# 第 N 次重试等 backoff_s * factor**(N-1);factor=1 就是固定间隔。
|
|
523
672
|
delay = policy.backoff_s * (policy.backoff_factor ** (attempt - 2))
|
|
524
673
|
await asyncio.sleep(min(delay, MAX_BACKOFF_S))
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
674
|
+
if attempt == 1 and resumed_raw is not None:
|
|
675
|
+
raw = resumed_raw
|
|
676
|
+
else:
|
|
677
|
+
cb_tok = (_ON_CHILD_SESSION.set(self._session_cb(node.id))
|
|
678
|
+
if self._on_node_session is not None and not self._emit_suppressed else None)
|
|
679
|
+
try:
|
|
680
|
+
raw = await self._executor.run_agent(
|
|
681
|
+
replace(spec, metadata={**spec.metadata, "attempt": attempt}),
|
|
682
|
+
user_input, parent_loop=self._loop, driver_sid=driver_sid,
|
|
683
|
+
stop_event=node_tok,
|
|
684
|
+
)
|
|
685
|
+
finally:
|
|
686
|
+
if cb_tok is not None:
|
|
687
|
+
_ON_CHILD_SESSION.reset(cb_tok)
|
|
530
688
|
if attempt >= max_attempts or not self._should_retry(raw, triggers):
|
|
531
689
|
break
|
|
532
690
|
# 取消/预算耗尽时不再重试:那不是「这次没跑好」,是整个 run 该停了。
|
|
533
|
-
if
|
|
691
|
+
if node_tok.is_cancelled() or (
|
|
534
692
|
self._budget is not None and not self._budget.can_spawn()
|
|
535
693
|
):
|
|
536
694
|
break
|
|
@@ -546,7 +704,7 @@ class WorkflowEngine:
|
|
|
546
704
|
for c_i in range(1, cont.max_continuations + 1):
|
|
547
705
|
if str(raw.get("status")) not in ("hit_round_limit", "context_checkpoint") or not raw.get("session_id"):
|
|
548
706
|
break
|
|
549
|
-
if
|
|
707
|
+
if node_tok.is_cancelled() or (
|
|
550
708
|
self._budget is not None and not self._budget.can_spawn()
|
|
551
709
|
):
|
|
552
710
|
break
|
|
@@ -558,7 +716,7 @@ class WorkflowEngine:
|
|
|
558
716
|
node.id, c_i, cont.max_continuations, cont.extra_rounds)
|
|
559
717
|
try:
|
|
560
718
|
more = await self._continue_leaf(
|
|
561
|
-
str(raw["session_id"]), prompt, extra_rounds=cont.extra_rounds)
|
|
719
|
+
str(raw["session_id"]), prompt, extra_rounds=cont.extra_rounds, stop=node_tok)
|
|
562
720
|
except Exception as exc: # noqa: BLE001 — 续跑失败不毁掉已有成果
|
|
563
721
|
logger.warning("workflow leaf %s continuation failed: %s", node.id, exc)
|
|
564
722
|
break
|
|
@@ -577,7 +735,12 @@ class WorkflowEngine:
|
|
|
577
735
|
self._budget.commit(more.get("usage") or {})
|
|
578
736
|
|
|
579
737
|
if str(raw.get("status")) == "cancelled":
|
|
580
|
-
|
|
738
|
+
if node_tok.is_cancelled() and not self._cancel.is_cancelled():
|
|
739
|
+
# 只取消了这一个节点(cancel_node):run 照跑,这个节点按「放行的失败」记
|
|
740
|
+
self._node_cancelled.add(node.id)
|
|
741
|
+
raw = {**raw, "error": raw.get("error") or f"节点被单独取消:{node_tok.reason}"}
|
|
742
|
+
else:
|
|
743
|
+
self._cancelled = True
|
|
581
744
|
payload: dict[str, Any] | None = None
|
|
582
745
|
err: str | None = None
|
|
583
746
|
if node.output_schema is not None and raw.get("status") == "completed":
|
|
@@ -624,11 +787,59 @@ class WorkflowEngine:
|
|
|
624
787
|
await self._emit_step(res)
|
|
625
788
|
return res
|
|
626
789
|
|
|
627
|
-
# continue_on_error:失败但明确放行——记下来,run
|
|
628
|
-
if res.status == "failed" and node.continue_on_error:
|
|
790
|
+
# continue_on_error:失败但明确放行——记下来,run 终态判定跳过它。被单独取消的节点同样放行。
|
|
791
|
+
if (res.status == "failed" and node.continue_on_error) or node.id in self._node_cancelled:
|
|
629
792
|
self._tolerated.add(node.id)
|
|
630
793
|
return res
|
|
631
794
|
|
|
795
|
+
def _session_cb(self, node_id: str) -> Callable[[str], Awaitable[None]]:
|
|
796
|
+
async def _cb(sid: str) -> None:
|
|
797
|
+
if self._on_node_session is not None:
|
|
798
|
+
try:
|
|
799
|
+
await self._on_node_session(node_id, sid)
|
|
800
|
+
except Exception: # noqa: BLE001 — 观测失败不毁叶子
|
|
801
|
+
logger.debug("on_node_session failed for %s", node_id, exc_info=True)
|
|
802
|
+
return _cb
|
|
803
|
+
|
|
804
|
+
def cancel_node(self, node_id: str, reason: str = "cancelled by host") -> int:
|
|
805
|
+
"""只取消一个节点(6.46):它在跑就在下一个检查点停下,run 和别的节点照跑;被取消的节点按「放行的失败」记。
|
|
806
|
+
返回取消了几个在跑的叶子(foreach 的迭代共用一个 id,会一起停)。"""
|
|
807
|
+
n = 0
|
|
808
|
+
for tok in self._node_tokens.get(node_id) or []:
|
|
809
|
+
if not tok.is_cancelled():
|
|
810
|
+
tok.cancel(reason)
|
|
811
|
+
n += 1
|
|
812
|
+
return n
|
|
813
|
+
|
|
814
|
+
def _eval_when(self, when: dict[str, Any]) -> tuple[bool, str]:
|
|
815
|
+
"""``when`` 成立吗?引用取不到(上游失败 / 跳过 / 没交结构化结果)按不成立。返回 (成立, 说明)。"""
|
|
816
|
+
ref = str(when.get("ref") or "")
|
|
817
|
+
op = str(when.get("op") or "truthy")
|
|
818
|
+
try:
|
|
819
|
+
value = self._resolve_ref(ref)
|
|
820
|
+
except WorkflowRunError as exc:
|
|
821
|
+
return False, f"取不到 {ref}({exc})"
|
|
822
|
+
|
|
823
|
+
def norm(x: Any) -> Any:
|
|
824
|
+
if isinstance(x, str) and x.strip().lower() in ("true", "false"):
|
|
825
|
+
return x.strip().lower() == "true"
|
|
826
|
+
return x
|
|
827
|
+
|
|
828
|
+
v = norm(value)
|
|
829
|
+
target = when.get("value")
|
|
830
|
+
if op == "truthy":
|
|
831
|
+
ok = bool(v)
|
|
832
|
+
elif op == "falsy":
|
|
833
|
+
ok = not bool(v)
|
|
834
|
+
elif op == "eq":
|
|
835
|
+
ok = v == norm(target) or str(value) == str(target)
|
|
836
|
+
elif op == "ne":
|
|
837
|
+
ok = not (v == norm(target) or str(value) == str(target))
|
|
838
|
+
else: # in
|
|
839
|
+
ok = any(v == norm(x) or str(value) == str(x) for x in (target or []))
|
|
840
|
+
shown = json.dumps(value, ensure_ascii=False)[:80] if not isinstance(value, str) else value[:80]
|
|
841
|
+
return ok, f"{ref} = {shown}(要求 {op}{'' if op in ('truthy', 'falsy') else ' ' + json.dumps(target, ensure_ascii=False)[:60]})"
|
|
842
|
+
|
|
632
843
|
def _render_fileref(self, path: str, slice_expr: str) -> str:
|
|
633
844
|
if self._file_io is None:
|
|
634
845
|
return ""
|
|
@@ -651,14 +862,15 @@ class WorkflowEngine:
|
|
|
651
862
|
except Exception: # noqa: BLE001
|
|
652
863
|
return []
|
|
653
864
|
|
|
654
|
-
async def _continue_leaf(self, session_id: str, prompt: str, *, extra_rounds: int
|
|
865
|
+
async def _continue_leaf(self, session_id: str, prompt: str, *, extra_rounds: int,
|
|
866
|
+
stop: CancellationToken | None = None) -> dict[str, Any]:
|
|
655
867
|
"""原会话补轮。走 executor 的 ``continue_agent``(host 可换实现,如加自己的 guards);
|
|
656
868
|
没有该方法的旧 executor 退回 InProcessExecutor 的默认路径。"""
|
|
657
869
|
fn = getattr(self._executor, "continue_agent", None)
|
|
658
870
|
if fn is None:
|
|
659
871
|
fn = InProcessExecutor().continue_agent
|
|
660
872
|
return await fn(session_id, prompt, parent_loop=self._loop,
|
|
661
|
-
extra_rounds=extra_rounds, stop_event=self._cancel)
|
|
873
|
+
extra_rounds=extra_rounds, stop_event=stop or self._cancel)
|
|
662
874
|
|
|
663
875
|
@staticmethod
|
|
664
876
|
def _should_retry(raw: dict[str, Any], triggers: set[str]) -> bool:
|
|
@@ -189,6 +189,23 @@ async def node_start(
|
|
|
189
189
|
await store.mutate_runtime_state(parent_sid, run_key(run_id), _apply, default=None)
|
|
190
190
|
|
|
191
191
|
|
|
192
|
+
async def node_session(
|
|
193
|
+
store: SessionStore, parent_sid: str, run_id: str, *, node_id: str, session_id: str
|
|
194
|
+
) -> None:
|
|
195
|
+
"""记下节点的叶子会话(``live_sessions[node_id] = session_id``,6.46)。进程重启后 resume 对没做完、
|
|
196
|
+
有会话的节点在原会话接着跑,不再从头开新会话(叶子的上下文和半成品都在)。"""
|
|
197
|
+
def _apply(jn: Any) -> Any:
|
|
198
|
+
if jn is None or _is_terminal(jn.get("status")):
|
|
199
|
+
return MUTATE_SKIP
|
|
200
|
+
ls = dict(jn.get("live_sessions") or {})
|
|
201
|
+
ls[node_id] = session_id
|
|
202
|
+
jn["live_sessions"] = ls
|
|
203
|
+
jn["updated_at_ms"] = _now_ms()
|
|
204
|
+
return jn
|
|
205
|
+
|
|
206
|
+
await store.mutate_runtime_state(parent_sid, run_key(run_id), _apply, default=None)
|
|
207
|
+
|
|
208
|
+
|
|
192
209
|
async def record_step(
|
|
193
210
|
store: SessionStore,
|
|
194
211
|
parent_sid: str,
|
|
@@ -42,6 +42,7 @@ from .runner import (
|
|
|
42
42
|
_LIVE_RUN_IDS,
|
|
43
43
|
OnComplete,
|
|
44
44
|
is_run_live,
|
|
45
|
+
make_on_node_session,
|
|
45
46
|
make_on_node_start,
|
|
46
47
|
make_on_step,
|
|
47
48
|
spawn_background,
|
|
@@ -50,6 +51,7 @@ from .spec import (
|
|
|
50
51
|
AgentNode,
|
|
51
52
|
BranchNode,
|
|
52
53
|
ForeachNode,
|
|
54
|
+
LoopNode,
|
|
53
55
|
ParallelNode,
|
|
54
56
|
SequenceNode,
|
|
55
57
|
WorkflowNode,
|
|
@@ -97,6 +99,12 @@ def _collect(node: WorkflowNode, *, in_foreach_body: bool, out: set[str]) -> Non
|
|
|
97
99
|
out.add(node.id) # the aggregate is atomically replayable
|
|
98
100
|
_collect(node.body, in_foreach_body=True, out=out) # body ids are not
|
|
99
101
|
return
|
|
102
|
+
if isinstance(node, LoopNode):
|
|
103
|
+
# 循环整体重放(聚合里带着最后一轮的结果);循环体里的节点不单独重放——没跑完的循环 resume 时从头再跑(6.46)
|
|
104
|
+
if not in_foreach_body:
|
|
105
|
+
out.add(node.id)
|
|
106
|
+
_collect(node.body, in_foreach_body=True, out=out)
|
|
107
|
+
return
|
|
100
108
|
if isinstance(node, BranchNode):
|
|
101
109
|
for c in node.cases.values():
|
|
102
110
|
_collect(c, in_foreach_body=in_foreach_body, out=out)
|
|
@@ -143,6 +151,31 @@ async def rehydrate(
|
|
|
143
151
|
return spec, replay, j
|
|
144
152
|
|
|
145
153
|
|
|
154
|
+
def _session_resumable_ids(node: WorkflowNode, *, in_foreach: bool, out: set[str]) -> None:
|
|
155
|
+
"""能在原会话接着跑的节点:foreach 体以外的 agent(循环体里的也算——循环重跑时第一轮接着原会话)。"""
|
|
156
|
+
if isinstance(node, AgentNode):
|
|
157
|
+
if not in_foreach:
|
|
158
|
+
out.add(node.id)
|
|
159
|
+
return
|
|
160
|
+
for child in (getattr(node, "steps", None) or getattr(node, "branches", None) or ()):
|
|
161
|
+
_session_resumable_ids(child, in_foreach=in_foreach, out=out)
|
|
162
|
+
if isinstance(node, BranchNode):
|
|
163
|
+
for c in node.cases.values():
|
|
164
|
+
_session_resumable_ids(c, in_foreach=in_foreach, out=out)
|
|
165
|
+
if node.default is not None:
|
|
166
|
+
_session_resumable_ids(node.default, in_foreach=in_foreach, out=out)
|
|
167
|
+
if isinstance(node, (ForeachNode, LoopNode)):
|
|
168
|
+
_session_resumable_ids(node.body, in_foreach=in_foreach or isinstance(node, ForeachNode), out=out)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def resume_sessions(spec: WorkflowSpec, replay: dict[str, AgentResult], j: dict[str, Any]) -> dict[str, str]:
|
|
172
|
+
"""没做完、但记下了叶子会话的节点(6.46):resume 时在原会话接着跑,不从头开。foreach 迭代不在内(共用 id)。"""
|
|
173
|
+
ok: set[str] = set()
|
|
174
|
+
_session_resumable_ids(spec.root, in_foreach=False, out=ok)
|
|
175
|
+
return {nid: str(sid) for nid, sid in (j.get("live_sessions") or {}).items()
|
|
176
|
+
if nid in ok and nid not in replay and sid}
|
|
177
|
+
|
|
178
|
+
|
|
146
179
|
def _journaled_clamp(j: dict[str, Any]) -> frozenset[str] | None:
|
|
147
180
|
"""The run's journaled capability clamp (see ``journal.new_journal``).
|
|
148
181
|
|
|
@@ -208,6 +241,8 @@ async def resume_run(
|
|
|
208
241
|
loop, executor=executor, budget=budget,
|
|
209
242
|
on_step=make_on_step(store, parent_sid, run_id),
|
|
210
243
|
on_node_start=make_on_node_start(store, parent_sid, run_id),
|
|
244
|
+
on_node_session=make_on_node_session(store, parent_sid, run_id),
|
|
245
|
+
resume_sessions=resume_sessions(spec, replay, j),
|
|
211
246
|
replay=replay, run_id=run_id,
|
|
212
247
|
allowed_tools=_journaled_clamp(j),
|
|
213
248
|
file_io=file_io,
|
|
@@ -259,6 +294,8 @@ async def resume_detached(
|
|
|
259
294
|
loop, executor=executor, budget=budget,
|
|
260
295
|
on_step=make_on_step(store, parent_sid, run_id),
|
|
261
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),
|
|
262
299
|
replay=replay, run_id=run_id, stop_event=cancel_token,
|
|
263
300
|
allowed_tools=_journaled_clamp(j),
|
|
264
301
|
file_io=file_io,
|