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.
Files changed (113) hide show
  1. {power_loop-6.45.0 → power_loop-6.46.0}/PKG-INFO +1 -1
  2. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/__init__.py +1 -1
  3. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/agent/stateful_loop.py +22 -0
  4. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/core/pipeline.py +4 -0
  5. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/spec.py +16 -0
  6. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/workflow/__init__.py +2 -0
  7. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/workflow/engine.py +230 -18
  8. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/workflow/journal.py +17 -0
  9. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/workflow/resume.py +37 -0
  10. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/workflow/runner.py +30 -2
  11. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/workflow/spec.py +159 -6
  12. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/workflow/tool.py +10 -2
  13. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop.egg-info/PKG-INFO +1 -1
  14. {power_loop-6.45.0 → power_loop-6.46.0}/LICENSE +0 -0
  15. {power_loop-6.45.0 → power_loop-6.46.0}/README.md +0 -0
  16. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/_vendor/__init__.py +0 -0
  17. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/_vendor/llm_client/__init__.py +0 -0
  18. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/_vendor/llm_client/anthropic_factory.py +0 -0
  19. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/_vendor/llm_client/capabilities.py +0 -0
  20. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/_vendor/llm_client/interface.py +0 -0
  21. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/_vendor/llm_client/llm_factory.py +0 -0
  22. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/_vendor/llm_client/llm_tooling.py +0 -0
  23. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/_vendor/llm_client/llm_utils.py +0 -0
  24. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/_vendor/llm_client/multimodal.py +0 -0
  25. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/_vendor/llm_client/proxy_fallback.py +0 -0
  26. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/agent/__init__.py +0 -0
  27. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/agent/follow_up.py +0 -0
  28. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/agent/sink.py +0 -0
  29. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/agent/system_prompt.py +0 -0
  30. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/agent/types.py +0 -0
  31. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/contracts/__init__.py +0 -0
  32. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/contracts/errors.py +0 -0
  33. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/contracts/event_payloads.py +0 -0
  34. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/contracts/events.py +0 -0
  35. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/contracts/handlers.py +0 -0
  36. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/contracts/hook_contexts.py +0 -0
  37. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/contracts/hooks.py +0 -0
  38. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/contracts/messages.py +0 -0
  39. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/contracts/protocols.py +0 -0
  40. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/contracts/tools.py +0 -0
  41. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/contrib/__init__.py +0 -0
  42. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/contrib/_redact.py +0 -0
  43. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/contrib/jsonl_sink.py +0 -0
  44. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/contrib/logging_sink.py +0 -0
  45. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/contrib/mcp.py +0 -0
  46. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/contrib/metrics_sink.py +0 -0
  47. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/contrib/otel_sink.py +0 -0
  48. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/core/agent_context.py +0 -0
  49. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/core/events.py +0 -0
  50. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/core/hooks.py +0 -0
  51. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/core/phase.py +0 -0
  52. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/core/runner.py +0 -0
  53. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/core/state.py +0 -0
  54. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/py.typed +0 -0
  55. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/blackboard.py +0 -0
  56. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/budget.py +0 -0
  57. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/cancellation.py +0 -0
  58. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/capability_probe.py +0 -0
  59. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/compact.py +0 -0
  60. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/env.py +0 -0
  61. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/exec_backend.py +0 -0
  62. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/fold.py +0 -0
  63. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/fold_adapter.py +0 -0
  64. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/history_projector.py +0 -0
  65. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/history_sanitize.py +0 -0
  66. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/human_input.py +0 -0
  67. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/image_recall.py +0 -0
  68. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/memory.py +0 -0
  69. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/notes.py +0 -0
  70. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/provider.py +0 -0
  71. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/representation.py +0 -0
  72. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/retry.py +0 -0
  73. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/runtime_state.py +0 -0
  74. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/session_store.py +0 -0
  75. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/skills.py +0 -0
  76. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/steer.py +0 -0
  77. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/stop_policy.py +0 -0
  78. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/store/__init__.py +0 -0
  79. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/store/backends/__init__.py +0 -0
  80. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/store/backends/mysql.py +0 -0
  81. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/store/backends/postgres.py +0 -0
  82. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/store/backends/sqlite.py +0 -0
  83. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/store/capabilities.py +0 -0
  84. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/store/db.py +0 -0
  85. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/store/dialect.py +0 -0
  86. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/store/factory.py +0 -0
  87. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/store/schema.py +0 -0
  88. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/store/store.py +0 -0
  89. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/store/types.py +0 -0
  90. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/structured.py +0 -0
  91. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/stub_provider.py +0 -0
  92. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/timers.py +0 -0
  93. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/todos.py +0 -0
  94. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/runtime/usage_estimate.py +0 -0
  95. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/tools/__init__.py +0 -0
  96. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/tools/blackboard.py +0 -0
  97. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/tools/command_policy.py +0 -0
  98. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/tools/command_rules.py +0 -0
  99. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/tools/default_manifest.py +0 -0
  100. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/tools/default_tools.py +0 -0
  101. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/tools/registry.py +0 -0
  102. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/tools/spawn_agent.py +0 -0
  103. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/workflow/api.py +0 -0
  104. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/workflow/introspect.py +0 -0
  105. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/workflow/result.py +0 -0
  106. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/workflow/subprocess_executor.py +0 -0
  107. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop/workflow/worker.py +0 -0
  108. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop.egg-info/SOURCES.txt +0 -0
  109. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop.egg-info/dependency_links.txt +0 -0
  110. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop.egg-info/requires.txt +0 -0
  111. {power_loop-6.45.0 → power_loop-6.46.0}/power_loop.egg-info/top_level.txt +0 -0
  112. {power_loop-6.45.0 → power_loop-6.46.0}/pyproject.toml +0 -0
  113. {power_loop-6.45.0 → power_loop-6.46.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: power-loop
3
- Version: 6.45.0
3
+ Version: 6.46.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.45.0"
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
- self._cancel: CancellationToken = CancellationToken.from_any(stop_event)
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
- if self._cancelled or self._cancel.is_cancelled():
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 != "completed":
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
- raw = await self._executor.run_agent(
526
- replace(spec, metadata={**spec.metadata, "attempt": attempt}),
527
- user_input, parent_loop=self._loop, driver_sid=driver_sid,
528
- stop_event=self._cancel,
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 self._cancel.is_cancelled() or (
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 self._cancel.is_cancelled() or (
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
- self._cancelled = True
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) -> dict[str, Any]:
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,