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.
Files changed (105) hide show
  1. {power_loop-2.2.0 → power_loop-3.0.1}/PKG-INFO +1 -1
  2. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/__init__.py +24 -12
  3. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/agent/stateful_loop.py +175 -87
  4. power_loop-3.0.1/power_loop/agent/types.py +294 -0
  5. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/core/pipeline.py +3 -1
  6. power_loop-3.0.1/power_loop/runtime/fold.py +413 -0
  7. power_loop-3.0.1/power_loop/runtime/fold_adapter.py +67 -0
  8. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/history_projector.py +9 -0
  9. power_loop-3.0.1/power_loop/runtime/representation.py +308 -0
  10. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/store/dialect.py +1 -1
  11. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/store/schema.py +10 -1
  12. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/store/store.py +55 -34
  13. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/tools/default_tools.py +8 -1
  14. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/workflow/runner.py +23 -8
  15. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop.egg-info/PKG-INFO +1 -1
  16. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop.egg-info/SOURCES.txt +3 -0
  17. power_loop-2.2.0/power_loop/agent/types.py +0 -159
  18. {power_loop-2.2.0 → power_loop-3.0.1}/LICENSE +0 -0
  19. {power_loop-2.2.0 → power_loop-3.0.1}/README.md +0 -0
  20. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/_vendor/__init__.py +0 -0
  21. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/_vendor/llm_client/__init__.py +0 -0
  22. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/_vendor/llm_client/anthropic_factory.py +0 -0
  23. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/_vendor/llm_client/capabilities.py +0 -0
  24. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/_vendor/llm_client/interface.py +0 -0
  25. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/_vendor/llm_client/llm_factory.py +0 -0
  26. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/_vendor/llm_client/llm_tooling.py +0 -0
  27. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/_vendor/llm_client/llm_utils.py +0 -0
  28. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/_vendor/llm_client/multimodal.py +0 -0
  29. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/agent/__init__.py +0 -0
  30. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/agent/follow_up.py +0 -0
  31. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/agent/sink.py +0 -0
  32. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/agent/system_prompt.py +0 -0
  33. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/contracts/__init__.py +0 -0
  34. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/contracts/errors.py +0 -0
  35. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/contracts/event_payloads.py +0 -0
  36. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/contracts/events.py +0 -0
  37. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/contracts/handlers.py +0 -0
  38. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/contracts/hook_contexts.py +0 -0
  39. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/contracts/hooks.py +0 -0
  40. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/contracts/messages.py +0 -0
  41. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/contracts/protocols.py +0 -0
  42. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/contracts/tools.py +0 -0
  43. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/contrib/__init__.py +0 -0
  44. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/contrib/_redact.py +0 -0
  45. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/contrib/jsonl_sink.py +0 -0
  46. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/contrib/logging_sink.py +0 -0
  47. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/contrib/mcp.py +0 -0
  48. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/contrib/metrics_sink.py +0 -0
  49. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/contrib/otel_sink.py +0 -0
  50. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/core/agent_context.py +0 -0
  51. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/core/events.py +0 -0
  52. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/core/hooks.py +0 -0
  53. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/core/phase.py +0 -0
  54. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/core/runner.py +0 -0
  55. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/core/state.py +0 -0
  56. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/py.typed +0 -0
  57. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/blackboard.py +0 -0
  58. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/budget.py +0 -0
  59. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/cancellation.py +0 -0
  60. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/compact.py +0 -0
  61. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/env.py +0 -0
  62. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/exec_backend.py +0 -0
  63. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/history_sanitize.py +0 -0
  64. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/human_input.py +0 -0
  65. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/memory.py +0 -0
  66. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/notes.py +0 -0
  67. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/provider.py +0 -0
  68. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/retry.py +0 -0
  69. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/runtime_state.py +0 -0
  70. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/session_store.py +0 -0
  71. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/skills.py +0 -0
  72. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/spec.py +0 -0
  73. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/store/__init__.py +0 -0
  74. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/store/backends/__init__.py +0 -0
  75. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/store/backends/mysql.py +0 -0
  76. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/store/backends/postgres.py +0 -0
  77. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/store/backends/sqlite.py +0 -0
  78. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/store/capabilities.py +0 -0
  79. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/store/db.py +0 -0
  80. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/store/factory.py +0 -0
  81. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/store/types.py +0 -0
  82. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/structured.py +0 -0
  83. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/stub_provider.py +0 -0
  84. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/runtime/timers.py +0 -0
  85. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/tools/__init__.py +0 -0
  86. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/tools/blackboard.py +0 -0
  87. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/tools/default_manifest.py +0 -0
  88. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/tools/registry.py +0 -0
  89. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/tools/spawn_agent.py +0 -0
  90. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/workflow/__init__.py +0 -0
  91. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/workflow/api.py +0 -0
  92. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/workflow/engine.py +0 -0
  93. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/workflow/introspect.py +0 -0
  94. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/workflow/journal.py +0 -0
  95. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/workflow/result.py +0 -0
  96. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/workflow/resume.py +0 -0
  97. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/workflow/spec.py +0 -0
  98. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/workflow/subprocess_executor.py +0 -0
  99. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/workflow/tool.py +0 -0
  100. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop/workflow/worker.py +0 -0
  101. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop.egg-info/dependency_links.txt +0 -0
  102. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop.egg-info/requires.txt +0 -0
  103. {power_loop-2.2.0 → power_loop-3.0.1}/power_loop.egg-info/top_level.txt +0 -0
  104. {power_loop-2.2.0 → power_loop-3.0.1}/pyproject.toml +0 -0
  105. {power_loop-2.2.0 → power_loop-3.0.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: power-loop
3
- Version: 2.2.0
3
+ Version: 3.0.1
4
4
  Summary: Embeddable agent execution kernel — LLM loop, hooks, events, tools, dynamic sub-agents.
5
5
  Author-email: zhangran <zhangran24@126.com>
6
6
  License: MIT
@@ -15,7 +15,7 @@ Stability tiers
15
15
  无版本承诺,可随时变更或删除。
16
16
  """
17
17
 
18
- __version__ = "2.2.0"
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.history_projector import (
149
- DefaultDeterministicProjector,
150
- HistoryProjector,
151
- IdentityProjector,
152
- ProjectedCompact,
153
- ProjectedRow,
154
- ProjectedSend,
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
- "HistoryProjector",
324
- "IdentityProjector",
325
- "DefaultDeterministicProjector",
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.history_projector import HistoryProjector
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
- tool_calls = pending.get("tool_calls") or []
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
- projector = self.config.history_projector
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(projector, "trigger_ratio", 0) or 0) if projector else None
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(projector, "keep_last_sends", 0) or 0) if projector else None
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.migrate_history_on_projection_switch:
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: HistoryProjector,
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). Uses
1456
- ``projector.compact`` for the fold, so it auto-uses whatever projector is configured. Never
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(projector, "keep_last_sends", 0) or 0), 0)
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 projector.compact (deterministic for the default projector).
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
- folded = (
1497
- projector.compact(to_compact)
1498
- if any(r.kind in ("user", "project") for r in to_compact)
1499
- else None
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, max(fold))
1504
- elif note is not None:
1505
- # Only the note, nothing foldable beyond keep → preserve the note as a standalone
1506
- # compact sitting just before the kept tail (or the current send).
1507
- to_send = (min(recent) - 1) if recent else (current_send_index - 1)
1508
- compact_tuple = ({"summary": note.content or ""}, None, 0, max(0, to_send))
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: HistoryProjector,
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
- def plan_compaction(
1554
- prior: ProjectMessageRow | None, after_rows: list[ProjectMessageRow]
1555
- ) -> tuple[Any, str | None, int, int] | None:
1556
- """Projection-layer compaction decision PURE (no I/O); runs inside the store's
1557
- locked transaction. Once the rendered projected prefix reaches ``max_tokens ×
1558
- trigger_ratio`` (mirrors DefaultCompactor's token-driven policy), fold the OLDEST
1559
- uncompacted sends always keeping the most-recent ``keep_last_sends`` — into one
1560
- ``compact`` row, rolling any prior compact forward so nothing is lost. The folded
1561
- user/project rows REMAIN (recoverable via recall_send); pl_messages is untouched.
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()