power-loop 2.1.0__tar.gz → 2.2.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 (102) hide show
  1. {power_loop-2.1.0 → power_loop-2.2.0}/PKG-INFO +2 -1
  2. {power_loop-2.1.0 → power_loop-2.2.0}/README.md +1 -0
  3. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/__init__.py +17 -1
  4. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/agent/sink.py +6 -0
  5. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/agent/stateful_loop.py +445 -32
  6. power_loop-2.2.0/power_loop/agent/types.py +159 -0
  7. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/contracts/errors.py +8 -4
  8. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/contracts/tools.py +11 -1
  9. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/core/pipeline.py +13 -1
  10. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/runtime/compact.py +156 -7
  11. power_loop-2.2.0/power_loop/runtime/history_projector.py +385 -0
  12. power_loop-2.2.0/power_loop/runtime/history_sanitize.py +104 -0
  13. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/runtime/store/dialect.py +54 -3
  14. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/runtime/store/schema.py +96 -3
  15. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/runtime/store/store.py +276 -6
  16. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/runtime/store/types.py +33 -0
  17. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/tools/default_manifest.py +17 -0
  18. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/tools/default_tools.py +64 -0
  19. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop.egg-info/PKG-INFO +2 -1
  20. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop.egg-info/SOURCES.txt +2 -0
  21. power_loop-2.1.0/power_loop/agent/types.py +0 -90
  22. {power_loop-2.1.0 → power_loop-2.2.0}/LICENSE +0 -0
  23. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/_vendor/__init__.py +0 -0
  24. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/_vendor/llm_client/__init__.py +0 -0
  25. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/_vendor/llm_client/anthropic_factory.py +0 -0
  26. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/_vendor/llm_client/capabilities.py +0 -0
  27. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/_vendor/llm_client/interface.py +0 -0
  28. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/_vendor/llm_client/llm_factory.py +0 -0
  29. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/_vendor/llm_client/llm_tooling.py +0 -0
  30. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/_vendor/llm_client/llm_utils.py +0 -0
  31. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/_vendor/llm_client/multimodal.py +0 -0
  32. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/agent/__init__.py +0 -0
  33. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/agent/follow_up.py +0 -0
  34. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/agent/system_prompt.py +0 -0
  35. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/contracts/__init__.py +0 -0
  36. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/contracts/event_payloads.py +0 -0
  37. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/contracts/events.py +0 -0
  38. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/contracts/handlers.py +0 -0
  39. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/contracts/hook_contexts.py +0 -0
  40. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/contracts/hooks.py +0 -0
  41. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/contracts/messages.py +0 -0
  42. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/contracts/protocols.py +0 -0
  43. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/contrib/__init__.py +0 -0
  44. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/contrib/_redact.py +0 -0
  45. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/contrib/jsonl_sink.py +0 -0
  46. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/contrib/logging_sink.py +0 -0
  47. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/contrib/mcp.py +0 -0
  48. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/contrib/metrics_sink.py +0 -0
  49. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/contrib/otel_sink.py +0 -0
  50. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/core/agent_context.py +0 -0
  51. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/core/events.py +0 -0
  52. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/core/hooks.py +0 -0
  53. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/core/phase.py +0 -0
  54. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/core/runner.py +0 -0
  55. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/core/state.py +0 -0
  56. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/py.typed +0 -0
  57. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/runtime/blackboard.py +0 -0
  58. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/runtime/budget.py +0 -0
  59. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/runtime/cancellation.py +0 -0
  60. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/runtime/env.py +0 -0
  61. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/runtime/exec_backend.py +0 -0
  62. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/runtime/human_input.py +0 -0
  63. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/runtime/memory.py +0 -0
  64. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/runtime/notes.py +0 -0
  65. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/runtime/provider.py +0 -0
  66. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/runtime/retry.py +0 -0
  67. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/runtime/runtime_state.py +0 -0
  68. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/runtime/session_store.py +0 -0
  69. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/runtime/skills.py +0 -0
  70. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/runtime/spec.py +0 -0
  71. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/runtime/store/__init__.py +0 -0
  72. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/runtime/store/backends/__init__.py +0 -0
  73. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/runtime/store/backends/mysql.py +0 -0
  74. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/runtime/store/backends/postgres.py +0 -0
  75. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/runtime/store/backends/sqlite.py +0 -0
  76. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/runtime/store/capabilities.py +0 -0
  77. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/runtime/store/db.py +0 -0
  78. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/runtime/store/factory.py +0 -0
  79. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/runtime/structured.py +0 -0
  80. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/runtime/stub_provider.py +0 -0
  81. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/runtime/timers.py +0 -0
  82. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/tools/__init__.py +0 -0
  83. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/tools/blackboard.py +0 -0
  84. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/tools/registry.py +0 -0
  85. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/tools/spawn_agent.py +0 -0
  86. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/workflow/__init__.py +0 -0
  87. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/workflow/api.py +0 -0
  88. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/workflow/engine.py +0 -0
  89. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/workflow/introspect.py +0 -0
  90. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/workflow/journal.py +0 -0
  91. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/workflow/result.py +0 -0
  92. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/workflow/resume.py +0 -0
  93. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/workflow/runner.py +0 -0
  94. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/workflow/spec.py +0 -0
  95. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/workflow/subprocess_executor.py +0 -0
  96. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/workflow/tool.py +0 -0
  97. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/workflow/worker.py +0 -0
  98. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop.egg-info/dependency_links.txt +0 -0
  99. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop.egg-info/requires.txt +0 -0
  100. {power_loop-2.1.0 → power_loop-2.2.0}/power_loop.egg-info/top_level.txt +0 -0
  101. {power_loop-2.1.0 → power_loop-2.2.0}/pyproject.toml +0 -0
  102. {power_loop-2.1.0 → power_loop-2.2.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: power-loop
3
- Version: 2.1.0
3
+ Version: 2.2.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
@@ -154,6 +154,7 @@ Most "agent frameworks" ask you to build your app *inside* them. power-loop is t
154
154
  | **Process sandboxing** | Each workflow leaf in its own OS process + own DB; wrap each in gVisor/Docker per leaf | [Sandboxing](docs/en/user-guide/sandboxing.md) |
155
155
  | **Durable timers** | Agents schedule their own wake-ups; survive restarts; one-shot or recurring | [Timers](docs/en/user-guide/timers.md) |
156
156
  | **Context compaction** | Auto-summarize old turns (never splits a tool-call pair); `recall_compacted` to pull originals back | [Compaction](docs/en/user-guide/compaction.md) |
157
+ | **Send-context projection** | Opt-in: feed a per-send plain-text projection of finished sends (derived `pl_project_messages`) instead of verbatim history; `pl_messages` stays immutable; `recall_send` to re-expand | [Projection](docs/en/user-guide/send-context-projection.md) |
157
158
  | **Durability ops** | Portable migration-version table, retention/prune, VACUUM, `export_session`/`import_session`, graceful `aclose()` | [Sessions](docs/en/user-guide/sessions.md) |
158
159
  | **Observability** | Typed `seq`-ordered events → durable JSONL + `replay`, Prometheus/StatsD metrics, OpenTelemetry spans | [Observability](docs/en/user-guide/observability.md) |
159
160
  | **MCP tools** | Surface a Model Context Protocol server's tools as power-loop tools | [Extending](docs/en/user-guide/extending-tools.md) |
@@ -81,6 +81,7 @@ Most "agent frameworks" ask you to build your app *inside* them. power-loop is t
81
81
  | **Process sandboxing** | Each workflow leaf in its own OS process + own DB; wrap each in gVisor/Docker per leaf | [Sandboxing](docs/en/user-guide/sandboxing.md) |
82
82
  | **Durable timers** | Agents schedule their own wake-ups; survive restarts; one-shot or recurring | [Timers](docs/en/user-guide/timers.md) |
83
83
  | **Context compaction** | Auto-summarize old turns (never splits a tool-call pair); `recall_compacted` to pull originals back | [Compaction](docs/en/user-guide/compaction.md) |
84
+ | **Send-context projection** | Opt-in: feed a per-send plain-text projection of finished sends (derived `pl_project_messages`) instead of verbatim history; `pl_messages` stays immutable; `recall_send` to re-expand | [Projection](docs/en/user-guide/send-context-projection.md) |
84
85
  | **Durability ops** | Portable migration-version table, retention/prune, VACUUM, `export_session`/`import_session`, graceful `aclose()` | [Sessions](docs/en/user-guide/sessions.md) |
85
86
  | **Observability** | Typed `seq`-ordered events → durable JSONL + `replay`, Prometheus/StatsD metrics, OpenTelemetry spans | [Observability](docs/en/user-guide/observability.md) |
86
87
  | **MCP tools** | Surface a Model Context Protocol server's tools as power-loop tools | [Extending](docs/en/user-guide/extending-tools.md) |
@@ -15,7 +15,7 @@ Stability tiers
15
15
  无版本承诺,可随时变更或删除。
16
16
  """
17
17
 
18
- __version__ = "2.1.0"
18
+ __version__ = "2.2.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,6 +145,14 @@ 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,
155
+ )
148
156
  from power_loop.runtime.human_input import HumanInputRequired, request_user_input
149
157
  from power_loop.runtime.memory import MemoryProvider, MemorySnapshot, tag_as_memory
150
158
  from power_loop.runtime.notes import (
@@ -186,6 +194,7 @@ from power_loop.runtime.store.store import (
186
194
  from power_loop.runtime.store.types import (
187
195
  MessageRow,
188
196
  MessageState,
197
+ ProjectMessageRow,
189
198
  SessionKind,
190
199
  SessionRow,
191
200
  SessionStatsRow,
@@ -311,6 +320,13 @@ __all__ = [
311
320
  "BackgroundRuntimeProjector",
312
321
  "default_runtime_projectors",
313
322
  "get_tool_runtime_context",
323
+ "HistoryProjector",
324
+ "IdentityProjector",
325
+ "DefaultDeterministicProjector",
326
+ "ProjectedSend",
327
+ "ProjectedRow",
328
+ "ProjectedCompact",
329
+ "ProjectMessageRow",
314
330
  "CancellationToken",
315
331
  "CancellationLike",
316
332
  "RuntimeEnv",
@@ -158,6 +158,8 @@ class SQLiteSink:
158
158
  tool_call_id=tool_call_id,
159
159
  name=message.get("name"),
160
160
  round_index=round_index,
161
+ meta=message.get("meta"),
162
+ send_index=message.get("send_index"),
161
163
  )
162
164
  self._history_seqs.append(seq)
163
165
  self._history_ord.append(seq)
@@ -199,6 +201,8 @@ class SQLiteSink:
199
201
  content=_as_text(message.get("content")),
200
202
  tool_calls=list(tool_calls) if tool_calls else None,
201
203
  round_index=round_index,
204
+ meta=message.get("meta"),
205
+ send_index=message.get("send_index"),
202
206
  )
203
207
  self._history_seqs.append(seq)
204
208
  self._history_ord.append(seq)
@@ -212,6 +216,8 @@ class SQLiteSink:
212
216
  content=_as_text(message.get("content")),
213
217
  name=message.get("name"),
214
218
  round_index=round_index,
219
+ meta=message.get("meta"),
220
+ send_index=message.get("send_index"),
215
221
  )
216
222
  self._history_seqs.append(seq)
217
223
  self._history_ord.append(seq)
@@ -25,7 +25,7 @@ import threading
25
25
  from collections import OrderedDict
26
26
  from collections.abc import Coroutine, Sequence
27
27
  from dataclasses import dataclass, field, replace
28
- from typing import Any
28
+ from typing import TYPE_CHECKING, Any
29
29
 
30
30
  from power_loop._vendor.llm_client.interface import LLMService
31
31
  from power_loop.agent.follow_up import FollowUpQueued, merge_follow_up_inputs
@@ -48,7 +48,9 @@ from power_loop.core.pipeline import (
48
48
  _truncate_result,
49
49
  )
50
50
  from power_loop.core.runner import AgentRunner
51
+ from power_loop.runtime.budget import estimate_tokens
51
52
  from power_loop.runtime.cancellation import CancellationLike
53
+ from power_loop.runtime.history_sanitize import align_tool_calls
52
54
  from power_loop.runtime.skills import SkillLoader
53
55
  from power_loop.runtime.store.schema import SchemaPolicy
54
56
  from power_loop.runtime.store.store import (
@@ -60,10 +62,15 @@ from power_loop.runtime.store.store import (
60
62
  from power_loop.runtime.store.types import (
61
63
  MessageRow,
62
64
  MessageState,
65
+ ProjectMessageRow,
66
+ SessionRow,
63
67
  SubagentLifecycle,
64
68
  )
65
69
  from power_loop.tools.registry import ToolRegistry
66
70
 
71
+ if TYPE_CHECKING:
72
+ from power_loop.runtime.history_projector import HistoryProjector
73
+
67
74
  logger = logging.getLogger(__name__)
68
75
 
69
76
 
@@ -1014,17 +1021,27 @@ class StatefulAgentLoop:
1014
1021
  role = str(user_input.get("role", "user"))
1015
1022
  content = _as_text(user_input.get("content"))
1016
1023
  name = user_input.get("name")
1017
- seq = await store.append_message(sid, role=role, content=content, name=name)
1024
+ # Allocate the next monotonic SEND index for this session (atomic RMW under the
1025
+ # session_state row lock — never resets, unlike round_index). This is the single
1026
+ # send-begin point (exactly one user row per send; resume()/follow-up drains do
1027
+ # NOT pass through here, so they correctly inherit the in-flight send's index).
1028
+ # Stamped into meta so the transcript can delimit sends authoritatively.
1029
+ send_index = await store.mutate_runtime_state(
1030
+ sid, "send_index", lambda v: int(v or 0) + 1, default=0
1031
+ )
1032
+ seq = await store.append_message(
1033
+ sid, role=role, content=content, name=name, send_index=send_index
1034
+ )
1018
1035
  # Keep a live cache entry current with the loop's OWN append (no reload): the next
1019
1036
  # send's next_seq token will then match and reuse the cached window. No-op if this
1020
1037
  # session isn't cached. The row mirrors what append_message persisted (only
1021
- # seq/role/content/name are consumed when rebuilding the working history).
1038
+ # seq/role/content/name/send_index are consumed when rebuilding the working history).
1022
1039
  self._cache_append(
1023
1040
  sid,
1024
1041
  MessageRow(
1025
1042
  session_id=sid, seq=seq, role=role, name=name, content=content,
1026
1043
  tool_calls=None, tool_call_id=None, round_index=None,
1027
- state=MessageState.ACTIVE, meta={}, created_at=0,
1044
+ state=MessageState.ACTIVE, meta={}, created_at=0, send_index=send_index,
1028
1045
  ),
1029
1046
  new_next_seq=seq + 1,
1030
1047
  )
@@ -1096,43 +1113,246 @@ class StatefulAgentLoop:
1096
1113
  system_prompt: str | None = None,
1097
1114
  ) -> StatefulResult:
1098
1115
  store = await self._ensure_store()
1116
+ projector = self.config.history_projector
1117
+ # The current send's authoritative index (set by _persist_user_input; inherited by
1118
+ # resume()/follow-up). Read up-front so projection mode can partition history by it.
1119
+ si = await store.get_runtime_state(sid, "send_index", default=0)
1120
+ # send_index is allocated >= 1 by _persist_user_input and persists across resume();
1121
+ # 0 is the unallocated/legacy default. Treat ONLY a real allocation (>= 1) as the current
1122
+ # send — explicit (matches the `is not None` convention used below), never conflating the
1123
+ # unallocated 0 with a (hypothetical) explicit send 0. Coerce defensively: a corrupted
1124
+ # runtime_state value (non-numeric / inf / nan) must degrade to "unallocated", never crash
1125
+ # the reader with int()'s ValueError/OverflowError.
1126
+ try:
1127
+ si_int = int(si)
1128
+ except (TypeError, ValueError, OverflowError):
1129
+ si_int = 0
1130
+ current_send_index = si_int if si_int >= 1 else None
1099
1131
  # Cache only the plain-send path: resume()/submit_input() pass a pre-primed sink built
1100
1132
  # from pending state (NOT a full init_history_seqs), so they must neither read from nor
1101
1133
  # write to the window cache — they self-invalidate via the next_seq bump from their own
1102
- # appended tool rows. Capture eligibility BEFORE constructing the default sink.
1103
- cache_eligible = sink is None and self._session_cache_size > 0
1134
+ # appended tool rows. Projection mode bypasses the window cache too (it caches verbatim
1135
+ # rows, but projected history != verbatim). Capture eligibility BEFORE the default sink.
1136
+ cache_eligible = sink is None and self._session_cache_size > 0 and projector is None
1104
1137
  sink = sink if sink is not None else SQLiteSink(store, sid)
1105
- # The async store offloads its own blocking I/O (SQLite threadpool; PG/MySQL → real
1106
- # async). When this session's window cache is current (token = session_state.next_seq,
1107
- # already advanced by the just-persisted user input), reuse it and skip the O(active-
1108
- # history) load entirely; otherwise load in full and (re)populate the cache.
1109
- active_rows: list[MessageRow] | None = None
1110
- cache_token: tuple[int, int] | None = None
1111
- if cache_eligible:
1112
- state = await store.get_state(sid)
1113
- if state is not None:
1114
- cache_token = (state.next_seq, state.last_compact_seq)
1115
- active_rows = self._cache_get(sid, state.next_seq, state.last_compact_seq)
1116
- if active_rows is None:
1138
+ # Record the session's ORIGINAL history mode + projector config ONCE, in the session
1139
+ # METADATA (inspectable, and the baseline for switch-detection). A later mode switch never
1140
+ # fails the run it logs a warning and renders best-effort (the config-level
1141
+ # projector/compactor exclusion can't catch a cross-RUN switch). Loaded here once and
1142
+ # reused below for the system-prompt precedence.
1143
+ session_row = await store.get_session(sid)
1144
+ current_mode = "projection" if projector is not None else "default"
1145
+ try:
1146
+ recorded = session_row.metadata.get("history_mode") if session_row is not None else None
1147
+ if recorded is None:
1148
+ await store.merge_session_metadata(sid, {
1149
+ "history_mode": current_mode,
1150
+ "projector_version": int(getattr(projector, "version", 0) or 0) if projector else None,
1151
+ "projector_trigger_ratio": (
1152
+ float(getattr(projector, "trigger_ratio", 0) or 0) if projector else None
1153
+ ),
1154
+ "projector_keep_last_sends": (
1155
+ int(getattr(projector, "keep_last_sends", 0) or 0) if projector else None
1156
+ ),
1157
+ })
1158
+ elif recorded != current_mode:
1159
+ logger.warning(
1160
+ "session %s: running in %r history mode but it was originally %r — rendering "
1161
+ "best-effort (prior-mode history shown verbatim or compacted).",
1162
+ sid, current_mode, recorded,
1163
+ )
1164
+ except Exception:
1165
+ logger.exception("session %s: history-mode bookkeeping failed (continuing)", sid)
1166
+
1167
+ projection_active = projector is not None
1168
+ active_rows: list[Any] | None = None
1169
+ if projection_active:
1117
1170
  active_rows = await store.load_active_messages(sid)
1118
- if cache_eligible and cache_token is not None:
1119
- self._cache_put(sid, cache_token[0], active_rows, cache_token[1])
1120
- history = [_row_to_loop_message(r) for r in active_rows]
1121
- # Mirror loaded seqs into the sink so the compactor can translate
1122
- # in-memory indices back to store rows when it folds messages. Pass the
1123
- # parallel logical positions too: a compact_note's identity seq is high,
1124
- # but it sits at its logical ``ord`` (set when it was folded) — load order
1125
- # and the sink's index map must agree on that, or the next fold mis-maps.
1126
- sink.init_history_seqs(
1127
- [r.seq for r in active_rows],
1128
- [
1171
+ migrated = (
1172
+ session_row is not None and session_row.metadata.get("projection_migrated") is not None
1173
+ )
1174
+ if current_send_index is None:
1175
+ # resume()/submit_input() before any send() no in-flight send to partition by.
1176
+ logger.warning(
1177
+ "session %s: projection mode degrading to verbatim rendering (no allocated "
1178
+ "send_index resume before any send); best-effort.", sid,
1179
+ )
1180
+ projection_active = False
1181
+ elif not migrated and self.config.migrate_history_on_projection_switch:
1182
+ # One-time migration: a session with prior NON-projection history opened in
1183
+ # projection mode. Fold that prior history into the projection table so it becomes
1184
+ # projection-native (a compact + the recent keep_last_sends as project rows) rather
1185
+ # than rendering it verbatim forever. Only when the projection table is EMPTY (a
1186
+ # genuine switch / in-place-compacted / never-projected session) — an incidental
1187
+ # missing/stale row on an already-projected session is left to the verbatim
1188
+ # fallback below.
1189
+ has_note = any(r.name == "compact_note" for r in active_rows)
1190
+ has_prior = any(
1191
+ r.send_index is not None and r.send_index < current_send_index
1192
+ and r.name != "compact_note"
1193
+ for r in active_rows
1194
+ )
1195
+ if has_note or has_prior:
1196
+ if await store.load_project_messages(sid):
1197
+ # Already projection-native (normal session, or a version bump handled by
1198
+ # the verbatim fallback) — mark resolved so we don't re-check every send.
1199
+ try:
1200
+ await store.merge_session_metadata(sid, {"projection_migrated": 0})
1201
+ except Exception:
1202
+ logger.exception("session %s: migration bookkeeping failed", sid)
1203
+ migrated = True
1204
+ else:
1205
+ try:
1206
+ # Writes the rows AND the projection_migrated marker in one tx (atomic).
1207
+ await self._migrate_prior_history_to_projection(
1208
+ store, sid, projector,
1209
+ current_send_index=current_send_index, active_rows=active_rows,
1210
+ )
1211
+ migrated = True
1212
+ logger.info(
1213
+ "session %s: migrated prior history into the projection table "
1214
+ "(mode switch).", sid,
1215
+ )
1216
+ except Exception:
1217
+ # Best-effort: fall back to verbatim this send (and retry next send).
1218
+ logger.exception(
1219
+ "session %s: projection migration failed (continuing best-effort)",
1220
+ sid,
1221
+ )
1222
+ # A compact_note that migration did NOT fold in (migration off/failed) can't be
1223
+ # send_index-partitioned → degrade to verbatim. Once migrated, the note is represented
1224
+ # by the projection compact and is excluded from the partition below.
1225
+ if (
1226
+ projection_active
1227
+ and not migrated
1228
+ and any(r.name == "compact_note" for r in active_rows)
1229
+ ):
1230
+ logger.warning(
1231
+ "session %s: projection mode degrading to verbatim rendering (in-place "
1232
+ "compaction history present, not migrated); best-effort.", sid,
1233
+ )
1234
+ projection_active = False
1235
+
1236
+ if projection_active:
1237
+ # ── Projection mode (v2): history = rendered projections of FINISHED sends + the
1238
+ # in-flight send's structured rows. pl_messages is NEVER compacted here (compactor is
1239
+ # None, enforced by AgentLoopConfig), so it stays in seq order; we partition by the
1240
+ # send_index stamped on every row. (active_rows loaded above; current_send_index set.)
1241
+ assert current_send_index is not None and active_rows is not None
1242
+ # Rows predating the projection era (pre-v2 / export→import) carry send_index=NULL —
1243
+ # render them VERBATIM as a temporally-first prefix (never dropped, never lumped into
1244
+ # the current send). compact_note rows are in-place-compactor artifacts: in projection
1245
+ # mode they are represented by the projection compact (after migration) and must never
1246
+ # be rendered verbatim, so they are excluded from both the prefix and the partition.
1247
+ legacy_rows = [
1248
+ r for r in active_rows if r.send_index is None and r.name != "compact_note"
1249
+ ]
1250
+ current_rows = [
1251
+ r for r in active_rows
1252
+ if r.send_index == current_send_index and r.name != "compact_note"
1253
+ ]
1254
+ # pl_messages is the immutable audit log and is NEVER compacted in projection mode, so
1255
+ # every past send's rows are still present here — the FALLBACK source whenever a send's
1256
+ # projection row is missing (a best-effort end-of-send projection write failed/crashed)
1257
+ # or was written by a DIFFERENT projector version (the user changed the projector).
1258
+ # Without this fallback such a send would be silently dropped from context forever.
1259
+ version = int(getattr(projector, "version", 0) or 0)
1260
+ active_by_send: dict[int, list[Any]] = {}
1261
+ for r in active_rows:
1262
+ if r.send_index is not None and r.name != "compact_note":
1263
+ active_by_send.setdefault(r.send_index, []).append(r)
1264
+
1265
+ def _verbatim(send_idx: int) -> list[LoopMessage]:
1266
+ return [_row_to_loop_message(m) for m in active_by_send.get(send_idx, [])]
1267
+
1268
+ compact = await store.latest_project_compact(sid)
1269
+ cutoff = compact.compact_to_send if compact is not None else None
1270
+ proj_rows = [
1271
+ r
1272
+ for r in await store.load_project_messages(sid, after_send_index=cutoff)
1273
+ if r.send_index < current_send_index
1274
+ ]
1275
+ # Only rows written by the CURRENT projector version are faithfully renderable by it;
1276
+ # rows from another version fall back to verbatim (below), keyed per send.
1277
+ cur_proj_by_send: dict[int, list[Any]] = {}
1278
+ for r in proj_rows:
1279
+ if int(r.projector_version or 0) == version:
1280
+ cur_proj_by_send.setdefault(r.send_index, []).append(r)
1281
+
1282
+ prefix_msgs: list[LoopMessage] = []
1283
+ # (1) folded-history compact: render via the projector when it matches the current
1284
+ # version, else render its covered send range verbatim from the audit log.
1285
+ if compact is not None:
1286
+ if int(compact.projector_version or 0) == version:
1287
+ prefix_msgs.extend(projector.render([compact]))
1288
+ else:
1289
+ for si2 in range(compact.compact_from_send or 0, (compact.compact_to_send or 0) + 1):
1290
+ prefix_msgs.extend(_verbatim(si2))
1291
+ # (2) each uncompacted past send in order: projected when a current-version row exists,
1292
+ # else verbatim fallback (missing or stale projection).
1293
+ lo = cutoff or 0
1294
+ for si2 in sorted(s for s in active_by_send if lo < s < current_send_index):
1295
+ rows_for_send = cur_proj_by_send.get(si2)
1296
+ prefix_msgs.extend(projector.render(rows_for_send) if rows_for_send else _verbatim(si2))
1297
+
1298
+ legacy_msgs = [_row_to_loop_message(r) for r in legacy_rows]
1299
+ current_msgs = [_row_to_loop_message(r) for r in current_rows]
1300
+ history = legacy_msgs + prefix_msgs + current_msgs
1301
+ # Sink index↔seq map: legacy + in-flight rows carry real seqs; the rendered prefix
1302
+ # (projected OR verbatim-fallback) has no foldable DB row (None). Compaction is off in
1303
+ # projection mode, so this map is only kept aligned for the appends, never used to fold.
1304
+ hist_seqs: list[int | None] = (
1305
+ [r.seq for r in legacy_rows]
1306
+ + [None] * len(prefix_msgs)
1307
+ + [r.seq for r in current_rows]
1308
+ )
1309
+ hist_ords: list[int | None] = list(hist_seqs)
1310
+ else:
1311
+ # ── Default / degraded-verbatim mode: full verbatim history (+ window cache + in-place
1312
+ # compactor). When degrading from projection, active_rows is already loaded above and
1313
+ # the cache is bypassed (cache_eligible is False whenever a projector is configured). ──
1314
+ cache_token: tuple[int, int] | None = None
1315
+ if cache_eligible:
1316
+ state = await store.get_state(sid)
1317
+ if state is not None:
1318
+ cache_token = (state.next_seq, state.last_compact_seq)
1319
+ active_rows = self._cache_get(sid, state.next_seq, state.last_compact_seq)
1320
+ if active_rows is None:
1321
+ active_rows = await store.load_active_messages(sid)
1322
+ if cache_eligible and cache_token is not None:
1323
+ self._cache_put(sid, cache_token[0], active_rows, cache_token[1])
1324
+ history = [_row_to_loop_message(r) for r in active_rows]
1325
+ # Mirror loaded seqs into the sink so the compactor can translate in-memory indices
1326
+ # back to store rows when it folds. Pass the parallel logical positions too: a
1327
+ # compact_note's identity seq is high, but it sits at its logical ``ord``.
1328
+ hist_seqs = [r.seq for r in active_rows]
1329
+ hist_ords = [
1129
1330
  int(r.meta["ord"]) if r.name == "compact_note" and r.meta.get("ord") is not None
1130
1331
  else r.seq
1131
1332
  for r in active_rows
1132
- ],
1333
+ ]
1334
+
1335
+ # ── Robustness backstop (mode-agnostic, always on): repair tool-call/result misalignment
1336
+ # in the assembled prompt so a corrupt row (crash mid-tool, bad import/edit, projection
1337
+ # mismatch) can't make the provider reject the history and brick the session forever. No-op
1338
+ # on a healthy history. With repair_corrupt_history=True, the dropped orphan rows are also
1339
+ # deactivated in the store so the fix is durable (else the prompt is just re-sanitized). ──
1340
+ history, hist_seqs, hist_ords, dropped_seqs, synthesized = align_tool_calls(
1341
+ history, hist_seqs, hist_ords
1133
1342
  )
1134
- session_row = await store.get_session(sid)
1135
- # System prompt precedence: per-call > session > config.
1343
+ if dropped_seqs or synthesized:
1344
+ logger.warning(
1345
+ "session %s: repaired malformed tool-call pairing in the prompt "
1346
+ "(dropped %d orphan result(s) seqs=%s, synthesized %d placeholder result(s))",
1347
+ sid, len(dropped_seqs), dropped_seqs, synthesized,
1348
+ )
1349
+ if dropped_seqs and self.config.repair_corrupt_history:
1350
+ try:
1351
+ await store.deactivate_messages(sid, dropped_seqs)
1352
+ except Exception:
1353
+ logger.exception("session %s: durable history repair (deactivate) failed", sid)
1354
+ sink.init_history_seqs(hist_seqs, hist_ords)
1355
+ # System prompt precedence: per-call > session > config. (session_row loaded above.)
1136
1356
  effective_sp = system_prompt
1137
1357
  if effective_sp is None and session_row is not None and session_row.system_prompt:
1138
1358
  effective_sp = session_row.system_prompt
@@ -1160,6 +1380,11 @@ class StatefulAgentLoop:
1160
1380
  store=store,
1161
1381
  drain_follow_ups=_drain_follow_ups,
1162
1382
  )
1383
+ # Every row this run appends (assistant/tool/system, plus follow-up and
1384
+ # trailing-notice rows drained mid-run) inherits the current send index, so
1385
+ # the whole send shares one index. _persist_user_input bumped it for a fresh
1386
+ # send; resume()/submit_input() leave it, correctly attaching to the prior send.
1387
+ pipeline.send_index = current_send_index
1163
1388
  try:
1164
1389
  result: AgentLoopResult = await pipeline.run(history)
1165
1390
  except Exception as exc:
@@ -1191,6 +1416,18 @@ class StatefulAgentLoop:
1191
1416
  )
1192
1417
  except Exception:
1193
1418
  logger.exception("session_stats bump failed for %s (continuing)", sid)
1419
+ # Send-context projection (v2): at end-of-send, project the finished send's pl_messages
1420
+ # rows into pl_project_messages so the NEXT send reads them as plain-text history. Derived
1421
+ # + best-effort: a failure never affects the send result (pl_messages is the source of truth).
1422
+ # Skipped when projection degraded to verbatim this run (projection_active=False).
1423
+ if projection_active and current_send_index is not None:
1424
+ try:
1425
+ await self._write_send_projection(
1426
+ store, sid, send_index=current_send_index, status=result.status,
1427
+ session_row=session_row, projector=projector, tool_registry=effective_registry,
1428
+ )
1429
+ except Exception:
1430
+ logger.exception("send-context projection write failed for %s (continuing)", sid)
1194
1431
  return StatefulResult(
1195
1432
  session_id=sid,
1196
1433
  status=result.status,
@@ -1202,6 +1439,182 @@ class StatefulAgentLoop:
1202
1439
  tool_calls=result.tool_calls,
1203
1440
  )
1204
1441
 
1442
+ async def _migrate_prior_history_to_projection(
1443
+ self,
1444
+ store: SessionStore,
1445
+ sid: str,
1446
+ projector: HistoryProjector,
1447
+ *,
1448
+ current_send_index: int,
1449
+ active_rows: list[Any],
1450
+ ) -> None:
1451
+ """One-time best-effort migration: fold a session's prior NON-projection history into the
1452
+ projection table so it becomes projection-native (starts with a ``compact`` covering the
1453
+ old sends, plus the most-recent ``keep_last_sends`` as individual project rows). Handles
1454
+ 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
+ from power_loop.runtime.store.types import ProjectMessageRow
1459
+
1460
+ keep = max(int(getattr(projector, "keep_last_sends", 0) or 0), 0)
1461
+ version = int(getattr(projector, "version", 0) or 0)
1462
+ note = next((r for r in active_rows if r.name == "compact_note"), None)
1463
+ by_send: dict[int, list[Any]] = {}
1464
+ for r in active_rows:
1465
+ if r.send_index is not None and r.send_index < current_send_index and r.name != "compact_note":
1466
+ by_send.setdefault(r.send_index, []).append(r)
1467
+ prior = sorted(by_send)
1468
+ if not prior and note is None:
1469
+ return # nothing to migrate
1470
+
1471
+ projected = {si: projector.project_send(by_send[si], send_index=si, tool_registry=None) for si in prior}
1472
+ # Fold all but the most-recent `keep` prior sends; keep the rest as individual project rows.
1473
+ if keep > 0 and len(prior) > keep:
1474
+ fold, recent = prior[:-keep], prior[-keep:]
1475
+ elif keep == 0:
1476
+ fold, recent = prior, []
1477
+ else:
1478
+ fold, recent = [], prior
1479
+
1480
+ def _pm(si: int, kind: str, content: Any) -> ProjectMessageRow:
1481
+ return ProjectMessageRow(
1482
+ session_id=sid, send_index=si, kind=kind, content=content, rendered_text=None,
1483
+ source_seq_lo=None, source_seq_hi=None, compact_from_send=None,
1484
+ compact_to_send=None, projector_version=version, token_estimate=None, created_at=0,
1485
+ )
1486
+
1487
+ # 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).
1489
+ to_compact: list[ProjectMessageRow] = []
1490
+ if note is not None:
1491
+ to_compact.append(_pm(0, "compact", {"summary": note.content or ""}))
1492
+ for si in fold:
1493
+ for pr in projected[si].rows:
1494
+ to_compact.append(_pm(si, pr.kind, pr.content))
1495
+ 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
+ )
1501
+ if folded is not None:
1502
+ 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))
1509
+
1510
+ project_rows = [
1511
+ (si, pr.kind, pr.content, pr.rendered_text)
1512
+ for si in recent
1513
+ for pr in projected[si].rows
1514
+ ]
1515
+ # Mark migrated in the SAME transaction as the rows (atomic): a crash can't leave the
1516
+ # migration written with an unset marker.
1517
+ await store.write_projection_migration(
1518
+ sid, project_rows=project_rows, compact=compact_tuple, projector_version=version,
1519
+ metadata_patch={"projection_migrated": current_send_index},
1520
+ )
1521
+
1522
+ async def _write_send_projection(
1523
+ self,
1524
+ store: SessionStore,
1525
+ sid: str,
1526
+ *,
1527
+ send_index: int,
1528
+ status: str,
1529
+ session_row: SessionRow | None,
1530
+ projector: HistoryProjector,
1531
+ tool_registry: ToolRegistry | None,
1532
+ ) -> None:
1533
+ """Project the just-finished send's ``pl_messages`` rows into ``pl_project_messages`` (v2).
1534
+
1535
+ Skips sub-agent CHILD sessions (their transcript lives in their own pl_session) by
1536
+ ``parent_session_id`` — NOT ``scope``, which ``_finalize`` hardcodes to ``'main'`` even
1537
+ for children. Defers on a still-in-flight status (the resume re-finalizes under the SAME
1538
+ ``send_index``; the idempotent UPSERT then overwrites). Best-effort — the caller swallows
1539
+ failures, since ``pl_messages`` remains the source of truth."""
1540
+ if session_row is not None and session_row.parent_session_id is not None:
1541
+ return # a spawned sub-agent run, not a top-level send
1542
+ if status in ("waiting_for_input", "pending_tools"):
1543
+ return # send not finished; the resume will re-finalize under the same send_index
1544
+ rows = [
1545
+ r for r in await store.load_active_messages(sid) if r.send_index == send_index
1546
+ ]
1547
+ if not rows:
1548
+ return
1549
+ projected = projector.project_send(rows, send_index=send_index, tool_registry=tool_registry)
1550
+ version = int(getattr(projector, "version", 0) or 0)
1551
+ proj_rows_to_write = [(pr.kind, pr.content, pr.rendered_text) for pr in projected.rows]
1552
+
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,
1614
+ projector_version=version,
1615
+ plan_compaction=plan_compaction,
1616
+ )
1617
+
1205
1618
  async def _prime_sink_from_pending(self, sid: str, sink: SQLiteSink) -> None:
1206
1619
  store = await self._ensure_store()
1207
1620
  state = await store.get_state(sid)