power-loop 3.0.2__tar.gz → 3.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {power_loop-3.0.2 → power_loop-3.2.0}/PKG-INFO +3 -2
- {power_loop-3.0.2 → power_loop-3.2.0}/README.md +2 -1
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/__init__.py +12 -2
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/agent/sink.py +7 -12
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/agent/stateful_loop.py +57 -39
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/agent/system_prompt.py +50 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/agent/types.py +76 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/contracts/hook_contexts.py +4 -1
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/core/hooks.py +51 -3
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/core/pipeline.py +146 -128
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/core/state.py +23 -8
- power_loop-3.2.0/power_loop/runtime/memory.py +240 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/runtime/notes.py +17 -6
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/runtime/store/dialect.py +46 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/runtime/store/schema.py +10 -2
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/runtime/store/store.py +66 -4
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/runtime/store/types.py +29 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop.egg-info/PKG-INFO +3 -2
- power_loop-3.0.2/power_loop/runtime/memory.py +0 -107
- {power_loop-3.0.2 → power_loop-3.2.0}/LICENSE +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/_vendor/__init__.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/_vendor/llm_client/__init__.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/_vendor/llm_client/anthropic_factory.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/_vendor/llm_client/capabilities.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/_vendor/llm_client/interface.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/_vendor/llm_client/llm_factory.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/_vendor/llm_client/llm_tooling.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/_vendor/llm_client/llm_utils.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/_vendor/llm_client/multimodal.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/agent/__init__.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/agent/follow_up.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/contracts/__init__.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/contracts/errors.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/contracts/event_payloads.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/contracts/events.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/contracts/handlers.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/contracts/hooks.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/contracts/messages.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/contracts/protocols.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/contracts/tools.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/contrib/__init__.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/contrib/_redact.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/contrib/jsonl_sink.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/contrib/logging_sink.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/contrib/mcp.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/contrib/metrics_sink.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/contrib/otel_sink.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/core/agent_context.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/core/events.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/core/phase.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/core/runner.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/py.typed +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/runtime/blackboard.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/runtime/budget.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/runtime/cancellation.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/runtime/compact.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/runtime/env.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/runtime/exec_backend.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/runtime/fold.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/runtime/fold_adapter.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/runtime/history_projector.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/runtime/history_sanitize.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/runtime/human_input.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/runtime/provider.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/runtime/representation.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/runtime/retry.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/runtime/runtime_state.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/runtime/session_store.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/runtime/skills.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/runtime/spec.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/runtime/store/__init__.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/runtime/store/backends/__init__.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/runtime/store/backends/mysql.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/runtime/store/backends/postgres.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/runtime/store/backends/sqlite.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/runtime/store/capabilities.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/runtime/store/db.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/runtime/store/factory.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/runtime/structured.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/runtime/stub_provider.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/runtime/timers.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/tools/__init__.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/tools/blackboard.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/tools/default_manifest.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/tools/default_tools.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/tools/registry.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/tools/spawn_agent.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/workflow/__init__.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/workflow/api.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/workflow/engine.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/workflow/introspect.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/workflow/journal.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/workflow/result.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/workflow/resume.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/workflow/runner.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/workflow/spec.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/workflow/subprocess_executor.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/workflow/tool.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop/workflow/worker.py +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop.egg-info/SOURCES.txt +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop.egg-info/dependency_links.txt +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop.egg-info/requires.txt +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/power_loop.egg-info/top_level.txt +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/pyproject.toml +0 -0
- {power_loop-3.0.2 → power_loop-3.2.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: power-loop
|
|
3
|
-
Version: 3.0
|
|
3
|
+
Version: 3.2.0
|
|
4
4
|
Summary: Embeddable agent execution kernel — LLM loop, hooks, events, tools, dynamic sub-agents.
|
|
5
5
|
Author-email: zhangran <zhangran24@126.com>
|
|
6
6
|
License: MIT
|
|
@@ -161,7 +161,8 @@ 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
|
+
| **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) |
|
|
@@ -88,7 +88,8 @@ 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
|
+
| **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.0
|
|
18
|
+
__version__ = "3.2.0"
|
|
19
19
|
|
|
20
20
|
# Public LLM contract (SDK-free) re-exported so callers (e.g. writing llm.* hooks or
|
|
21
21
|
# a custom LLMService) don't reach into the internal vendored transport package (H3.4).
|
|
@@ -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
|
|
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,
|
|
@@ -199,6 +205,7 @@ from power_loop.runtime.store.store import (
|
|
|
199
205
|
SessionStore,
|
|
200
206
|
)
|
|
201
207
|
from power_loop.runtime.store.types import (
|
|
208
|
+
HookEventRow,
|
|
202
209
|
MessageRow,
|
|
203
210
|
MessageState,
|
|
204
211
|
ProjectMessageRow,
|
|
@@ -305,6 +312,7 @@ __all__ = [
|
|
|
305
312
|
"SessionKind",
|
|
306
313
|
"SubagentLifecycle",
|
|
307
314
|
"MessageRow",
|
|
315
|
+
"HookEventRow",
|
|
308
316
|
"MessageState",
|
|
309
317
|
"MAX_SPAWN_DEPTH",
|
|
310
318
|
"DEFAULT_DB_PATH",
|
|
@@ -361,10 +369,12 @@ __all__ = [
|
|
|
361
369
|
"LlmDegradedPayload",
|
|
362
370
|
"LoopCancelledPayload",
|
|
363
371
|
"MemoryProvider",
|
|
372
|
+
"MemoryRecallHook",
|
|
364
373
|
"MemorySnapshot",
|
|
365
374
|
"tag_as_memory",
|
|
366
375
|
"NotesPolicy",
|
|
367
376
|
"NotesFullError",
|
|
377
|
+
"NoteMemory",
|
|
368
378
|
"SQLiteNoteMemory",
|
|
369
379
|
"DEFAULT_NOTES_POLICY",
|
|
370
380
|
"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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
|
|
@@ -203,6 +197,7 @@ class SQLiteSink:
|
|
|
203
197
|
round_index=round_index,
|
|
204
198
|
meta=message.get("meta"),
|
|
205
199
|
send_index=message.get("send_index"),
|
|
200
|
+
hook_injected=message.get("hook_injected"),
|
|
206
201
|
)
|
|
207
202
|
self._history_seqs.append(seq)
|
|
208
203
|
self._history_ord.append(seq)
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
910
|
-
|
|
911
|
-
|
|
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
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
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
|
-
|
|
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,54 @@ 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
|
|
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"
|
|
114
163
|
# Bounds for the note_add/note_update/note_delete tools (agent-authored
|
|
115
164
|
# notes). None → DEFAULT_NOTES_POLICY. See power_loop.runtime.notes.
|
|
116
165
|
notes_policy: NotesPolicy | None = None
|
|
@@ -132,9 +181,36 @@ class AgentLoopConfig:
|
|
|
132
181
|
inject_tool_descriptions: bool = True
|
|
133
182
|
tool_catalog_header: str = "# Available Tools"
|
|
134
183
|
|
|
184
|
+
def effective_context_budget(self) -> int:
|
|
185
|
+
"""Fold/compaction budget after reserving headroom for the ephemeral
|
|
186
|
+
memory block.
|
|
187
|
+
|
|
188
|
+
Memory is injected at the per-call tail by the built-in hook and is NOT
|
|
189
|
+
counted by the fold trigger (it isn't in ``self.history``). To keep
|
|
190
|
+
``history + memory`` within the model window, the fold threshold targets
|
|
191
|
+
``max_tokens − memory_budget_tokens`` so folding fires early enough.
|
|
192
|
+
``0``/``None`` max_tokens means "no explicit budget" → returned
|
|
193
|
+
unchanged.
|
|
194
|
+
"""
|
|
195
|
+
mt = int(self.max_tokens or 0)
|
|
196
|
+
if mt <= 0:
|
|
197
|
+
return mt
|
|
198
|
+
if self.memory is not None and self.builtin_memory_hook:
|
|
199
|
+
return max(1, mt - int(self.memory_budget_tokens or 0))
|
|
200
|
+
return mt
|
|
201
|
+
|
|
135
202
|
def __post_init__(self) -> None:
|
|
136
203
|
self._map_legacy_axes()
|
|
137
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)
|
|
138
214
|
# Mark init complete so __setattr__ starts re-validating reassignments (the dataclass
|
|
139
215
|
# is mutable; a post-hoc reassignment of an axis or max_tokens must stay valid).
|
|
140
216
|
object.__setattr__(self, "_initialized", True)
|
|
@@ -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(
|
|
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, [])
|
|
51
|
-
|
|
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:
|