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.
- {power_loop-2.2.0 → power_loop-3.0.0}/PKG-INFO +1 -1
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/__init__.py +24 -12
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/agent/stateful_loop.py +137 -80
- power_loop-3.0.0/power_loop/agent/types.py +279 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/core/pipeline.py +3 -1
- power_loop-3.0.0/power_loop/runtime/fold.py +413 -0
- power_loop-3.0.0/power_loop/runtime/fold_adapter.py +67 -0
- power_loop-3.0.0/power_loop/runtime/representation.py +291 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/store/store.py +55 -34
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop.egg-info/PKG-INFO +1 -1
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop.egg-info/SOURCES.txt +3 -0
- power_loop-2.2.0/power_loop/agent/types.py +0 -159
- {power_loop-2.2.0 → power_loop-3.0.0}/LICENSE +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/README.md +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/_vendor/__init__.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/_vendor/llm_client/__init__.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/_vendor/llm_client/anthropic_factory.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/_vendor/llm_client/capabilities.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/_vendor/llm_client/interface.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/_vendor/llm_client/llm_factory.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/_vendor/llm_client/llm_tooling.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/_vendor/llm_client/llm_utils.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/_vendor/llm_client/multimodal.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/agent/__init__.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/agent/follow_up.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/agent/sink.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/agent/system_prompt.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/contracts/__init__.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/contracts/errors.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/contracts/event_payloads.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/contracts/events.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/contracts/handlers.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/contracts/hook_contexts.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/contracts/hooks.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/contracts/messages.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/contracts/protocols.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/contracts/tools.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/contrib/__init__.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/contrib/_redact.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/contrib/jsonl_sink.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/contrib/logging_sink.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/contrib/mcp.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/contrib/metrics_sink.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/contrib/otel_sink.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/core/agent_context.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/core/events.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/core/hooks.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/core/phase.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/core/runner.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/core/state.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/py.typed +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/blackboard.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/budget.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/cancellation.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/compact.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/env.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/exec_backend.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/history_projector.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/history_sanitize.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/human_input.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/memory.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/notes.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/provider.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/retry.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/runtime_state.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/session_store.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/skills.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/spec.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/store/__init__.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/store/backends/__init__.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/store/backends/mysql.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/store/backends/postgres.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/store/backends/sqlite.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/store/capabilities.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/store/db.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/store/dialect.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/store/factory.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/store/schema.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/store/types.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/structured.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/stub_provider.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/runtime/timers.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/tools/__init__.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/tools/blackboard.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/tools/default_manifest.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/tools/default_tools.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/tools/registry.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/tools/spawn_agent.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/workflow/__init__.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/workflow/api.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/workflow/engine.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/workflow/introspect.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/workflow/journal.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/workflow/result.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/workflow/resume.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/workflow/runner.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/workflow/spec.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/workflow/subprocess_executor.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/workflow/tool.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop/workflow/worker.py +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop.egg-info/dependency_links.txt +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop.egg-info/requires.txt +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/power_loop.egg-info/top_level.txt +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/pyproject.toml +0 -0
- {power_loop-2.2.0 → power_loop-3.0.0}/setup.cfg +0 -0
|
@@ -15,7 +15,7 @@ Stability tiers
|
|
|
15
15
|
无版本承诺,可随时变更或删除。
|
|
16
16
|
"""
|
|
17
17
|
|
|
18
|
-
__version__ = "
|
|
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.
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
148
|
+
from power_loop.runtime.fold import (
|
|
149
|
+
AgenticFold,
|
|
150
|
+
FoldContext,
|
|
151
|
+
FoldResult,
|
|
152
|
+
FoldStrategy,
|
|
153
|
+
LLMSummaryFold,
|
|
154
|
+
NoteOp,
|
|
155
155
|
)
|
|
156
156
|
from power_loop.runtime.human_input import HumanInputRequired, request_user_input
|
|
157
157
|
from power_loop.runtime.memory import MemoryProvider, MemorySnapshot, tag_as_memory
|
|
@@ -167,6 +167,13 @@ from power_loop.runtime.provider import (
|
|
|
167
167
|
create_llm_service_from_config,
|
|
168
168
|
create_llm_service_from_env,
|
|
169
169
|
)
|
|
170
|
+
from power_loop.runtime.representation import (
|
|
171
|
+
ProjectedRepresentation,
|
|
172
|
+
ProjectedRow,
|
|
173
|
+
ProjectedSend,
|
|
174
|
+
Representation,
|
|
175
|
+
VerbatimRepresentation,
|
|
176
|
+
)
|
|
170
177
|
from power_loop.runtime.retry import LLMRetryPolicy, with_retry
|
|
171
178
|
from power_loop.runtime.runtime_state import (
|
|
172
179
|
BackgroundRuntimeProjector,
|
|
@@ -320,12 +327,17 @@ __all__ = [
|
|
|
320
327
|
"BackgroundRuntimeProjector",
|
|
321
328
|
"default_runtime_projectors",
|
|
322
329
|
"get_tool_runtime_context",
|
|
323
|
-
"
|
|
324
|
-
"
|
|
325
|
-
"
|
|
330
|
+
"Representation",
|
|
331
|
+
"VerbatimRepresentation",
|
|
332
|
+
"ProjectedRepresentation",
|
|
333
|
+
"FoldStrategy",
|
|
334
|
+
"FoldContext",
|
|
335
|
+
"FoldResult",
|
|
336
|
+
"NoteOp",
|
|
337
|
+
"LLMSummaryFold",
|
|
338
|
+
"AgenticFold",
|
|
326
339
|
"ProjectedSend",
|
|
327
340
|
"ProjectedRow",
|
|
328
|
-
"ProjectedCompact",
|
|
329
341
|
"ProjectMessageRow",
|
|
330
342
|
"CancellationToken",
|
|
331
343
|
"CancellationLike",
|
|
@@ -69,7 +69,7 @@ from power_loop.runtime.store.types import (
|
|
|
69
69
|
from power_loop.tools.registry import ToolRegistry
|
|
70
70
|
|
|
71
71
|
if TYPE_CHECKING:
|
|
72
|
-
from power_loop.runtime.
|
|
72
|
+
from power_loop.runtime.representation import Representation
|
|
73
73
|
|
|
74
74
|
logger = logging.getLogger(__name__)
|
|
75
75
|
|
|
@@ -1113,7 +1113,10 @@ class StatefulAgentLoop:
|
|
|
1113
1113
|
system_prompt: str | None = None,
|
|
1114
1114
|
) -> StatefulResult:
|
|
1115
1115
|
store = await self._ensure_store()
|
|
1116
|
-
|
|
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(
|
|
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(
|
|
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.
|
|
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:
|
|
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).
|
|
1456
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
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,
|
|
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:
|
|
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
|
-
|
|
1554
|
-
|
|
1555
|
-
)
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
Wrapped so a misbehaving projector (render/compact/trigger raising) degrades to
|
|
1564
|
-
"rows written, NO fold" instead of aborting the whole locked write — the send's
|
|
1565
|
-
per-send projection rows still commit, and pl_messages stays the source of truth."""
|
|
1566
|
-
try:
|
|
1567
|
-
keep = int(getattr(projector, "keep_last_sends", 0) or 0)
|
|
1568
|
-
if keep <= 0:
|
|
1569
|
-
return None
|
|
1570
|
-
live_sends = sorted(
|
|
1571
|
-
{r.send_index for r in after_rows if r.kind in ("user", "project")}
|
|
1572
|
-
)
|
|
1573
|
-
if len(live_sends) <= keep:
|
|
1574
|
-
return None # nothing foldable beyond the keep-recent floor
|
|
1575
|
-
trigger_ratio = float(getattr(projector, "trigger_ratio", 0.75) or 0.75)
|
|
1576
|
-
threshold = int((self.config.max_tokens or 8000) * trigger_ratio)
|
|
1577
|
-
rendered_prefix = projector.render(
|
|
1578
|
-
([prior] if prior is not None else []) + after_rows
|
|
1579
|
-
)
|
|
1580
|
-
if estimate_tokens(rendered_prefix) < threshold:
|
|
1581
|
-
return None # below threshold — small per-send projections just accumulate
|
|
1582
|
-
fold_sends = set(live_sends[: len(live_sends) - keep])
|
|
1583
|
-
fold_rows = [
|
|
1584
|
-
r
|
|
1585
|
-
for r in after_rows
|
|
1586
|
-
if r.kind in ("user", "project") and r.send_index in fold_sends
|
|
1587
|
-
]
|
|
1588
|
-
to_compact = ([prior] if prior is not None else []) + fold_rows # roll prior fwd
|
|
1589
|
-
folded = projector.compact(to_compact)
|
|
1590
|
-
if folded is None:
|
|
1591
|
-
return None
|
|
1592
|
-
from_send = (
|
|
1593
|
-
prior.compact_from_send
|
|
1594
|
-
if (prior is not None and prior.compact_from_send is not None)
|
|
1595
|
-
else min(fold_sends)
|
|
1596
|
-
)
|
|
1597
|
-
return (folded.content, folded.rendered_text, from_send, max(fold_sends))
|
|
1598
|
-
except Exception:
|
|
1599
|
-
logger.exception(
|
|
1600
|
-
"projection fold planning failed for %s (skipping fold; rows still written)",
|
|
1601
|
-
sid,
|
|
1602
|
-
)
|
|
1603
|
-
return None
|
|
1604
|
-
|
|
1605
|
-
# One atomic, session-locked write: all of this send's projection rows + the optional fold
|
|
1606
|
-
# commit together (no half-projected send), and the read-decide-write of the fold is
|
|
1607
|
-
# serialized against a concurrent loop sharing this store (no double-compaction clobber).
|
|
1608
|
-
await store.write_send_projection_locked(
|
|
1609
|
-
sid,
|
|
1610
|
-
send_index=send_index,
|
|
1611
|
-
rows=proj_rows_to_write,
|
|
1612
|
-
source_seq_lo=projected.source_seq_lo,
|
|
1613
|
-
source_seq_hi=projected.source_seq_hi,
|
|
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
|