power-loop 2.2.0__tar.gz → 3.0.1__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-2.2.0 → power_loop-3.0.1}/PKG-INFO +1 -1
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/__init__.py +24 -12
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/agent/stateful_loop.py +175 -87
- power_loop-3.0.1/power_loop/agent/types.py +294 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/core/pipeline.py +3 -1
- power_loop-3.0.1/power_loop/runtime/fold.py +413 -0
- power_loop-3.0.1/power_loop/runtime/fold_adapter.py +67 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/history_projector.py +9 -0
- power_loop-3.0.1/power_loop/runtime/representation.py +308 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/store/dialect.py +1 -1
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/store/schema.py +10 -1
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/store/store.py +55 -34
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/tools/default_tools.py +8 -1
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/workflow/runner.py +23 -8
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop.egg-info/PKG-INFO +1 -1
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop.egg-info/SOURCES.txt +3 -0
- power_loop-2.2.0/power_loop/agent/types.py +0 -159
- {power_loop-2.2.0 → power_loop-3.0.1}/LICENSE +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/README.md +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/_vendor/__init__.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/_vendor/llm_client/__init__.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/_vendor/llm_client/anthropic_factory.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/_vendor/llm_client/capabilities.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/_vendor/llm_client/interface.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/_vendor/llm_client/llm_factory.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/_vendor/llm_client/llm_tooling.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/_vendor/llm_client/llm_utils.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/_vendor/llm_client/multimodal.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/agent/__init__.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/agent/follow_up.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/agent/sink.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/agent/system_prompt.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/contracts/__init__.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/contracts/errors.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/contracts/event_payloads.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/contracts/events.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/contracts/handlers.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/contracts/hook_contexts.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/contracts/hooks.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/contracts/messages.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/contracts/protocols.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/contracts/tools.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/contrib/__init__.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/contrib/_redact.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/contrib/jsonl_sink.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/contrib/logging_sink.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/contrib/mcp.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/contrib/metrics_sink.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/contrib/otel_sink.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/core/agent_context.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/core/events.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/core/hooks.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/core/phase.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/core/runner.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/core/state.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/py.typed +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/blackboard.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/budget.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/cancellation.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/compact.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/env.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/exec_backend.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/history_sanitize.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/human_input.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/memory.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/notes.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/provider.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/retry.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/runtime_state.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/session_store.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/skills.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/spec.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/store/__init__.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/store/backends/__init__.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/store/backends/mysql.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/store/backends/postgres.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/store/backends/sqlite.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/store/capabilities.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/store/db.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/store/factory.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/store/types.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/structured.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/stub_provider.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/timers.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/tools/__init__.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/tools/blackboard.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/tools/default_manifest.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/tools/registry.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/tools/spawn_agent.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/workflow/__init__.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/workflow/api.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/workflow/engine.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/workflow/introspect.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/workflow/journal.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/workflow/result.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/workflow/resume.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/workflow/spec.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/workflow/subprocess_executor.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/workflow/tool.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/workflow/worker.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop.egg-info/dependency_links.txt +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop.egg-info/requires.txt +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/power_loop.egg-info/top_level.txt +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/pyproject.toml +0 -0
- {power_loop-2.2.0 → power_loop-3.0.1}/setup.cfg +0 -0
|
@@ -15,7 +15,7 @@ Stability tiers
|
|
|
15
15
|
无版本承诺,可随时变更或删除。
|
|
16
16
|
"""
|
|
17
17
|
|
|
18
|
-
__version__ = "
|
|
18
|
+
__version__ = "3.0.1"
|
|
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).
|
|
@@ -145,13 +145,13 @@ from power_loop.runtime.exec_backend import (
|
|
|
145
145
|
LocalShellBackend,
|
|
146
146
|
ShellBackend,
|
|
147
147
|
)
|
|
148
|
-
from power_loop.runtime.
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
148
|
+
from power_loop.runtime.fold import (
|
|
149
|
+
AgenticFold,
|
|
150
|
+
FoldContext,
|
|
151
|
+
FoldResult,
|
|
152
|
+
FoldStrategy,
|
|
153
|
+
LLMSummaryFold,
|
|
154
|
+
NoteOp,
|
|
155
155
|
)
|
|
156
156
|
from power_loop.runtime.human_input import HumanInputRequired, request_user_input
|
|
157
157
|
from power_loop.runtime.memory import MemoryProvider, MemorySnapshot, tag_as_memory
|
|
@@ -167,6 +167,13 @@ from power_loop.runtime.provider import (
|
|
|
167
167
|
create_llm_service_from_config,
|
|
168
168
|
create_llm_service_from_env,
|
|
169
169
|
)
|
|
170
|
+
from power_loop.runtime.representation import (
|
|
171
|
+
ProjectedRepresentation,
|
|
172
|
+
ProjectedRow,
|
|
173
|
+
ProjectedSend,
|
|
174
|
+
Representation,
|
|
175
|
+
VerbatimRepresentation,
|
|
176
|
+
)
|
|
170
177
|
from power_loop.runtime.retry import LLMRetryPolicy, with_retry
|
|
171
178
|
from power_loop.runtime.runtime_state import (
|
|
172
179
|
BackgroundRuntimeProjector,
|
|
@@ -320,12 +327,17 @@ __all__ = [
|
|
|
320
327
|
"BackgroundRuntimeProjector",
|
|
321
328
|
"default_runtime_projectors",
|
|
322
329
|
"get_tool_runtime_context",
|
|
323
|
-
"
|
|
324
|
-
"
|
|
325
|
-
"
|
|
330
|
+
"Representation",
|
|
331
|
+
"VerbatimRepresentation",
|
|
332
|
+
"ProjectedRepresentation",
|
|
333
|
+
"FoldStrategy",
|
|
334
|
+
"FoldContext",
|
|
335
|
+
"FoldResult",
|
|
336
|
+
"NoteOp",
|
|
337
|
+
"LLMSummaryFold",
|
|
338
|
+
"AgenticFold",
|
|
326
339
|
"ProjectedSend",
|
|
327
340
|
"ProjectedRow",
|
|
328
|
-
"ProjectedCompact",
|
|
329
341
|
"ProjectMessageRow",
|
|
330
342
|
"CancellationToken",
|
|
331
343
|
"CancellationLike",
|
|
@@ -69,7 +69,7 @@ from power_loop.runtime.store.types import (
|
|
|
69
69
|
from power_loop.tools.registry import ToolRegistry
|
|
70
70
|
|
|
71
71
|
if TYPE_CHECKING:
|
|
72
|
-
from power_loop.runtime.
|
|
72
|
+
from power_loop.runtime.representation import Representation
|
|
73
73
|
|
|
74
74
|
logger = logging.getLogger(__name__)
|
|
75
75
|
|
|
@@ -774,6 +774,11 @@ class StatefulAgentLoop:
|
|
|
774
774
|
sink = SQLiteSink(store, session_id)
|
|
775
775
|
sink._unresolved = {str(tc.get("id") or "") for tc in tool_calls}
|
|
776
776
|
sink._assistant_seq = pending.get("assistant_seq")
|
|
777
|
+
# Prime _tool_calls so a crash mid-abort (after some but not all <aborted> rows land)
|
|
778
|
+
# persists a CONSISTENT intermediate pending — on_message_appended rebuilds the still-pending
|
|
779
|
+
# tool_calls from self._tool_calls (sink.py:171-174); left empty it would write
|
|
780
|
+
# {tool_call_ids:[…], tool_calls:[]}, a self-inconsistent pending.
|
|
781
|
+
sink._tool_calls = list(tool_calls)
|
|
777
782
|
for tc in tool_calls:
|
|
778
783
|
cid = str(tc.get("id") or "")
|
|
779
784
|
name = _tool_call_name(tc) if "function" in tc or "name" in tc else None
|
|
@@ -1056,7 +1061,13 @@ class StatefulAgentLoop:
|
|
|
1056
1061
|
if pending.get("pending_interactions"):
|
|
1057
1062
|
return
|
|
1058
1063
|
round_index = int(pending.get("round_index") or 0)
|
|
1059
|
-
|
|
1064
|
+
# Fall back to tool_call_ids (as abort_pending / _prime_sink_from_pending do): a pending that
|
|
1065
|
+
# carries only ids (e.g. a crash mid-abort left {tool_call_ids:[…], tool_calls:[]}) must
|
|
1066
|
+
# still be resolved here, else resume() returns "completed" while the pending stays set and
|
|
1067
|
+
# the session is permanently stranded.
|
|
1068
|
+
tool_calls = pending.get("tool_calls") or [
|
|
1069
|
+
{"id": cid} for cid in (pending.get("tool_call_ids") or [])
|
|
1070
|
+
]
|
|
1060
1071
|
if not tool_calls:
|
|
1061
1072
|
return
|
|
1062
1073
|
# Initialize sink's in-memory unresolved set so auto-resolve works.
|
|
@@ -1064,6 +1075,19 @@ class StatefulAgentLoop:
|
|
|
1064
1075
|
for tc in tool_calls:
|
|
1065
1076
|
cid = str(tc.get("id") or "")
|
|
1066
1077
|
name = _tool_call_name(tc)
|
|
1078
|
+
if name is None:
|
|
1079
|
+
# Reconstructed from ids only — no name/args to replay. Resolve the protocol with an
|
|
1080
|
+
# aborted marker (clears unresolved → pending cleared) instead of stranding.
|
|
1081
|
+
await sink.on_message_appended(
|
|
1082
|
+
{
|
|
1083
|
+
"role": "tool",
|
|
1084
|
+
"tool_call_id": cid,
|
|
1085
|
+
"name": None,
|
|
1086
|
+
"content": "<aborted: unrecoverable tool_call on resume>",
|
|
1087
|
+
},
|
|
1088
|
+
round_index=round_index,
|
|
1089
|
+
)
|
|
1090
|
+
continue
|
|
1067
1091
|
args = _tool_call_args(tc)
|
|
1068
1092
|
if self.tool_registry is None:
|
|
1069
1093
|
output, failed = (
|
|
@@ -1113,7 +1137,10 @@ class StatefulAgentLoop:
|
|
|
1113
1137
|
system_prompt: str | None = None,
|
|
1114
1138
|
) -> StatefulResult:
|
|
1115
1139
|
store = await self._ensure_store()
|
|
1116
|
-
|
|
1140
|
+
# 3.0: projection-style representation drives the derived-layer path; verbatim → None
|
|
1141
|
+
# (in-place compactor path). The fold trigger/keep come from config.fold_strategy.
|
|
1142
|
+
projector = self.config.projection_representation
|
|
1143
|
+
fold_strategy = self.config.fold_strategy
|
|
1117
1144
|
# The current send's authoritative index (set by _persist_user_input; inherited by
|
|
1118
1145
|
# resume()/follow-up). Read up-front so projection mode can partition history by it.
|
|
1119
1146
|
si = await store.get_runtime_state(sid, "send_index", default=0)
|
|
@@ -1149,10 +1176,10 @@ class StatefulAgentLoop:
|
|
|
1149
1176
|
"history_mode": current_mode,
|
|
1150
1177
|
"projector_version": int(getattr(projector, "version", 0) or 0) if projector else None,
|
|
1151
1178
|
"projector_trigger_ratio": (
|
|
1152
|
-
float(getattr(
|
|
1179
|
+
float(getattr(fold_strategy, "trigger_ratio", 0) or 0) if projector else None
|
|
1153
1180
|
),
|
|
1154
1181
|
"projector_keep_last_sends": (
|
|
1155
|
-
int(getattr(
|
|
1182
|
+
int(getattr(fold_strategy, "keep_last_sends", 0) or 0) if projector else None
|
|
1156
1183
|
),
|
|
1157
1184
|
})
|
|
1158
1185
|
elif recorded != current_mode:
|
|
@@ -1178,7 +1205,7 @@ class StatefulAgentLoop:
|
|
|
1178
1205
|
"send_index — resume before any send); best-effort.", sid,
|
|
1179
1206
|
)
|
|
1180
1207
|
projection_active = False
|
|
1181
|
-
elif not migrated and self.config.
|
|
1208
|
+
elif not migrated and self.config.migrate_history_on_switch:
|
|
1182
1209
|
# One-time migration: a session with prior NON-projection history opened in
|
|
1183
1210
|
# projection mode. Fold that prior history into the projection table so it becomes
|
|
1184
1211
|
# projection-native (a compact + the recent keep_last_sends as project rows) rather
|
|
@@ -1443,7 +1470,7 @@ class StatefulAgentLoop:
|
|
|
1443
1470
|
self,
|
|
1444
1471
|
store: SessionStore,
|
|
1445
1472
|
sid: str,
|
|
1446
|
-
projector:
|
|
1473
|
+
projector: Representation,
|
|
1447
1474
|
*,
|
|
1448
1475
|
current_send_index: int,
|
|
1449
1476
|
active_rows: list[Any],
|
|
@@ -1452,12 +1479,12 @@ class StatefulAgentLoop:
|
|
|
1452
1479
|
projection table so it becomes projection-native (starts with a ``compact`` covering the
|
|
1453
1480
|
old sends, plus the most-recent ``keep_last_sends`` as individual project rows). Handles
|
|
1454
1481
|
both a clean default→projection switch (no projection rows yet) and a session the in-place
|
|
1455
|
-
compactor had folded (its ``compact_note`` summary seeds the projection compact).
|
|
1456
|
-
|
|
1457
|
-
raises (the caller swallows + sets the migrated marker only on success)."""
|
|
1482
|
+
compactor had folded (its ``compact_note`` summary seeds the projection compact). Folds via
|
|
1483
|
+
the configured ``fold_strategy`` (bounded by ``fold_timeout_s``, OUTSIDE the migration write
|
|
1484
|
+
lock). Never raises (the caller swallows + sets the migrated marker only on success)."""
|
|
1458
1485
|
from power_loop.runtime.store.types import ProjectMessageRow
|
|
1459
1486
|
|
|
1460
|
-
keep = max(int(getattr(
|
|
1487
|
+
keep = max(int(getattr(self.config.fold_strategy, "keep_last_sends", 0) or 0), 0)
|
|
1461
1488
|
version = int(getattr(projector, "version", 0) or 0)
|
|
1462
1489
|
note = next((r for r in active_rows if r.name == "compact_note"), None)
|
|
1463
1490
|
by_send: dict[int, list[Any]] = {}
|
|
@@ -1485,7 +1512,7 @@ class StatefulAgentLoop:
|
|
|
1485
1512
|
)
|
|
1486
1513
|
|
|
1487
1514
|
# Build the seed compact: the in-place compactor's note (if any) rolled forward + the
|
|
1488
|
-
# folded sends, via
|
|
1515
|
+
# folded sends, via the configured fold_strategy (run below with the fold timeout).
|
|
1489
1516
|
to_compact: list[ProjectMessageRow] = []
|
|
1490
1517
|
if note is not None:
|
|
1491
1518
|
to_compact.append(_pm(0, "compact", {"summary": note.content or ""}))
|
|
@@ -1493,23 +1520,41 @@ class StatefulAgentLoop:
|
|
|
1493
1520
|
for pr in projected[si].rows:
|
|
1494
1521
|
to_compact.append(_pm(si, pr.kind, pr.content))
|
|
1495
1522
|
compact_tuple: tuple[Any, str | None, int, int] | None = None
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1523
|
+
migration_note_ops: list[Any] = []
|
|
1524
|
+
folded = None
|
|
1525
|
+
if any(r.kind in ("user", "project") for r in to_compact):
|
|
1526
|
+
from power_loop.runtime.fold import FoldContext
|
|
1527
|
+
|
|
1528
|
+
# Same fold-timeout guard as the end-of-send path (this runs OUTSIDE the migration's
|
|
1529
|
+
# write lock — write_projection_migration is called afterwards with the result).
|
|
1530
|
+
folded = await self._run_fold_with_timeout(
|
|
1531
|
+
self.config.fold_strategy,
|
|
1532
|
+
to_compact,
|
|
1533
|
+
FoldContext(
|
|
1534
|
+
session_id=sid, round_index=0, representation=projector,
|
|
1535
|
+
llm=self.llm, max_tokens=self.config.max_tokens,
|
|
1536
|
+
),
|
|
1537
|
+
)
|
|
1538
|
+
fold_as_project: list[int] = []
|
|
1501
1539
|
if folded is not None:
|
|
1502
1540
|
from_send = 0 if note is not None else (min(fold) if fold else 0)
|
|
1503
|
-
compact_tuple = (folded.content, folded.rendered_text, from_send,
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
#
|
|
1507
|
-
|
|
1508
|
-
|
|
1541
|
+
compact_tuple = (folded.content, folded.rendered_text, from_send, folded.folded_to_send)
|
|
1542
|
+
migration_note_ops = list(folded.note_ops)
|
|
1543
|
+
else:
|
|
1544
|
+
# The fold soft-failed (LLM error/timeout/empty) OR nothing was foldable. Do NOT write a
|
|
1545
|
+
# compact that claims to COVER sends it never merged — the reader uses compact_to_send as
|
|
1546
|
+
# the exclusion cutoff, so an over-claiming range silently drops real history (B4), and a
|
|
1547
|
+
# marker-set no-op drops compression forever (B13). Instead preserve everything: keep the
|
|
1548
|
+
# note as a standalone compact that covers NO real send (to_send=0), and write the
|
|
1549
|
+
# would-be-folded sends as individual project rows. A later end-of-send fold compresses
|
|
1550
|
+
# them (rolling this note compact forward) once over budget.
|
|
1551
|
+
if note is not None:
|
|
1552
|
+
compact_tuple = ({"summary": note.content or ""}, None, 0, 0)
|
|
1553
|
+
fold_as_project = fold
|
|
1509
1554
|
|
|
1510
1555
|
project_rows = [
|
|
1511
1556
|
(si, pr.kind, pr.content, pr.rendered_text)
|
|
1512
|
-
for si in recent
|
|
1557
|
+
for si in (fold_as_project + recent)
|
|
1513
1558
|
for pr in projected[si].rows
|
|
1514
1559
|
]
|
|
1515
1560
|
# Mark migrated in the SAME transaction as the rows (atomic): a crash can't leave the
|
|
@@ -1518,6 +1563,14 @@ class StatefulAgentLoop:
|
|
|
1518
1563
|
sid, project_rows=project_rows, compact=compact_tuple, projector_version=version,
|
|
1519
1564
|
metadata_patch={"projection_migrated": current_send_index},
|
|
1520
1565
|
)
|
|
1566
|
+
for op in migration_note_ops: # agentic-fold facts from the migrated history (best-effort)
|
|
1567
|
+
try:
|
|
1568
|
+
if getattr(op, "op", None) == "add":
|
|
1569
|
+
await store.add_note(sid, op.content or "", pinned=bool(op.pinned))
|
|
1570
|
+
elif getattr(op, "op", None) == "update":
|
|
1571
|
+
await store.update_note(sid, op.note_id, content=op.content, pinned=op.pinned)
|
|
1572
|
+
except Exception:
|
|
1573
|
+
logger.exception("session %s: migration note op failed (continuing)", sid)
|
|
1521
1574
|
|
|
1522
1575
|
async def _write_send_projection(
|
|
1523
1576
|
self,
|
|
@@ -1527,7 +1580,7 @@ class StatefulAgentLoop:
|
|
|
1527
1580
|
send_index: int,
|
|
1528
1581
|
status: str,
|
|
1529
1582
|
session_row: SessionRow | None,
|
|
1530
|
-
projector:
|
|
1583
|
+
projector: Representation,
|
|
1531
1584
|
tool_registry: ToolRegistry | None,
|
|
1532
1585
|
) -> None:
|
|
1533
1586
|
"""Project the just-finished send's ``pl_messages`` rows into ``pl_project_messages`` (v2).
|
|
@@ -1550,70 +1603,105 @@ class StatefulAgentLoop:
|
|
|
1550
1603
|
version = int(getattr(projector, "version", 0) or 0)
|
|
1551
1604
|
proj_rows_to_write = [(pr.kind, pr.content, pr.rendered_text) for pr in projected.rows]
|
|
1552
1605
|
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
)
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
Wrapped so a misbehaving projector (render/compact/trigger raising) degrades to
|
|
1564
|
-
"rows written, NO fold" instead of aborting the whole locked write — the send's
|
|
1565
|
-
per-send projection rows still commit, and pl_messages stays the source of truth."""
|
|
1566
|
-
try:
|
|
1567
|
-
keep = int(getattr(projector, "keep_last_sends", 0) or 0)
|
|
1568
|
-
if keep <= 0:
|
|
1569
|
-
return None
|
|
1570
|
-
live_sends = sorted(
|
|
1571
|
-
{r.send_index for r in after_rows if r.kind in ("user", "project")}
|
|
1572
|
-
)
|
|
1573
|
-
if len(live_sends) <= keep:
|
|
1574
|
-
return None # nothing foldable beyond the keep-recent floor
|
|
1575
|
-
trigger_ratio = float(getattr(projector, "trigger_ratio", 0.75) or 0.75)
|
|
1576
|
-
threshold = int((self.config.max_tokens or 8000) * trigger_ratio)
|
|
1577
|
-
rendered_prefix = projector.render(
|
|
1578
|
-
([prior] if prior is not None else []) + after_rows
|
|
1579
|
-
)
|
|
1580
|
-
if estimate_tokens(rendered_prefix) < threshold:
|
|
1581
|
-
return None # below threshold — small per-send projections just accumulate
|
|
1582
|
-
fold_sends = set(live_sends[: len(live_sends) - keep])
|
|
1583
|
-
fold_rows = [
|
|
1584
|
-
r
|
|
1585
|
-
for r in after_rows
|
|
1586
|
-
if r.kind in ("user", "project") and r.send_index in fold_sends
|
|
1587
|
-
]
|
|
1588
|
-
to_compact = ([prior] if prior is not None else []) + fold_rows # roll prior fwd
|
|
1589
|
-
folded = projector.compact(to_compact)
|
|
1590
|
-
if folded is None:
|
|
1591
|
-
return None
|
|
1592
|
-
from_send = (
|
|
1593
|
-
prior.compact_from_send
|
|
1594
|
-
if (prior is not None and prior.compact_from_send is not None)
|
|
1595
|
-
else min(fold_sends)
|
|
1596
|
-
)
|
|
1597
|
-
return (folded.content, folded.rendered_text, from_send, max(fold_sends))
|
|
1598
|
-
except Exception:
|
|
1599
|
-
logger.exception(
|
|
1600
|
-
"projection fold planning failed for %s (skipping fold; rows still written)",
|
|
1601
|
-
sid,
|
|
1602
|
-
)
|
|
1603
|
-
return None
|
|
1604
|
-
|
|
1605
|
-
# One atomic, session-locked write: all of this send's projection rows + the optional fold
|
|
1606
|
-
# commit together (no half-projected send), and the read-decide-write of the fold is
|
|
1607
|
-
# serialized against a concurrent loop sharing this store (no double-compaction clobber).
|
|
1608
|
-
await store.write_send_projection_locked(
|
|
1609
|
-
sid,
|
|
1610
|
-
send_index=send_index,
|
|
1611
|
-
rows=proj_rows_to_write,
|
|
1612
|
-
source_seq_lo=projected.source_seq_lo,
|
|
1613
|
-
source_seq_hi=projected.source_seq_hi,
|
|
1606
|
+
# power-loop 3.0, THREE phases so the (multi-second / possibly-hung) LLM fold never runs
|
|
1607
|
+
# inside a DB transaction or under the session lock:
|
|
1608
|
+
# 1) write this send's projection rows under a SHORT lock + snapshot the live rows;
|
|
1609
|
+
# 2) decide + run the fold OUTSIDE the lock (bounded by fold_timeout_s, soft-fails);
|
|
1610
|
+
# 3) commit the compact under a SHORT lock with optimistic concurrency (skip if a
|
|
1611
|
+
# concurrent loop already advanced the compact cursor).
|
|
1612
|
+
prior, snapshot = await store.write_send_projection_rows(
|
|
1613
|
+
sid, send_index=send_index, rows=proj_rows_to_write,
|
|
1614
|
+
source_seq_lo=projected.source_seq_lo, source_seq_hi=projected.source_seq_hi,
|
|
1614
1615
|
projector_version=version,
|
|
1615
|
-
plan_compaction=plan_compaction,
|
|
1616
1616
|
)
|
|
1617
|
+
plan, note_ops = await self._plan_and_run_projection_fold(sid, projector, prior, snapshot)
|
|
1618
|
+
if plan is None:
|
|
1619
|
+
return
|
|
1620
|
+
content, rendered_text, from_send, to_send = plan
|
|
1621
|
+
committed = await store.commit_projection_fold(
|
|
1622
|
+
sid, content=content, rendered_text=rendered_text, from_send=from_send,
|
|
1623
|
+
to_send=to_send, projector_version=version,
|
|
1624
|
+
expected_prior_to_send=(prior.compact_to_send if prior is not None else None),
|
|
1625
|
+
)
|
|
1626
|
+
if committed:
|
|
1627
|
+
await self._apply_fold_notes(store, sid, note_ops)
|
|
1628
|
+
|
|
1629
|
+
async def _plan_and_run_projection_fold(
|
|
1630
|
+
self, sid: str, projector: Representation,
|
|
1631
|
+
prior: ProjectMessageRow | None, snapshot: list[ProjectMessageRow],
|
|
1632
|
+
) -> tuple[tuple[Any, str | None, int, int] | None, tuple[Any, ...]]:
|
|
1633
|
+
"""Decide whether to fold (token threshold + keep-recent floor) and, if so, run the
|
|
1634
|
+
configured ``fold_strategy`` OUTSIDE any lock (bounded by ``fold_timeout_s``). Always keeps
|
|
1635
|
+
the most-recent ``keep_last_sends`` whole sends (never splits an atomic tool pair). Rolls any
|
|
1636
|
+
prior compact forward so nothing is lost; the folded rows REMAIN (recall_send). Returns
|
|
1637
|
+
``((content, rendered_text, from_send, to_send), note_ops)`` or ``(None, ())``. Soft-fails
|
|
1638
|
+
(no fold, rows already written) on any error/timeout — pl_messages stays the source of truth."""
|
|
1639
|
+
fold_strategy = self.config.fold_strategy
|
|
1640
|
+
try:
|
|
1641
|
+
keep = int(getattr(fold_strategy, "keep_last_sends", 0) or 0)
|
|
1642
|
+
if keep <= 0:
|
|
1643
|
+
return None, ()
|
|
1644
|
+
live_sends = sorted({r.send_index for r in snapshot if r.kind in ("user", "project")})
|
|
1645
|
+
if len(live_sends) <= keep:
|
|
1646
|
+
return None, () # nothing foldable beyond the keep-recent floor
|
|
1647
|
+
trigger_ratio = float(getattr(fold_strategy, "trigger_ratio", 0.75) or 0.75)
|
|
1648
|
+
threshold = int((self.config.max_tokens or 8000) * trigger_ratio)
|
|
1649
|
+
rendered_prefix = projector.render(([prior] if prior is not None else []) + snapshot)
|
|
1650
|
+
if estimate_tokens(rendered_prefix) < threshold:
|
|
1651
|
+
return None, () # below threshold — small per-send projections just accumulate
|
|
1652
|
+
fold_sends = set(live_sends[: len(live_sends) - keep])
|
|
1653
|
+
fold_rows = [
|
|
1654
|
+
r for r in snapshot
|
|
1655
|
+
if r.kind in ("user", "project") and r.send_index in fold_sends
|
|
1656
|
+
]
|
|
1657
|
+
to_compact = ([prior] if prior is not None else []) + fold_rows # roll prior fwd
|
|
1658
|
+
from_send = (
|
|
1659
|
+
prior.compact_from_send
|
|
1660
|
+
if (prior is not None and prior.compact_from_send is not None)
|
|
1661
|
+
else min(fold_sends)
|
|
1662
|
+
)
|
|
1663
|
+
from power_loop.runtime.fold import FoldContext
|
|
1664
|
+
|
|
1665
|
+
ctx = FoldContext(
|
|
1666
|
+
session_id=sid, round_index=0, representation=projector,
|
|
1667
|
+
llm=self.llm, max_tokens=self.config.max_tokens,
|
|
1668
|
+
)
|
|
1669
|
+
fr = await self._run_fold_with_timeout(fold_strategy, to_compact, ctx)
|
|
1670
|
+
if fr is None:
|
|
1671
|
+
return None, ()
|
|
1672
|
+
return (fr.content, fr.rendered_text, from_send, fr.folded_to_send), tuple(fr.note_ops)
|
|
1673
|
+
except Exception:
|
|
1674
|
+
logger.exception(
|
|
1675
|
+
"projection fold planning failed for %s (skipping fold; rows still written)", sid,
|
|
1676
|
+
)
|
|
1677
|
+
return None, ()
|
|
1678
|
+
|
|
1679
|
+
async def _run_fold_with_timeout(self, fold_strategy: Any, rows: Any, ctx: Any) -> Any | None:
|
|
1680
|
+
"""Await ``fold_strategy.fold`` bounded by ``config.fold_timeout_s`` (None disables). A
|
|
1681
|
+
timeout soft-fails to None (no fold this send; rows already committed)."""
|
|
1682
|
+
timeout = self.config.fold_timeout_s
|
|
1683
|
+
try:
|
|
1684
|
+
if timeout is not None and timeout > 0:
|
|
1685
|
+
return await asyncio.wait_for(fold_strategy.fold(rows, context=ctx), timeout)
|
|
1686
|
+
return await fold_strategy.fold(rows, context=ctx)
|
|
1687
|
+
except (TimeoutError, asyncio.TimeoutError):
|
|
1688
|
+
logger.warning(
|
|
1689
|
+
"projection fold timed out after %ss for %s (skipping fold this send)",
|
|
1690
|
+
timeout, ctx.session_id,
|
|
1691
|
+
)
|
|
1692
|
+
return None
|
|
1693
|
+
|
|
1694
|
+
async def _apply_fold_notes(self, store: SessionStore, sid: str, note_ops: tuple[Any, ...]) -> None:
|
|
1695
|
+
"""Apply an agentic fold's captured NoteOps (best-effort, additive memory — NOT
|
|
1696
|
+
transactional with the compact; a rare crash here loses a note, never corrupts context)."""
|
|
1697
|
+
for op in note_ops:
|
|
1698
|
+
try:
|
|
1699
|
+
if getattr(op, "op", None) == "add":
|
|
1700
|
+
await store.add_note(sid, op.content or "", pinned=bool(op.pinned))
|
|
1701
|
+
elif getattr(op, "op", None) == "update":
|
|
1702
|
+
await store.update_note(sid, op.note_id, content=op.content, pinned=op.pinned)
|
|
1703
|
+
except Exception:
|
|
1704
|
+
logger.exception("session %s: applying fold note op failed (continuing)", sid)
|
|
1617
1705
|
|
|
1618
1706
|
async def _prime_sink_from_pending(self, sid: str, sink: SQLiteSink) -> None:
|
|
1619
1707
|
store = await self._ensure_store()
|