power-loop 7.3.0__tar.gz → 8.0.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 (127) hide show
  1. {power_loop-7.3.0 → power_loop-8.0.0}/PKG-INFO +1 -1
  2. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/__init__.py +3 -2
  3. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/agent/stateful_loop.py +7 -6
  4. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/core/pass_turn.py +20 -1
  5. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/core/pipeline.py +8 -1
  6. power_loop-8.0.0/power_loop/runtime/delegation.py +382 -0
  7. power_loop-8.0.0/power_loop/runtime/recall.py +215 -0
  8. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/representation.py +5 -0
  9. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/spec.py +51 -30
  10. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/store/dialect.py +6 -3
  11. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/store/store.py +60 -1
  12. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/store/types.py +1 -0
  13. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/tool_archive.py +12 -0
  14. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/tool_context.py +5 -0
  15. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/tools/default_manifest.py +21 -18
  16. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/tools/default_tools.py +32 -7
  17. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/tools/spawn_agent.py +72 -28
  18. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop.egg-info/PKG-INFO +1 -1
  19. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop.egg-info/SOURCES.txt +2 -0
  20. {power_loop-7.3.0 → power_loop-8.0.0}/LICENSE +0 -0
  21. {power_loop-7.3.0 → power_loop-8.0.0}/README.md +0 -0
  22. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/_vendor/__init__.py +0 -0
  23. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/_vendor/llm_client/__init__.py +0 -0
  24. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/_vendor/llm_client/anthropic_factory.py +0 -0
  25. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/_vendor/llm_client/attempt_scope.py +0 -0
  26. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/_vendor/llm_client/capabilities.py +0 -0
  27. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/_vendor/llm_client/interface.py +0 -0
  28. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/_vendor/llm_client/llm_factory.py +0 -0
  29. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/_vendor/llm_client/llm_tooling.py +0 -0
  30. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/_vendor/llm_client/llm_utils.py +0 -0
  31. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/_vendor/llm_client/multimodal.py +0 -0
  32. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/_vendor/llm_client/proxy_fallback.py +0 -0
  33. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/agent/__init__.py +0 -0
  34. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/agent/follow_up.py +0 -0
  35. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/agent/sink.py +0 -0
  36. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/agent/system_prompt.py +0 -0
  37. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/agent/types.py +0 -0
  38. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/contracts/__init__.py +0 -0
  39. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/contracts/context_policy.py +0 -0
  40. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/contracts/errors.py +0 -0
  41. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/contracts/event_payloads.py +0 -0
  42. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/contracts/events.py +0 -0
  43. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/contracts/handlers.py +0 -0
  44. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/contracts/hook_contexts.py +0 -0
  45. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/contracts/hooks.py +0 -0
  46. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/contracts/messages.py +0 -0
  47. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/contracts/protocols.py +0 -0
  48. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/contracts/tools.py +0 -0
  49. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/contrib/__init__.py +0 -0
  50. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/contrib/_redact.py +0 -0
  51. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/contrib/jsonl_sink.py +0 -0
  52. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/contrib/logging_sink.py +0 -0
  53. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/contrib/mcp.py +0 -0
  54. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/contrib/metrics_sink.py +0 -0
  55. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/contrib/otel_sink.py +0 -0
  56. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/core/agent_context.py +0 -0
  57. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/core/events.py +0 -0
  58. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/core/hooks.py +0 -0
  59. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/core/phase.py +0 -0
  60. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/core/runner.py +0 -0
  61. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/core/state.py +0 -0
  62. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/py.typed +0 -0
  63. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/background.py +0 -0
  64. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/bash_capture.py +0 -0
  65. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/blackboard.py +0 -0
  66. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/budget.py +0 -0
  67. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/cancellation.py +0 -0
  68. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/capability_probe.py +0 -0
  69. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/compact.py +0 -0
  70. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/context_worker.py +0 -0
  71. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/env.py +0 -0
  72. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/exec_backend.py +0 -0
  73. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/fold.py +0 -0
  74. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/fold_adapter.py +0 -0
  75. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/history_projector.py +0 -0
  76. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/history_sanitize.py +0 -0
  77. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/human_input.py +0 -0
  78. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/image_recall.py +0 -0
  79. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/lineage.py +0 -0
  80. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/memory.py +0 -0
  81. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/notes.py +0 -0
  82. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/provider.py +0 -0
  83. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/retry.py +0 -0
  84. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/runtime_state.py +0 -0
  85. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/session_store.py +0 -0
  86. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/skills.py +0 -0
  87. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/steer.py +0 -0
  88. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/stop_policy.py +0 -0
  89. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/store/__init__.py +0 -0
  90. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/store/backends/__init__.py +0 -0
  91. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/store/backends/mysql.py +0 -0
  92. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/store/backends/postgres.py +0 -0
  93. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/store/backends/sqlite.py +0 -0
  94. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/store/capabilities.py +0 -0
  95. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/store/context_cache.py +0 -0
  96. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/store/db.py +0 -0
  97. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/store/factory.py +0 -0
  98. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/store/schema.py +0 -0
  99. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/structured.py +0 -0
  100. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/stub_provider.py +0 -0
  101. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/timers.py +0 -0
  102. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/todos.py +0 -0
  103. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/tool_truncation.py +0 -0
  104. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/usage_estimate.py +0 -0
  105. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/tools/__init__.py +0 -0
  106. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/tools/blackboard.py +0 -0
  107. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/tools/command_policy.py +0 -0
  108. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/tools/command_rules.py +0 -0
  109. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/tools/registry.py +0 -0
  110. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/workflow/__init__.py +0 -0
  111. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/workflow/admission.py +0 -0
  112. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/workflow/api.py +0 -0
  113. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/workflow/engine.py +0 -0
  114. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/workflow/introspect.py +0 -0
  115. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/workflow/journal.py +0 -0
  116. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/workflow/result.py +0 -0
  117. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/workflow/resume.py +0 -0
  118. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/workflow/runner.py +0 -0
  119. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/workflow/spec.py +0 -0
  120. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/workflow/subprocess_executor.py +0 -0
  121. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/workflow/tool.py +0 -0
  122. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/workflow/worker.py +0 -0
  123. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop.egg-info/dependency_links.txt +0 -0
  124. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop.egg-info/requires.txt +0 -0
  125. {power_loop-7.3.0 → power_loop-8.0.0}/power_loop.egg-info/top_level.txt +0 -0
  126. {power_loop-7.3.0 → power_loop-8.0.0}/pyproject.toml +0 -0
  127. {power_loop-7.3.0 → power_loop-8.0.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: power-loop
3
- Version: 7.3.0
3
+ Version: 8.0.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__ = "7.3.0"
18
+ __version__ = "8.0.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).
@@ -140,7 +140,7 @@ from power_loop.contracts.tools import ToolDefinition, validate_tool_args
140
140
  from power_loop.core.agent_context import get_effective_tools
141
141
  from power_loop.core.events import AgentEventBus
142
142
  from power_loop.core.hooks import AgentHooks
143
- from power_loop.core.pass_turn import PASS_TURN_TOOL, request_pass
143
+ from power_loop.core.pass_turn import PASS_TURN_TOOL, pass_progress, request_pass
144
144
  from power_loop.core.phase import PhaseContext, PhaseResult, phase
145
145
  from power_loop.core.pipeline import AgentPipeline
146
146
  from power_loop.core.runner import AgentRunner
@@ -306,6 +306,7 @@ STABLE_API = (
306
306
  __all__ = [
307
307
  "PASS_TURN_TOOL",
308
308
  "request_pass",
309
+ "pass_progress",
309
310
  "ToolContextPolicy",
310
311
  "ToolContextConfig",
311
312
  "__version__",
@@ -1302,6 +1302,8 @@ class StatefulAgentLoop:
1302
1302
  "title": str(w.get("workflow") or "")[:80],
1303
1303
  "status": "running" if is_run_live(rid) else "orphaned",
1304
1304
  "started_at": ts, "origin": _origin(ts)})
1305
+ from power_loop.runtime.delegation import running_jobs
1306
+ jobs.extend(running_jobs(store, session_id))
1305
1307
  for tm in await store.list_timers(session_id, statuses=("armed",)):
1306
1308
  jobs.append({"id": f"timer:{tm.timer_id}", "kind": "timer",
1307
1309
  "title": str(tm.note or "")[:80], "status": "armed",
@@ -1343,6 +1345,9 @@ class StatefulAgentLoop:
1343
1345
  result = "stopped"
1344
1346
  elif kind == "wf" and ref:
1345
1347
  result = await stop_run(ref, grace_s=pol.workflow_s, reason="stopped")
1348
+ elif kind == "sub" and ref:
1349
+ from power_loop.runtime.delegation import stop
1350
+ result = await stop(store, session_id, ref, pol.workflow_s)
1346
1351
  elif kind == "timer" and ref.isdigit():
1347
1352
  result = "stopped" if await self.cancel_timer(session_id, int(ref)) else "finished"
1348
1353
  return {"id": job_id, "result": result}
@@ -2407,14 +2412,10 @@ class StatefulAgentLoop:
2407
2412
  ) -> None:
2408
2413
  """Project the just-finished send's ``pl_messages`` rows into ``pl_project_messages`` (v2).
2409
2414
 
2410
- Skips sub-agent CHILD sessions (their transcript lives in their own pl_session) by
2411
- ``parent_session_id`` — NOT ``scope``, which ``_finalize`` hardcodes to ``'main'`` even
2412
- for children. Defers on a still-in-flight status (the resume re-finalizes under the SAME
2415
+ Includes child sessions regardless of their tool-context/fuse setting.
2416
+ Defers on a still-in-flight status (the resume re-finalizes under the SAME
2413
2417
  ``send_index``; the idempotent UPSERT then overwrites). Best-effort — the caller swallows
2414
2418
  failures, since ``pl_messages`` remains the source of truth."""
2415
- if (session_row is not None and session_row.parent_session_id is not None
2416
- and self.config.tool_context is None):
2417
- return # legacy behavior; policy-enabled children prepare their own context
2418
2419
  if status in ("waiting_for_input", "pending_tools"):
2419
2420
  return # send not finished; the resume will re-finalize under the same send_index
2420
2421
  # 只取本 send 的行:这里要投影的就是这一个 send,从前是读全表再在 Python 里过滤,
@@ -43,7 +43,7 @@ _state: ContextVar[dict[str, Any] | None] = ContextVar("power_loop_pass_turn", d
43
43
 
44
44
  def begin() -> Token:
45
45
  """一个 send 开始:装一份新的状态。返回还原用的 token。"""
46
- return _state.set({"passed": False, "reason": None, "new_input_after_pass": False})
46
+ return _state.set({"passed": False, "reason": None, "new_input_after_pass": False, "texts": 0, "work_tools": 0})
47
47
 
48
48
 
49
49
  def end(token: Token) -> None:
@@ -64,6 +64,25 @@ def request_pass(reason: str = "") -> bool:
64
64
  return True
65
65
 
66
66
 
67
+ def note_step(wrote_text: bool, tool_names: list[str]) -> None:
68
+ """pipeline 每一轮调一次:记下这次 send 写过几段字、调过几次别的工具(pass_turn 不算)。"""
69
+ st = _state.get()
70
+ if st is None:
71
+ return
72
+ if wrote_text:
73
+ st["texts"] = st.get("texts", 0) + 1
74
+ st["work_tools"] = st.get("work_tools", 0) + sum(1 for n in tool_names if n != PASS_TURN_TOOL.name)
75
+
76
+
77
+ def pass_progress() -> dict[str, int] | None:
78
+ """这次 send 到现在:写过几段字(texts)、调过几次别的工具(work_tools)。宿主覆盖 pass_turn 时用来判断
79
+ 「还什么都没交就要结束」。不在 send 里返回 None。"""
80
+ st = _state.get()
81
+ if st is None:
82
+ return None
83
+ return {"texts": int(st.get("texts", 0)), "work_tools": int(st.get("work_tools", 0))}
84
+
85
+
67
86
  def pass_requested() -> bool:
68
87
  st = _state.get()
69
88
  return bool(st and st.get("passed"))
@@ -1906,6 +1906,8 @@ class AgentPipeline:
1906
1906
  if assistant_text:
1907
1907
  self._step_texts.append({"step": round_idx + 1, "text": assistant_text,
1908
1908
  "tools": [_tool_call_name(tc) for tc in (tool_calls or [])]})
1909
+ from power_loop.core import pass_turn as _pass_state
1910
+ _pass_state.note_step(bool(assistant_text), [_tool_call_name(tc) for tc in (tool_calls or [])])
1909
1911
 
1910
1912
  # Any productive round (said something OR called a tool) clears the empty streak.
1911
1913
  if assistant_text or tool_calls:
@@ -2336,8 +2338,13 @@ class AgentPipeline:
2336
2338
  tool_call_id=call_id, failed=failed),
2337
2339
  round_index=round_idx)
2338
2340
 
2341
+ stored_output = _truncate_result(output, self.config.tool_result_max_chars)
2342
+ if tool_name == "spawn_agent":
2343
+ from power_loop.runtime.delegation import result_view
2344
+ # Structured receipts/results cannot be cut through their metadata or JSON.
2345
+ stored_output = result_view(str(output), self.config.tool_result_max_chars or 20000) or stored_output
2339
2346
  await self._append_message(
2340
- {"role": "tool", "tool_call_id": call_id, "name": tool_name, "content": _truncate_result(output, self.config.tool_result_max_chars)},
2347
+ {"role": "tool", "tool_call_id": call_id, "name": tool_name, "content": stored_output},
2341
2348
  round_index=round_idx,
2342
2349
  )
2343
2350
 
@@ -0,0 +1,382 @@
1
+ """Asynchronous subagent sessions, durable results and serialized continuation.
2
+
3
+ The host owns idle-parent wake routing. No notification callback starts a bare parent loop.
4
+ Execution is process-local; records and undelivered inbox items survive the process.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import contextlib
10
+ import logging
11
+ import secrets
12
+ import weakref
13
+ from dataclasses import dataclass, field, replace
14
+ from typing import Any
15
+
16
+ from power_loop.agent.follow_up import InboxItem
17
+ from power_loop.core.agent_context import get_session_id
18
+ from power_loop.runtime.cancellation import CancellationToken
19
+ from power_loop.runtime.recall import authorize, dumps
20
+ from power_loop.runtime.spec import (
21
+ _ON_CHILD_SESSION,
22
+ _USAGE_SINK,
23
+ AgentSpec,
24
+ run_agent_spec,
25
+ workflow_continuation_loop,
26
+ )
27
+
28
+ log = logging.getLogger(__name__)
29
+ RESULT_PREFIX = "[subagent_result]\n"
30
+
31
+
32
+ @dataclass
33
+ class Running:
34
+ run_id: str
35
+ task: asyncio.Task | None = None
36
+ token: Any = None
37
+ parent: str = ""
38
+ title: str = ""
39
+ suppress_wake: bool = False
40
+ phase: str = "running"
41
+ descendant_usage: dict = field(default_factory=dict)
42
+ projection_expected: bool = True
43
+
44
+
45
+ _RUNNING: dict[tuple[int, str], Running] = {}
46
+ _TASKS: set[asyncio.Task] = set()
47
+
48
+
49
+ def _retain(task):
50
+ _TASKS.add(task)
51
+ task.add_done_callback(_TASKS.discard)
52
+
53
+
54
+ async def _save_result(store, parent, sid, run, raw, after_seq, lifecycle):
55
+ messages = await store.history_bounds(sid)
56
+ messages = [max(after_seq + 1, messages[0]), messages[1]] if messages and messages[1] > after_seq else None
57
+ projection, projection_status = None, "empty"
58
+ try:
59
+ projection = await store.history_bounds(sid, source="projection", after_seq=after_seq)
60
+ projection_status = "ready" if projection else "failed" if run.projection_expected else "disabled"
61
+ except ValueError:
62
+ projection_status = "disabled"
63
+ usage = dict(raw.get("usage") or {})
64
+ for name, value in run.descendant_usage.items():
65
+ usage[name] = usage.get(name, 0) + value
66
+ result = {**raw, "usage": usage, "session_id": sid, "run_id": run.run_id, "messages": messages,
67
+ "projection": projection, "history_status": "available" if messages else "empty",
68
+ "projection_status": projection_status}
69
+ # Persist ownership/prepared result BEFORE any destructive cleanup.
70
+ key = "subagent:run:" + run.run_id
71
+ await store.set_runtime_state(parent, key, {"phase": "prepared", "result": result})
72
+ if lifecycle == "ephemeral" and raw.get("status") == "completed":
73
+ await store.close_session(sid, cascade=True)
74
+ result.update(messages=None, projection=None, history_status="deleted", projection_status="deleted")
75
+ await store.set_runtime_state(parent, "subagent:child:" + sid,
76
+ {"deleted": True, "lifecycle": lifecycle})
77
+ await store.set_runtime_state(parent, key, {"phase": "completed", "result": result, "notified": False})
78
+ return result
79
+
80
+
81
+ async def _finish(loop, parent, sid, running, raw, after_seq, lifecycle, finalize, on_complete):
82
+ store = await loop.ensure_store()
83
+ running.phase = "settling"
84
+ children = [r.task for (key, _), r in list(_RUNNING.items())
85
+ if key == id(store) and r.parent == sid and r.task is not None]
86
+ if children:
87
+ await asyncio.gather(*children)
88
+ if finalize is not None:
89
+ raw = await finalize(sid, raw)
90
+ # A nudge racing finalization is already durable. Consume it in this run before returning.
91
+ continuation = None
92
+ while (await store.inbox_counts(sid))["pending"]:
93
+ if running.token.is_cancelled():
94
+ break
95
+ if continuation is None:
96
+ continuation = await workflow_continuation_loop(loop, sid)
97
+ with contextlib.ExitStack() as guards:
98
+ for _, guard in getattr(loop, "_child_run_guards", ()):
99
+ guards.enter_context(guard())
100
+ extra = await continuation.flush_follow_ups(sid, stop_event=running.token)
101
+ if extra is None:
102
+ break
103
+ raw["final_text"] = str(extra.final_text or "")
104
+ raw["status"] = extra.status
105
+ raw["rounds"] = int(raw.get("rounds", 0)) + int(extra.rounds)
106
+ usage = dict(raw.get("usage") or {})
107
+ for key, value in (extra.usage or {}).items():
108
+ usage[key] = usage.get(key, 0) + value
109
+ raw["usage"] = usage
110
+ if finalize is not None:
111
+ raw = await finalize(sid, raw)
112
+ # From the final pending check through cleanup, nudge uses our gate (below).
113
+ async with _gate(store, sid):
114
+ child_active = any(key == id(store) and r.parent == sid for (key, _), r in _RUNNING.items())
115
+ if ((await store.inbox_counts(sid))["pending"] or child_active) and not running.token.is_cancelled():
116
+ # Re-enter outside the gate; callers can enqueue without losing accepted input.
117
+ again = True
118
+ else:
119
+ again = False
120
+ result = await _save_result(store, parent, sid, running, raw, after_seq, lifecycle)
121
+ def accumulate(value):
122
+ totals = dict(value or {})
123
+ for name, count in result.get("usage", {}).items():
124
+ if isinstance(count, (int, float)) and not isinstance(count, bool):
125
+ totals[name] = totals.get(name, 0) + count
126
+ return totals
127
+ await store.mutate_runtime_state(parent, "subagent:usage:total", accumulate, default={})
128
+ parent_run = _RUNNING.get((id(store), parent))
129
+ if parent_run is not None:
130
+ for name, value in result.get("usage", {}).items():
131
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
132
+ parent_run.descendant_usage[name] = parent_run.descendant_usage.get(name, 0) + value
133
+ _RUNNING.pop((id(store), sid), None)
134
+ _publish_completion(loop, parent, sid, result)
135
+ if again:
136
+ return await _finish(loop, parent, sid, running, raw, after_seq, lifecycle, finalize, on_complete)
137
+ try:
138
+ if running.suppress_wake:
139
+ return result
140
+ # Inbox delivery is durable and deduplicated before asking the host to wake an idle
141
+ # parent. A failed/skipped wake still leaves the result for its next normal send.
142
+ await loop.deliver(InboxItem(RESULT_PREFIX + completion_view(loop, result), kind="task_done",
143
+ item_id="subagent:" + running.run_id), parent, enqueue_only=True)
144
+ if on_complete is not None:
145
+ await on_complete(parent, result)
146
+ record = await store.get_runtime_state(parent, "subagent:run:" + running.run_id)
147
+ await store.set_runtime_state(parent, "subagent:run:" + running.run_id, {**record, "notified": True})
148
+ except Exception:
149
+ log.exception("subagent completion not delivered; durable result retained: %s", running.run_id)
150
+ return result
151
+
152
+
153
+ _GATES: weakref.WeakValueDictionary = weakref.WeakValueDictionary()
154
+
155
+
156
+ def _gate(store, sid):
157
+ return _GATES.setdefault((id(store), sid), asyncio.Lock())
158
+
159
+
160
+ async def _complete(loop, parent, sid, run, raw, after_seq, lifecycle, finalize, on_complete):
161
+ """Finalization errors/cancellation must not leave an immortal running job."""
162
+ store = await loop.ensure_store()
163
+ try:
164
+ return await _finish(loop, parent, sid, run, raw, after_seq, lifecycle, finalize, on_complete)
165
+ except BaseException as exc:
166
+ failed = {**raw, "status": "cancelled" if isinstance(exc, asyncio.CancelledError) else "failed",
167
+ "error": f"subagent finalization: {type(exc).__name__}: {exc}"}
168
+ try:
169
+ result = await _save_result(store, parent, sid, run, failed, after_seq, lifecycle)
170
+ _publish_completion(loop, parent, sid, result)
171
+ if not run.suppress_wake:
172
+ await loop.deliver(InboxItem(RESULT_PREFIX + completion_view(loop, result), kind="task_done",
173
+ item_id="subagent:" + run.run_id), parent, enqueue_only=True)
174
+ if on_complete is not None:
175
+ await on_complete(parent, result)
176
+ return result
177
+ except Exception:
178
+ log.exception("subagent finalization failed; original records retained: %s", run.run_id)
179
+ finally:
180
+ if _RUNNING.get((id(store), sid)) is run:
181
+ _RUNNING.pop((id(store), sid), None)
182
+
183
+
184
+ async def start(loop, spec: AgentSpec, task: str, *, on_complete=None, finalize=None,
185
+ admission: asyncio.Semaphore | None = None) -> dict[str, Any]:
186
+ parent = get_session_id()
187
+ if parent is None:
188
+ raise ValueError("spawn_agent requires an active parent session")
189
+ store = await loop.ensure_store()
190
+ parent_run = _RUNNING.get((id(store), parent))
191
+ ready = asyncio.get_running_loop().create_future()
192
+ run = Running(secrets.token_hex(8), token=CancellationToken(), parent=parent, title=spec.name)
193
+ if parent_run is not None:
194
+ run.token = parent_run.token.child()
195
+
196
+ admitted = False
197
+
198
+ async def created(sid):
199
+ nonlocal admitted
200
+ session = await store.get_session(sid)
201
+ run.projection_expected = session.config.get("context_projection", True)
202
+ _RUNNING[(id(store), sid)] = run
203
+ await store.set_runtime_state(parent, "subagent:child:" + sid,
204
+ {"deleted": False, "lifecycle": spec.lifecycle})
205
+ await store.set_runtime_state(parent, "subagent:run:" + run.run_id,
206
+ {"phase": "running", "session_id": sid, "run_id": run.run_id})
207
+ await store.merge_session_metadata(sid, {"standalone_subagent": True})
208
+ ready.set_result(sid)
209
+ if admission is not None:
210
+ run.phase = "queued"
211
+ await admission.acquire()
212
+ admitted = True
213
+ run.phase = "running"
214
+
215
+ async def execute():
216
+ token = _ON_CHILD_SESSION.set(created)
217
+ usage = _USAGE_SINK.set(None)
218
+ sid = None
219
+ try:
220
+ # Manager owns cleanup, after repair/projection/range collection.
221
+ raw = await run_agent_spec(replace(spec, lifecycle="linked", metadata={**spec.metadata, "managed_subagent": True}), task,
222
+ parent_loop=loop, stop_event=run.token)
223
+ sid = ready.result() if ready.done() and not ready.cancelled() else None
224
+ if sid is None:
225
+ raise ValueError(raw.get("final_text") or "child session was not created")
226
+ except BaseException as exc:
227
+ if not ready.done():
228
+ ready.set_exception(exc)
229
+ return
230
+ sid = ready.result()
231
+ raw = {"status": "cancelled" if isinstance(exc, asyncio.CancelledError) else "failed",
232
+ "final_text": "", "rounds": 0, "usage": {}, "error": str(exc)}
233
+ finally:
234
+ _ON_CHILD_SESSION.reset(token)
235
+ _USAGE_SINK.reset(usage)
236
+ if admitted:
237
+ admission.release()
238
+ await _complete(loop, parent, sid, run, raw, 0, spec.lifecycle, finalize, on_complete)
239
+
240
+ run.task = asyncio.create_task(execute(), name="subagent-" + run.run_id)
241
+ _retain(run.task)
242
+ sid = await asyncio.shield(ready)
243
+ return {"session_id": sid, "run_id": run.run_id, "mode": "create", "status": "queued"}
244
+
245
+
246
+ async def nudge(loop, sid: str, message: str, *, on_complete=None, finalize=None,
247
+ admission: asyncio.Semaphore | None = None) -> dict[str, Any]:
248
+ parent = get_session_id()
249
+ if parent is None:
250
+ raise ValueError("nudge requires an active parent session")
251
+ store = await loop.ensure_store()
252
+ _, unavailable = await authorize(store, parent, sid)
253
+ if unavailable:
254
+ return {"status": "unavailable", "reason": unavailable, "session_id": sid}
255
+ row = await store.get_session(sid)
256
+ if not row.metadata.get("standalone_subagent"):
257
+ raise ValueError("not a standalone subagent; use the owning workflow's nudge")
258
+ # Build using the saved child contract and current parent policy, never the parent's full tools.
259
+ child_loop = await workflow_continuation_loop(loop, sid)
260
+ async with _gate(store, sid):
261
+ running = _RUNNING.get((id(store), sid))
262
+ if running is not None:
263
+ await child_loop.deliver(InboxItem(message, mode="steer"), sid, enqueue_only=True)
264
+ return {"session_id": sid, "run_id": running.run_id, "mode": "steer", "status": "accepted"}
265
+ if child_loop._lock_for(sid).locked():
266
+ raise ValueError("session is owned by another execution; nudge was not accepted")
267
+ record = await store.get_runtime_state(parent, "subagent:child:" + sid)
268
+ lifecycle = record.get("lifecycle", "linked")
269
+ before = await store.history_bounds(sid)
270
+ after_seq = before[1] if before else 0
271
+ run = Running(secrets.token_hex(8), token=CancellationToken(), parent=parent, title=str(row.metadata.get("spec_name") or "subagent"))
272
+ run.projection_expected = child_loop.config.projection_representation is not None
273
+ parent_run = _RUNNING.get((id(store), parent))
274
+ if parent_run is not None:
275
+ run.token = parent_run.token.child()
276
+ _RUNNING[(id(store), sid)] = run
277
+ await store.set_runtime_state(parent, "subagent:run:" + run.run_id,
278
+ {"phase": "queued", "session_id": sid, "run_id": run.run_id})
279
+
280
+ async def execute():
281
+ from power_loop.contracts.event_payloads import SubagentTaskStartPayload
282
+ from power_loop.contracts.events import AgentEventType
283
+ from power_loop.runtime.spec import _publish_subagent_event
284
+ from power_loop.workflow.engine import InProcessExecutor
285
+ _publish_subagent_event(loop, parent, AgentEventType.SUBAGENT_TASK_START,
286
+ SubagentTaskStartPayload(task=message, preset=run.title,
287
+ sub_session_id=sid, depth=row.spawn_depth))
288
+ admitted = False
289
+ try:
290
+ if admission is not None:
291
+ run.phase = "queued"
292
+ await admission.acquire()
293
+ admitted = True
294
+ run.phase = "running"
295
+ raw = await InProcessExecutor().continue_agent(
296
+ sid, message, parent_loop=loop, extra_rounds=child_loop.config.max_rounds,
297
+ stop_event=run.token)
298
+ except BaseException as exc:
299
+ raw = {"status": "cancelled" if isinstance(exc, asyncio.CancelledError) else "failed",
300
+ "final_text": "", "rounds": 0, "usage": {}, "error": str(exc)}
301
+ finally:
302
+ if admitted:
303
+ admission.release()
304
+ await _complete(loop, parent, sid, run, raw, after_seq, lifecycle, finalize, on_complete)
305
+
306
+ run.task = asyncio.create_task(execute(), name="subagent-" + run.run_id)
307
+ _retain(run.task)
308
+ return {"session_id": sid, "run_id": run.run_id, "mode": "continue", "status": "queued"}
309
+
310
+
311
+ def result_view(text: str, cap: int = 600) -> str | None:
312
+ """Tool-context rule: only final_text may change, never envelope metadata."""
313
+ import json
314
+ try:
315
+ value = json.loads(text)
316
+ except (ValueError, TypeError):
317
+ return None
318
+ if not isinstance(value, dict) or "session_id" not in value or not value.get("run_id"):
319
+ return None
320
+ if isinstance(value.get("final_text"), str) and len(value["final_text"]) > cap:
321
+ value["final_text"] = value["final_text"][:max(0, cap)] + "…[truncated]"
322
+ return dumps(value)
323
+
324
+
325
+ def running_jobs(store, parent):
326
+ return [{"id": "sub:" + run.run_id, "kind": "subagent", "title": run.title,
327
+ "status": run.phase, "session_id": sid, "started_at": None, "origin": ""}
328
+ for (key, sid), run in _RUNNING.items() if key == id(store) and run.parent == parent]
329
+
330
+
331
+ async def stop(store, parent, run_id, grace_s):
332
+ run = next((run for (key, _), run in _RUNNING.items()
333
+ if key == id(store) and run.parent == parent and run.run_id == run_id), None)
334
+ if run is None:
335
+ return "finished"
336
+ run.suppress_wake = True
337
+ run.token.cancel("stopped")
338
+ try:
339
+ await asyncio.wait_for(asyncio.shield(run.task), timeout=grace_s)
340
+ return "stopped"
341
+ except TimeoutError:
342
+ run.task.cancel()
343
+ return "forced"
344
+
345
+
346
+ async def await_children(store, parent, stop_event=None, timeout_s=1800):
347
+ """Owning executors must collect asynchronous descendants before settling a leaf."""
348
+ deadline = asyncio.get_running_loop().time() + timeout_s
349
+ token = CancellationToken.from_any(stop_event)
350
+ while True:
351
+ runs = [run for (key, _), run in _RUNNING.items()
352
+ if key == id(store) and run.parent == parent and run.task is not None]
353
+ if not runs:
354
+ return
355
+ if token.is_cancelled():
356
+ for run in runs:
357
+ run.token.cancel("parent cancelled")
358
+ raise ValueError("cancelled while waiting for subagents")
359
+ if asyncio.get_running_loop().time() >= deadline:
360
+ raise ValueError("subagents still pending after completion wait")
361
+ await asyncio.wait([run.task for run in runs], timeout=0.1)
362
+
363
+
364
+ def completion_view(loop, result):
365
+ config = getattr(loop, "config", None)
366
+ policy = getattr(config, "tool_context", None)
367
+ projection = getattr(getattr(config, "representation", None), "projection_cfg", {}) or {}
368
+ overrides = getattr(policy, "overrides", {}) or {}
369
+ cap = overrides.get("spawn_agent", {}).get("result_max_chars") or projection.get("final_text_cap") or 600
370
+ return result_view(dumps(result), int(cap))
371
+
372
+
373
+ def _publish_completion(loop, parent, sid, result):
374
+ from power_loop.contracts.event_payloads import SubagentCompletedPayload, SubagentTextPayload
375
+ from power_loop.contracts.events import AgentEventType
376
+ from power_loop.runtime.spec import _publish_subagent_event
377
+ status = str(result.get("status") or "failed")
378
+ fields = {"sub_session_id": sid, "status": status, "rounds": int(result.get("rounds") or 0),
379
+ "final_text": str(result.get("final_text") or "")}
380
+ if status == "completed":
381
+ _publish_subagent_event(loop, parent, AgentEventType.SUBAGENT_TEXT, SubagentTextPayload(**fields))
382
+ _publish_subagent_event(loop, parent, AgentEventType.SUBAGENT_COMPLETED, SubagentCompletedPayload(**fields))