power-loop 7.3.1__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.1 → power_loop-8.0.0}/PKG-INFO +1 -1
  2. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/__init__.py +1 -1
  3. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/agent/stateful_loop.py +7 -6
  4. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/core/pipeline.py +6 -1
  5. power_loop-8.0.0/power_loop/runtime/delegation.py +382 -0
  6. power_loop-8.0.0/power_loop/runtime/recall.py +215 -0
  7. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/runtime/representation.py +5 -0
  8. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/runtime/spec.py +51 -30
  9. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/runtime/store/dialect.py +6 -3
  10. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/runtime/store/store.py +60 -1
  11. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/runtime/store/types.py +1 -0
  12. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/runtime/tool_archive.py +12 -0
  13. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/runtime/tool_context.py +5 -0
  14. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/tools/default_manifest.py +21 -18
  15. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/tools/default_tools.py +32 -7
  16. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/tools/spawn_agent.py +72 -28
  17. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop.egg-info/PKG-INFO +1 -1
  18. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop.egg-info/SOURCES.txt +2 -0
  19. {power_loop-7.3.1 → power_loop-8.0.0}/LICENSE +0 -0
  20. {power_loop-7.3.1 → power_loop-8.0.0}/README.md +0 -0
  21. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/_vendor/__init__.py +0 -0
  22. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/_vendor/llm_client/__init__.py +0 -0
  23. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/_vendor/llm_client/anthropic_factory.py +0 -0
  24. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/_vendor/llm_client/attempt_scope.py +0 -0
  25. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/_vendor/llm_client/capabilities.py +0 -0
  26. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/_vendor/llm_client/interface.py +0 -0
  27. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/_vendor/llm_client/llm_factory.py +0 -0
  28. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/_vendor/llm_client/llm_tooling.py +0 -0
  29. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/_vendor/llm_client/llm_utils.py +0 -0
  30. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/_vendor/llm_client/multimodal.py +0 -0
  31. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/_vendor/llm_client/proxy_fallback.py +0 -0
  32. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/agent/__init__.py +0 -0
  33. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/agent/follow_up.py +0 -0
  34. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/agent/sink.py +0 -0
  35. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/agent/system_prompt.py +0 -0
  36. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/agent/types.py +0 -0
  37. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/contracts/__init__.py +0 -0
  38. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/contracts/context_policy.py +0 -0
  39. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/contracts/errors.py +0 -0
  40. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/contracts/event_payloads.py +0 -0
  41. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/contracts/events.py +0 -0
  42. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/contracts/handlers.py +0 -0
  43. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/contracts/hook_contexts.py +0 -0
  44. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/contracts/hooks.py +0 -0
  45. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/contracts/messages.py +0 -0
  46. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/contracts/protocols.py +0 -0
  47. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/contracts/tools.py +0 -0
  48. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/contrib/__init__.py +0 -0
  49. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/contrib/_redact.py +0 -0
  50. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/contrib/jsonl_sink.py +0 -0
  51. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/contrib/logging_sink.py +0 -0
  52. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/contrib/mcp.py +0 -0
  53. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/contrib/metrics_sink.py +0 -0
  54. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/contrib/otel_sink.py +0 -0
  55. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/core/agent_context.py +0 -0
  56. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/core/events.py +0 -0
  57. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/core/hooks.py +0 -0
  58. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/core/pass_turn.py +0 -0
  59. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/core/phase.py +0 -0
  60. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/core/runner.py +0 -0
  61. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/core/state.py +0 -0
  62. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/py.typed +0 -0
  63. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/runtime/background.py +0 -0
  64. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/runtime/bash_capture.py +0 -0
  65. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/runtime/blackboard.py +0 -0
  66. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/runtime/budget.py +0 -0
  67. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/runtime/cancellation.py +0 -0
  68. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/runtime/capability_probe.py +0 -0
  69. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/runtime/compact.py +0 -0
  70. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/runtime/context_worker.py +0 -0
  71. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/runtime/env.py +0 -0
  72. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/runtime/exec_backend.py +0 -0
  73. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/runtime/fold.py +0 -0
  74. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/runtime/fold_adapter.py +0 -0
  75. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/runtime/history_projector.py +0 -0
  76. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/runtime/history_sanitize.py +0 -0
  77. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/runtime/human_input.py +0 -0
  78. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/runtime/image_recall.py +0 -0
  79. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/runtime/lineage.py +0 -0
  80. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/runtime/memory.py +0 -0
  81. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/runtime/notes.py +0 -0
  82. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/runtime/provider.py +0 -0
  83. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/runtime/retry.py +0 -0
  84. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/runtime/runtime_state.py +0 -0
  85. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/runtime/session_store.py +0 -0
  86. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/runtime/skills.py +0 -0
  87. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/runtime/steer.py +0 -0
  88. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/runtime/stop_policy.py +0 -0
  89. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/runtime/store/__init__.py +0 -0
  90. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/runtime/store/backends/__init__.py +0 -0
  91. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/runtime/store/backends/mysql.py +0 -0
  92. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/runtime/store/backends/postgres.py +0 -0
  93. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/runtime/store/backends/sqlite.py +0 -0
  94. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/runtime/store/capabilities.py +0 -0
  95. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/runtime/store/context_cache.py +0 -0
  96. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/runtime/store/db.py +0 -0
  97. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/runtime/store/factory.py +0 -0
  98. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/runtime/store/schema.py +0 -0
  99. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/runtime/structured.py +0 -0
  100. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/runtime/stub_provider.py +0 -0
  101. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/runtime/timers.py +0 -0
  102. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/runtime/todos.py +0 -0
  103. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/runtime/tool_truncation.py +0 -0
  104. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/runtime/usage_estimate.py +0 -0
  105. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/tools/__init__.py +0 -0
  106. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/tools/blackboard.py +0 -0
  107. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/tools/command_policy.py +0 -0
  108. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/tools/command_rules.py +0 -0
  109. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/tools/registry.py +0 -0
  110. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/workflow/__init__.py +0 -0
  111. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/workflow/admission.py +0 -0
  112. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/workflow/api.py +0 -0
  113. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/workflow/engine.py +0 -0
  114. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/workflow/introspect.py +0 -0
  115. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/workflow/journal.py +0 -0
  116. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/workflow/result.py +0 -0
  117. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/workflow/resume.py +0 -0
  118. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/workflow/runner.py +0 -0
  119. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/workflow/spec.py +0 -0
  120. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/workflow/subprocess_executor.py +0 -0
  121. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/workflow/tool.py +0 -0
  122. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop/workflow/worker.py +0 -0
  123. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop.egg-info/dependency_links.txt +0 -0
  124. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop.egg-info/requires.txt +0 -0
  125. {power_loop-7.3.1 → power_loop-8.0.0}/power_loop.egg-info/top_level.txt +0 -0
  126. {power_loop-7.3.1 → power_loop-8.0.0}/pyproject.toml +0 -0
  127. {power_loop-7.3.1 → 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.1
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.1"
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).
@@ -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 里过滤,
@@ -2338,8 +2338,13 @@ class AgentPipeline:
2338
2338
  tool_call_id=call_id, failed=failed),
2339
2339
  round_index=round_idx)
2340
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
2341
2346
  await self._append_message(
2342
- {"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},
2343
2348
  round_index=round_idx,
2344
2349
  )
2345
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))
@@ -0,0 +1,215 @@
1
+ """Bounded audit reads and exclusive JSONL exports; never runs recalled tools."""
2
+ from __future__ import annotations
3
+
4
+ import base64
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import re
9
+ import tempfile
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+
14
+ def dumps(value: Any) -> str:
15
+ return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
16
+
17
+
18
+ async def authorize(store: Any, parent: str, child: str | None) -> tuple[str, str | None]:
19
+ if child is None:
20
+ return parent, None
21
+ row = await store.get_session(child)
22
+ if row is not None:
23
+ if row.parent_session_id != parent:
24
+ raise ValueError("access_denied: sub_session must be your direct child")
25
+ return child, None
26
+ ownership = await store.get_runtime_state(parent, "subagent:child:" + child, default=None)
27
+ if isinstance(ownership, dict) and ownership.get("deleted"):
28
+ return child, "session_deleted"
29
+ raise ValueError("access_denied: no authorized child session")
30
+
31
+
32
+ def _integer(value: Any, name: str, minimum: int, maximum: int) -> int:
33
+ if isinstance(value, bool) or not isinstance(value, int) or not minimum <= value <= maximum:
34
+ raise ValueError(f"{name} must be an integer in [{minimum},{maximum}]")
35
+ return value
36
+
37
+
38
+ def _screen(value: Any) -> Any:
39
+ if isinstance(value, str):
40
+ return re.sub(r"data:[^\s\"']*?;base64,[A-Za-z0-9+/=]+", "[inline media omitted]", value)
41
+ if isinstance(value, dict):
42
+ return {k: _screen(v) for k, v in value.items()}
43
+ if isinstance(value, list):
44
+ return [_screen(v) for v in value]
45
+ return value
46
+
47
+
48
+ async def recall(
49
+ store: Any, parent: str, *, sub_session: str | None = None, source: str = "messages",
50
+ ids: list[int] | None = None, seq: int | None = None, id: int | None = None,
51
+ send_index: int | None = None, limit: int = 20, cursor: str | None = None,
52
+ char_offset: int | None = None, char_limit: int | None = None,
53
+ output_file: str | None = None,
54
+ ) -> str:
55
+ """Return valid JSON envelopes; cursor binds the selection and captured upper bound."""
56
+ try:
57
+ sid, unavailable = await authorize(store, parent, sub_session)
58
+ if unavailable:
59
+ return dumps({"status": "unavailable", "reason": unavailable, "session_id": sid})
60
+ if source not in {"messages", "projection"}:
61
+ raise ValueError("source must be messages or projection")
62
+ limit = _integer(limit, "limit", 1, 100)
63
+ offset = _integer(0 if char_offset is None else char_offset, "char_offset", 0, 2**63 - 1)
64
+ budget = _integer(8000 if char_limit is None else char_limit, "char_limit", 256, 32000)
65
+ if seq is not None and source != "messages" or id is not None and source != "projection":
66
+ raise ValueError("seq selects messages; id selects projection")
67
+ single = seq if source == "messages" else id
68
+ if ids is not None and (single is not None or send_index is not None):
69
+ raise ValueError("choose ids OR a single record OR send_index")
70
+ if ids is None and single is None and send_index is None:
71
+ raise ValueError("select ids=[lo,hi], seq (messages), id (projection), or send_index")
72
+ if send_index is not None:
73
+ _integer(send_index, "send_index", 1, 2**63 - 1)
74
+ lo, hi = 0, None
75
+ if ids is not None:
76
+ if not isinstance(ids, list) or len(ids) != 2:
77
+ raise ValueError("ids must be [inclusive_start,inclusive_end]")
78
+ lo, hi = [_integer(x, "ids", 1, 2**63 - 1) for x in ids]
79
+ if lo > hi:
80
+ raise ValueError("ids start exceeds end")
81
+ if single is not None:
82
+ lo = hi = _integer(single, "record coordinate", 1, 2**63 - 1)
83
+ elif offset:
84
+ raise ValueError("char_offset requires seq or id")
85
+ if output_file is not None and not str(output_file).strip():
86
+ raise ValueError("output_file must be a new workspace path")
87
+ if output_file is not None and (cursor is not None or char_offset is not None or char_limit is not None):
88
+ raise ValueError("output_file exports full records; omit cursor/char_offset/char_limit")
89
+ coord = "seq" if source == "messages" else "id"
90
+ selection = {"session_id": sid, "source": source, "ids": ids, "single": single,
91
+ "send_index": send_index}
92
+ signature = hashlib.sha256(dumps(selection).encode()).hexdigest()
93
+ after = 0
94
+ if cursor:
95
+ try:
96
+ state = json.loads(base64.urlsafe_b64decode(cursor.encode()))
97
+ if state["selection"] != signature:
98
+ raise ValueError
99
+ after = _integer(state["after"], "cursor", 0, 2**63 - 1)
100
+ hi = _integer(state["hi"], "cursor upper bound", 1, 2**63 - 1)
101
+ if ids is not None and hi > ids[1] or after < lo - 1:
102
+ raise ValueError
103
+ except (ValueError, TypeError, KeyError, UnicodeError):
104
+ raise ValueError("invalid cursor for this session/selection") from None
105
+ else:
106
+ bounds = await store.history_bounds(sid, source=source)
107
+ if bounds is None:
108
+ return dumps({"status": "unavailable", "reason": "records_not_found"})
109
+ hi = min(hi, bounds[1]) if hi is not None else bounds[1]
110
+ rows = await store.history_records(sid, source=source, lo=lo, hi=hi, after=after,
111
+ limit=limit + 1, send_index=send_index)
112
+ if not rows:
113
+ return dumps({"status": "unavailable", "reason": "records_not_found"})
114
+ if output_file:
115
+ return await _export(store, sid, source, lo, hi, send_index, output_file)
116
+ if single is not None:
117
+ text = dumps(_screen(rows[0]))
118
+ def envelope(size):
119
+ piece = text[offset:offset + size]
120
+ end = offset + len(piece)
121
+ return dumps({"status": "ok", "session_id": sid, "source": source, coord: single,
122
+ "format": "record_json_text", "text": piece, "total_chars": len(text),
123
+ "next_char_offset": end if end < len(text) else None,
124
+ "truncated": end < len(text)})
125
+ left, right = 0, min(budget, max(0, len(text) - offset))
126
+ while left < right:
127
+ middle = (left + right + 1) // 2
128
+ if len(envelope(middle)) <= budget:
129
+ left = middle
130
+ else:
131
+ right = middle - 1
132
+ if not left and offset < len(text):
133
+ raise ValueError("char_limit is too small for record metadata; increase it")
134
+ return envelope(left)
135
+ shown = []
136
+ # Reserve room for envelope/cursor. Each preview includes ALL large fields in its budget.
137
+ used = 0
138
+ for row in rows[:limit]:
139
+ item = {coord: row[coord], "send_index": row.get("send_index")}
140
+ for key in ("role", "kind", "source_seq_lo", "source_seq_hi", "projector_version"):
141
+ if key in row:
142
+ item[key] = row[key]
143
+ text = dumps(_screen(row))
144
+ room = max(0, budget - used - len(dumps(item)) - 80)
145
+ if room < 80 and shown:
146
+ break
147
+ item.update(preview=text[:min(room, 500)], total_chars=len(text),
148
+ truncated=len(text) > min(room, 500))
149
+ shown.append(item)
150
+ used += len(dumps(item))
151
+ while shown:
152
+ last = shown[-1][coord]
153
+ more = len(shown) < len(rows)
154
+ next_cursor = base64.urlsafe_b64encode(dumps(
155
+ {"selection": signature, "after": last, "hi": hi}).encode()).decode() if more else None
156
+ answer = dumps({"status": "ok", "session_id": sid, "source": source,
157
+ "records": shown, "next_cursor": next_cursor})
158
+ if len(answer) <= budget:
159
+ return answer
160
+ if len(shown) == 1:
161
+ if shown[0]["preview"]:
162
+ shown[0]["preview"] = ""
163
+ shown[0]["truncated"] = True
164
+ continue
165
+ raise ValueError("char_limit is too small for list metadata; increase it")
166
+ shown.pop()
167
+ except (ValueError, OSError) as exc:
168
+ return dumps({"status": "error", "error": str(exc)})
169
+
170
+
171
+ async def _export(store, sid, source, lo, hi, send_index, output_file):
172
+ from power_loop.runtime.env import safe_path
173
+
174
+ directory, filename = os.path.split(output_file)
175
+ if not filename or filename in {".", ".."}:
176
+ raise ValueError("output_file must name a new file")
177
+ # Resolve/check the parent but preserve the final component: a dangling symlink is an
178
+ # existing target too, not permission to write through it to a different path.
179
+ target = safe_path(directory or ".", "w") / filename
180
+ safe_path(str(target), "w")
181
+ if target.exists() or target.is_symlink():
182
+ raise ValueError("output_file already exists; choose a new path")
183
+ target.parent.mkdir(parents=True, exist_ok=True)
184
+ fd, temporary = tempfile.mkstemp(prefix=".recall-", dir=target.parent)
185
+ stream = os.fdopen(fd, "wb")
186
+ count = size = 0
187
+ try:
188
+ # One read snapshot, no transaction spans an LLM/network request. Stream batches to disk.
189
+ async with store._db.transaction() as tx:
190
+ if store._db.dialect.name == "postgres":
191
+ await tx.execute("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY")
192
+ with stream:
193
+ after = 0
194
+ while True:
195
+ batch = await store.history_records(
196
+ sid, source=source, lo=lo, hi=hi, after=after, limit=100,
197
+ send_index=send_index, _fetchall=tx.fetchall)
198
+ if not batch:
199
+ break
200
+ for row in batch:
201
+ encoded = (dumps({"source": source, **row}) + "\n").encode("utf-8")
202
+ stream.write(encoded)
203
+ size += len(encoded)
204
+ count += 1
205
+ after = batch[-1]["seq" if source == "messages" else "id"]
206
+ stream.flush()
207
+ os.fsync(stream.fileno())
208
+ if not count:
209
+ raise ValueError("records_not_found")
210
+ # link is atomic and fails if another writer created the target; never replace it.
211
+ os.link(temporary, target)
212
+ finally:
213
+ stream.close()
214
+ Path(temporary).unlink(missing_ok=True)
215
+ return dumps({"status": "exported", "path": output_file, "records": count, "bytes": size})