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.
- {power_loop-7.3.0 → power_loop-8.0.0}/PKG-INFO +1 -1
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/__init__.py +3 -2
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/agent/stateful_loop.py +7 -6
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/core/pass_turn.py +20 -1
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/core/pipeline.py +8 -1
- power_loop-8.0.0/power_loop/runtime/delegation.py +382 -0
- power_loop-8.0.0/power_loop/runtime/recall.py +215 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/representation.py +5 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/spec.py +51 -30
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/store/dialect.py +6 -3
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/store/store.py +60 -1
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/store/types.py +1 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/tool_archive.py +12 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/tool_context.py +5 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/tools/default_manifest.py +21 -18
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/tools/default_tools.py +32 -7
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/tools/spawn_agent.py +72 -28
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop.egg-info/PKG-INFO +1 -1
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop.egg-info/SOURCES.txt +2 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/LICENSE +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/README.md +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/_vendor/__init__.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/_vendor/llm_client/__init__.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/_vendor/llm_client/anthropic_factory.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/_vendor/llm_client/attempt_scope.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/_vendor/llm_client/capabilities.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/_vendor/llm_client/interface.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/_vendor/llm_client/llm_factory.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/_vendor/llm_client/llm_tooling.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/_vendor/llm_client/llm_utils.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/_vendor/llm_client/multimodal.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/_vendor/llm_client/proxy_fallback.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/agent/__init__.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/agent/follow_up.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/agent/sink.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/agent/system_prompt.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/agent/types.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/contracts/__init__.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/contracts/context_policy.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/contracts/errors.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/contracts/event_payloads.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/contracts/events.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/contracts/handlers.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/contracts/hook_contexts.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/contracts/hooks.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/contracts/messages.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/contracts/protocols.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/contracts/tools.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/contrib/__init__.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/contrib/_redact.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/contrib/jsonl_sink.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/contrib/logging_sink.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/contrib/mcp.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/contrib/metrics_sink.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/contrib/otel_sink.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/core/agent_context.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/core/events.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/core/hooks.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/core/phase.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/core/runner.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/core/state.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/py.typed +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/background.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/bash_capture.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/blackboard.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/budget.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/cancellation.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/capability_probe.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/compact.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/context_worker.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/env.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/exec_backend.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/fold.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/fold_adapter.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/history_projector.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/history_sanitize.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/human_input.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/image_recall.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/lineage.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/memory.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/notes.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/provider.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/retry.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/runtime_state.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/session_store.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/skills.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/steer.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/stop_policy.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/store/__init__.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/store/backends/__init__.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/store/backends/mysql.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/store/backends/postgres.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/store/backends/sqlite.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/store/capabilities.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/store/context_cache.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/store/db.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/store/factory.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/store/schema.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/structured.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/stub_provider.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/timers.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/todos.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/tool_truncation.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/runtime/usage_estimate.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/tools/__init__.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/tools/blackboard.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/tools/command_policy.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/tools/command_rules.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/tools/registry.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/workflow/__init__.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/workflow/admission.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/workflow/api.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/workflow/engine.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/workflow/introspect.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/workflow/journal.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/workflow/result.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/workflow/resume.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/workflow/runner.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/workflow/spec.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/workflow/subprocess_executor.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/workflow/tool.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop/workflow/worker.py +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop.egg-info/dependency_links.txt +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop.egg-info/requires.txt +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/power_loop.egg-info/top_level.txt +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/pyproject.toml +0 -0
- {power_loop-7.3.0 → power_loop-8.0.0}/setup.cfg +0 -0
|
@@ -15,7 +15,7 @@ Stability tiers
|
|
|
15
15
|
无版本承诺,可随时变更或删除。
|
|
16
16
|
"""
|
|
17
17
|
|
|
18
|
-
__version__ = "
|
|
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
|
-
|
|
2411
|
-
|
|
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":
|
|
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))
|