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.
- {power_loop-2.1.0 → power_loop-2.2.0}/PKG-INFO +2 -1
- {power_loop-2.1.0 → power_loop-2.2.0}/README.md +1 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/__init__.py +17 -1
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/agent/sink.py +6 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/agent/stateful_loop.py +445 -32
- power_loop-2.2.0/power_loop/agent/types.py +159 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/contracts/errors.py +8 -4
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/contracts/tools.py +11 -1
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/core/pipeline.py +13 -1
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/runtime/compact.py +156 -7
- power_loop-2.2.0/power_loop/runtime/history_projector.py +385 -0
- power_loop-2.2.0/power_loop/runtime/history_sanitize.py +104 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/runtime/store/dialect.py +54 -3
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/runtime/store/schema.py +96 -3
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/runtime/store/store.py +276 -6
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/runtime/store/types.py +33 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/tools/default_manifest.py +17 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/tools/default_tools.py +64 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop.egg-info/PKG-INFO +2 -1
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop.egg-info/SOURCES.txt +2 -0
- power_loop-2.1.0/power_loop/agent/types.py +0 -90
- {power_loop-2.1.0 → power_loop-2.2.0}/LICENSE +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/_vendor/__init__.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/_vendor/llm_client/__init__.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/_vendor/llm_client/anthropic_factory.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/_vendor/llm_client/capabilities.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/_vendor/llm_client/interface.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/_vendor/llm_client/llm_factory.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/_vendor/llm_client/llm_tooling.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/_vendor/llm_client/llm_utils.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/_vendor/llm_client/multimodal.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/agent/__init__.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/agent/follow_up.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/agent/system_prompt.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/contracts/__init__.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/contracts/event_payloads.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/contracts/events.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/contracts/handlers.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/contracts/hook_contexts.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/contracts/hooks.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/contracts/messages.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/contracts/protocols.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/contrib/__init__.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/contrib/_redact.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/contrib/jsonl_sink.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/contrib/logging_sink.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/contrib/mcp.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/contrib/metrics_sink.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/contrib/otel_sink.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/core/agent_context.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/core/events.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/core/hooks.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/core/phase.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/core/runner.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/core/state.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/py.typed +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/runtime/blackboard.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/runtime/budget.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/runtime/cancellation.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/runtime/env.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/runtime/exec_backend.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/runtime/human_input.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/runtime/memory.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/runtime/notes.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/runtime/provider.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/runtime/retry.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/runtime/runtime_state.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/runtime/session_store.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/runtime/skills.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/runtime/spec.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/runtime/store/__init__.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/runtime/store/backends/__init__.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/runtime/store/backends/mysql.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/runtime/store/backends/postgres.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/runtime/store/backends/sqlite.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/runtime/store/capabilities.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/runtime/store/db.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/runtime/store/factory.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/runtime/structured.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/runtime/stub_provider.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/runtime/timers.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/tools/__init__.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/tools/blackboard.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/tools/registry.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/tools/spawn_agent.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/workflow/__init__.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/workflow/api.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/workflow/engine.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/workflow/introspect.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/workflow/journal.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/workflow/result.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/workflow/resume.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/workflow/runner.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/workflow/spec.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/workflow/subprocess_executor.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/workflow/tool.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop/workflow/worker.py +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop.egg-info/dependency_links.txt +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop.egg-info/requires.txt +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/power_loop.egg-info/top_level.txt +0 -0
- {power_loop-2.1.0 → power_loop-2.2.0}/pyproject.toml +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
1103
|
-
|
|
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
|
-
#
|
|
1106
|
-
#
|
|
1107
|
-
#
|
|
1108
|
-
#
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
if
|
|
1112
|
-
|
|
1113
|
-
if
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
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
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
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
|
-
|
|
1135
|
-
|
|
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)
|