power-loop 3.0.2__tar.gz → 3.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. {power_loop-3.0.2 → power_loop-3.1.0}/PKG-INFO +2 -2
  2. {power_loop-3.0.2 → power_loop-3.1.0}/README.md +1 -1
  3. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/__init__.py +10 -2
  4. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/agent/sink.py +6 -12
  5. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/agent/stateful_loop.py +57 -39
  6. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/agent/system_prompt.py +50 -0
  7. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/agent/types.py +50 -0
  8. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/contracts/hook_contexts.py +4 -1
  9. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/core/hooks.py +51 -3
  10. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/core/pipeline.py +45 -119
  11. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/core/state.py +23 -8
  12. power_loop-3.1.0/power_loop/runtime/memory.py +240 -0
  13. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/runtime/notes.py +17 -6
  14. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop.egg-info/PKG-INFO +2 -2
  15. power_loop-3.0.2/power_loop/runtime/memory.py +0 -107
  16. {power_loop-3.0.2 → power_loop-3.1.0}/LICENSE +0 -0
  17. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/_vendor/__init__.py +0 -0
  18. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/_vendor/llm_client/__init__.py +0 -0
  19. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/_vendor/llm_client/anthropic_factory.py +0 -0
  20. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/_vendor/llm_client/capabilities.py +0 -0
  21. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/_vendor/llm_client/interface.py +0 -0
  22. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/_vendor/llm_client/llm_factory.py +0 -0
  23. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/_vendor/llm_client/llm_tooling.py +0 -0
  24. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/_vendor/llm_client/llm_utils.py +0 -0
  25. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/_vendor/llm_client/multimodal.py +0 -0
  26. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/agent/__init__.py +0 -0
  27. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/agent/follow_up.py +0 -0
  28. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/contracts/__init__.py +0 -0
  29. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/contracts/errors.py +0 -0
  30. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/contracts/event_payloads.py +0 -0
  31. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/contracts/events.py +0 -0
  32. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/contracts/handlers.py +0 -0
  33. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/contracts/hooks.py +0 -0
  34. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/contracts/messages.py +0 -0
  35. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/contracts/protocols.py +0 -0
  36. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/contracts/tools.py +0 -0
  37. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/contrib/__init__.py +0 -0
  38. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/contrib/_redact.py +0 -0
  39. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/contrib/jsonl_sink.py +0 -0
  40. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/contrib/logging_sink.py +0 -0
  41. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/contrib/mcp.py +0 -0
  42. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/contrib/metrics_sink.py +0 -0
  43. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/contrib/otel_sink.py +0 -0
  44. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/core/agent_context.py +0 -0
  45. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/core/events.py +0 -0
  46. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/core/phase.py +0 -0
  47. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/core/runner.py +0 -0
  48. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/py.typed +0 -0
  49. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/runtime/blackboard.py +0 -0
  50. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/runtime/budget.py +0 -0
  51. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/runtime/cancellation.py +0 -0
  52. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/runtime/compact.py +0 -0
  53. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/runtime/env.py +0 -0
  54. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/runtime/exec_backend.py +0 -0
  55. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/runtime/fold.py +0 -0
  56. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/runtime/fold_adapter.py +0 -0
  57. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/runtime/history_projector.py +0 -0
  58. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/runtime/history_sanitize.py +0 -0
  59. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/runtime/human_input.py +0 -0
  60. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/runtime/provider.py +0 -0
  61. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/runtime/representation.py +0 -0
  62. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/runtime/retry.py +0 -0
  63. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/runtime/runtime_state.py +0 -0
  64. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/runtime/session_store.py +0 -0
  65. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/runtime/skills.py +0 -0
  66. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/runtime/spec.py +0 -0
  67. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/runtime/store/__init__.py +0 -0
  68. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/runtime/store/backends/__init__.py +0 -0
  69. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/runtime/store/backends/mysql.py +0 -0
  70. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/runtime/store/backends/postgres.py +0 -0
  71. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/runtime/store/backends/sqlite.py +0 -0
  72. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/runtime/store/capabilities.py +0 -0
  73. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/runtime/store/db.py +0 -0
  74. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/runtime/store/dialect.py +0 -0
  75. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/runtime/store/factory.py +0 -0
  76. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/runtime/store/schema.py +0 -0
  77. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/runtime/store/store.py +0 -0
  78. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/runtime/store/types.py +0 -0
  79. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/runtime/structured.py +0 -0
  80. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/runtime/stub_provider.py +0 -0
  81. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/runtime/timers.py +0 -0
  82. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/tools/__init__.py +0 -0
  83. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/tools/blackboard.py +0 -0
  84. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/tools/default_manifest.py +0 -0
  85. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/tools/default_tools.py +0 -0
  86. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/tools/registry.py +0 -0
  87. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/tools/spawn_agent.py +0 -0
  88. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/workflow/__init__.py +0 -0
  89. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/workflow/api.py +0 -0
  90. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/workflow/engine.py +0 -0
  91. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/workflow/introspect.py +0 -0
  92. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/workflow/journal.py +0 -0
  93. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/workflow/result.py +0 -0
  94. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/workflow/resume.py +0 -0
  95. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/workflow/runner.py +0 -0
  96. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/workflow/spec.py +0 -0
  97. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/workflow/subprocess_executor.py +0 -0
  98. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/workflow/tool.py +0 -0
  99. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop/workflow/worker.py +0 -0
  100. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop.egg-info/SOURCES.txt +0 -0
  101. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop.egg-info/dependency_links.txt +0 -0
  102. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop.egg-info/requires.txt +0 -0
  103. {power_loop-3.0.2 → power_loop-3.1.0}/power_loop.egg-info/top_level.txt +0 -0
  104. {power_loop-3.0.2 → power_loop-3.1.0}/pyproject.toml +0 -0
  105. {power_loop-3.0.2 → power_loop-3.1.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: power-loop
3
- Version: 3.0.2
3
+ Version: 3.1.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
@@ -161,7 +161,7 @@ Most "agent frameworks" ask you to build your app *inside* them. power-loop is t
161
161
  | **MCP tools** | Surface a Model Context Protocol server's tools as power-loop tools | [Extending](docs/en/user-guide/extending-tools.md) |
162
162
  | **Hooks & events** | Veto/observe at every lifecycle point; strongly-typed event payloads | [Hooks](docs/en/user-guide/hooks.md) · [Events](docs/en/user-guide/events.md) |
163
163
  | **Structured output** | `output_schema` → provider `response_format` → parsed & validated | [Structured](docs/en/user-guide/structured-output.md) |
164
- | **Pluggable memory** | Cross-session recall via a `MemoryProvider` Protocol | [Memory](docs/en/user-guide/memory.md) |
164
+ | **Pluggable memory** | Cross-session recall via a `MemoryProvider` Protocol; a default-on built-in hook injects it ephemerally at the request tail (prefix-cacheable) | [Memory](docs/en/user-guide/memory.md) |
165
165
  | **Retry / cancel / budgets** | Provider-aware retry, a unified cancellation token, hard per-run token caps | [Retry & Cancel](docs/en/user-guide/retry-cancel.md) |
166
166
  | **Stable error codes** | Every `PowerLoopError` carries a frozen machine-readable `code` — branch on `exc.code` | [API: error codes](docs/en/api/index.md#error-codes) |
167
167
  | **Crash recovery** | `heal_pending` / `resume` / `abort_pending` for runs killed mid tool-call | [Pending recovery](docs/en/user-guide/sessions.md#pending-recovery) |
@@ -88,7 +88,7 @@ Most "agent frameworks" ask you to build your app *inside* them. power-loop is t
88
88
  | **MCP tools** | Surface a Model Context Protocol server's tools as power-loop tools | [Extending](docs/en/user-guide/extending-tools.md) |
89
89
  | **Hooks & events** | Veto/observe at every lifecycle point; strongly-typed event payloads | [Hooks](docs/en/user-guide/hooks.md) · [Events](docs/en/user-guide/events.md) |
90
90
  | **Structured output** | `output_schema` → provider `response_format` → parsed & validated | [Structured](docs/en/user-guide/structured-output.md) |
91
- | **Pluggable memory** | Cross-session recall via a `MemoryProvider` Protocol | [Memory](docs/en/user-guide/memory.md) |
91
+ | **Pluggable memory** | Cross-session recall via a `MemoryProvider` Protocol; a default-on built-in hook injects it ephemerally at the request tail (prefix-cacheable) | [Memory](docs/en/user-guide/memory.md) |
92
92
  | **Retry / cancel / budgets** | Provider-aware retry, a unified cancellation token, hard per-run token caps | [Retry & Cancel](docs/en/user-guide/retry-cancel.md) |
93
93
  | **Stable error codes** | Every `PowerLoopError` carries a frozen machine-readable `code` — branch on `exc.code` | [API: error codes](docs/en/api/index.md#error-codes) |
94
94
  | **Crash recovery** | `heal_pending` / `resume` / `abort_pending` for runs killed mid tool-call | [Pending recovery](docs/en/user-guide/sessions.md#pending-recovery) |
@@ -15,7 +15,7 @@ Stability tiers
15
15
  无版本承诺,可随时变更或删除。
16
16
  """
17
17
 
18
- __version__ = "3.0.2"
18
+ __version__ = "3.1.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).
@@ -154,9 +154,15 @@ from power_loop.runtime.fold import (
154
154
  NoteOp,
155
155
  )
156
156
  from power_loop.runtime.human_input import HumanInputRequired, request_user_input
157
- from power_loop.runtime.memory import MemoryProvider, MemorySnapshot, tag_as_memory
157
+ from power_loop.runtime.memory import (
158
+ MemoryProvider,
159
+ MemoryRecallHook,
160
+ MemorySnapshot,
161
+ tag_as_memory,
162
+ )
158
163
  from power_loop.runtime.notes import (
159
164
  DEFAULT_NOTES_POLICY,
165
+ NoteMemory,
160
166
  NotesFullError,
161
167
  NotesPolicy,
162
168
  SQLiteNoteMemory,
@@ -361,10 +367,12 @@ __all__ = [
361
367
  "LlmDegradedPayload",
362
368
  "LoopCancelledPayload",
363
369
  "MemoryProvider",
370
+ "MemoryRecallHook",
364
371
  "MemorySnapshot",
365
372
  "tag_as_memory",
366
373
  "NotesPolicy",
367
374
  "NotesFullError",
375
+ "NoteMemory",
368
376
  "SQLiteNoteMemory",
369
377
  "DEFAULT_NOTES_POLICY",
370
378
  "render_notes",
@@ -32,7 +32,6 @@ class MessageSink(Protocol):
32
32
 
33
33
  async def on_round_started(self, round_index: int) -> None: ...
34
34
  async def on_message_appended(self, message: LoopMessage, *, round_index: int | None) -> None: ...
35
- def on_messages_inserted(self, *, index: int, count: int) -> None: ... # pure (no I/O) → sync
36
35
  async def on_assistant_tool_calls(
37
36
  self, *, assistant_seq: int, tool_calls: list[dict[str, Any]], round_index: int
38
37
  ) -> None: ...
@@ -57,7 +56,6 @@ class NullSink:
57
56
 
58
57
  async def on_round_started(self, round_index: int) -> None: ...
59
58
  async def on_message_appended(self, message: LoopMessage, *, round_index: int | None) -> None: ...
60
- def on_messages_inserted(self, *, index: int, count: int) -> None: ...
61
59
  async def on_assistant_tool_calls(
62
60
  self, *, assistant_seq: int, tool_calls: list[dict[str, Any]], round_index: int
63
61
  ) -> None: ...
@@ -129,16 +127,12 @@ class SQLiteSink:
129
127
  self._history_seqs = list(seqs)
130
128
  self._history_ord = list(ords) if ords is not None else list(seqs)
131
129
 
132
- def on_messages_inserted(self, *, index: int, count: int) -> None:
133
- """Record that ``count`` in-memory-only messages were spliced into
134
- ``pipeline.history`` at ``index`` without being persisted (recalled
135
- ``memory_*``). Insert matching ``None`` placeholders so ``_history_seqs``
136
- stays index-aligned with ``history`` and later folds map to the right rows."""
137
- if count <= 0:
138
- return
139
- idx = max(0, min(index, len(self._history_seqs)))
140
- self._history_seqs[idx:idx] = [None] * count
141
- self._history_ord[idx:idx] = [None] * count
130
+ # NOTE: on_messages_inserted was removed when memory recall moved to the
131
+ # ephemeral tail-injection LLM_BEFORE hook (it never enters self.history, so
132
+ # there is no in-memory-only row to align). The _history_seqs/_history_ord
133
+ # maps stay `list[int | None]` and on_compaction keeps its None-guards: those
134
+ # remain load-bearing for projection mode (seeds None prefixes) and for
135
+ # corrupt-history repair (align_tool_calls synthesizes placeholders).
142
136
 
143
137
  # ── messages ───────────────────────────────────────────────
144
138
 
@@ -31,10 +31,7 @@ from power_loop._vendor.llm_client.interface import LLMService
31
31
  from power_loop.agent.follow_up import FollowUpQueued, merge_follow_up_inputs
32
32
  from power_loop.agent.sink import SQLiteSink
33
33
  from power_loop.agent.system_prompt import (
34
- DEFAULT_AGENT_SYSTEM_PROMPT,
35
- SystemPromptContext,
36
- format_tool_catalog,
37
- section_skills,
34
+ resolve_runtime_system_prompt,
38
35
  )
39
36
  from power_loop.agent.types import AgentLoopConfig, AgentLoopResult, LoopMessage
40
37
  from power_loop.contracts.errors import SessionNotFoundError, SessionPendingError
@@ -51,7 +48,6 @@ from power_loop.core.runner import AgentRunner
51
48
  from power_loop.runtime.budget import estimate_tokens
52
49
  from power_loop.runtime.cancellation import CancellationLike
53
50
  from power_loop.runtime.history_sanitize import align_tool_calls
54
- from power_loop.runtime.skills import SkillLoader
55
51
  from power_loop.runtime.store.schema import SchemaPolicy
56
52
  from power_loop.runtime.store.store import (
57
53
  DEFAULT_DB_PATH,
@@ -210,7 +206,13 @@ class StatefulAgentLoop:
210
206
  self._orphaned_close_task: asyncio.Future[None] | None = None
211
207
  self.config = config if config is not None else AgentLoopConfig()
212
208
  self.tool_registry = tool_registry
213
- self._runner = AgentRunner(event_bus=event_bus, hooks=hooks)
209
+ # Own a FRESH AgentHooks when the caller supplies none — NOT the shared
210
+ # module-level DEFAULT_HOOKS singleton — so per-loop built-in hooks (e.g.
211
+ # the memory recall hook) don't stack across loops or leak config.
212
+ self._runner = AgentRunner(
213
+ event_bus=event_bus, hooks=hooks if hooks is not None else AgentHooks()
214
+ )
215
+ self._register_builtin_hooks()
214
216
  self._locks: dict[str, asyncio.Lock] = {}
215
217
  self._follow_up_queues: dict[str, list[str | LoopMessage]] = {}
216
218
  self._follow_up_queue_locks: dict[str, asyncio.Lock] = {}
@@ -222,6 +224,33 @@ class StatefulAgentLoop:
222
224
  self._cache_misses = 0
223
225
  self._cache_evictions = 0
224
226
 
227
+ def _register_builtin_hooks(self) -> None:
228
+ """Register power-loop's default functional hooks on this loop's own
229
+ AgentHooks. They carry a ``builtin.*`` name so a host can override them
230
+ (``hooks.replace(..., name=...)``) or disable them (``hooks.remove(...)``).
231
+ """
232
+ cfg = self.config
233
+ if cfg.memory is not None and getattr(cfg, "builtin_memory_hook", True):
234
+ from power_loop.contracts.hooks import HookPoint
235
+ from power_loop.runtime.memory import MemoryRecallHook
236
+
237
+ hook = MemoryRecallHook(
238
+ cfg.memory,
239
+ budget_tokens=int(cfg.memory_budget_tokens or 0),
240
+ position=getattr(cfg, "memory_position", "tail"),
241
+ hooks=self._runner.hooks,
242
+ event_bus=self._runner.event_bus,
243
+ )
244
+ # order=100 → runs AFTER host LLM_BEFORE hooks (default order 0) so
245
+ # memory lands at the true request tail. Skip if the host already
246
+ # registered one under this name (their override wins); a host can
247
+ # also override/disable post-construction via loop.hooks.replace /
248
+ # .remove(MemoryRecallHook.NAME).
249
+ if not self._runner.hooks.has(MemoryRecallHook.NAME):
250
+ self._runner.hooks.register(
251
+ HookPoint.LLM_BEFORE, hook, order=100, name=MemoryRecallHook.NAME,
252
+ )
253
+
225
254
  async def ensure_store(self) -> SessionStore:
226
255
  """Public accessor: return this loop's store, opening an owned one on first use.
227
256
 
@@ -906,9 +935,14 @@ class StatefulAgentLoop:
906
935
  Returns
907
936
  -------
908
937
  str
909
- The fully resolved prompt string exactly what the LLM
910
- will see as the system message on the next :meth:`send`
911
- call.
938
+ The fully resolved prompt for a :meth:`send` with **no per-call
939
+ overrides** base (session/config) + auto-injected tool catalog
940
+ (full registry) + skill section, via the same
941
+ ``resolve_runtime_system_prompt`` helper the live pipeline uses.
942
+
943
+ A per-call ``send(system_prompt=...)`` or ``send(tools=[...])`` is
944
+ applied at send time and is NOT reflected here (this previews the
945
+ no-override case; pass nothing at ``send`` for a byte-identical match).
912
946
  """
913
947
  # Session-level prompt wins over config-level prompt.
914
948
  base: str | None = None
@@ -917,36 +951,18 @@ class StatefulAgentLoop:
917
951
  row = await store.get_session(session_id)
918
952
  if row is not None:
919
953
  base = row.system_prompt
920
-
921
954
  if base is None or not base.strip():
922
- base = self.config.system_prompt or DEFAULT_AGENT_SYSTEM_PROMPT
923
-
924
- base = base.strip()
925
-
926
- if self.config.inject_tool_descriptions and self.tool_registry is not None:
927
- catalog = format_tool_catalog(
928
- self.tool_registry,
929
- header=self.config.tool_catalog_header,
930
- )
931
- if catalog:
932
- base = f"{base}\n\n{catalog}"
933
-
934
- skills = None
935
- if self.config.skills_dir:
936
- try:
937
- loader = SkillLoader(self.config.skills_dir)
938
- skills = section_skills(
939
- SystemPromptContext(
940
- skills_dir=str(loader.skills_dir),
941
- skill_descriptions=loader.get_descriptions(),
942
- )
943
- )
944
- except Exception:
945
- skills = None
946
- if skills:
947
- base = f"{base}\n\n{skills}"
948
-
949
- return base
955
+ base = self.config.system_prompt
956
+
957
+ # Shared assembly — the SAME helper AgentPipeline.__init__ uses — so this
958
+ # preview is byte-identical to what the LLM actually receives.
959
+ return resolve_runtime_system_prompt(
960
+ base,
961
+ inject_tool_descriptions=self.config.inject_tool_descriptions,
962
+ tool_catalog_header=self.config.tool_catalog_header,
963
+ tool_registry=self.tool_registry,
964
+ skills_dir=self.config.skills_dir,
965
+ )
950
966
 
951
967
  # ── internals ─────────────────────────────────────────────────────────
952
968
 
@@ -1645,7 +1661,9 @@ class StatefulAgentLoop:
1645
1661
  if len(live_sends) <= keep:
1646
1662
  return None, () # nothing foldable beyond the keep-recent floor
1647
1663
  trigger_ratio = float(getattr(fold_strategy, "trigger_ratio", 0.75) or 0.75)
1648
- threshold = int((self.config.max_tokens or 8000) * trigger_ratio)
1664
+ # Reserve headroom for the ephemeral tail-injected memory block (not
1665
+ # part of the projected snapshot, so invisible here) — fold earlier.
1666
+ threshold = int((self.config.effective_context_budget() or 8000) * trigger_ratio)
1649
1667
  rendered_prefix = projector.render(([prior] if prior is not None else []) + snapshot)
1650
1668
  if estimate_tokens(rendered_prefix) < threshold:
1651
1669
  return None, () # below threshold — small per-send projections just accumulate
@@ -417,6 +417,56 @@ DEFAULT_EXPLORE_SUBAGENT_SYSTEM_PROMPT = (
417
417
  ).build(SystemPromptContext())
418
418
 
419
419
 
420
+ def build_skill_section(skills_dir: str | None) -> str:
421
+ """Render the auto-injected skill-catalog section for ``skills_dir``.
422
+
423
+ Returns ``""`` when no skills_dir is set or loading fails. Lazy-imports
424
+ SkillLoader to avoid a core↔runtime import cycle.
425
+ """
426
+ if not skills_dir:
427
+ return ""
428
+ try:
429
+ from power_loop.runtime.skills import SkillLoader
430
+
431
+ loader = SkillLoader(skills_dir)
432
+ section = section_skills(
433
+ SystemPromptContext(
434
+ skills_dir=str(loader.skills_dir),
435
+ skill_descriptions=loader.get_descriptions(),
436
+ )
437
+ )
438
+ return section or ""
439
+ except Exception:
440
+ return ""
441
+
442
+
443
+ def resolve_runtime_system_prompt(
444
+ base: str | None,
445
+ *,
446
+ inject_tool_descriptions: bool,
447
+ tool_catalog_header: str,
448
+ tool_registry: Any,
449
+ skills_dir: str | None,
450
+ ) -> str:
451
+ """Single source of truth for runtime system-prompt assembly:
452
+ ``base → tool catalog → skill section`` (each joined by ``"\\n\\n"``).
453
+
454
+ Shared by :meth:`AgentPipeline.__init__` (the live prompt) and
455
+ :meth:`StatefulAgentLoop.resolve_system_prompt` (the preview), so the two
456
+ can never drift. Callers resolve ``base`` themselves (config vs session
457
+ override) and pass it in; everything after is computed here once.
458
+ """
459
+ out = (base or DEFAULT_AGENT_SYSTEM_PROMPT).strip()
460
+ if inject_tool_descriptions and tool_registry is not None:
461
+ catalog = format_tool_catalog(tool_registry, header=tool_catalog_header)
462
+ if catalog:
463
+ out = f"{out}\n\n{catalog}"
464
+ skill = build_skill_section(skills_dir)
465
+ if skill:
466
+ out = f"{out}\n\n{skill}"
467
+ return out
468
+
469
+
420
470
  def build_agent_system_prompt(
421
471
  ctx: SystemPromptContext,
422
472
  extra: str | None = None,
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import os
3
4
  import warnings
4
5
  from dataclasses import dataclass, field
5
6
  from typing import TYPE_CHECKING, Any, Literal
@@ -111,6 +112,37 @@ class AgentLoopConfig:
111
112
  retry_policy: LLMRetryPolicy | None = None
112
113
  memory: MemoryProvider | None = None
113
114
  memory_budget_tokens: int = 1500
115
+ #: Where the built-in MemoryRecallHook injects recalled memory into the
116
+ #: per-call request: "tail" (default — after history, keeps the prior-history
117
+ #: prefix byte-stable and prefix-cacheable) or "front" (after leading system
118
+ #: messages — legacy position; breaks prefix caching when memory changes).
119
+ memory_position: str = "tail"
120
+ #: Auto-register the built-in MemoryRecallHook when ``memory`` is set. Turn
121
+ #: off to inject memory yourself via an LLM_BEFORE hook.
122
+ builtin_memory_hook: bool = True
123
+
124
+ # ── Microcompact (large tool-output spill-to-disk) ──
125
+ #
126
+ # A cheap, no-LLM per-round mechanism that replaces OLD oversized tool
127
+ # outputs (older than the hot tail) with a short on-disk pointer, to save
128
+ # context tokens — orthogonal to the LLM-summary fold/compactor. Verbatim
129
+ # mode only (projection renders finished sends from the projection store).
130
+ #
131
+ # DEFAULT OFF as of 3.1.x: it only helps when those old outputs are never
132
+ # needed again; otherwise the pointer just trades for a re-read. Projection
133
+ # mode + fold + provider prefix-caching already cover context budget. Turn it
134
+ # on for long verbatim sessions that read many large files and rarely revisit
135
+ # the old ones. The thresholds default from the legacy CONTEXT_MICRO_* env
136
+ # vars for back-compat; the config fields take precedence.
137
+ microcompact_enabled: bool = False
138
+ microcompact_size_limit: int = field(
139
+ default_factory=lambda: int(os.getenv("CONTEXT_MICRO_SIZE_LIMIT", "1000"))
140
+ )
141
+ microcompact_hot_tail: int = field(
142
+ default_factory=lambda: int(os.getenv("CONTEXT_MICRO_HOT_TAIL", "10"))
143
+ )
144
+ #: Where spilled outputs are written. None → the runtime home's ``.cache``.
145
+ microcompact_spill_dir: str | None = None
114
146
  # Bounds for the note_add/note_update/note_delete tools (agent-authored
115
147
  # notes). None → DEFAULT_NOTES_POLICY. See power_loop.runtime.notes.
116
148
  notes_policy: NotesPolicy | None = None
@@ -132,6 +164,24 @@ class AgentLoopConfig:
132
164
  inject_tool_descriptions: bool = True
133
165
  tool_catalog_header: str = "# Available Tools"
134
166
 
167
+ def effective_context_budget(self) -> int:
168
+ """Fold/compaction budget after reserving headroom for the ephemeral
169
+ memory block.
170
+
171
+ Memory is injected at the per-call tail by the built-in hook and is NOT
172
+ counted by the fold trigger (it isn't in ``self.history``). To keep
173
+ ``history + memory`` within the model window, the fold threshold targets
174
+ ``max_tokens − memory_budget_tokens`` so folding fires early enough.
175
+ ``0``/``None`` max_tokens means "no explicit budget" → returned
176
+ unchanged.
177
+ """
178
+ mt = int(self.max_tokens or 0)
179
+ if mt <= 0:
180
+ return mt
181
+ if self.memory is not None and self.builtin_memory_hook:
182
+ return max(1, mt - int(self.memory_budget_tokens or 0))
183
+ return mt
184
+
135
185
  def __post_init__(self) -> None:
136
186
  self._map_legacy_axes()
137
187
  self._validate_context_config()
@@ -122,7 +122,9 @@ class LlmBeforeCtx(BaseHookCtx):
122
122
  """Context for :pyattr:`HookPoint.LLM_BEFORE`.
123
123
 
124
124
  Directives: SHORT_CIRCUIT (set ``output`` to an ``LLMResponse``), BREAK.
125
- Handler may modify any input field.
125
+ Handler may modify any input field. ``messages`` is the fresh per-call list
126
+ actually sent to the LLM — mutating it (e.g. appending an ephemeral memory
127
+ block) never touches the loop's persisted history.
126
128
  """
127
129
 
128
130
  messages: list[LoopMessage] = field(default_factory=list)
@@ -130,6 +132,7 @@ class LlmBeforeCtx(BaseHookCtx):
130
132
  tools: list[dict[str, Any]] | None = None
131
133
  max_tokens: int = 8000
132
134
  temperature: float = 0.0
135
+ session_id: str | None = None
133
136
  # Handler output (for SHORT_CIRCUIT)
134
137
  output: LLMResponse | None = None
135
138
 
@@ -26,6 +26,7 @@ Legacy handlers that receive ``HookContext`` and return
26
26
  class _HookEntry:
27
27
  handler: HookHandlerFn
28
28
  order: int
29
+ name: str | None = None
29
30
 
30
31
 
31
32
  class AgentHooks:
@@ -45,10 +46,57 @@ class AgentHooks:
45
46
  def __init__(self) -> None:
46
47
  self._handlers: dict[str, list[_HookEntry]] = {}
47
48
 
48
- def register(self, hook_point: HookPoint | str, handler: HookHandlerFn, *, order: int = 0) -> None:
49
+ def register(
50
+ self,
51
+ hook_point: HookPoint | str,
52
+ handler: HookHandlerFn,
53
+ *,
54
+ order: int = 0,
55
+ name: str | None = None,
56
+ replace: bool = False,
57
+ ) -> None:
58
+ """Register a handler at ``hook_point``.
59
+
60
+ ``name`` gives the entry a stable identity so it can later be
61
+ :meth:`remove`-d or :meth:`replace`-d — used by built-in hooks
62
+ (``builtin.*``) so hosts can override them. ``replace=True`` (with a
63
+ ``name``) drops any existing entry of the same name at this point first,
64
+ so re-registering is idempotent.
65
+ """
49
66
  key = str(hook_point)
50
- self._handlers.setdefault(key, []).append(_HookEntry(handler=handler, order=order))
51
- self._handlers[key].sort(key=lambda e: e.order)
67
+ entries = self._handlers.setdefault(key, [])
68
+ if name is not None and replace:
69
+ entries[:] = [e for e in entries if e.name != name]
70
+ entries.append(_HookEntry(handler=handler, order=order, name=name))
71
+ entries.sort(key=lambda e: e.order)
72
+
73
+ def replace(
74
+ self, hook_point: HookPoint | str, handler: HookHandlerFn, *, name: str, order: int = 0
75
+ ) -> None:
76
+ """Replace (or add) the named handler at ``hook_point``."""
77
+ self.register(hook_point, handler, order=order, name=name, replace=True)
78
+
79
+ def remove(self, name: str, hook_point: HookPoint | str | None = None) -> int:
80
+ """Remove every entry with ``name`` (optionally scoped to one point).
81
+
82
+ Returns the number of entries removed. Used to disable a built-in hook:
83
+ ``hooks.remove("builtin.memory_recall")``.
84
+ """
85
+ keys = [str(hook_point)] if hook_point is not None else list(self._handlers)
86
+ removed = 0
87
+ for key in keys:
88
+ entries = self._handlers.get(key)
89
+ if not entries:
90
+ continue
91
+ before = len(entries)
92
+ entries[:] = [e for e in entries if e.name != name]
93
+ removed += before - len(entries)
94
+ return removed
95
+
96
+ def has(self, name: str, hook_point: HookPoint | str | None = None) -> bool:
97
+ """Whether a named entry is registered (optionally scoped to one point)."""
98
+ keys = [str(hook_point)] if hook_point is not None else list(self._handlers)
99
+ return any(e.name == name for key in keys for e in self._handlers.get(key, []))
52
100
 
53
101
  def clear(self, hook_point: HookPoint | str | None = None) -> None:
54
102
  if hook_point is None: