power-loop 3.0.1__tar.gz → 3.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {power_loop-3.0.1 → power_loop-3.1.0}/PKG-INFO +33 -10
- {power_loop-3.0.1 → power_loop-3.1.0}/README.md +32 -9
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/__init__.py +10 -2
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/agent/sink.py +6 -12
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/agent/stateful_loop.py +57 -39
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/agent/system_prompt.py +50 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/agent/types.py +50 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/contracts/hook_contexts.py +4 -1
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/core/hooks.py +51 -3
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/core/pipeline.py +45 -119
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/core/state.py +23 -8
- power_loop-3.1.0/power_loop/runtime/memory.py +240 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/runtime/notes.py +17 -6
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop.egg-info/PKG-INFO +33 -10
- power_loop-3.0.1/power_loop/runtime/memory.py +0 -107
- {power_loop-3.0.1 → power_loop-3.1.0}/LICENSE +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/_vendor/__init__.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/_vendor/llm_client/__init__.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/_vendor/llm_client/anthropic_factory.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/_vendor/llm_client/capabilities.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/_vendor/llm_client/interface.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/_vendor/llm_client/llm_factory.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/_vendor/llm_client/llm_tooling.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/_vendor/llm_client/llm_utils.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/_vendor/llm_client/multimodal.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/agent/__init__.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/agent/follow_up.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/contracts/__init__.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/contracts/errors.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/contracts/event_payloads.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/contracts/events.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/contracts/handlers.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/contracts/hooks.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/contracts/messages.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/contracts/protocols.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/contracts/tools.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/contrib/__init__.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/contrib/_redact.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/contrib/jsonl_sink.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/contrib/logging_sink.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/contrib/mcp.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/contrib/metrics_sink.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/contrib/otel_sink.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/core/agent_context.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/core/events.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/core/phase.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/core/runner.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/py.typed +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/runtime/blackboard.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/runtime/budget.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/runtime/cancellation.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/runtime/compact.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/runtime/env.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/runtime/exec_backend.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/runtime/fold.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/runtime/fold_adapter.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/runtime/history_projector.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/runtime/history_sanitize.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/runtime/human_input.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/runtime/provider.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/runtime/representation.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/runtime/retry.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/runtime/runtime_state.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/runtime/session_store.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/runtime/skills.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/runtime/spec.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/runtime/store/__init__.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/runtime/store/backends/__init__.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/runtime/store/backends/mysql.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/runtime/store/backends/postgres.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/runtime/store/backends/sqlite.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/runtime/store/capabilities.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/runtime/store/db.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/runtime/store/dialect.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/runtime/store/factory.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/runtime/store/schema.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/runtime/store/store.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/runtime/store/types.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/runtime/structured.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/runtime/stub_provider.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/runtime/timers.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/tools/__init__.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/tools/blackboard.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/tools/default_manifest.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/tools/default_tools.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/tools/registry.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/tools/spawn_agent.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/workflow/__init__.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/workflow/api.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/workflow/engine.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/workflow/introspect.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/workflow/journal.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/workflow/result.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/workflow/resume.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/workflow/runner.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/workflow/spec.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/workflow/subprocess_executor.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/workflow/tool.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop/workflow/worker.py +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop.egg-info/SOURCES.txt +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop.egg-info/dependency_links.txt +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop.egg-info/requires.txt +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/power_loop.egg-info/top_level.txt +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/pyproject.toml +0 -0
- {power_loop-3.0.1 → power_loop-3.1.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: power-loop
|
|
3
|
-
Version: 3.0
|
|
3
|
+
Version: 3.1.0
|
|
4
4
|
Summary: Embeddable agent execution kernel — LLM loop, hooks, events, tools, dynamic sub-agents.
|
|
5
5
|
Author-email: zhangran <zhangran24@126.com>
|
|
6
6
|
License: MIT
|
|
@@ -106,7 +106,7 @@ print((await loop.send("And my second-favorite?", session_id=sid)).final_text)
|
|
|
106
106
|
pip install 'power-loop[openai]' # or [anthropic] · add [postgres] / [mysql] for those backends
|
|
107
107
|
```
|
|
108
108
|
|
|
109
|
-
> **1.0
|
|
109
|
+
> **Stable since 1.0; now 3.x.** The public API is frozen under SemVer and machine-enforced by a baseline guard in CI — and the two major bumps since prove the discipline rather than undercut it: **2.0** moved storage to a pluggable async backend, **3.0** made context handling two orthogonal axes. Both were real breaking changes, so both got a major bump. The **core has zero runtime dependencies** (pure stdlib; verified by a CI job that imports it with nothing else installed) — LLM transports *and database drivers* are optional extras. Backed by **900+ unit tests**, a **live-LLM** suite, and a **3-backend conformance suite** (SQLite/PostgreSQL/MySQL). See [Stability](#stability--semver) and the [honest caveats](#honest-scope) — a young, single-maintainer project says so plainly.
|
|
110
110
|
|
|
111
111
|
---
|
|
112
112
|
|
|
@@ -116,7 +116,7 @@ pip install 'power-loop[openai]' # or [anthropic] · add [postgres] / [mysq
|
|
|
116
116
|
|---|---|
|
|
117
117
|
| 🚀 **New** — show me the 5-minute version | [Getting Started](docs/en/getting-started.md) |
|
|
118
118
|
| 🛠️ **Learning by building** | [Tutorials](docs/en/tutorials/index.md) — chatbot · tools · human-in-the-loop · multi-agent |
|
|
119
|
-
| 🧩 **Browsing runnable code** | [
|
|
119
|
+
| 🧩 **Browsing runnable code** | [43 examples](examples/README.md) — `00_hello_world.py` → full chatbot |
|
|
120
120
|
| 📚 **Looking something up** | [User Guide](docs/en/user-guide/index.md) · [API reference](docs/en/api/index.md) |
|
|
121
121
|
| 🤔 **Deciding if it fits** | [How it compares](#how-it-compares) · [Honest scope](#honest-scope) |
|
|
122
122
|
|
|
@@ -128,10 +128,11 @@ pip install 'power-loop[openai]' # or [anthropic] · add [postgres] / [mysq
|
|
|
128
128
|
|
|
129
129
|
Most "agent frameworks" ask you to build your app *inside* them. power-loop is the opposite: a **library you embed**. You keep your HTTP layer, your auth, your queues, your RAG, your UI, your deploy. It runs the agent loop — and lets you *engineer* it.
|
|
130
130
|
|
|
131
|
-
- 🪶 **Featherweight & zero-dependency.** No `pydantic`, no LangChain, no graph DSL. A compact, pure-stdlib core (~
|
|
131
|
+
- 🪶 **Featherweight & zero-dependency.** No `pydantic`, no LangChain, no graph DSL. A compact, pure-stdlib core (~24k lines) whose public surface is essentially one class — and **zero runtime dependencies**. LLM transports *and* the Postgres/MySQL drivers are pulled in only by the extra you install.
|
|
132
132
|
- 🗄️ **Pluggable storage, zero-infra default.** Sessions, timers, sub-agent trees, workflow journals, the shared blackboard — one backend-neutral store written once against a tiny `Database`/`Dialect` port. The default is **one SQLite file** (copy the file, you've copied the state); point a DSN at **PostgreSQL or MySQL** when you want a real multi-writer server — same code, same conformance suite. Tables are auto-created, or **provisioned out-of-band** with a printed DDL script (see [Storage backends](docs/en/user-guide/storage-backends.md)).
|
|
133
133
|
- ♻️ **Stateless, resumable loops.** A `StatefulAgentLoop` carries no authoritative state — all of it lives in the store. So a loop is cheap to create and trivially **restored from a DSN + a session id** (ideal for web handlers, workers, cold starts). It self-caches each session's active window (a rebuildable accelerator that never changes what the model sees) to skip re-reads on hot paths.
|
|
134
134
|
- ⏱️ **Durable by default.** Crash mid-run and `resume()`. Agents schedule their own **durable timers** that survive restarts. Workflows **replay finished steps and re-run only the unfinished tail** after a process death. The store survives version upgrades (a portable, backend-neutral migration-version table) and can be **pruned, VACUUMed, and exported**.
|
|
135
|
+
- 🧠 **Context engineering, not one fixed strategy.** How each finished send is *recorded/rendered* (**representation**: full **verbatim** or a terse per-send **projection**) and how older history is *compacted* once over budget (**fold strategy**: a single **LLM summary**, or an **agentic** pass that also writes durable notes) are two **orthogonal, config-driven axes** — any representation composes with any fold strategy, and both take your own `Representation` / `FoldStrategy` implementation. Folds always keep whole sends (never split a tool-call/result pair); `recall_send` / `recall_compacted` pull the original detail back from the immutable audit log.
|
|
135
136
|
- 🧩 **Composable from one loop to a fleet.** Start with `send()`. Add tools. Spawn sub-agents. Fan out a deterministic **workflow** (`sequence`/`parallel`/`foreach`/`branch`). Run each leaf in its **own process and DB** behind a sandbox. Same primitives all the way up.
|
|
136
137
|
- 🛡️ **Isolation seams where it counts.** Tool-level sandboxing via a `ShellBackend` (drop in gVisor/Docker for `bash`); process-level via a `WorkerLauncher` (wrap a whole sub-agent worker per leaf). power-loop stays sandbox-agnostic; you choose the policy.
|
|
137
138
|
- 🔬 **Built to be observed.** Typed events for every stream chunk, tool call, round, and **individual LLM call** — each `seq`-ordered + monotonic-clock stamped. Pluggable sinks behind extras: durable **JSONL** (with `replay`), **Prometheus/StatsD** metrics, an **OpenTelemetry** span tree. Per-run + per-session token accounting and hard per-run budgets.
|
|
@@ -153,14 +154,14 @@ Most "agent frameworks" ask you to build your app *inside* them. power-loop is t
|
|
|
153
154
|
| **Workflow resume** | Journals each step; after a crash, replays completed steps and re-runs only the tail | [Workflows](docs/en/user-guide/workflows.md) |
|
|
154
155
|
| **Process sandboxing** | Each workflow leaf in its own OS process + own DB; wrap each in gVisor/Docker per leaf | [Sandboxing](docs/en/user-guide/sandboxing.md) |
|
|
155
156
|
| **Durable timers** | Agents schedule their own wake-ups; survive restarts; one-shot or recurring | [Timers](docs/en/user-guide/timers.md) |
|
|
156
|
-
| **Context
|
|
157
|
-
| **
|
|
157
|
+
| **Context — representation** | Record/render each finished send **verbatim** or as a terse per-send **projection** (derived `pl_project_messages`); `pl_messages` stays immutable; `recall_send` re-expands | [Projection](docs/en/user-guide/send-context-projection.md) |
|
|
158
|
+
| **Context — fold strategy** | Compact older history once over budget: **LLM summary** or **agentic** (also writes notes); pluggable `FoldStrategy`; never splits a tool pair; `recall_compacted` re-expands | [Compaction](docs/en/user-guide/compaction.md) |
|
|
158
159
|
| **Durability ops** | Portable migration-version table, retention/prune, VACUUM, `export_session`/`import_session`, graceful `aclose()` | [Sessions](docs/en/user-guide/sessions.md) |
|
|
159
160
|
| **Observability** | Typed `seq`-ordered events → durable JSONL + `replay`, Prometheus/StatsD metrics, OpenTelemetry spans | [Observability](docs/en/user-guide/observability.md) |
|
|
160
161
|
| **MCP tools** | Surface a Model Context Protocol server's tools as power-loop tools | [Extending](docs/en/user-guide/extending-tools.md) |
|
|
161
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) |
|
|
162
163
|
| **Structured output** | `output_schema` → provider `response_format` → parsed & validated | [Structured](docs/en/user-guide/structured-output.md) |
|
|
163
|
-
| **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) |
|
|
164
165
|
| **Retry / cancel / budgets** | Provider-aware retry, a unified cancellation token, hard per-run token caps | [Retry & Cancel](docs/en/user-guide/retry-cancel.md) |
|
|
165
166
|
| **Stable error codes** | Every `PowerLoopError` carries a frozen machine-readable `code` — branch on `exc.code` | [API: error codes](docs/en/api/index.md#error-codes) |
|
|
166
167
|
| **Crash recovery** | `heal_pending` / `resume` / `abort_pending` for runs killed mid tool-call | [Pending recovery](docs/en/user-guide/sessions.md#pending-recovery) |
|
|
@@ -200,6 +201,28 @@ result = await loop.send(user_text, session_id=session_id)
|
|
|
200
201
|
|
|
201
202
|
Under the hood the loop keeps a per-session **active-window cache** — but it caches only the *durable* projection, validated by a monotonic `next_seq` token, so it's a pure accelerator: a cold loop with an empty cache produces byte-for-byte the same prompts (proven by a warm-vs-cold conformance test, including the recall/compaction/prompt-edit edge cases).
|
|
202
203
|
|
|
204
|
+
### Context engineering — two orthogonal axes you choose (and can implement yourself)
|
|
205
|
+
|
|
206
|
+
Long conversations outgrow the window. Most libraries give you *one* fixed compaction behavior; power-loop (3.0) splits it into two independent, config-driven axes:
|
|
207
|
+
|
|
208
|
+
- **Representation** — how each *finished send* is recorded & rendered: `VerbatimRepresentation` (full, byte-identical history) or `ProjectedRepresentation` (a terse per-send plain-text projection). The original detail always stays in the immutable `pl_messages` audit log.
|
|
209
|
+
- **Fold strategy** — how *older* history is compacted once the rendered prefix crosses the budget: `LLMSummaryFold` (one summary call) or `AgenticFold` (a bounded tool loop that also persists durable facts as notes).
|
|
210
|
+
|
|
211
|
+
```python
|
|
212
|
+
from power_loop import (
|
|
213
|
+
StatefulAgentLoop, AgentLoopConfig,
|
|
214
|
+
ProjectedRepresentation, AgenticFold, # mix & match either axis — or pass your own impl
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
cfg = AgentLoopConfig(
|
|
218
|
+
representation=ProjectedRepresentation(max_chars=300), # terse projection (or VerbatimRepresentation)
|
|
219
|
+
fold_strategy=AgenticFold(keep_last_sends=4), # summarize older sends + write notes
|
|
220
|
+
)
|
|
221
|
+
loop = StatefulAgentLoop(llm=llm, dsn="app.db", config=cfg)
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Any representation composes with any fold strategy, and each axis is a small `Protocol` you can implement yourself. A fold always keeps **whole sends** (it never splits an atomic tool-call/result pair), and the model can call `recall_send(send_index=N)` / `recall_compacted()` to pull the full original detail back from the audit log. (The two classes above are public but **provisional** — added in 3.0, not yet frozen into `STABLE_API`; `AgentLoopConfig` itself is Stable.)
|
|
225
|
+
|
|
203
226
|
### Deterministic multi-agent workflows — that the model can author, and that survive a crash
|
|
204
227
|
|
|
205
228
|
Sub-agent delegation is *model-driven* ("go do this"). When you want **code-driven, deterministic** orchestration — fan out over a list, branch on a result, run a pipeline — describe it as a `WorkflowSpec` and let the engine interpret it. The only LLM calls are the leaves; `sequence`/`parallel`/`foreach`/`branch` are plain code.
|
|
@@ -281,7 +304,7 @@ await register_mcp_tools(registry, client, prefix="fs.") # MCP tools → power
|
|
|
281
304
|
|
|
282
305
|
The seam is a tiny `MCPToolSource` Protocol, so the `mcp` SDK is optional and any client works.
|
|
283
306
|
|
|
284
|
-
> More: hard token budgets, structured output, crash recovery, memory, the blackboard — see [`examples/`](examples/README.md) (
|
|
307
|
+
> More: hard token budgets, structured output, crash recovery, memory, the blackboard — see [`examples/`](examples/README.md) (43 runnable programs) and the [docs](docs/en/index.md).
|
|
285
308
|
|
|
286
309
|
---
|
|
287
310
|
|
|
@@ -289,7 +312,7 @@ The seam is a tiny `MCPToolSource` Protocol, so the `mcp` SDK is optional and an
|
|
|
289
312
|
|
|
290
313
|
power-loop is a **kernel**, not a platform — that's the whole trade-off.
|
|
291
314
|
|
|
292
|
-
- **vs. LangChain / LangGraph / LlamaIndex / CrewAI / AutoGen** — those are batteries-included frameworks with large ecosystems (connectors, vector stores, integrations) and heavy dependency trees. power-loop deliberately ships **none of that**: a compact (~
|
|
315
|
+
- **vs. LangChain / LangGraph / LlamaIndex / CrewAI / AutoGen** — those are batteries-included frameworks with large ecosystems (connectors, vector stores, integrations) and heavy dependency trees. power-loop deliberately ships **none of that**: a compact (~24k-line) pure-stdlib core with zero runtime dependencies, and you bring your own tools (or an MCP server). You get durable sessions across SQLite/PG/MySQL, crash-resumable workflows, and real sandbox seams out of the box; you do **not** get a bundled RAG stack or 100 connectors.
|
|
293
316
|
- **Choose power-loop** when you want to *embed* an agent in an existing app, keep your dependency surface tiny, pick your own database, and care about durability + isolation + a stable contract.
|
|
294
317
|
- **Choose a framework** when you want batteries included, a big integration catalog, and don't mind the weight.
|
|
295
318
|
|
|
@@ -321,7 +344,7 @@ Python 3.10+. See [Getting Started](docs/en/getting-started.md). Optional extras
|
|
|
321
344
|
|
|
322
345
|
## Stability & SemVer
|
|
323
346
|
|
|
324
|
-
|
|
347
|
+
Since **1.0**, the **STABLE** API (listed in `power_loop.STABLE_API`) is under SemVer: a breaking change requires a major bump, enforced by a frozen-baseline test in CI — including the flagship `StatefulAgentLoop` *and the LLM contract needed to construct it*. Error `.code` strings are frozen too. The two majors since (2.0 pluggable async storage, 3.0 orthogonal context axes) were exactly that policy in action — breaking changes earned a major bump, each documented in the [Changelog](CHANGELOG.md).
|
|
325
348
|
|
|
326
349
|
| Tier | Meaning |
|
|
327
350
|
|---|---|
|
|
@@ -33,7 +33,7 @@ print((await loop.send("And my second-favorite?", session_id=sid)).final_text)
|
|
|
33
33
|
pip install 'power-loop[openai]' # or [anthropic] · add [postgres] / [mysql] for those backends
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
-
> **1.0
|
|
36
|
+
> **Stable since 1.0; now 3.x.** The public API is frozen under SemVer and machine-enforced by a baseline guard in CI — and the two major bumps since prove the discipline rather than undercut it: **2.0** moved storage to a pluggable async backend, **3.0** made context handling two orthogonal axes. Both were real breaking changes, so both got a major bump. The **core has zero runtime dependencies** (pure stdlib; verified by a CI job that imports it with nothing else installed) — LLM transports *and database drivers* are optional extras. Backed by **900+ unit tests**, a **live-LLM** suite, and a **3-backend conformance suite** (SQLite/PostgreSQL/MySQL). See [Stability](#stability--semver) and the [honest caveats](#honest-scope) — a young, single-maintainer project says so plainly.
|
|
37
37
|
|
|
38
38
|
---
|
|
39
39
|
|
|
@@ -43,7 +43,7 @@ pip install 'power-loop[openai]' # or [anthropic] · add [postgres] / [mysq
|
|
|
43
43
|
|---|---|
|
|
44
44
|
| 🚀 **New** — show me the 5-minute version | [Getting Started](docs/en/getting-started.md) |
|
|
45
45
|
| 🛠️ **Learning by building** | [Tutorials](docs/en/tutorials/index.md) — chatbot · tools · human-in-the-loop · multi-agent |
|
|
46
|
-
| 🧩 **Browsing runnable code** | [
|
|
46
|
+
| 🧩 **Browsing runnable code** | [43 examples](examples/README.md) — `00_hello_world.py` → full chatbot |
|
|
47
47
|
| 📚 **Looking something up** | [User Guide](docs/en/user-guide/index.md) · [API reference](docs/en/api/index.md) |
|
|
48
48
|
| 🤔 **Deciding if it fits** | [How it compares](#how-it-compares) · [Honest scope](#honest-scope) |
|
|
49
49
|
|
|
@@ -55,10 +55,11 @@ pip install 'power-loop[openai]' # or [anthropic] · add [postgres] / [mysq
|
|
|
55
55
|
|
|
56
56
|
Most "agent frameworks" ask you to build your app *inside* them. power-loop is the opposite: a **library you embed**. You keep your HTTP layer, your auth, your queues, your RAG, your UI, your deploy. It runs the agent loop — and lets you *engineer* it.
|
|
57
57
|
|
|
58
|
-
- 🪶 **Featherweight & zero-dependency.** No `pydantic`, no LangChain, no graph DSL. A compact, pure-stdlib core (~
|
|
58
|
+
- 🪶 **Featherweight & zero-dependency.** No `pydantic`, no LangChain, no graph DSL. A compact, pure-stdlib core (~24k lines) whose public surface is essentially one class — and **zero runtime dependencies**. LLM transports *and* the Postgres/MySQL drivers are pulled in only by the extra you install.
|
|
59
59
|
- 🗄️ **Pluggable storage, zero-infra default.** Sessions, timers, sub-agent trees, workflow journals, the shared blackboard — one backend-neutral store written once against a tiny `Database`/`Dialect` port. The default is **one SQLite file** (copy the file, you've copied the state); point a DSN at **PostgreSQL or MySQL** when you want a real multi-writer server — same code, same conformance suite. Tables are auto-created, or **provisioned out-of-band** with a printed DDL script (see [Storage backends](docs/en/user-guide/storage-backends.md)).
|
|
60
60
|
- ♻️ **Stateless, resumable loops.** A `StatefulAgentLoop` carries no authoritative state — all of it lives in the store. So a loop is cheap to create and trivially **restored from a DSN + a session id** (ideal for web handlers, workers, cold starts). It self-caches each session's active window (a rebuildable accelerator that never changes what the model sees) to skip re-reads on hot paths.
|
|
61
61
|
- ⏱️ **Durable by default.** Crash mid-run and `resume()`. Agents schedule their own **durable timers** that survive restarts. Workflows **replay finished steps and re-run only the unfinished tail** after a process death. The store survives version upgrades (a portable, backend-neutral migration-version table) and can be **pruned, VACUUMed, and exported**.
|
|
62
|
+
- 🧠 **Context engineering, not one fixed strategy.** How each finished send is *recorded/rendered* (**representation**: full **verbatim** or a terse per-send **projection**) and how older history is *compacted* once over budget (**fold strategy**: a single **LLM summary**, or an **agentic** pass that also writes durable notes) are two **orthogonal, config-driven axes** — any representation composes with any fold strategy, and both take your own `Representation` / `FoldStrategy` implementation. Folds always keep whole sends (never split a tool-call/result pair); `recall_send` / `recall_compacted` pull the original detail back from the immutable audit log.
|
|
62
63
|
- 🧩 **Composable from one loop to a fleet.** Start with `send()`. Add tools. Spawn sub-agents. Fan out a deterministic **workflow** (`sequence`/`parallel`/`foreach`/`branch`). Run each leaf in its **own process and DB** behind a sandbox. Same primitives all the way up.
|
|
63
64
|
- 🛡️ **Isolation seams where it counts.** Tool-level sandboxing via a `ShellBackend` (drop in gVisor/Docker for `bash`); process-level via a `WorkerLauncher` (wrap a whole sub-agent worker per leaf). power-loop stays sandbox-agnostic; you choose the policy.
|
|
64
65
|
- 🔬 **Built to be observed.** Typed events for every stream chunk, tool call, round, and **individual LLM call** — each `seq`-ordered + monotonic-clock stamped. Pluggable sinks behind extras: durable **JSONL** (with `replay`), **Prometheus/StatsD** metrics, an **OpenTelemetry** span tree. Per-run + per-session token accounting and hard per-run budgets.
|
|
@@ -80,14 +81,14 @@ Most "agent frameworks" ask you to build your app *inside* them. power-loop is t
|
|
|
80
81
|
| **Workflow resume** | Journals each step; after a crash, replays completed steps and re-runs only the tail | [Workflows](docs/en/user-guide/workflows.md) |
|
|
81
82
|
| **Process sandboxing** | Each workflow leaf in its own OS process + own DB; wrap each in gVisor/Docker per leaf | [Sandboxing](docs/en/user-guide/sandboxing.md) |
|
|
82
83
|
| **Durable timers** | Agents schedule their own wake-ups; survive restarts; one-shot or recurring | [Timers](docs/en/user-guide/timers.md) |
|
|
83
|
-
| **Context
|
|
84
|
-
| **
|
|
84
|
+
| **Context — representation** | Record/render each finished send **verbatim** or as a terse per-send **projection** (derived `pl_project_messages`); `pl_messages` stays immutable; `recall_send` re-expands | [Projection](docs/en/user-guide/send-context-projection.md) |
|
|
85
|
+
| **Context — fold strategy** | Compact older history once over budget: **LLM summary** or **agentic** (also writes notes); pluggable `FoldStrategy`; never splits a tool pair; `recall_compacted` re-expands | [Compaction](docs/en/user-guide/compaction.md) |
|
|
85
86
|
| **Durability ops** | Portable migration-version table, retention/prune, VACUUM, `export_session`/`import_session`, graceful `aclose()` | [Sessions](docs/en/user-guide/sessions.md) |
|
|
86
87
|
| **Observability** | Typed `seq`-ordered events → durable JSONL + `replay`, Prometheus/StatsD metrics, OpenTelemetry spans | [Observability](docs/en/user-guide/observability.md) |
|
|
87
88
|
| **MCP tools** | Surface a Model Context Protocol server's tools as power-loop tools | [Extending](docs/en/user-guide/extending-tools.md) |
|
|
88
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) |
|
|
89
90
|
| **Structured output** | `output_schema` → provider `response_format` → parsed & validated | [Structured](docs/en/user-guide/structured-output.md) |
|
|
90
|
-
| **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) |
|
|
91
92
|
| **Retry / cancel / budgets** | Provider-aware retry, a unified cancellation token, hard per-run token caps | [Retry & Cancel](docs/en/user-guide/retry-cancel.md) |
|
|
92
93
|
| **Stable error codes** | Every `PowerLoopError` carries a frozen machine-readable `code` — branch on `exc.code` | [API: error codes](docs/en/api/index.md#error-codes) |
|
|
93
94
|
| **Crash recovery** | `heal_pending` / `resume` / `abort_pending` for runs killed mid tool-call | [Pending recovery](docs/en/user-guide/sessions.md#pending-recovery) |
|
|
@@ -127,6 +128,28 @@ result = await loop.send(user_text, session_id=session_id)
|
|
|
127
128
|
|
|
128
129
|
Under the hood the loop keeps a per-session **active-window cache** — but it caches only the *durable* projection, validated by a monotonic `next_seq` token, so it's a pure accelerator: a cold loop with an empty cache produces byte-for-byte the same prompts (proven by a warm-vs-cold conformance test, including the recall/compaction/prompt-edit edge cases).
|
|
129
130
|
|
|
131
|
+
### Context engineering — two orthogonal axes you choose (and can implement yourself)
|
|
132
|
+
|
|
133
|
+
Long conversations outgrow the window. Most libraries give you *one* fixed compaction behavior; power-loop (3.0) splits it into two independent, config-driven axes:
|
|
134
|
+
|
|
135
|
+
- **Representation** — how each *finished send* is recorded & rendered: `VerbatimRepresentation` (full, byte-identical history) or `ProjectedRepresentation` (a terse per-send plain-text projection). The original detail always stays in the immutable `pl_messages` audit log.
|
|
136
|
+
- **Fold strategy** — how *older* history is compacted once the rendered prefix crosses the budget: `LLMSummaryFold` (one summary call) or `AgenticFold` (a bounded tool loop that also persists durable facts as notes).
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
from power_loop import (
|
|
140
|
+
StatefulAgentLoop, AgentLoopConfig,
|
|
141
|
+
ProjectedRepresentation, AgenticFold, # mix & match either axis — or pass your own impl
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
cfg = AgentLoopConfig(
|
|
145
|
+
representation=ProjectedRepresentation(max_chars=300), # terse projection (or VerbatimRepresentation)
|
|
146
|
+
fold_strategy=AgenticFold(keep_last_sends=4), # summarize older sends + write notes
|
|
147
|
+
)
|
|
148
|
+
loop = StatefulAgentLoop(llm=llm, dsn="app.db", config=cfg)
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Any representation composes with any fold strategy, and each axis is a small `Protocol` you can implement yourself. A fold always keeps **whole sends** (it never splits an atomic tool-call/result pair), and the model can call `recall_send(send_index=N)` / `recall_compacted()` to pull the full original detail back from the audit log. (The two classes above are public but **provisional** — added in 3.0, not yet frozen into `STABLE_API`; `AgentLoopConfig` itself is Stable.)
|
|
152
|
+
|
|
130
153
|
### Deterministic multi-agent workflows — that the model can author, and that survive a crash
|
|
131
154
|
|
|
132
155
|
Sub-agent delegation is *model-driven* ("go do this"). When you want **code-driven, deterministic** orchestration — fan out over a list, branch on a result, run a pipeline — describe it as a `WorkflowSpec` and let the engine interpret it. The only LLM calls are the leaves; `sequence`/`parallel`/`foreach`/`branch` are plain code.
|
|
@@ -208,7 +231,7 @@ await register_mcp_tools(registry, client, prefix="fs.") # MCP tools → power
|
|
|
208
231
|
|
|
209
232
|
The seam is a tiny `MCPToolSource` Protocol, so the `mcp` SDK is optional and any client works.
|
|
210
233
|
|
|
211
|
-
> More: hard token budgets, structured output, crash recovery, memory, the blackboard — see [`examples/`](examples/README.md) (
|
|
234
|
+
> More: hard token budgets, structured output, crash recovery, memory, the blackboard — see [`examples/`](examples/README.md) (43 runnable programs) and the [docs](docs/en/index.md).
|
|
212
235
|
|
|
213
236
|
---
|
|
214
237
|
|
|
@@ -216,7 +239,7 @@ The seam is a tiny `MCPToolSource` Protocol, so the `mcp` SDK is optional and an
|
|
|
216
239
|
|
|
217
240
|
power-loop is a **kernel**, not a platform — that's the whole trade-off.
|
|
218
241
|
|
|
219
|
-
- **vs. LangChain / LangGraph / LlamaIndex / CrewAI / AutoGen** — those are batteries-included frameworks with large ecosystems (connectors, vector stores, integrations) and heavy dependency trees. power-loop deliberately ships **none of that**: a compact (~
|
|
242
|
+
- **vs. LangChain / LangGraph / LlamaIndex / CrewAI / AutoGen** — those are batteries-included frameworks with large ecosystems (connectors, vector stores, integrations) and heavy dependency trees. power-loop deliberately ships **none of that**: a compact (~24k-line) pure-stdlib core with zero runtime dependencies, and you bring your own tools (or an MCP server). You get durable sessions across SQLite/PG/MySQL, crash-resumable workflows, and real sandbox seams out of the box; you do **not** get a bundled RAG stack or 100 connectors.
|
|
220
243
|
- **Choose power-loop** when you want to *embed* an agent in an existing app, keep your dependency surface tiny, pick your own database, and care about durability + isolation + a stable contract.
|
|
221
244
|
- **Choose a framework** when you want batteries included, a big integration catalog, and don't mind the weight.
|
|
222
245
|
|
|
@@ -248,7 +271,7 @@ Python 3.10+. See [Getting Started](docs/en/getting-started.md). Optional extras
|
|
|
248
271
|
|
|
249
272
|
## Stability & SemVer
|
|
250
273
|
|
|
251
|
-
|
|
274
|
+
Since **1.0**, the **STABLE** API (listed in `power_loop.STABLE_API`) is under SemVer: a breaking change requires a major bump, enforced by a frozen-baseline test in CI — including the flagship `StatefulAgentLoop` *and the LLM contract needed to construct it*. Error `.code` strings are frozen too. The two majors since (2.0 pluggable async storage, 3.0 orthogonal context axes) were exactly that policy in action — breaking changes earned a major bump, each documented in the [Changelog](CHANGELOG.md).
|
|
252
275
|
|
|
253
276
|
| Tier | Meaning |
|
|
254
277
|
|---|---|
|
|
@@ -15,7 +15,7 @@ Stability tiers
|
|
|
15
15
|
无版本承诺,可随时变更或删除。
|
|
16
16
|
"""
|
|
17
17
|
|
|
18
|
-
__version__ = "3.0
|
|
18
|
+
__version__ = "3.1.0"
|
|
19
19
|
|
|
20
20
|
# Public LLM contract (SDK-free) re-exported so callers (e.g. writing llm.* hooks or
|
|
21
21
|
# a custom LLMService) don't reach into the internal vendored transport package (H3.4).
|
|
@@ -154,9 +154,15 @@ from power_loop.runtime.fold import (
|
|
|
154
154
|
NoteOp,
|
|
155
155
|
)
|
|
156
156
|
from power_loop.runtime.human_input import HumanInputRequired, request_user_input
|
|
157
|
-
from power_loop.runtime.memory import
|
|
157
|
+
from power_loop.runtime.memory import (
|
|
158
|
+
MemoryProvider,
|
|
159
|
+
MemoryRecallHook,
|
|
160
|
+
MemorySnapshot,
|
|
161
|
+
tag_as_memory,
|
|
162
|
+
)
|
|
158
163
|
from power_loop.runtime.notes import (
|
|
159
164
|
DEFAULT_NOTES_POLICY,
|
|
165
|
+
NoteMemory,
|
|
160
166
|
NotesFullError,
|
|
161
167
|
NotesPolicy,
|
|
162
168
|
SQLiteNoteMemory,
|
|
@@ -361,10 +367,12 @@ __all__ = [
|
|
|
361
367
|
"LlmDegradedPayload",
|
|
362
368
|
"LoopCancelledPayload",
|
|
363
369
|
"MemoryProvider",
|
|
370
|
+
"MemoryRecallHook",
|
|
364
371
|
"MemorySnapshot",
|
|
365
372
|
"tag_as_memory",
|
|
366
373
|
"NotesPolicy",
|
|
367
374
|
"NotesFullError",
|
|
375
|
+
"NoteMemory",
|
|
368
376
|
"SQLiteNoteMemory",
|
|
369
377
|
"DEFAULT_NOTES_POLICY",
|
|
370
378
|
"render_notes",
|
|
@@ -32,7 +32,6 @@ class MessageSink(Protocol):
|
|
|
32
32
|
|
|
33
33
|
async def on_round_started(self, round_index: int) -> None: ...
|
|
34
34
|
async def on_message_appended(self, message: LoopMessage, *, round_index: int | None) -> None: ...
|
|
35
|
-
def on_messages_inserted(self, *, index: int, count: int) -> None: ... # pure (no I/O) → sync
|
|
36
35
|
async def on_assistant_tool_calls(
|
|
37
36
|
self, *, assistant_seq: int, tool_calls: list[dict[str, Any]], round_index: int
|
|
38
37
|
) -> None: ...
|
|
@@ -57,7 +56,6 @@ class NullSink:
|
|
|
57
56
|
|
|
58
57
|
async def on_round_started(self, round_index: int) -> None: ...
|
|
59
58
|
async def on_message_appended(self, message: LoopMessage, *, round_index: int | None) -> None: ...
|
|
60
|
-
def on_messages_inserted(self, *, index: int, count: int) -> None: ...
|
|
61
59
|
async def on_assistant_tool_calls(
|
|
62
60
|
self, *, assistant_seq: int, tool_calls: list[dict[str, Any]], round_index: int
|
|
63
61
|
) -> None: ...
|
|
@@ -129,16 +127,12 @@ class SQLiteSink:
|
|
|
129
127
|
self._history_seqs = list(seqs)
|
|
130
128
|
self._history_ord = list(ords) if ords is not None else list(seqs)
|
|
131
129
|
|
|
132
|
-
|
|
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
|
|
|
@@ -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,37 @@ class AgentLoopConfig:
|
|
|
111
112
|
retry_policy: LLMRetryPolicy | None = None
|
|
112
113
|
memory: MemoryProvider | None = None
|
|
113
114
|
memory_budget_tokens: int = 1500
|
|
115
|
+
#: Where the built-in MemoryRecallHook injects recalled memory into the
|
|
116
|
+
#: per-call request: "tail" (default — after history, keeps the prior-history
|
|
117
|
+
#: prefix byte-stable and prefix-cacheable) or "front" (after leading system
|
|
118
|
+
#: messages — legacy position; breaks prefix caching when memory changes).
|
|
119
|
+
memory_position: str = "tail"
|
|
120
|
+
#: Auto-register the built-in MemoryRecallHook when ``memory`` is set. Turn
|
|
121
|
+
#: off to inject memory yourself via an LLM_BEFORE hook.
|
|
122
|
+
builtin_memory_hook: bool = True
|
|
123
|
+
|
|
124
|
+
# ── Microcompact (large tool-output spill-to-disk) ──
|
|
125
|
+
#
|
|
126
|
+
# A cheap, no-LLM per-round mechanism that replaces OLD oversized tool
|
|
127
|
+
# outputs (older than the hot tail) with a short on-disk pointer, to save
|
|
128
|
+
# context tokens — orthogonal to the LLM-summary fold/compactor. Verbatim
|
|
129
|
+
# mode only (projection renders finished sends from the projection store).
|
|
130
|
+
#
|
|
131
|
+
# DEFAULT OFF as of 3.1.x: it only helps when those old outputs are never
|
|
132
|
+
# needed again; otherwise the pointer just trades for a re-read. Projection
|
|
133
|
+
# mode + fold + provider prefix-caching already cover context budget. Turn it
|
|
134
|
+
# on for long verbatim sessions that read many large files and rarely revisit
|
|
135
|
+
# the old ones. The thresholds default from the legacy CONTEXT_MICRO_* env
|
|
136
|
+
# vars for back-compat; the config fields take precedence.
|
|
137
|
+
microcompact_enabled: bool = False
|
|
138
|
+
microcompact_size_limit: int = field(
|
|
139
|
+
default_factory=lambda: int(os.getenv("CONTEXT_MICRO_SIZE_LIMIT", "1000"))
|
|
140
|
+
)
|
|
141
|
+
microcompact_hot_tail: int = field(
|
|
142
|
+
default_factory=lambda: int(os.getenv("CONTEXT_MICRO_HOT_TAIL", "10"))
|
|
143
|
+
)
|
|
144
|
+
#: Where spilled outputs are written. None → the runtime home's ``.cache``.
|
|
145
|
+
microcompact_spill_dir: str | None = None
|
|
114
146
|
# Bounds for the note_add/note_update/note_delete tools (agent-authored
|
|
115
147
|
# notes). None → DEFAULT_NOTES_POLICY. See power_loop.runtime.notes.
|
|
116
148
|
notes_policy: NotesPolicy | None = None
|
|
@@ -132,6 +164,24 @@ class AgentLoopConfig:
|
|
|
132
164
|
inject_tool_descriptions: bool = True
|
|
133
165
|
tool_catalog_header: str = "# Available Tools"
|
|
134
166
|
|
|
167
|
+
def effective_context_budget(self) -> int:
|
|
168
|
+
"""Fold/compaction budget after reserving headroom for the ephemeral
|
|
169
|
+
memory block.
|
|
170
|
+
|
|
171
|
+
Memory is injected at the per-call tail by the built-in hook and is NOT
|
|
172
|
+
counted by the fold trigger (it isn't in ``self.history``). To keep
|
|
173
|
+
``history + memory`` within the model window, the fold threshold targets
|
|
174
|
+
``max_tokens − memory_budget_tokens`` so folding fires early enough.
|
|
175
|
+
``0``/``None`` max_tokens means "no explicit budget" → returned
|
|
176
|
+
unchanged.
|
|
177
|
+
"""
|
|
178
|
+
mt = int(self.max_tokens or 0)
|
|
179
|
+
if mt <= 0:
|
|
180
|
+
return mt
|
|
181
|
+
if self.memory is not None and self.builtin_memory_hook:
|
|
182
|
+
return max(1, mt - int(self.memory_budget_tokens or 0))
|
|
183
|
+
return mt
|
|
184
|
+
|
|
135
185
|
def __post_init__(self) -> None:
|
|
136
186
|
self._map_legacy_axes()
|
|
137
187
|
self._validate_context_config()
|
|
@@ -122,7 +122,9 @@ class LlmBeforeCtx(BaseHookCtx):
|
|
|
122
122
|
"""Context for :pyattr:`HookPoint.LLM_BEFORE`.
|
|
123
123
|
|
|
124
124
|
Directives: SHORT_CIRCUIT (set ``output`` to an ``LLMResponse``), BREAK.
|
|
125
|
-
Handler may modify any input field.
|
|
125
|
+
Handler may modify any input field. ``messages`` is the fresh per-call list
|
|
126
|
+
actually sent to the LLM — mutating it (e.g. appending an ephemeral memory
|
|
127
|
+
block) never touches the loop's persisted history.
|
|
126
128
|
"""
|
|
127
129
|
|
|
128
130
|
messages: list[LoopMessage] = field(default_factory=list)
|
|
@@ -130,6 +132,7 @@ class LlmBeforeCtx(BaseHookCtx):
|
|
|
130
132
|
tools: list[dict[str, Any]] | None = None
|
|
131
133
|
max_tokens: int = 8000
|
|
132
134
|
temperature: float = 0.0
|
|
135
|
+
session_id: str | None = None
|
|
133
136
|
# Handler output (for SHORT_CIRCUIT)
|
|
134
137
|
output: LLMResponse | None = None
|
|
135
138
|
|