power-loop 3.1.0__tar.gz → 3.3.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 (104) hide show
  1. {power_loop-3.1.0 → power_loop-3.3.0}/PKG-INFO +2 -1
  2. {power_loop-3.1.0 → power_loop-3.3.0}/README.md +1 -0
  3. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/__init__.py +3 -1
  4. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/agent/sink.py +1 -0
  5. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/agent/types.py +26 -0
  6. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/core/pipeline.py +101 -9
  7. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/representation.py +16 -6
  8. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/store/dialect.py +46 -0
  9. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/store/schema.py +10 -2
  10. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/store/store.py +66 -4
  11. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/store/types.py +29 -0
  12. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop.egg-info/PKG-INFO +2 -1
  13. {power_loop-3.1.0 → power_loop-3.3.0}/LICENSE +0 -0
  14. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/_vendor/__init__.py +0 -0
  15. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/_vendor/llm_client/__init__.py +0 -0
  16. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/_vendor/llm_client/anthropic_factory.py +0 -0
  17. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/_vendor/llm_client/capabilities.py +0 -0
  18. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/_vendor/llm_client/interface.py +0 -0
  19. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/_vendor/llm_client/llm_factory.py +0 -0
  20. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/_vendor/llm_client/llm_tooling.py +0 -0
  21. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/_vendor/llm_client/llm_utils.py +0 -0
  22. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/_vendor/llm_client/multimodal.py +0 -0
  23. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/agent/__init__.py +0 -0
  24. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/agent/follow_up.py +0 -0
  25. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/agent/stateful_loop.py +0 -0
  26. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/agent/system_prompt.py +0 -0
  27. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/contracts/__init__.py +0 -0
  28. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/contracts/errors.py +0 -0
  29. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/contracts/event_payloads.py +0 -0
  30. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/contracts/events.py +0 -0
  31. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/contracts/handlers.py +0 -0
  32. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/contracts/hook_contexts.py +0 -0
  33. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/contracts/hooks.py +0 -0
  34. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/contracts/messages.py +0 -0
  35. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/contracts/protocols.py +0 -0
  36. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/contracts/tools.py +0 -0
  37. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/contrib/__init__.py +0 -0
  38. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/contrib/_redact.py +0 -0
  39. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/contrib/jsonl_sink.py +0 -0
  40. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/contrib/logging_sink.py +0 -0
  41. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/contrib/mcp.py +0 -0
  42. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/contrib/metrics_sink.py +0 -0
  43. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/contrib/otel_sink.py +0 -0
  44. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/core/agent_context.py +0 -0
  45. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/core/events.py +0 -0
  46. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/core/hooks.py +0 -0
  47. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/core/phase.py +0 -0
  48. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/core/runner.py +0 -0
  49. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/core/state.py +0 -0
  50. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/py.typed +0 -0
  51. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/blackboard.py +0 -0
  52. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/budget.py +0 -0
  53. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/cancellation.py +0 -0
  54. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/compact.py +0 -0
  55. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/env.py +0 -0
  56. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/exec_backend.py +0 -0
  57. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/fold.py +0 -0
  58. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/fold_adapter.py +0 -0
  59. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/history_projector.py +0 -0
  60. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/history_sanitize.py +0 -0
  61. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/human_input.py +0 -0
  62. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/memory.py +0 -0
  63. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/notes.py +0 -0
  64. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/provider.py +0 -0
  65. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/retry.py +0 -0
  66. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/runtime_state.py +0 -0
  67. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/session_store.py +0 -0
  68. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/skills.py +0 -0
  69. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/spec.py +0 -0
  70. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/store/__init__.py +0 -0
  71. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/store/backends/__init__.py +0 -0
  72. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/store/backends/mysql.py +0 -0
  73. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/store/backends/postgres.py +0 -0
  74. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/store/backends/sqlite.py +0 -0
  75. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/store/capabilities.py +0 -0
  76. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/store/db.py +0 -0
  77. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/store/factory.py +0 -0
  78. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/structured.py +0 -0
  79. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/stub_provider.py +0 -0
  80. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/timers.py +0 -0
  81. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/tools/__init__.py +0 -0
  82. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/tools/blackboard.py +0 -0
  83. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/tools/default_manifest.py +0 -0
  84. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/tools/default_tools.py +0 -0
  85. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/tools/registry.py +0 -0
  86. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/tools/spawn_agent.py +0 -0
  87. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/workflow/__init__.py +0 -0
  88. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/workflow/api.py +0 -0
  89. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/workflow/engine.py +0 -0
  90. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/workflow/introspect.py +0 -0
  91. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/workflow/journal.py +0 -0
  92. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/workflow/result.py +0 -0
  93. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/workflow/resume.py +0 -0
  94. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/workflow/runner.py +0 -0
  95. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/workflow/spec.py +0 -0
  96. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/workflow/subprocess_executor.py +0 -0
  97. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/workflow/tool.py +0 -0
  98. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/workflow/worker.py +0 -0
  99. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop.egg-info/SOURCES.txt +0 -0
  100. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop.egg-info/dependency_links.txt +0 -0
  101. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop.egg-info/requires.txt +0 -0
  102. {power_loop-3.1.0 → power_loop-3.3.0}/power_loop.egg-info/top_level.txt +0 -0
  103. {power_loop-3.1.0 → power_loop-3.3.0}/pyproject.toml +0 -0
  104. {power_loop-3.1.0 → power_loop-3.3.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: power-loop
3
- Version: 3.1.0
3
+ Version: 3.3.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
@@ -162,6 +162,7 @@ Most "agent frameworks" ask you to build your app *inside* them. power-loop is t
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
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
+ | **Hook-injection audit** | Optionally record the ephemeral context `LLM_BEFORE` hooks inject each round (e.g. recalled memory) into `pl_hook_events` — observability only, never re-enters history/the request | [Hook-events audit](docs/en/user-guide/hook-events-audit.md) |
165
166
  | **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
167
  | **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
168
  | **Crash recovery** | `heal_pending` / `resume` / `abort_pending` for runs killed mid tool-call | [Pending recovery](docs/en/user-guide/sessions.md#pending-recovery) |
@@ -89,6 +89,7 @@ Most "agent frameworks" ask you to build your app *inside* them. power-loop is t
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
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
+ | **Hook-injection audit** | Optionally record the ephemeral context `LLM_BEFORE` hooks inject each round (e.g. recalled memory) into `pl_hook_events` — observability only, never re-enters history/the request | [Hook-events audit](docs/en/user-guide/hook-events-audit.md) |
92
93
  | **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
94
  | **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
95
  | **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.1.0"
18
+ __version__ = "3.3.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).
@@ -205,6 +205,7 @@ from power_loop.runtime.store.store import (
205
205
  SessionStore,
206
206
  )
207
207
  from power_loop.runtime.store.types import (
208
+ HookEventRow,
208
209
  MessageRow,
209
210
  MessageState,
210
211
  ProjectMessageRow,
@@ -311,6 +312,7 @@ __all__ = [
311
312
  "SessionKind",
312
313
  "SubagentLifecycle",
313
314
  "MessageRow",
315
+ "HookEventRow",
314
316
  "MessageState",
315
317
  "MAX_SPAWN_DEPTH",
316
318
  "DEFAULT_DB_PATH",
@@ -197,6 +197,7 @@ class SQLiteSink:
197
197
  round_index=round_index,
198
198
  meta=message.get("meta"),
199
199
  send_index=message.get("send_index"),
200
+ hook_injected=message.get("hook_injected"),
200
201
  )
201
202
  self._history_seqs.append(seq)
202
203
  self._history_ord.append(seq)
@@ -143,6 +143,23 @@ class AgentLoopConfig:
143
143
  )
144
144
  #: Where spilled outputs are written. None → the runtime home's ``.cache``.
145
145
  microcompact_spill_dir: str | None = None
146
+ # Audit the EPHEMERAL context that LLM_BEFORE hooks inject per round (e.g. recalled memory),
147
+ # which otherwise vanishes after the call. Recorded into the {prefix}hook_events store table,
148
+ # linked to the round's assistant message; observability ONLY — never read back into history or
149
+ # the LLM request, so it can't change context or prefix-caching.
150
+ # "off" — do not capture (default; zero overhead).
151
+ # "metadata" — record name/source/char-count/position per injected item, NOT the text.
152
+ # "full" — also record the injected content text. NOTE: stored VERBATIM with no per-item
153
+ # cap, so the audit table grows with large RAG/memory blocks — use "metadata" if
154
+ # volume is a concern.
155
+ # ONE row is written per ROUND (the LLM_BEFORE hook runs each round; the builtin memory block is
156
+ # memoized once per send but re-injected every round), so a multi-round send yields one audit row
157
+ # per round. Assumes LLM_BEFORE handlers MUTATE ctx.messages in place (the builtin contract) and
158
+ # captures only APPENDED injection, not in-place edits of existing messages. A handler that
159
+ # REPLACES all or most of ctx.messages with fresh copies makes the per-injection diff
160
+ # unresolvable — the row is then a small "inject_unresolved" marker (still never affects
161
+ # context/cache).
162
+ record_hook_events: str = "off"
146
163
  # Bounds for the note_add/note_update/note_delete tools (agent-authored
147
164
  # notes). None → DEFAULT_NOTES_POLICY. See power_loop.runtime.notes.
148
165
  notes_policy: NotesPolicy | None = None
@@ -185,6 +202,15 @@ class AgentLoopConfig:
185
202
  def __post_init__(self) -> None:
186
203
  self._map_legacy_axes()
187
204
  self._validate_context_config()
205
+ # record_hook_events is a closed enum; normalize case and reject typos loudly (consistent
206
+ # with the file's loud-config convention) rather than silently capturing nothing.
207
+ rhe = str(self.record_hook_events or "off").strip().lower()
208
+ if rhe not in ("off", "metadata", "full"):
209
+ raise ValueError(
210
+ "AgentLoopConfig: record_hook_events must be 'off' | 'metadata' | 'full'; "
211
+ f"got {self.record_hook_events!r}"
212
+ )
213
+ object.__setattr__(self, "record_hook_events", rhe)
188
214
  # Mark init complete so __setattr__ starts re-validating reassignments (the dataclass
189
215
  # is mutable; a post-hoc reassignment of an axis or max_tokens must stay valid).
190
216
  object.__setattr__(self, "_initialized", True)
@@ -292,9 +292,79 @@ class AgentPipeline:
292
292
  ordering is preserved; the loop runs other sessions during the I/O."""
293
293
  await fn(*args, **kwargs)
294
294
 
295
+ # ── Helper: audit ephemeral LLM_BEFORE hook injections ──
296
+
297
+ @staticmethod
298
+ def _summarize_hook_injection(
299
+ final_messages: list[Any], pre_hook_ids: set[int] | None, mode: str
300
+ ) -> dict[str, Any] | None:
301
+ """Diff the post-LLM_BEFORE message list against the pre-hook identity snapshot to recover
302
+ exactly the messages a hook injected this round, and summarize them for the hook_events
303
+ audit. Returns None when auditing is off or nothing was injected. ``mode``: ``metadata``
304
+ (no text) or ``full`` (include injected ``content``). Identity-diff (not a tail slice) so it
305
+ captures both tail- and front-positioned injection."""
306
+ if mode not in ("metadata", "full") or pre_hook_ids is None:
307
+ return None
308
+ injected_idx = [i for i, m in enumerate(final_messages) if id(m) not in pre_hook_ids]
309
+ if not injected_idx:
310
+ return None
311
+ injected_set = set(injected_idx)
312
+ orig_idx = [i for i in range(len(final_messages)) if i not in injected_set]
313
+ # Rebind fail-safe: an LLM_BEFORE hook may REPLACE all or PART of ctx.messages with fresh
314
+ # copies (legal per LlmBeforeCtx, though the builtin memory hook mutates in place). Those
315
+ # copies are id-novel, so the identity diff would mislabel pre-existing turns as "injected"
316
+ # — in `full` mode that would dump the conversation into the audit. A genuine injection is a
317
+ # small minority of the request; so treat it as a rebind (record a small truthful marker, no
318
+ # content) when NOTHING survived id-stable OR the id-novel messages are an implausible
319
+ # majority. The >3 floor keeps short, legitimately memory-heavy sends from tripping it.
320
+ rebound = (not orig_idx) or (
321
+ len(injected_idx) > len(final_messages) / 2 and len(injected_idx) > 3
322
+ )
323
+ if pre_hook_ids and rebound:
324
+ return {
325
+ "hook_point": "LLM_BEFORE", "hook": "llm_before", "position": "unknown",
326
+ "kind": "inject_unresolved",
327
+ "payload": {
328
+ "v": 1, "items": [], "item_count": len(injected_idx), "total_chars": 0,
329
+ "rebound": True,
330
+ },
331
+ }
332
+ items: list[dict[str, Any]] = []
333
+ sources: set[str] = set()
334
+ for i in injected_idx:
335
+ m = final_messages[i]
336
+ name = m.get("name") if isinstance(m, dict) else None
337
+ content = m.get("content") if isinstance(m, dict) else None
338
+ text = content if isinstance(content, str) else ("" if content is None else str(content))
339
+ source = "builtin.memory_recall" if str(name or "").startswith("memory_") else "llm_before"
340
+ sources.add(source)
341
+ item: dict[str, Any] = {
342
+ "role": (m.get("role") if isinstance(m, dict) else None),
343
+ "name": name, "source": source, "chars": len(text),
344
+ }
345
+ if mode == "full":
346
+ item["content"] = text
347
+ items.append(item)
348
+ # Tail when every injected item sits after every pre-existing message; else front/mixed.
349
+ position = "tail" if (not orig_idx or min(injected_idx) > max(orig_idx)) else "front"
350
+ hook = next(iter(sources)) if len(sources) == 1 else "llm_before"
351
+ return {
352
+ "hook_point": "LLM_BEFORE", "hook": hook, "position": position, "kind": "inject",
353
+ "payload": {
354
+ "v": 1, "items": items, "item_count": len(items),
355
+ "total_chars": sum(int(it["chars"]) for it in items),
356
+ },
357
+ }
358
+
295
359
  # ── Helper: append message (with MESSAGE_APPEND hook) ──
296
360
 
297
- async def _append_message(self, msg: LoopMessage, *, round_index: int | None = None) -> None:
361
+ async def _append_message(
362
+ self,
363
+ msg: LoopMessage,
364
+ *,
365
+ round_index: int | None = None,
366
+ hook_injected: dict[str, Any] | None = None,
367
+ ) -> None:
298
368
  ctx = MessageAppendCtx(
299
369
  round_index=round_index or 0,
300
370
  message=dict(msg),
@@ -307,13 +377,16 @@ class AgentPipeline:
307
377
  if self._tok_len == len(self.history) - 1:
308
378
  self._tok_total += estimate_message_tokens(ctx.message)
309
379
  self._tok_len = len(self.history)
310
- # Carry the send_index to the sink — but ONLY on the copy handed to the sink, never on
311
- # ctx.message (which lives in self.history and is sent verbatim to the LLM; an unknown
312
- # field would leak / break the provider). The sink persists it into the messages.send_index
313
- # column; it never reaches the LLM.
380
+ # Carry the send_index (and hook-injection audit) to the sink — but ONLY on the copy handed
381
+ # to the sink, never on ctx.message (which lives in self.history and is sent verbatim to the
382
+ # LLM; an unknown field would leak / break the provider). The sink persists send_index into
383
+ # the messages.send_index column and hook_injected into the hook_events table; neither
384
+ # reaches the LLM.
314
385
  sink_msg = ctx.message
315
386
  if self.send_index is not None:
316
- sink_msg = {**ctx.message, "send_index": self.send_index}
387
+ sink_msg = {**sink_msg, "send_index": self.send_index}
388
+ if hook_injected is not None:
389
+ sink_msg = {**sink_msg, "hook_injected": hook_injected}
317
390
  await self._emit_sink(self.sink.on_message_appended, sink_msg, round_index=round_index)
318
391
 
319
392
  async def _resolve_skipped_tool_calls(
@@ -848,6 +921,16 @@ class AgentPipeline:
848
921
  runtime_messages = await self._runtime_messages_for_round(round_idx)
849
922
  llm_messages = [*self.history, *runtime_messages]
850
923
 
924
+ # Audit (opt-in): snapshot the message identities BEFORE LLM_BEFORE hooks run, so we can
925
+ # later diff out exactly what they ephemerally injected (e.g. recalled memory) for this
926
+ # round's LLM call — by identity, so it works regardless of inject position (tail/front).
927
+ _hook_audit_mode = self.config.record_hook_events
928
+ _pre_hook_ids = (
929
+ {id(m) for m in llm_messages}
930
+ if _hook_audit_mode in ("metadata", "full")
931
+ else None
932
+ )
933
+
851
934
  # ── Hook: LLM_BEFORE ──
852
935
  llm_before = LlmBeforeCtx(
853
936
  round_index=round_idx,
@@ -859,6 +942,9 @@ class AgentPipeline:
859
942
  session_id=self.session_id,
860
943
  )
861
944
  await self.hooks.run_typed_async(HookPoint.LLM_BEFORE, llm_before)
945
+ hook_audit = self._summarize_hook_injection(
946
+ llm_before.messages, _pre_hook_ids, _hook_audit_mode
947
+ )
862
948
 
863
949
  if llm_before.directive == HookDirective.SHORT_CIRCUIT:
864
950
  response = llm_before.output
@@ -902,7 +988,10 @@ class AgentPipeline:
902
988
  round_index=round_idx,
903
989
  )
904
990
  msg = f"[degraded: LLM {reason} — {type(inner).__name__}: {str(inner)[:200]}]"
905
- await self._append_message({"role": "assistant", "content": msg}, round_index=round_idx)
991
+ await self._append_message(
992
+ {"role": "assistant", "content": msg},
993
+ round_index=round_idx, hook_injected=hook_audit,
994
+ )
906
995
  await self._finalize("degraded", final_text=msg, rounds=round_idx + 1)
907
996
  return self._make_result("degraded", final_text=msg, rounds=round_idx + 1)
908
997
 
@@ -915,7 +1004,10 @@ class AgentPipeline:
915
1004
  await self.hooks.run_typed_async(HookPoint.LLM_AFTER, llm_after)
916
1005
  if llm_after.directive == HookDirective.BREAK:
917
1006
  text = (getattr(response, "raw_text", "") or "").strip()
918
- await self._append_message({"role": "assistant", "content": text}, round_index=round_idx)
1007
+ await self._append_message(
1008
+ {"role": "assistant", "content": text},
1009
+ round_index=round_idx, hook_injected=hook_audit,
1010
+ )
919
1011
  await self._finalize("hook_break", final_text=text, rounds=round_idx + 1)
920
1012
  return self._make_result("completed", final_text=text, rounds=round_idx + 1)
921
1013
  # After hook may replace the response
@@ -939,7 +1031,7 @@ class AgentPipeline:
939
1031
  if tool_calls:
940
1032
  sanitized_tool_calls = _sanitize_tool_calls(tool_calls)
941
1033
  assistant_msg["tool_calls"] = sanitized_tool_calls
942
- await self._append_message(assistant_msg, round_index=round_idx)
1034
+ await self._append_message(assistant_msg, round_index=round_idx, hook_injected=hook_audit)
943
1035
  # Mark pending IMMEDIATELY so a crash here leaves a recoverable state.
944
1036
  if sanitized_tool_calls:
945
1037
  assistant_seq = len(self.history) # 1-based position in history
@@ -178,7 +178,8 @@ class VerbatimRepresentation:
178
178
  @dataclass
179
179
  class ProjectedRepresentation:
180
180
  """Generic, deterministic, no-LLM per-send projection. Each send →
181
- ``user`` row: ``{"human": [<user inputs>]}`` (a LIST — folded follow-ups preserved) +
181
+ ``user`` row: ``{"input": [<user/trigger inputs, verbatim>]}`` (a LIST — folded follow-ups
182
+ preserved; pre-3.3 rows used the key ``human``) +
182
183
  ``project`` row: ``{"tools": [...], "final_text": ...}``. Each tool call is summarized via its
183
184
  ``ToolDefinition.project`` hook when present, else a truncating fallback. Rendered to terse
184
185
  plain text with NO tool-protocol structure. (This is the old ``DefaultDeterministicProjector``
@@ -220,9 +221,13 @@ class ProjectedRepresentation:
220
221
  seqs = [r.seq for r in send_rows]
221
222
  rows: list[ProjectedRow] = []
222
223
  if users:
223
- rows.append(
224
- ProjectedRow("user", {"human": [_truncate(u.content, self.max_chars) for u in users]})
225
- )
224
+ # The INPUT side of a send (the user/trigger turn) is kept VERBATIM — it is the actual
225
+ # conversation content, it is short relative to tool output, and truncating it would drop
226
+ # context the model genuinely needs. Only the assistant's WORK (tool args/results +
227
+ # final_text) is compressed, which is where the token savings actually are. Key is
228
+ # ``input`` (the input turn — not necessarily a human; a multi-agent host feeds another
229
+ # agent's message here); pre-3.3 rows used ``human`` and are still read (see render()).
230
+ rows.append(ProjectedRow("user", {"input": [u.content for u in users]}))
226
231
  rows.append(
227
232
  ProjectedRow(
228
233
  "project",
@@ -269,9 +274,14 @@ class ProjectedRepresentation:
269
274
  for r in rows:
270
275
  si = r.send_index
271
276
  if r.kind == "user":
272
- humans = (r.content or {}).get("human") or []
277
+ content = r.content or {}
278
+ # ``input`` since 3.3; ``human`` is the pre-3.3 key — read both so old projection
279
+ # rows still render correctly after upgrade.
280
+ inputs = content.get("input")
281
+ if inputs is None:
282
+ inputs = content.get("human") or []
273
283
  tag = f"[#{si}] " if si is not None else ""
274
- out.append({"role": "user", "content": tag + "\n".join(str(h) for h in humans)})
284
+ out.append({"role": "user", "content": tag + "\n".join(str(h) for h in inputs)})
275
285
  elif r.kind == "project":
276
286
  tag = f"#{si} " if si is not None else ""
277
287
  out.append({"role": "assistant", "content": tag + self._render_project(r.content)})
@@ -35,6 +35,13 @@ class Dialect(Protocol):
35
35
  :meth:`ddl` for fresh provisioning. Idempotent (CREATE … IF NOT EXISTS)."""
36
36
  ...
37
37
 
38
+ def hook_events_ddl(self, prefix: str) -> list[str]:
39
+ """DDL for the ``hook_events`` table + index (audit log of ephemeral hook
40
+ augmentations, e.g. LLM_BEFORE memory injection), split out so the v2→v3
41
+ migration can add just this table. Included in :meth:`ddl` for fresh
42
+ provisioning. Idempotent (CREATE … IF NOT EXISTS)."""
43
+ ...
44
+
38
45
  def upsert(
39
46
  self,
40
47
  table: str,
@@ -131,6 +138,7 @@ class SqliteDialect:
131
138
  pinned INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL,
132
139
  updated_at INTEGER NOT NULL, PRIMARY KEY (session_id, note_id))""",
133
140
  *self.project_messages_ddl(p),
141
+ *self.hook_events_ddl(p),
134
142
  ]
135
143
 
136
144
  def project_messages_ddl(self, prefix: str) -> list[str]:
@@ -147,6 +155,18 @@ class SqliteDialect:
147
155
  f"ON {p}project_messages(session_id, kind, send_index)",
148
156
  ]
149
157
 
158
+ def hook_events_ddl(self, prefix: str) -> list[str]:
159
+ p = prefix
160
+ return [
161
+ f"""CREATE TABLE IF NOT EXISTS {p}hook_events (
162
+ session_id TEXT NOT NULL, event_id INTEGER NOT NULL, message_seq INTEGER,
163
+ round_index INTEGER, send_index INTEGER, hook_point TEXT NOT NULL, hook TEXT,
164
+ position TEXT, kind TEXT NOT NULL, payload_json TEXT, created_at INTEGER NOT NULL,
165
+ PRIMARY KEY (session_id, event_id))""",
166
+ f"CREATE INDEX IF NOT EXISTS {p}idx_hook_events_session_msg "
167
+ f"ON {p}hook_events(session_id, message_seq)",
168
+ ]
169
+
150
170
  def upsert(self, table, key_cols, val_cols, *, add_cols=(), insert_only_cols=()):
151
171
  return _onconflict_upsert(table, key_cols, val_cols, add_cols, insert_only_cols)
152
172
 
@@ -260,6 +280,7 @@ class PostgresDialect:
260
280
  pinned SMALLINT NOT NULL DEFAULT 0, created_at BIGINT NOT NULL,
261
281
  updated_at BIGINT NOT NULL, PRIMARY KEY (session_id, note_id))""",
262
282
  *self.project_messages_ddl(p),
283
+ *self.hook_events_ddl(p),
263
284
  ]
264
285
 
265
286
  def project_messages_ddl(self, prefix: str) -> list[str]:
@@ -276,6 +297,18 @@ class PostgresDialect:
276
297
  f"ON {p}project_messages(session_id, kind, send_index)",
277
298
  ]
278
299
 
300
+ def hook_events_ddl(self, prefix: str) -> list[str]:
301
+ p = prefix
302
+ return [
303
+ f"""CREATE TABLE IF NOT EXISTS {p}hook_events (
304
+ session_id TEXT NOT NULL, event_id BIGINT NOT NULL, message_seq BIGINT,
305
+ round_index BIGINT, send_index BIGINT, hook_point TEXT NOT NULL, hook TEXT,
306
+ position TEXT, kind TEXT NOT NULL, payload_json TEXT, created_at BIGINT NOT NULL,
307
+ PRIMARY KEY (session_id, event_id))""",
308
+ f"CREATE INDEX IF NOT EXISTS {p}idx_hook_events_session_msg "
309
+ f"ON {p}hook_events(session_id, message_seq)",
310
+ ]
311
+
279
312
  def upsert(self, table, key_cols, val_cols, *, add_cols=(), insert_only_cols=()):
280
313
  return _onconflict_upsert(table, key_cols, val_cols, add_cols, insert_only_cols)
281
314
 
@@ -376,6 +409,7 @@ class MySQLDialect:
376
409
  pinned TINYINT NOT NULL DEFAULT 0, created_at BIGINT NOT NULL,
377
410
  updated_at BIGINT NOT NULL, PRIMARY KEY (session_id, note_id)) {opts}""",
378
411
  *self.project_messages_ddl(p),
412
+ *self.hook_events_ddl(p),
379
413
  ]
380
414
 
381
415
  def project_messages_ddl(self, prefix: str) -> list[str]:
@@ -392,6 +426,18 @@ class MySQLDialect:
392
426
  KEY {p}idx_project_messages_session_kind (session_id, kind, send_index)) {opts}""",
393
427
  ]
394
428
 
429
+ def hook_events_ddl(self, prefix: str) -> list[str]:
430
+ p = prefix
431
+ opts = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
432
+ return [
433
+ f"""CREATE TABLE IF NOT EXISTS {p}hook_events (
434
+ session_id VARCHAR(255) NOT NULL, event_id BIGINT NOT NULL, message_seq BIGINT,
435
+ round_index BIGINT, send_index BIGINT, hook_point VARCHAR(32) NOT NULL, hook VARCHAR(255),
436
+ position VARCHAR(32), kind VARCHAR(32) NOT NULL, payload_json TEXT, created_at BIGINT NOT NULL,
437
+ PRIMARY KEY (session_id, event_id),
438
+ KEY {p}idx_hook_events_session_msg (session_id, message_seq)) {opts}""",
439
+ ]
440
+
395
441
  def upsert(self, table, key_cols, val_cols, *, add_cols=(), insert_only_cols=()):
396
442
  # MySQL: INSERT … AS new_row ON DUPLICATE KEY UPDATE col=new_row.col; accumulate via
397
443
  # col=col+new_row.col; insert_only_cols are inserted but omitted from the UPDATE (so
@@ -55,7 +55,8 @@ def validate_table_prefix(prefix: str) -> str:
55
55
 
56
56
  #: Bump + append a migration step for ANY schema change.
57
57
  #: v2 (2026-06): adds the ``{prefix}project_messages`` table (send-context projection).
58
- CURRENT_SCHEMA_VERSION = 2
58
+ #: v3 (2026-06): adds the ``{prefix}hook_events`` table (ephemeral hook-augmentation audit log).
59
+ CURRENT_SCHEMA_VERSION = 3
59
60
 
60
61
  #: The store's data tables (besides ``{prefix}schema_migrations``) — used by VERIFY to
61
62
  #: confirm the FULL schema is present, not just the version row. Keep in sync with
@@ -63,7 +64,7 @@ CURRENT_SCHEMA_VERSION = 2
63
64
  _STORE_TABLES: tuple[str, ...] = (
64
65
  "sessions", "messages", "compactions", "usage_rounds", "session_state",
65
66
  "session_runtime_state", "shared_state", "background_tasks", "session_stats",
66
- "timers", "notes", "project_messages",
67
+ "timers", "notes", "project_messages", "hook_events",
67
68
  )
68
69
 
69
70
 
@@ -94,6 +95,11 @@ async def _migration_steps(
94
95
  f"ALTER TABLE {prefix}messages ADD COLUMN send_index "
95
96
  f"{_send_index_column_type(db.dialect.name)}"
96
97
  )
98
+ if from_version < 3:
99
+ # v2 → v3: add the hook_events (ephemeral hook-augmentation audit) table + index. A new
100
+ # CREATE TABLE IF NOT EXISTS — no ALTER on the hot messages table, so no _column_exists
101
+ # probe needed (the CREATE is itself idempotent).
102
+ steps += db.dialect.hook_events_ddl(prefix)
97
103
  return steps
98
104
 
99
105
 
@@ -109,6 +115,8 @@ def migration_ddl_for_display(db: Database, prefix: str, *, from_version: int) -
109
115
  f"ALTER TABLE {prefix}messages ADD COLUMN send_index "
110
116
  f"{_send_index_column_type(db.dialect.name)}"
111
117
  )
118
+ if from_version < 3:
119
+ steps += db.dialect.hook_events_ddl(prefix)
112
120
  return steps
113
121
 
114
122
 
@@ -32,6 +32,7 @@ from power_loop.runtime.store.schema import (
32
32
  from power_loop.runtime.store.types import (
33
33
  BackgroundTaskRow,
34
34
  CompactionRow,
35
+ HookEventRow,
35
36
  MessageRow,
36
37
  MessageState,
37
38
  NoteRow,
@@ -119,12 +120,16 @@ class _Tables:
119
120
  self.timers = f"{prefix}timers"
120
121
  self.notes = f"{prefix}notes"
121
122
  self.project_messages = f"{prefix}project_messages"
123
+ self.hook_events = f"{prefix}hook_events"
122
124
 
123
125
 
124
126
  # Logical export schema: (logical_name, physical(t)->table, explicit_columns). Explicit
125
- # column lists (no SELECT *) keep the export wire format backend-neutral; background_tasks
126
- # (transient), shared_state (not session-scoped), and project_messages (a DERIVED projection
127
- # rebuildable from messages) are intentionally excluded.
127
+ # column lists (no SELECT *) keep the export wire format backend-neutral. Intentionally excluded:
128
+ # background_tasks (transient), shared_state (not session-scoped), project_messages (a DERIVED
129
+ # projection rebuildable from messages), and hook_events (an audit-only sidecar — observability,
130
+ # not session state). NOTE: hook_events is NOT rebuildable, so an export-then-prune / cross-store
131
+ # move does NOT carry the hook-injection audit; that is a deliberate scope choice (the audit lives in
132
+ # the live store where it is written), not an oversight.
128
133
  _EXPORT_TABLES: tuple[tuple[str, Any, tuple[str, ...]], ...] = (
129
134
  ("sessions", lambda t: t.sessions, (
130
135
  "session_id", "created_at", "updated_at", "system_prompt", "model", "config_json",
@@ -322,10 +327,17 @@ class SessionStore:
322
327
  round_index: int | None = None,
323
328
  meta: dict[str, Any] | None = None,
324
329
  send_index: int | None = None,
330
+ hook_injected: dict[str, Any] | None = None,
325
331
  ) -> int:
326
332
  """Append one message and return its allocated per-session ``seq`` (allocated +
327
333
  inserted atomically in one transaction). ``send_index`` is the authoritative per-session
328
- send index (a real column, NULL outside a send)."""
334
+ send index (a real column, NULL outside a send).
335
+
336
+ ``hook_injected`` (audit-only) records the EPHEMERAL context an ``LLM_BEFORE`` hook injected
337
+ into this round's LLM call (e.g. recalled memory) as a child ``hook_events`` row in the SAME
338
+ transaction — linked to this message's ``seq``. It NEVER touches the ``messages`` row itself,
339
+ so it can't reach history or the LLM request. Shape:
340
+ ``{hook_point, hook, position, kind, payload}``."""
329
341
  now = _now_ms()
330
342
  async with self._db.transaction() as tx:
331
343
  st = await self._db.dialect.lock_state(tx, self.t.session_state, session_id)
@@ -348,8 +360,46 @@ class SessionStore:
348
360
  await tx.execute(
349
361
  f"UPDATE {self.t.sessions} SET updated_at=? WHERE session_id=?", (now, session_id)
350
362
  )
363
+ if hook_injected:
364
+ # Per-session monotonic event_id; MAX+1 is race-free here because we hold the
365
+ # session_state lock for the whole append (serializes same-session writers).
366
+ ev = await tx.fetchone(
367
+ f"SELECT COALESCE(MAX(event_id), 0) + 1 AS nid FROM {self.t.hook_events} "
368
+ "WHERE session_id=?",
369
+ (session_id,),
370
+ )
371
+ await tx.execute(
372
+ f"INSERT INTO {self.t.hook_events} ("
373
+ "session_id, event_id, message_seq, round_index, send_index, hook_point, hook, "
374
+ "position, kind, payload_json, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
375
+ (
376
+ session_id, int(ev["nid"]), seq, round_index,
377
+ (int(send_index) if send_index is not None else None),
378
+ str(hook_injected.get("hook_point") or ""), hook_injected.get("hook"),
379
+ hook_injected.get("position"), str(hook_injected.get("kind") or ""),
380
+ _dumps(hook_injected.get("payload") or {}), now,
381
+ ),
382
+ )
351
383
  return seq
352
384
 
385
+ async def list_hook_events(
386
+ self, session_id: str, *, message_seq: int | None = None
387
+ ) -> list[HookEventRow]:
388
+ """Audit rows of hook augmentations for a session (chronological by ``event_id``).
389
+ ``message_seq`` filters to the augmentations that fed into one specific message."""
390
+ if message_seq is None:
391
+ rows = await self._db.fetchall(
392
+ f"SELECT * FROM {self.t.hook_events} WHERE session_id=? ORDER BY event_id ASC",
393
+ (session_id,),
394
+ )
395
+ else:
396
+ rows = await self._db.fetchall(
397
+ f"SELECT * FROM {self.t.hook_events} WHERE session_id=? AND message_seq=? "
398
+ "ORDER BY event_id ASC",
399
+ (session_id, int(message_seq)),
400
+ )
401
+ return [_row_to_hook_event(r) for r in rows]
402
+
353
403
  async def load_active_messages(
354
404
  self, session_id: str, *, after_seq: int | None = None
355
405
  ) -> list[MessageRow]:
@@ -793,6 +843,7 @@ class SessionStore:
793
843
  await tx.execute(
794
844
  f"DELETE FROM {self.t.project_messages} WHERE session_id=?", (session_id,)
795
845
  )
846
+ await tx.execute(f"DELETE FROM {self.t.hook_events} WHERE session_id=?", (session_id,))
796
847
  await tx.execute(f"DELETE FROM {self.t.session_state} WHERE session_id=?", (session_id,))
797
848
  affected = await tx.execute(
798
849
  f"DELETE FROM {self.t.sessions} WHERE session_id=?", (session_id,)
@@ -1553,6 +1604,17 @@ def _row_to_project_message(row: Row) -> ProjectMessageRow:
1553
1604
  )
1554
1605
 
1555
1606
 
1607
+ def _row_to_hook_event(row: Row) -> HookEventRow:
1608
+ return HookEventRow(
1609
+ session_id=row["session_id"], event_id=int(row["event_id"]),
1610
+ message_seq=(int(row["message_seq"]) if row["message_seq"] is not None else None),
1611
+ round_index=(int(row["round_index"]) if row["round_index"] is not None else None),
1612
+ send_index=(int(row["send_index"]) if row["send_index"] is not None else None),
1613
+ hook_point=row["hook_point"], hook=row["hook"], position=row["position"],
1614
+ kind=row["kind"], payload=_loads(row["payload_json"]), created_at=row["created_at"],
1615
+ )
1616
+
1617
+
1556
1618
  def _logical_order_key(m: MessageRow) -> tuple[int, int]:
1557
1619
  if m.name == "compact_note":
1558
1620
  ord_val = m.meta.get("ord")
@@ -30,6 +30,7 @@ __all__ = [
30
30
  "BackgroundTaskRow",
31
31
  "NoteRow",
32
32
  "ProjectMessageRow",
33
+ "HookEventRow",
33
34
  ]
34
35
 
35
36
 
@@ -205,3 +206,31 @@ class ProjectMessageRow:
205
206
  projector_version: int
206
207
  token_estimate: int | None
207
208
  created_at: int
209
+
210
+
211
+ @dataclass
212
+ class HookEventRow:
213
+ """One audit row of an EPHEMERAL hook augmentation (``{prefix}hook_events``) — e.g. the memory
214
+ block an ``LLM_BEFORE`` hook injected into a single LLM call's request. It is observability ONLY:
215
+ written alongside the message it fed into, NEVER read back into history or the LLM request. So
216
+ the injected context (which today vanishes after the call) is recoverable for audit.
217
+
218
+ ``event_id`` is a per-session monotonic id. ``message_seq`` links to the ``messages`` row this
219
+ augmentation fed into (the assistant response of that round); ``round_index``/``send_index``
220
+ locate the round/send. ``hook_point`` is the hook phase (``LLM_BEFORE``); ``hook`` a coarse
221
+ source label (``builtin.memory_recall`` / ``llm_before``); ``position`` is where the items landed
222
+ in the request (``tail``/``front``); ``kind`` the effect (``inject``). ``payload`` is the parsed
223
+ ``payload_json`` — ``{v, items:[{role,name,source,chars,content?}], item_count, total_chars}``
224
+ (``content`` present only when captured in ``full`` mode)."""
225
+
226
+ session_id: str
227
+ event_id: int
228
+ message_seq: int | None
229
+ round_index: int | None
230
+ send_index: int | None
231
+ hook_point: str
232
+ hook: str | None
233
+ position: str | None
234
+ kind: str
235
+ payload: Any
236
+ created_at: int
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: power-loop
3
- Version: 3.1.0
3
+ Version: 3.3.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
@@ -162,6 +162,7 @@ Most "agent frameworks" ask you to build your app *inside* them. power-loop is t
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
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
+ | **Hook-injection audit** | Optionally record the ephemeral context `LLM_BEFORE` hooks inject each round (e.g. recalled memory) into `pl_hook_events` — observability only, never re-enters history/the request | [Hook-events audit](docs/en/user-guide/hook-events-audit.md) |
165
166
  | **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
167
  | **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
168
  | **Crash recovery** | `heal_pending` / `resume` / `abort_pending` for runs killed mid tool-call | [Pending recovery](docs/en/user-guide/sessions.md#pending-recovery) |
File without changes
File without changes
File without changes