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.
- {power_loop-3.1.0 → power_loop-3.3.0}/PKG-INFO +2 -1
- {power_loop-3.1.0 → power_loop-3.3.0}/README.md +1 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/__init__.py +3 -1
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/agent/sink.py +1 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/agent/types.py +26 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/core/pipeline.py +101 -9
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/representation.py +16 -6
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/store/dialect.py +46 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/store/schema.py +10 -2
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/store/store.py +66 -4
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/store/types.py +29 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop.egg-info/PKG-INFO +2 -1
- {power_loop-3.1.0 → power_loop-3.3.0}/LICENSE +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/_vendor/__init__.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/_vendor/llm_client/__init__.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/_vendor/llm_client/anthropic_factory.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/_vendor/llm_client/capabilities.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/_vendor/llm_client/interface.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/_vendor/llm_client/llm_factory.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/_vendor/llm_client/llm_tooling.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/_vendor/llm_client/llm_utils.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/_vendor/llm_client/multimodal.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/agent/__init__.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/agent/follow_up.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/agent/stateful_loop.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/agent/system_prompt.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/contracts/__init__.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/contracts/errors.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/contracts/event_payloads.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/contracts/events.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/contracts/handlers.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/contracts/hook_contexts.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/contracts/hooks.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/contracts/messages.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/contracts/protocols.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/contracts/tools.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/contrib/__init__.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/contrib/_redact.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/contrib/jsonl_sink.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/contrib/logging_sink.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/contrib/mcp.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/contrib/metrics_sink.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/contrib/otel_sink.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/core/agent_context.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/core/events.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/core/hooks.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/core/phase.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/core/runner.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/core/state.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/py.typed +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/blackboard.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/budget.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/cancellation.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/compact.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/env.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/exec_backend.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/fold.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/fold_adapter.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/history_projector.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/history_sanitize.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/human_input.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/memory.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/notes.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/provider.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/retry.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/runtime_state.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/session_store.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/skills.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/spec.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/store/__init__.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/store/backends/__init__.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/store/backends/mysql.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/store/backends/postgres.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/store/backends/sqlite.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/store/capabilities.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/store/db.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/store/factory.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/structured.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/stub_provider.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/runtime/timers.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/tools/__init__.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/tools/blackboard.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/tools/default_manifest.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/tools/default_tools.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/tools/registry.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/tools/spawn_agent.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/workflow/__init__.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/workflow/api.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/workflow/engine.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/workflow/introspect.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/workflow/journal.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/workflow/result.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/workflow/resume.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/workflow/runner.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/workflow/spec.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/workflow/subprocess_executor.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/workflow/tool.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop/workflow/worker.py +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop.egg-info/SOURCES.txt +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop.egg-info/dependency_links.txt +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop.egg-info/requires.txt +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/power_loop.egg-info/top_level.txt +0 -0
- {power_loop-3.1.0 → power_loop-3.3.0}/pyproject.toml +0 -0
- {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.
|
|
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.
|
|
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",
|
|
@@ -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(
|
|
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
|
|
311
|
-
# ctx.message (which lives in self.history and is sent verbatim to the
|
|
312
|
-
# field would leak / break the provider). The sink persists
|
|
313
|
-
# column
|
|
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 = {**
|
|
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(
|
|
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(
|
|
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: ``{"
|
|
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
|
-
|
|
224
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
126
|
-
# (transient), shared_state (not session-scoped),
|
|
127
|
-
# rebuildable from messages)
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|