power-loop 2.2.0__tar.gz → 3.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. {power_loop-2.2.0 → power_loop-3.0.0}/PKG-INFO +1 -1
  2. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/__init__.py +24 -12
  3. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/agent/stateful_loop.py +137 -80
  4. power_loop-3.0.0/power_loop/agent/types.py +279 -0
  5. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/core/pipeline.py +3 -1
  6. power_loop-3.0.0/power_loop/runtime/fold.py +413 -0
  7. power_loop-3.0.0/power_loop/runtime/fold_adapter.py +67 -0
  8. power_loop-3.0.0/power_loop/runtime/representation.py +291 -0
  9. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/store/store.py +55 -34
  10. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop.egg-info/PKG-INFO +1 -1
  11. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop.egg-info/SOURCES.txt +3 -0
  12. power_loop-2.2.0/power_loop/agent/types.py +0 -159
  13. {power_loop-2.2.0 → power_loop-3.0.0}/LICENSE +0 -0
  14. {power_loop-2.2.0 → power_loop-3.0.0}/README.md +0 -0
  15. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/_vendor/__init__.py +0 -0
  16. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/_vendor/llm_client/__init__.py +0 -0
  17. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/_vendor/llm_client/anthropic_factory.py +0 -0
  18. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/_vendor/llm_client/capabilities.py +0 -0
  19. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/_vendor/llm_client/interface.py +0 -0
  20. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/_vendor/llm_client/llm_factory.py +0 -0
  21. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/_vendor/llm_client/llm_tooling.py +0 -0
  22. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/_vendor/llm_client/llm_utils.py +0 -0
  23. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/_vendor/llm_client/multimodal.py +0 -0
  24. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/agent/__init__.py +0 -0
  25. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/agent/follow_up.py +0 -0
  26. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/agent/sink.py +0 -0
  27. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/agent/system_prompt.py +0 -0
  28. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/contracts/__init__.py +0 -0
  29. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/contracts/errors.py +0 -0
  30. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/contracts/event_payloads.py +0 -0
  31. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/contracts/events.py +0 -0
  32. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/contracts/handlers.py +0 -0
  33. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/contracts/hook_contexts.py +0 -0
  34. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/contracts/hooks.py +0 -0
  35. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/contracts/messages.py +0 -0
  36. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/contracts/protocols.py +0 -0
  37. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/contracts/tools.py +0 -0
  38. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/contrib/__init__.py +0 -0
  39. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/contrib/_redact.py +0 -0
  40. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/contrib/jsonl_sink.py +0 -0
  41. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/contrib/logging_sink.py +0 -0
  42. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/contrib/mcp.py +0 -0
  43. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/contrib/metrics_sink.py +0 -0
  44. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/contrib/otel_sink.py +0 -0
  45. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/core/agent_context.py +0 -0
  46. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/core/events.py +0 -0
  47. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/core/hooks.py +0 -0
  48. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/core/phase.py +0 -0
  49. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/core/runner.py +0 -0
  50. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/core/state.py +0 -0
  51. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/py.typed +0 -0
  52. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/blackboard.py +0 -0
  53. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/budget.py +0 -0
  54. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/cancellation.py +0 -0
  55. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/compact.py +0 -0
  56. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/env.py +0 -0
  57. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/exec_backend.py +0 -0
  58. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/history_projector.py +0 -0
  59. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/history_sanitize.py +0 -0
  60. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/human_input.py +0 -0
  61. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/memory.py +0 -0
  62. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/notes.py +0 -0
  63. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/provider.py +0 -0
  64. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/retry.py +0 -0
  65. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/runtime_state.py +0 -0
  66. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/session_store.py +0 -0
  67. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/skills.py +0 -0
  68. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/spec.py +0 -0
  69. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/store/__init__.py +0 -0
  70. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/store/backends/__init__.py +0 -0
  71. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/store/backends/mysql.py +0 -0
  72. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/store/backends/postgres.py +0 -0
  73. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/store/backends/sqlite.py +0 -0
  74. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/store/capabilities.py +0 -0
  75. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/store/db.py +0 -0
  76. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/store/dialect.py +0 -0
  77. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/store/factory.py +0 -0
  78. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/store/schema.py +0 -0
  79. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/store/types.py +0 -0
  80. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/structured.py +0 -0
  81. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/stub_provider.py +0 -0
  82. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/timers.py +0 -0
  83. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/tools/__init__.py +0 -0
  84. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/tools/blackboard.py +0 -0
  85. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/tools/default_manifest.py +0 -0
  86. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/tools/default_tools.py +0 -0
  87. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/tools/registry.py +0 -0
  88. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/tools/spawn_agent.py +0 -0
  89. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/workflow/__init__.py +0 -0
  90. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/workflow/api.py +0 -0
  91. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/workflow/engine.py +0 -0
  92. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/workflow/introspect.py +0 -0
  93. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/workflow/journal.py +0 -0
  94. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/workflow/result.py +0 -0
  95. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/workflow/resume.py +0 -0
  96. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/workflow/runner.py +0 -0
  97. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/workflow/spec.py +0 -0
  98. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/workflow/subprocess_executor.py +0 -0
  99. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/workflow/tool.py +0 -0
  100. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/workflow/worker.py +0 -0
  101. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop.egg-info/dependency_links.txt +0 -0
  102. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop.egg-info/requires.txt +0 -0
  103. {power_loop-2.2.0 → power_loop-3.0.0}/power_loop.egg-info/top_level.txt +0 -0
  104. {power_loop-2.2.0 → power_loop-3.0.0}/pyproject.toml +0 -0
  105. {power_loop-2.2.0 → power_loop-3.0.0}/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.0
4
4
  Summary: Embeddable agent execution kernel — LLM loop, hooks, events, tools, dynamic sub-agents.
5
5
  Author-email: zhangran <zhangran24@126.com>
6
6
  License: MIT
@@ -15,7 +15,7 @@ Stability tiers
15
15
  无版本承诺,可随时变更或删除。
16
16
  """
17
17
 
18
- __version__ = "2.2.0"
18
+ __version__ = "3.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).
@@ -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
 
@@ -1113,7 +1113,10 @@ class StatefulAgentLoop:
1113
1113
  system_prompt: str | None = None,
1114
1114
  ) -> StatefulResult:
1115
1115
  store = await self._ensure_store()
1116
- projector = self.config.history_projector
1116
+ # 3.0: projection-style representation drives the derived-layer path; verbatim → None
1117
+ # (in-place compactor path). The fold trigger/keep come from config.fold_strategy.
1118
+ projector = self.config.projection_representation
1119
+ fold_strategy = self.config.fold_strategy
1117
1120
  # The current send's authoritative index (set by _persist_user_input; inherited by
1118
1121
  # resume()/follow-up). Read up-front so projection mode can partition history by it.
1119
1122
  si = await store.get_runtime_state(sid, "send_index", default=0)
@@ -1149,10 +1152,10 @@ class StatefulAgentLoop:
1149
1152
  "history_mode": current_mode,
1150
1153
  "projector_version": int(getattr(projector, "version", 0) or 0) if projector else None,
1151
1154
  "projector_trigger_ratio": (
1152
- float(getattr(projector, "trigger_ratio", 0) or 0) if projector else None
1155
+ float(getattr(fold_strategy, "trigger_ratio", 0) or 0) if projector else None
1153
1156
  ),
1154
1157
  "projector_keep_last_sends": (
1155
- int(getattr(projector, "keep_last_sends", 0) or 0) if projector else None
1158
+ int(getattr(fold_strategy, "keep_last_sends", 0) or 0) if projector else None
1156
1159
  ),
1157
1160
  })
1158
1161
  elif recorded != current_mode:
@@ -1178,7 +1181,7 @@ class StatefulAgentLoop:
1178
1181
  "send_index — resume before any send); best-effort.", sid,
1179
1182
  )
1180
1183
  projection_active = False
1181
- elif not migrated and self.config.migrate_history_on_projection_switch:
1184
+ elif not migrated and self.config.migrate_history_on_switch:
1182
1185
  # One-time migration: a session with prior NON-projection history opened in
1183
1186
  # projection mode. Fold that prior history into the projection table so it becomes
1184
1187
  # projection-native (a compact + the recent keep_last_sends as project rows) rather
@@ -1443,7 +1446,7 @@ class StatefulAgentLoop:
1443
1446
  self,
1444
1447
  store: SessionStore,
1445
1448
  sid: str,
1446
- projector: HistoryProjector,
1449
+ projector: Representation,
1447
1450
  *,
1448
1451
  current_send_index: int,
1449
1452
  active_rows: list[Any],
@@ -1452,12 +1455,12 @@ class StatefulAgentLoop:
1452
1455
  projection table so it becomes projection-native (starts with a ``compact`` covering the
1453
1456
  old sends, plus the most-recent ``keep_last_sends`` as individual project rows). Handles
1454
1457
  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)."""
1458
+ compactor had folded (its ``compact_note`` summary seeds the projection compact). Folds via
1459
+ the configured ``fold_strategy`` (bounded by ``fold_timeout_s``, OUTSIDE the migration write
1460
+ lock). Never raises (the caller swallows + sets the migrated marker only on success)."""
1458
1461
  from power_loop.runtime.store.types import ProjectMessageRow
1459
1462
 
1460
- keep = max(int(getattr(projector, "keep_last_sends", 0) or 0), 0)
1463
+ keep = max(int(getattr(self.config.fold_strategy, "keep_last_sends", 0) or 0), 0)
1461
1464
  version = int(getattr(projector, "version", 0) or 0)
1462
1465
  note = next((r for r in active_rows if r.name == "compact_note"), None)
1463
1466
  by_send: dict[int, list[Any]] = {}
@@ -1485,7 +1488,7 @@ class StatefulAgentLoop:
1485
1488
  )
1486
1489
 
1487
1490
  # 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).
1491
+ # folded sends, via the configured fold_strategy (run below with the fold timeout).
1489
1492
  to_compact: list[ProjectMessageRow] = []
1490
1493
  if note is not None:
1491
1494
  to_compact.append(_pm(0, "compact", {"summary": note.content or ""}))
@@ -1493,14 +1496,25 @@ class StatefulAgentLoop:
1493
1496
  for pr in projected[si].rows:
1494
1497
  to_compact.append(_pm(si, pr.kind, pr.content))
1495
1498
  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
- )
1499
+ migration_note_ops: list[Any] = []
1500
+ folded = None
1501
+ if any(r.kind in ("user", "project") for r in to_compact):
1502
+ from power_loop.runtime.fold import FoldContext
1503
+
1504
+ # Same fold-timeout guard as the end-of-send path (this runs OUTSIDE the migration's
1505
+ # write lock — write_projection_migration is called afterwards with the result).
1506
+ folded = await self._run_fold_with_timeout(
1507
+ self.config.fold_strategy,
1508
+ to_compact,
1509
+ FoldContext(
1510
+ session_id=sid, round_index=0, representation=projector,
1511
+ llm=self.llm, max_tokens=self.config.max_tokens,
1512
+ ),
1513
+ )
1501
1514
  if folded is not None:
1502
1515
  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))
1516
+ compact_tuple = (folded.content, folded.rendered_text, from_send, folded.folded_to_send)
1517
+ migration_note_ops = list(folded.note_ops)
1504
1518
  elif note is not None:
1505
1519
  # Only the note, nothing foldable beyond keep → preserve the note as a standalone
1506
1520
  # compact sitting just before the kept tail (or the current send).
@@ -1518,6 +1532,14 @@ class StatefulAgentLoop:
1518
1532
  sid, project_rows=project_rows, compact=compact_tuple, projector_version=version,
1519
1533
  metadata_patch={"projection_migrated": current_send_index},
1520
1534
  )
1535
+ for op in migration_note_ops: # agentic-fold facts from the migrated history (best-effort)
1536
+ try:
1537
+ if getattr(op, "op", None) == "add":
1538
+ await store.add_note(sid, op.content or "", pinned=bool(op.pinned))
1539
+ elif getattr(op, "op", None) == "update":
1540
+ await store.update_note(sid, op.note_id, content=op.content, pinned=op.pinned)
1541
+ except Exception:
1542
+ logger.exception("session %s: migration note op failed (continuing)", sid)
1521
1543
 
1522
1544
  async def _write_send_projection(
1523
1545
  self,
@@ -1527,7 +1549,7 @@ class StatefulAgentLoop:
1527
1549
  send_index: int,
1528
1550
  status: str,
1529
1551
  session_row: SessionRow | None,
1530
- projector: HistoryProjector,
1552
+ projector: Representation,
1531
1553
  tool_registry: ToolRegistry | None,
1532
1554
  ) -> None:
1533
1555
  """Project the just-finished send's ``pl_messages`` rows into ``pl_project_messages`` (v2).
@@ -1550,70 +1572,105 @@ class StatefulAgentLoop:
1550
1572
  version = int(getattr(projector, "version", 0) or 0)
1551
1573
  proj_rows_to_write = [(pr.kind, pr.content, pr.rendered_text) for pr in projected.rows]
1552
1574
 
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,
1575
+ # power-loop 3.0, THREE phases so the (multi-second / possibly-hung) LLM fold never runs
1576
+ # inside a DB transaction or under the session lock:
1577
+ # 1) write this send's projection rows under a SHORT lock + snapshot the live rows;
1578
+ # 2) decide + run the fold OUTSIDE the lock (bounded by fold_timeout_s, soft-fails);
1579
+ # 3) commit the compact under a SHORT lock with optimistic concurrency (skip if a
1580
+ # concurrent loop already advanced the compact cursor).
1581
+ prior, snapshot = await store.write_send_projection_rows(
1582
+ sid, send_index=send_index, rows=proj_rows_to_write,
1583
+ source_seq_lo=projected.source_seq_lo, source_seq_hi=projected.source_seq_hi,
1614
1584
  projector_version=version,
1615
- plan_compaction=plan_compaction,
1616
1585
  )
1586
+ plan, note_ops = await self._plan_and_run_projection_fold(sid, projector, prior, snapshot)
1587
+ if plan is None:
1588
+ return
1589
+ content, rendered_text, from_send, to_send = plan
1590
+ committed = await store.commit_projection_fold(
1591
+ sid, content=content, rendered_text=rendered_text, from_send=from_send,
1592
+ to_send=to_send, projector_version=version,
1593
+ expected_prior_to_send=(prior.compact_to_send if prior is not None else None),
1594
+ )
1595
+ if committed:
1596
+ await self._apply_fold_notes(store, sid, note_ops)
1597
+
1598
+ async def _plan_and_run_projection_fold(
1599
+ self, sid: str, projector: Representation,
1600
+ prior: ProjectMessageRow | None, snapshot: list[ProjectMessageRow],
1601
+ ) -> tuple[tuple[Any, str | None, int, int] | None, tuple[Any, ...]]:
1602
+ """Decide whether to fold (token threshold + keep-recent floor) and, if so, run the
1603
+ configured ``fold_strategy`` OUTSIDE any lock (bounded by ``fold_timeout_s``). Always keeps
1604
+ the most-recent ``keep_last_sends`` whole sends (never splits an atomic tool pair). Rolls any
1605
+ prior compact forward so nothing is lost; the folded rows REMAIN (recall_send). Returns
1606
+ ``((content, rendered_text, from_send, to_send), note_ops)`` or ``(None, ())``. Soft-fails
1607
+ (no fold, rows already written) on any error/timeout — pl_messages stays the source of truth."""
1608
+ fold_strategy = self.config.fold_strategy
1609
+ try:
1610
+ keep = int(getattr(fold_strategy, "keep_last_sends", 0) or 0)
1611
+ if keep <= 0:
1612
+ return None, ()
1613
+ live_sends = sorted({r.send_index for r in snapshot if r.kind in ("user", "project")})
1614
+ if len(live_sends) <= keep:
1615
+ return None, () # nothing foldable beyond the keep-recent floor
1616
+ trigger_ratio = float(getattr(fold_strategy, "trigger_ratio", 0.75) or 0.75)
1617
+ threshold = int((self.config.max_tokens or 8000) * trigger_ratio)
1618
+ rendered_prefix = projector.render(([prior] if prior is not None else []) + snapshot)
1619
+ if estimate_tokens(rendered_prefix) < threshold:
1620
+ return None, () # below threshold — small per-send projections just accumulate
1621
+ fold_sends = set(live_sends[: len(live_sends) - keep])
1622
+ fold_rows = [
1623
+ r for r in snapshot
1624
+ if r.kind in ("user", "project") and r.send_index in fold_sends
1625
+ ]
1626
+ to_compact = ([prior] if prior is not None else []) + fold_rows # roll prior fwd
1627
+ from_send = (
1628
+ prior.compact_from_send
1629
+ if (prior is not None and prior.compact_from_send is not None)
1630
+ else min(fold_sends)
1631
+ )
1632
+ from power_loop.runtime.fold import FoldContext
1633
+
1634
+ ctx = FoldContext(
1635
+ session_id=sid, round_index=0, representation=projector,
1636
+ llm=self.llm, max_tokens=self.config.max_tokens,
1637
+ )
1638
+ fr = await self._run_fold_with_timeout(fold_strategy, to_compact, ctx)
1639
+ if fr is None:
1640
+ return None, ()
1641
+ return (fr.content, fr.rendered_text, from_send, fr.folded_to_send), tuple(fr.note_ops)
1642
+ except Exception:
1643
+ logger.exception(
1644
+ "projection fold planning failed for %s (skipping fold; rows still written)", sid,
1645
+ )
1646
+ return None, ()
1647
+
1648
+ async def _run_fold_with_timeout(self, fold_strategy: Any, rows: Any, ctx: Any) -> Any | None:
1649
+ """Await ``fold_strategy.fold`` bounded by ``config.fold_timeout_s`` (None disables). A
1650
+ timeout soft-fails to None (no fold this send; rows already committed)."""
1651
+ timeout = self.config.fold_timeout_s
1652
+ try:
1653
+ if timeout is not None and timeout > 0:
1654
+ return await asyncio.wait_for(fold_strategy.fold(rows, context=ctx), timeout)
1655
+ return await fold_strategy.fold(rows, context=ctx)
1656
+ except (TimeoutError, asyncio.TimeoutError):
1657
+ logger.warning(
1658
+ "projection fold timed out after %ss for %s (skipping fold this send)",
1659
+ timeout, ctx.session_id,
1660
+ )
1661
+ return None
1662
+
1663
+ async def _apply_fold_notes(self, store: SessionStore, sid: str, note_ops: tuple[Any, ...]) -> None:
1664
+ """Apply an agentic fold's captured NoteOps (best-effort, additive memory — NOT
1665
+ transactional with the compact; a rare crash here loses a note, never corrupts context)."""
1666
+ for op in note_ops:
1667
+ try:
1668
+ if getattr(op, "op", None) == "add":
1669
+ await store.add_note(sid, op.content or "", pinned=bool(op.pinned))
1670
+ elif getattr(op, "op", None) == "update":
1671
+ await store.update_note(sid, op.note_id, content=op.content, pinned=op.pinned)
1672
+ except Exception:
1673
+ logger.exception("session %s: applying fold note op failed (continuing)", sid)
1617
1674
 
1618
1675
  async def _prime_sink_from_pending(self, sid: str, sink: SQLiteSink) -> None:
1619
1676
  store = await self._ensure_store()
@@ -0,0 +1,279 @@
1
+ from __future__ import annotations
2
+
3
+ import warnings
4
+ from dataclasses import dataclass, field
5
+ from typing import TYPE_CHECKING, Any, Literal
6
+
7
+ #: Distinguishes "caller passed nothing" from an explicit None on the deprecated 2.x kwargs.
8
+ _UNSET: Any = object()
9
+
10
+ if TYPE_CHECKING:
11
+ from power_loop.runtime.fold import FoldStrategy
12
+ from power_loop.runtime.memory import MemoryProvider
13
+ from power_loop.runtime.notes import NotesPolicy
14
+ from power_loop.runtime.representation import Representation
15
+ from power_loop.runtime.retry import LLMRetryPolicy
16
+ from power_loop.runtime.runtime_state import RuntimeProjector
17
+
18
+ LoopStatus = Literal["completed", "pending_tools", "waiting_for_input", "cancelled", "hit_round_limit", "budget_exceeded", "degraded"]
19
+ LoopMessage = dict[str, Any]
20
+
21
+
22
+ def _default_representation() -> Representation:
23
+ from power_loop.runtime.representation import VerbatimRepresentation
24
+ return VerbatimRepresentation()
25
+
26
+
27
+ def _default_fold_strategy() -> FoldStrategy:
28
+ from power_loop.runtime.fold import LLMSummaryFold
29
+ return LLMSummaryFold()
30
+
31
+
32
+ def _fold_from_legacy_projector(proj: Any) -> FoldStrategy:
33
+ """Seed the default LLM fold from a DEPRECATED ``history_projector``'s knobs so a legacy
34
+ projector's ``keep_last_sends`` / ``trigger_ratio`` keep taking effect (e.g. DeepTalk's
35
+ admin-configured projection settings). Without this the mapped fold would silently use
36
+ ``LLMSummaryFold`` defaults (4 / 0.75) and ignore the operator's config."""
37
+ from power_loop.runtime.fold import LLMSummaryFold
38
+ keep = max(1, int(getattr(proj, "keep_last_sends", 4) or 4))
39
+ trigger = float(getattr(proj, "trigger_ratio", 0.75) or 0.75)
40
+ return LLMSummaryFold(keep_last_sends=keep, trigger_ratio=trigger)
41
+
42
+
43
+ def _default_runtime_projectors() -> tuple[RuntimeProjector, ...]:
44
+ from power_loop.runtime.runtime_state import default_runtime_projectors
45
+ return default_runtime_projectors()
46
+
47
+
48
+ @dataclass
49
+ class AgentLoopConfig:
50
+ """Configuration for the agent loop."""
51
+
52
+ system_prompt: str | None = None
53
+ max_rounds: int = 24
54
+ temperature: float | None = 0.0
55
+ max_tokens: int | None = 8000
56
+ #: Per-loop model override. When set, every LLM request of this loop carries
57
+ #: it (the provider uses ``request.model`` over its own configured model), so
58
+ #: a sub-agent / workflow step can run on a different model than the global
59
+ #: one. ``None`` → use the LLM service's configured model.
60
+ model: str | None = None
61
+ #: OpenAI-compatible ``response_format`` (e.g. a json_schema dict from
62
+ #: ``StructuredOutputSpec.to_openai_response_format()``), forwarded on the
63
+ #: main generation call so the provider returns structured output. ``None``
64
+ #: → free-form text.
65
+ response_format: dict[str, Any] | None = None
66
+ #: Hard per-run token budget (prompt + completion summed over the whole
67
+ #: run, real provider usage — see ``ContextManager.usage_totals``). Checked
68
+ #: at round boundaries: the round that crosses the budget still finishes
69
+ #: (so no tool_calls are left dangling), then the loop stops with
70
+ #: status="budget_exceeded". ``None`` disables.
71
+ max_tokens_per_run: int | None = None
72
+ #: Context handling = REPRESENTATION × FOLD_STRATEGY (power-loop 3.0), two orthogonal axes.
73
+ #:
74
+ #: ``representation`` — how each finished send is recorded/rendered:
75
+ #: * ``VerbatimRepresentation`` (default): full messages, byte-identical history.
76
+ #: * ``ProjectedRepresentation``: a per-send terse plain-text projection (send-context
77
+ #: projection), original detail kept in ``pl_messages`` (recall_send re-expands).
78
+ #: Custom representations implement the ``Representation`` Protocol.
79
+ representation: Representation = _UNSET # resolved in __post_init__ (default or legacy-mapped)
80
+ #: ``fold_strategy`` — how older history is compacted (N records → 1 compact) once over budget:
81
+ #: * ``LLMSummaryFold`` (default): one LLM summary call, no side effects.
82
+ #: * ``AgenticFold``: LLM + a bounded tool loop that persists durable facts as notes.
83
+ #: * custom: any ``FoldStrategy`` Protocol impl.
84
+ #: Works under EITHER representation. Folds are always LLM-backed (no deterministic/never-fold).
85
+ #: Trigger + keep-recent come from the strategy (``trigger_ratio`` / ``keep_last_sends``);
86
+ #: the fold always keeps whole sends (never splits an atomic tool-call/result pair).
87
+ fold_strategy: FoldStrategy = _UNSET # resolved in __post_init__ (default or legacy-mapped)
88
+ #: Wall-clock bound (seconds) on a single fold's LLM/agentic call. The fold runs OUTSIDE the
89
+ #: store lock, but a hung provider would still stall the end-of-send path; on timeout the fold
90
+ #: soft-fails (rows committed, no compact this send — retried next send). None disables.
91
+ fold_timeout_s: float | None = 120.0
92
+ #: On a representation/fold change for an existing session, fold the prior history into the new
93
+ #: form ONCE (best-effort, never throws). Default True.
94
+ migrate_history_on_switch: bool = True
95
+ # ── deprecated 2.x kwargs (accepted + mapped onto the two axes in __post_init__) ──
96
+ # The public API is representation × fold_strategy; these keep existing call sites + DeepTalk
97
+ # working until migrated. A future major drops them.
98
+ compactor: Any = _UNSET
99
+ history_projector: Any = _UNSET
100
+ migrate_history_on_projection_switch: Any = _UNSET
101
+ #: History-repair backstop (the always-on prompt sanitizer in `align_tool_calls` realigns
102
+ #: tool-call/result pairing before every LLM call regardless). When True, the orphan
103
+ #: tool-result rows that sanitizer drops are ALSO physically deactivated in the store
104
+ #: (state=DROPPED), so the corruption is repaired durably and not re-sanitized every load.
105
+ #: Default False to keep `pl_messages` immutable; the prompt is kept valid either way.
106
+ repair_corrupt_history: bool = False
107
+ retry_policy: LLMRetryPolicy | None = None
108
+ memory: MemoryProvider | None = None
109
+ memory_budget_tokens: int = 1500
110
+ # Bounds for the note_add/note_update/note_delete tools (agent-authored
111
+ # notes). None → DEFAULT_NOTES_POLICY. See power_loop.runtime.notes.
112
+ notes_policy: NotesPolicy | None = None
113
+ skills_dir: str | None = None
114
+ runtime_projectors: tuple[RuntimeProjector, ...] = field(default_factory=_default_runtime_projectors)
115
+
116
+ # ── Tool catalog auto-injection (M1.10) ──
117
+ #
118
+ # When ``inject_tool_descriptions`` is True (default), the pipeline
119
+ # automatically appends a human-readable tool catalog to the resolved
120
+ # system prompt. The catalog is generated from the live
121
+ # ``ToolRegistry`` so the agent always knows which tools are
122
+ # available — even when the user-supplied system prompt does not
123
+ # mention them.
124
+ #
125
+ # The catalog lives inside ``self.system_prompt`` (a plain string
126
+ # attribute on the pipeline), NOT in ``self.history``, so the
127
+ # compactor never touches it.
128
+ inject_tool_descriptions: bool = True
129
+ tool_catalog_header: str = "# Available Tools"
130
+
131
+ def __post_init__(self) -> None:
132
+ self._map_legacy_axes()
133
+ self._validate_context_config()
134
+ # Mark init complete so __setattr__ starts re-validating reassignments (the dataclass
135
+ # is mutable; a post-hoc reassignment of an axis or max_tokens must stay valid).
136
+ object.__setattr__(self, "_initialized", True)
137
+
138
+ def _map_legacy_axes(self) -> None:
139
+ """Resolve representation/fold_strategy, mapping the deprecated 2.x ``history_projector`` /
140
+ ``compactor`` / ``migrate_history_on_projection_switch`` kwargs onto them (NEW fields win
141
+ when explicitly set). A legacy ``compactor`` (incl. ``None`` = no compaction) under verbatim
142
+ is preserved EXACTLY via ``_legacy_verbatim_compactor`` so old behavior is unchanged; a
143
+ legacy projector becomes the projection representation (its fold is now the fold_strategy)."""
144
+ legacy_proj = self.history_projector
145
+ legacy_comp = self.compactor
146
+ fold_was_unset = self.fold_strategy is _UNSET
147
+ if legacy_proj is not _UNSET or legacy_comp is not _UNSET or (
148
+ self.migrate_history_on_projection_switch is not _UNSET
149
+ ):
150
+ warnings.warn(
151
+ "AgentLoopConfig: history_projector / compactor / "
152
+ "migrate_history_on_projection_switch are deprecated; use representation / "
153
+ "fold_strategy / migrate_history_on_switch.",
154
+ DeprecationWarning,
155
+ stacklevel=3,
156
+ )
157
+ if self.representation is _UNSET:
158
+ rep = legacy_proj if legacy_proj not in (_UNSET, None) else _default_representation()
159
+ object.__setattr__(self, "representation", rep)
160
+ if fold_was_unset:
161
+ # Seed the fold from a legacy projector's knobs so its keep_last_sends / trigger_ratio
162
+ # keep taking effect (DeepTalk admin config); else the library default.
163
+ fs = (
164
+ _fold_from_legacy_projector(legacy_proj)
165
+ if legacy_proj not in (_UNSET, None)
166
+ else _default_fold_strategy()
167
+ )
168
+ object.__setattr__(self, "fold_strategy", fs)
169
+ # A legacy verbatim compactor (incl. an explicit None = no compaction) is preserved exactly
170
+ # via resolve_compactor — but ONLY on the pure-legacy path (no projector AND no explicit
171
+ # new fold_strategy). If the caller set fold_strategy explicitly, the new axis wins and a
172
+ # stray legacy compactor= must NOT silently disable it.
173
+ if legacy_comp is not _UNSET and legacy_proj in (_UNSET, None) and fold_was_unset:
174
+ object.__setattr__(self, "_legacy_verbatim_compactor", legacy_comp)
175
+ else:
176
+ object.__setattr__(self, "_legacy_verbatim_compactor", _UNSET)
177
+ if self.migrate_history_on_projection_switch is not _UNSET:
178
+ object.__setattr__(
179
+ self, "migrate_history_on_switch",
180
+ bool(self.migrate_history_on_projection_switch),
181
+ )
182
+ # Clear the deprecated fields so they never leak into fingerprints / re-validation.
183
+ object.__setattr__(self, "compactor", _UNSET)
184
+ object.__setattr__(self, "history_projector", _UNSET)
185
+ object.__setattr__(self, "migrate_history_on_projection_switch", _UNSET)
186
+
187
+ def _validate_context_config(self) -> None:
188
+ if self.representation is None or self.fold_strategy is None:
189
+ raise ValueError("AgentLoopConfig: representation and fold_strategy are required")
190
+ # max_tokens drives the fold trigger (max_tokens × trigger_ratio); a non-positive value
191
+ # would make the fold misbehave (always/never fold), so reject it up front.
192
+ if self.max_tokens is not None and self.max_tokens <= 0:
193
+ raise ValueError(
194
+ f"AgentLoopConfig: max_tokens must be > 0 (drives the fold trigger); "
195
+ f"got {self.max_tokens!r}"
196
+ )
197
+
198
+ def __setattr__(self, name: str, value: Any) -> None:
199
+ if name in ("representation", "fold_strategy", "max_tokens") and getattr(
200
+ self, "_initialized", False
201
+ ):
202
+ old = getattr(self, name)
203
+ super().__setattr__(name, value)
204
+ try:
205
+ self._validate_context_config()
206
+ except Exception:
207
+ # A rejected reassignment must not leave the config in the invalid state it was
208
+ # about to enter (mutate-then-validate would otherwise corrupt it) — roll back.
209
+ super().__setattr__(name, old)
210
+ raise
211
+ else:
212
+ super().__setattr__(name, value)
213
+
214
+ # ── internal resolution (3.0): map the two axes onto the loop's two mechanisms ──
215
+ # Projection-style representations drive the derived-layer path (fold via fold_strategy at
216
+ # end-of-send); verbatim drives the in-place compactor path (fold_strategy mapped to a
217
+ # Compactor whose span selection already keeps atomic tool pairs / keep_last_n intact).
218
+ @property
219
+ def projection_representation(self) -> Any | None:
220
+ """The representation when it's projection-style (renders per-send projections), else None
221
+ (verbatim → in-place fold path)."""
222
+ rep = self.representation
223
+ return rep if getattr(rep, "kind", "projection") != "verbatim" else None
224
+
225
+ def resolve_compactor(self) -> Any | None:
226
+ """Verbatim mode → an in-place ``Compactor`` mapped from ``fold_strategy``; projection mode
227
+ → ``None`` (projection folds at end-of-send via ``fold_strategy``). Constructed fresh per
228
+ call (cheap)."""
229
+ # Projection folds at end-of-send via fold_strategy — never an in-place compactor. Checked
230
+ # FIRST so a post-init switch to a projection representation can't leave a stale legacy
231
+ # verbatim compactor active (which would double-fold alongside the derived-layer fold).
232
+ if getattr(self.representation, "kind", "projection") != "verbatim":
233
+ return None
234
+ # A deprecated verbatim ``compactor=`` (incl. None) is honored verbatim, so legacy
235
+ # call sites keep their EXACT old compaction behavior.
236
+ legacy = getattr(self, "_legacy_verbatim_compactor", _UNSET)
237
+ if legacy is not _UNSET:
238
+ return legacy
239
+ from power_loop.runtime.fold import AgenticFold, LLMSummaryFold
240
+
241
+ fs = self.fold_strategy
242
+ if isinstance(fs, AgenticFold):
243
+ from power_loop.runtime.compact import AgenticMemoryCompactor
244
+
245
+ return AgenticMemoryCompactor(
246
+ keep_last_n=fs.keep_last_sends,
247
+ trigger_ratio=fs.trigger_ratio,
248
+ summary_max_tokens=fs.summary_max_tokens,
249
+ max_rounds=fs.max_rounds,
250
+ )
251
+ if isinstance(fs, LLMSummaryFold):
252
+ from power_loop.runtime.compact import DefaultCompactor
253
+
254
+ return DefaultCompactor(
255
+ keep_last_n=fs.keep_last_sends,
256
+ trigger_ratio=fs.trigger_ratio,
257
+ summary_max_tokens=fs.summary_max_tokens,
258
+ )
259
+ # A custom FoldStrategy under verbatim → adapt it onto the in-place Compactor interface
260
+ # (DefaultCompactor's span selection keeps atomic tool pairs / keep_last_n intact).
261
+ from power_loop.runtime.fold_adapter import FoldStrategyCompactor
262
+
263
+ return FoldStrategyCompactor(fs, max_tokens=self.max_tokens)
264
+
265
+
266
+ @dataclass
267
+ class AgentLoopResult:
268
+ status: LoopStatus
269
+ final_text: str = ""
270
+ rounds: int = 0
271
+ pending_tool_calls: list[dict[str, Any]] = field(default_factory=list)
272
+ pending_interactions: list[dict[str, Any]] = field(default_factory=list)
273
+ messages: list[LoopMessage] = field(default_factory=list)
274
+ #: Cumulative token usage across every LLM call of this run:
275
+ #: {prompt_tokens, completion_tokens, cache_read_tokens, reasoning_tokens,
276
+ #: total_tokens, calls}. Empty dict when the run never reached the LLM.
277
+ usage: dict[str, int] = field(default_factory=dict)
278
+ #: Tool invocations executed during this run.
279
+ tool_calls: int = 0