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