looplet 0.1.7__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.
- looplet-0.1.7/.gitignore +63 -0
- looplet-0.1.7/CHANGELOG.md +352 -0
- looplet-0.1.7/HOOK_GUIDE.md +265 -0
- looplet-0.1.7/LICENSE +201 -0
- looplet-0.1.7/PKG-INFO +196 -0
- looplet-0.1.7/README.md +161 -0
- looplet-0.1.7/pyproject.toml +105 -0
- looplet-0.1.7/src/looplet/__init__.py +177 -0
- looplet-0.1.7/src/looplet/__main__.py +143 -0
- looplet-0.1.7/src/looplet/approval.py +135 -0
- looplet-0.1.7/src/looplet/backends.py +536 -0
- looplet-0.1.7/src/looplet/budget.py +235 -0
- looplet-0.1.7/src/looplet/cache.py +211 -0
- looplet-0.1.7/src/looplet/checkpoint.py +286 -0
- looplet-0.1.7/src/looplet/compact.py +598 -0
- looplet-0.1.7/src/looplet/context.py +386 -0
- looplet-0.1.7/src/looplet/conversation.py +492 -0
- looplet-0.1.7/src/looplet/evals.py +768 -0
- looplet-0.1.7/src/looplet/events.py +100 -0
- looplet-0.1.7/src/looplet/examples/__init__.py +1 -0
- looplet-0.1.7/src/looplet/examples/coding_agent.py +747 -0
- looplet-0.1.7/src/looplet/examples/hello_world.py +83 -0
- looplet-0.1.7/src/looplet/examples/ollama_hello.py +63 -0
- looplet-0.1.7/src/looplet/flags.py +98 -0
- looplet-0.1.7/src/looplet/history.py +160 -0
- looplet-0.1.7/src/looplet/hook_decision.py +233 -0
- looplet-0.1.7/src/looplet/loop.py +1850 -0
- looplet-0.1.7/src/looplet/mcp.py +253 -0
- looplet-0.1.7/src/looplet/memory.py +99 -0
- looplet-0.1.7/src/looplet/parse.py +171 -0
- looplet-0.1.7/src/looplet/permissions.py +260 -0
- looplet-0.1.7/src/looplet/presets.py +478 -0
- looplet-0.1.7/src/looplet/prompts.py +245 -0
- looplet-0.1.7/src/looplet/provenance.py +1016 -0
- looplet-0.1.7/src/looplet/py.typed +0 -0
- looplet-0.1.7/src/looplet/recovery.py +289 -0
- looplet-0.1.7/src/looplet/recovery_strategies.py +86 -0
- looplet-0.1.7/src/looplet/router.py +380 -0
- looplet-0.1.7/src/looplet/scaffolding.py +813 -0
- looplet-0.1.7/src/looplet/session.py +324 -0
- looplet-0.1.7/src/looplet/skills.py +84 -0
- looplet-0.1.7/src/looplet/streaming.py +446 -0
- looplet-0.1.7/src/looplet/subagent.py +233 -0
- looplet-0.1.7/src/looplet/telemetry.py +429 -0
- looplet-0.1.7/src/looplet/testing.py +126 -0
- looplet-0.1.7/src/looplet/tools.py +465 -0
- looplet-0.1.7/src/looplet/types.py +532 -0
- looplet-0.1.7/src/looplet/validation.py +297 -0
- looplet-0.1.7/tests/__init__.py +0 -0
- looplet-0.1.7/tests/conftest.py +47 -0
- looplet-0.1.7/tests/test_approval_hook_smoke.py +103 -0
- looplet-0.1.7/tests/test_approval_protocol.py +75 -0
- looplet-0.1.7/tests/test_backend_context_window.py +82 -0
- looplet-0.1.7/tests/test_backends.py +190 -0
- looplet-0.1.7/tests/test_budget_continuation_smoke.py +95 -0
- looplet-0.1.7/tests/test_budget_smoke.py +130 -0
- looplet-0.1.7/tests/test_cache_smoke.py +136 -0
- looplet-0.1.7/tests/test_checkpoint.py +434 -0
- looplet-0.1.7/tests/test_compact_smoke.py +109 -0
- looplet-0.1.7/tests/test_compact_strategies_smoke.py +277 -0
- looplet-0.1.7/tests/test_compaction_boundary.py +80 -0
- looplet-0.1.7/tests/test_context.py +479 -0
- looplet-0.1.7/tests/test_context_manager_boundary.py +69 -0
- looplet-0.1.7/tests/test_context_pressure_event.py +109 -0
- looplet-0.1.7/tests/test_conversation.py +446 -0
- looplet-0.1.7/tests/test_conversation_integration.py +159 -0
- looplet-0.1.7/tests/test_default_build_prompt.py +93 -0
- looplet-0.1.7/tests/test_domain_adapter_smoke.py +88 -0
- looplet-0.1.7/tests/test_eval_smoke.py +348 -0
- looplet-0.1.7/tests/test_examples.py +40 -0
- looplet-0.1.7/tests/test_flags.py +119 -0
- looplet-0.1.7/tests/test_history_recorder.py +126 -0
- looplet-0.1.7/tests/test_hook_build_slots_smoke.py +128 -0
- looplet-0.1.7/tests/test_hook_decision_smoke.py +349 -0
- looplet-0.1.7/tests/test_integration.py +402 -0
- looplet-0.1.7/tests/test_lifecycle_events_smoke.py +190 -0
- looplet-0.1.7/tests/test_llm_cancel_token.py +163 -0
- looplet-0.1.7/tests/test_llm_compact_smoke.py +78 -0
- looplet-0.1.7/tests/test_loop.py +566 -0
- looplet-0.1.7/tests/test_loop_memory_integration.py +60 -0
- looplet-0.1.7/tests/test_loop_wiring.py +563 -0
- looplet-0.1.7/tests/test_mcp_adapter_smoke.py +48 -0
- looplet-0.1.7/tests/test_multi_block_content.py +82 -0
- looplet-0.1.7/tests/test_multimodal_compaction.py +66 -0
- looplet-0.1.7/tests/test_native_tools.py +277 -0
- looplet-0.1.7/tests/test_package_smoke.py +53 -0
- looplet-0.1.7/tests/test_parse.py +201 -0
- looplet-0.1.7/tests/test_permission_engine.py +152 -0
- looplet-0.1.7/tests/test_permission_hook_smoke.py +110 -0
- looplet-0.1.7/tests/test_persistent_memory.py +114 -0
- looplet-0.1.7/tests/test_presets.py +203 -0
- looplet-0.1.7/tests/test_proactive_compact_smoke.py +97 -0
- looplet-0.1.7/tests/test_prompts.py +222 -0
- looplet-0.1.7/tests/test_provenance_smoke.py +248 -0
- looplet-0.1.7/tests/test_reactive_recovery_parity.py +119 -0
- looplet-0.1.7/tests/test_recovery.py +335 -0
- looplet-0.1.7/tests/test_render_override_smoke.py +94 -0
- looplet-0.1.7/tests/test_replay_and_cli_smoke.py +136 -0
- looplet-0.1.7/tests/test_router.py +331 -0
- looplet-0.1.7/tests/test_scaffolding.py +667 -0
- looplet-0.1.7/tests/test_session.py +284 -0
- looplet-0.1.7/tests/test_skill_smoke.py +49 -0
- looplet-0.1.7/tests/test_streaming.py +341 -0
- looplet-0.1.7/tests/test_subagent.py +533 -0
- looplet-0.1.7/tests/test_subagent_events_smoke.py +99 -0
- looplet-0.1.7/tests/test_telemetry.py +422 -0
- looplet-0.1.7/tests/test_testing_smoke.py +44 -0
- looplet-0.1.7/tests/test_tool_context.py +145 -0
- looplet-0.1.7/tests/test_tool_error_taxonomy.py +118 -0
- looplet-0.1.7/tests/test_tool_progress_smoke.py +85 -0
- looplet-0.1.7/tests/test_tools.py +287 -0
- looplet-0.1.7/tests/test_tools_json_schema.py +248 -0
- looplet-0.1.7/tests/test_types.py +312 -0
- looplet-0.1.7/tests/test_validation.py +364 -0
looplet-0.1.7/.gitignore
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Byte-compiled / optimized
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.so
|
|
6
|
+
|
|
7
|
+
# Distribution / packaging
|
|
8
|
+
.Python
|
|
9
|
+
build/
|
|
10
|
+
develop-eggs/
|
|
11
|
+
dist/
|
|
12
|
+
downloads/
|
|
13
|
+
eggs/
|
|
14
|
+
.eggs/
|
|
15
|
+
lib/
|
|
16
|
+
lib64/
|
|
17
|
+
parts/
|
|
18
|
+
sdist/
|
|
19
|
+
var/
|
|
20
|
+
wheels/
|
|
21
|
+
*.egg-info/
|
|
22
|
+
.installed.cfg
|
|
23
|
+
*.egg
|
|
24
|
+
MANIFEST
|
|
25
|
+
|
|
26
|
+
# Virtualenvs
|
|
27
|
+
.env
|
|
28
|
+
.venv/
|
|
29
|
+
venv/
|
|
30
|
+
ENV/
|
|
31
|
+
|
|
32
|
+
# PyPA / tooling
|
|
33
|
+
.tox/
|
|
34
|
+
.coverage
|
|
35
|
+
.coverage.*
|
|
36
|
+
.cache/
|
|
37
|
+
.pytest_cache/
|
|
38
|
+
.mypy_cache/
|
|
39
|
+
.ruff_cache/
|
|
40
|
+
.hypothesis/
|
|
41
|
+
htmlcov/
|
|
42
|
+
coverage.xml
|
|
43
|
+
*.cover
|
|
44
|
+
|
|
45
|
+
# uv
|
|
46
|
+
uv.lock.backup
|
|
47
|
+
|
|
48
|
+
# IDE / OS
|
|
49
|
+
.idea/
|
|
50
|
+
.vscode/
|
|
51
|
+
*.swp
|
|
52
|
+
*.swo
|
|
53
|
+
.DS_Store
|
|
54
|
+
Thumbs.db
|
|
55
|
+
|
|
56
|
+
# Logs and local artifacts
|
|
57
|
+
*.log
|
|
58
|
+
logs/
|
|
59
|
+
scratch/
|
|
60
|
+
|
|
61
|
+
# Type checkers
|
|
62
|
+
.pyre/
|
|
63
|
+
.pytype/
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `looplet` are documented here. The format is
|
|
4
|
+
based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
|
5
|
+
this project adheres to [Semantic Versioning](https://semver.org/).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.1.7] - 2026-04-21
|
|
10
|
+
|
|
11
|
+
### Changed (project rename)
|
|
12
|
+
- **Renamed from `openharness` to `looplet`.** The PyPI name
|
|
13
|
+
`openharness` was taken by an unrelated project, so we renamed
|
|
14
|
+
before the first public release. The public API (imports, symbol
|
|
15
|
+
names, protocols) is unchanged except for the top-level package
|
|
16
|
+
name — update imports from `openharness` to `looplet`. The GitHub
|
|
17
|
+
repository is also renamed; old URLs redirect automatically.
|
|
18
|
+
|
|
19
|
+
### Added (launch polish)
|
|
20
|
+
- `ROADMAP.md` with a frozen v1.0 API contract and explicit
|
|
21
|
+
out-of-scope list.
|
|
22
|
+
- `docs/` site scaffold (tutorial, evals, recipes, hooks, good-first-issues,
|
|
23
|
+
discussions-seed, demo-script) + mkdocs-material config + GitHub
|
|
24
|
+
Pages workflow.
|
|
25
|
+
- `THIRD_PARTY_USERS.md` social-proof seed.
|
|
26
|
+
- `src/looplet/examples/ollama_hello.py` — zero-API-key onboarding.
|
|
27
|
+
- Codecov upload step in CI (non-blocking).
|
|
28
|
+
- Leaner README (<170 lines) with the pydantic-ai-harness disambiguation
|
|
29
|
+
moved to the top.
|
|
30
|
+
|
|
31
|
+
### Added (evals — pytest-style agent evaluation)
|
|
32
|
+
- **Eval framework** (`looplet.evals`). Write `eval_*` functions
|
|
33
|
+
that take `EvalContext` and return any of `float`, `bool`, `str`,
|
|
34
|
+
`dict`, or `EvalResult`. The framework normalizes all return types.
|
|
35
|
+
- **`eval_discover(path)`** — auto-discovers eval functions in
|
|
36
|
+
`eval_*.py` files (like pytest discovers `test_*`).
|
|
37
|
+
- **`eval_run(evals, ctx)`** — runs evaluators, auto-detects
|
|
38
|
+
`llm` parameter for LLM-as-judge, catches errors gracefully.
|
|
39
|
+
- **`eval_run_batch(evals, contexts)`** — runs same evals across
|
|
40
|
+
multiple trajectories with per-eval avg/min/max aggregation.
|
|
41
|
+
- **`eval_mark(*tags)`** — decorator for categorizing evals.
|
|
42
|
+
`eval_run` and `eval_run_batch` accept `include=`/`exclude=` to
|
|
43
|
+
filter by marks.
|
|
44
|
+
- **`eval_cli(args)`** — CLI runner with threshold-based pass/fail
|
|
45
|
+
exit codes for CI integration.
|
|
46
|
+
- **`EvalHook`** — LoopHook that builds EvalContext at `on_loop_end`
|
|
47
|
+
and runs all evaluators automatically during development.
|
|
48
|
+
- **`EvalContext.from_trajectory_dir()`** — loads context from saved
|
|
49
|
+
trajectories with support for both looplet and benchmark formats.
|
|
50
|
+
|
|
51
|
+
### Added (MCP + skills)
|
|
52
|
+
- **`MCPToolAdapter`** — wraps MCP server tools as `ToolSpec` instances
|
|
53
|
+
via JSON-RPC over stdio. No MCP SDK required.
|
|
54
|
+
- **`Skill`** — bundles tools + context + prompt fragment into one
|
|
55
|
+
loadable unit. `skill.register(registry)` adds all tools.
|
|
56
|
+
|
|
57
|
+
### Added (approval)
|
|
58
|
+
- **`ApprovalHook`** — stops the loop when a tool returns
|
|
59
|
+
`needs_approval=True`. Combined with `checkpoint_dir` for
|
|
60
|
+
crash-safe async human-in-the-loop approval.
|
|
61
|
+
- Renamed `elicit` → `approval` uniformly: `LoopConfig.approval_handler`,
|
|
62
|
+
`ToolContext.request_approval`, `ToolContext.approve()`.
|
|
63
|
+
|
|
64
|
+
### Changed (naming cleanup)
|
|
65
|
+
- Renamed internal names for clarity: `coerce_text` → `to_text`,
|
|
66
|
+
`DiminishingReturnsTracker` → `StallDetector`,
|
|
67
|
+
`reactive_compact` → `emergency_truncate`,
|
|
68
|
+
`compress_session_log` → `age_session_entries`,
|
|
69
|
+
`enforce_result_budget` → `trim_results`,
|
|
70
|
+
`should_compress_context` → `is_context_oversized`,
|
|
71
|
+
`HEAVY_BLOCK_KINDS` → `LARGE_CONTENT_TYPES`,
|
|
72
|
+
`DefaultSummarizer` → `default_summarizer`.
|
|
73
|
+
- Renamed compact services: `DefaultCompactService` → `TruncateCompact`,
|
|
74
|
+
`LLMCompactService` → `SummarizeCompact`.
|
|
75
|
+
- Renamed `normalise_hook_return` → `normalize_hook_return`.
|
|
76
|
+
- Moved `concurrent_dispatch` and `reactive_recovery` from `FLAGS`
|
|
77
|
+
global singleton to `LoopConfig` fields.
|
|
78
|
+
- Trimmed `__all__` from 154 → 54 symbols organized into labeled tiers.
|
|
79
|
+
|
|
80
|
+
### Changed (developer experience)
|
|
81
|
+
- Added `preview_prompt()` — shows what the LLM sees before the first
|
|
82
|
+
call. Invaluable for debugging.
|
|
83
|
+
- Added `TrajectoryRecorder.summary()` — one-liner run summary.
|
|
84
|
+
- Added `--trace DIR` to coding_agent example for trajectory recording.
|
|
85
|
+
- Added step-by-step tutorial to README (5 progressive steps).
|
|
86
|
+
- Added `LoopConfig` docstring with "start here" guide listing the
|
|
87
|
+
4 essential fields.
|
|
88
|
+
- Added `FileCheckpointStore.load_latest()` + auto-resume wiring in
|
|
89
|
+
`composable_loop` — crash-resume is now one line:
|
|
90
|
+
`LoopConfig(checkpoint_dir="./ckpt")`.
|
|
91
|
+
|
|
92
|
+
### Removed
|
|
93
|
+
- Removed `async_loop.py` (feature-frozen, no consumers).
|
|
94
|
+
- Removed 3 mock examples (calculator, code_review, research).
|
|
95
|
+
Replaced with `hello_world.py` (real LLM) + `coding_agent.py`
|
|
96
|
+
(Claude Code-equivalent tools: bash, read, write, edit, glob,
|
|
97
|
+
grep, think, done).
|
|
98
|
+
- Removed all back-compat aliases.
|
|
99
|
+
- Removed all internal project references (cadence, primal_security).
|
|
100
|
+
|
|
101
|
+
### Added (compaction strategies)
|
|
102
|
+
- **`PruneToolResults`** — new zero-LLM-call compaction service that
|
|
103
|
+
clears old tool-result content while keeping conversation structure
|
|
104
|
+
intact. Configurable `keep_recent` (how many recent tool results
|
|
105
|
+
to preserve) and `compactable_tools` (restrict to specific tools).
|
|
106
|
+
Cheapest possible compaction — use as the first stage in a chain.
|
|
107
|
+
- **`compact_chain(*services)`** — combinator that tries compaction
|
|
108
|
+
services in order; first stage that has an effect wins. Replaces
|
|
109
|
+
the need for a separate `ChainedCompactService` class. Usage:
|
|
110
|
+
`compact_chain(PruneToolResults(), SummarizeCompact(), TruncateCompact())`.
|
|
111
|
+
- **`CompactOutcome.cleanup`** — optional post-compact callback.
|
|
112
|
+
When set, `run_compact()` invokes it after firing `POST_COMPACT`.
|
|
113
|
+
Use for domain-specific state resets (clear caches, re-inject
|
|
114
|
+
context, reset token baselines) without the loop knowing details.
|
|
115
|
+
|
|
116
|
+
### Changed (renames — back-compat aliases kept)
|
|
117
|
+
- **`DefaultCompactService`** → **`TruncateCompact`** — clearer name
|
|
118
|
+
for "drop old entries, keep N recent, zero LLM calls."
|
|
119
|
+
- **`LLMCompactService`** → **`SummarizeCompact`** — clearer name
|
|
120
|
+
for "LLM summarizes middle, keeps N recent."
|
|
121
|
+
- Old names (`DefaultCompactService`, `LLMCompactService`) remain
|
|
122
|
+
as aliases and continue to work.
|
|
123
|
+
|
|
124
|
+
### Added (context management pt. 2)
|
|
125
|
+
- **Prompt caching infrastructure** (`looplet.cache`). New
|
|
126
|
+
`CachePolicy` dataclass declares which stable prompt sections
|
|
127
|
+
(system prompt, tool schemas, memory) should carry Anthropic-style
|
|
128
|
+
`cache_control` markers, with per-section TTL (`ephemeral` / `1h`).
|
|
129
|
+
`LoopConfig.cache_policy` threads per-turn `CacheBreakpoint` lists
|
|
130
|
+
(label + SHA-256 hash + TTL) to backends that expose
|
|
131
|
+
`generate_with_cache(..., cache_breakpoints=[...])`. Backends
|
|
132
|
+
without the kwarg keep working unchanged — caching is strictly
|
|
133
|
+
additive. `CacheBreakDetector` ships as a drop-in observer hook
|
|
134
|
+
that records section-hash changes across turns for cache-miss
|
|
135
|
+
telemetry.
|
|
136
|
+
- **`LLMCompactService`** — new compaction strategy that spends one
|
|
137
|
+
LLM call to summarise the session. Produces a dense 4-section
|
|
138
|
+
summary (task goal, findings, open questions, recent decisions)
|
|
139
|
+
spliced into the session log as a synthetic entry after
|
|
140
|
+
keep-recent pruning. Falls back to deterministic keep-recent on
|
|
141
|
+
any summariser error. Trade-off vs `DefaultCompactService`: one
|
|
142
|
+
LLM call per compaction for preserved reasoning chains.
|
|
143
|
+
- **Threshold-tier context budgeting** (`looplet.budget`). New
|
|
144
|
+
`ContextBudget` dataclass with `warning_at` / `error_at` /
|
|
145
|
+
`compact_buffer` tiers. `ThresholdCompactHook` is a ready-to-register
|
|
146
|
+
`should_compact` implementation that fires proactive compaction
|
|
147
|
+
once estimated tokens cross the configured tier.
|
|
148
|
+
`BudgetTelemetry` observer records per-step tier samples and
|
|
149
|
+
exposes `peak_tier` for production dashboards.
|
|
150
|
+
|
|
151
|
+
### Added (architecture improvements)
|
|
152
|
+
- **Proactive compact hook slot** — `LoopHook.should_compact(state,
|
|
153
|
+
session_log, conversation, step_num) -> bool`. Fires at the top of
|
|
154
|
+
each step, before prompt build. Any hook returning `True` triggers
|
|
155
|
+
the configured `CompactService` preemptively. Complements the
|
|
156
|
+
reactive `prompt_too_long` path — use for message-count or
|
|
157
|
+
token-estimate heuristics. `StreamingHook` gets a no-op stub.
|
|
158
|
+
- **Tool-result streaming via `TOOL_PROGRESS`** — new
|
|
159
|
+
`LifecycleEvent.TOOL_PROGRESS`. When hooks are present, the loop
|
|
160
|
+
builds a `ToolContext.on_progress` callback per tool-call that
|
|
161
|
+
emits `TOOL_PROGRESS` (with the originating `tool_call`) whenever
|
|
162
|
+
the tool invokes `ctx.report_progress(stage, data)`. Observers can
|
|
163
|
+
stream intermediate output from long-running tools without
|
|
164
|
+
blocking dispatch.
|
|
165
|
+
- **Budget-aware turn continuation** — new
|
|
166
|
+
`LoopConfig.max_turn_continuations: int = 0`. When `> 0` and the
|
|
167
|
+
backend exposes `last_stop_reason`, `llm_call_with_retry` will
|
|
168
|
+
re-prompt up to N times on `stop_reason == "max_tokens"` and
|
|
169
|
+
concatenate outputs so long thoughts aren't truncated mid-message.
|
|
170
|
+
`LLMResult` gains `stop_reason` and `continuations` fields.
|
|
171
|
+
- **`build_briefing` / `build_prompt` as hook slots** — both are now
|
|
172
|
+
optional methods on `LoopHook`. First hook returning a non-`None`
|
|
173
|
+
string wins; the loop falls back to `LoopConfig.build_briefing` /
|
|
174
|
+
`config.build_prompt` / the built-in default. Lets domain hooks
|
|
175
|
+
own prompt construction without threading callables through
|
|
176
|
+
`LoopConfig` separately.
|
|
177
|
+
- **`DomainAdapter`** — new dataclass bundling the five domain
|
|
178
|
+
callables (`build_briefing`, `extract_entities`, `build_trace`,
|
|
179
|
+
`build_prompt`, `extract_step_metadata`) into a single object.
|
|
180
|
+
`LoopConfig.domain: DomainAdapter | None = None` seeds matching
|
|
181
|
+
flat fields when they are `None`. Flat fields still win over the
|
|
182
|
+
adapter, which wins over built-in defaults — use the adapter to
|
|
183
|
+
package a reusable agent in one handle instead of five kwargs.
|
|
184
|
+
|
|
185
|
+
### Removed (breaking)
|
|
186
|
+
- **`InvestigationLog`** backward-compat alias is gone — use
|
|
187
|
+
`SessionLog` directly.
|
|
188
|
+
- **`HARNESS_FLAGS`** backward-compat alias is gone — use `FLAGS`.
|
|
189
|
+
- **Legacy `CADENCE_*` environment variables** for feature flags are
|
|
190
|
+
no longer read; use the `LOOPLET_*` prefix.
|
|
191
|
+
- **`_clone_tools_excluding`** private alias is gone — use
|
|
192
|
+
`clone_tools_excluding`.
|
|
193
|
+
- **`LoopConfig.permissions`** is gone. Register a
|
|
194
|
+
`PermissionHook(PermissionEngine(...))` in `hooks=[...]` instead —
|
|
195
|
+
it flows through the same unified `HookDecision` + event bus as
|
|
196
|
+
every other hook.
|
|
197
|
+
|
|
198
|
+
### Added
|
|
199
|
+
- **Unified hook vocabulary — `HookDecision`** (`looplet.hook_decision`).
|
|
200
|
+
All hook slots now accept a single `HookDecision` return type (legacy
|
|
201
|
+
`None` / `bool` / `str` returns still work via `normalise_hook_return`).
|
|
202
|
+
Helpers `Allow()`, `Deny(reason)`, `Block(reason)`, `Stop(reason)`,
|
|
203
|
+
`Continue()`, `InjectContext(text)` make intent explicit at the call
|
|
204
|
+
site.
|
|
205
|
+
- **Lifecycle events — `on_event(payload)`** (`looplet.events`).
|
|
206
|
+
`LoopHook` gained an optional `on_event(EventPayload)` method. The
|
|
207
|
+
loop now fires 10 named events: `SESSION_START`, `PRE_LLM_CALL`,
|
|
208
|
+
`POST_LLM_RESPONSE`, `PRE_TOOL_USE`, `POST_TOOL_USE`,
|
|
209
|
+
`POST_TOOL_FAILURE`, `PRE_COMPACT`, `POST_COMPACT`, `STOP`,
|
|
210
|
+
`SUBAGENT_START`, `SUBAGENT_STOP`. Any hook can subscribe with a
|
|
211
|
+
single method instead of implementing every slot.
|
|
212
|
+
- **`PermissionHook`** (`looplet.permissions`) — wraps
|
|
213
|
+
`PermissionEngine` and plugs it into the event bus so policy
|
|
214
|
+
decisions flow through the same `HookDecision` path as custom hooks.
|
|
215
|
+
- **`CompactService` + `DefaultCompactService` + `run_compact(...)`**
|
|
216
|
+
(`looplet.compact`) — reactive compaction is now a swappable
|
|
217
|
+
service with `PRE_COMPACT` / `POST_COMPACT` events.
|
|
218
|
+
- **`LoopConfig.render_messages_override`** — byte-exact escape hatch.
|
|
219
|
+
Receives `(messages, default_prompt, step_num)` and returns the
|
|
220
|
+
exact prompt string sent to the LLM. Lets advanced callers take full
|
|
221
|
+
control of prompt rendering without forking the loop.
|
|
222
|
+
- **First-class subagents** — `run_sub_loop(..., subagent_id=...)`
|
|
223
|
+
now fires `SUBAGENT_START` / `SUBAGENT_STOP` events on the parent's
|
|
224
|
+
hooks and returns `subagent_id` in the result dict for correlation.
|
|
225
|
+
- **`replay_loop(trace_dir, tools=...)`** — rerun a captured trace
|
|
226
|
+
through a fresh `composable_loop` without calling the LLM again.
|
|
227
|
+
Useful for golden-trajectory regression tests, hook A/Bs, and
|
|
228
|
+
cost-free loop diffs. Raises `RuntimeError` if the replay loop
|
|
229
|
+
requests more calls than were recorded or diverges in method
|
|
230
|
+
(`generate` vs `generate_with_tools`). Falls back to
|
|
231
|
+
`call_NN_response.txt` files when `manifest.jsonl` is missing.
|
|
232
|
+
- **`python -m looplet show <trace-dir>`** — stdlib-only CLI that
|
|
233
|
+
prints a one-page summary of a captured trace (run id, termination,
|
|
234
|
+
per-step tool calls with durations, LLM totals). Exit code 1 when
|
|
235
|
+
the directory is missing or malformed.
|
|
236
|
+
- **`looplet.provenance`** — new module for debugging agent runs:
|
|
237
|
+
- `RecordingLLMBackend` / `AsyncRecordingLLMBackend` wrap any backend
|
|
238
|
+
and capture every prompt, system prompt, tool schema, response,
|
|
239
|
+
duration, and error as `LLMCall` records. `generate_with_tools` is
|
|
240
|
+
surfaced only when the wrapped backend supports it, so
|
|
241
|
+
`NativeToolBackend` detection stays honest.
|
|
242
|
+
- `TrajectoryRecorder` hook captures a structured `Trajectory` per
|
|
243
|
+
run (steps, context-before, termination reason, embedded `Tracer`
|
|
244
|
+
spans) and writes `trajectory.json` + `steps/step_NN.json`.
|
|
245
|
+
- `ProvenanceSink` is a 3-line facade: `wrap_llm(...)`,
|
|
246
|
+
`trajectory_hook()`, `flush()`.
|
|
247
|
+
- On-disk layout is diff-friendly: `call_NN_prompt.txt` /
|
|
248
|
+
`call_NN_response.txt` per LLM call plus a `manifest.jsonl`.
|
|
249
|
+
- Both recorders accept `redact=` for secret scrubbing and
|
|
250
|
+
`max_chars_per_call=` for bounded memory.
|
|
251
|
+
- See [PROVENANCE_GUIDE.md](PROVENANCE_GUIDE.md) for API reference,
|
|
252
|
+
recipes, and performance notes.
|
|
253
|
+
- `Step.pretty()` — human-readable CLI formatter complementing
|
|
254
|
+
`Step.summary()` (which is tuned for LLM context assembly).
|
|
255
|
+
|
|
256
|
+
## [0.1.6] - 2026-04-17
|
|
257
|
+
|
|
258
|
+
### Added
|
|
259
|
+
- **`looplet.testing`** — public test-utility module exposing
|
|
260
|
+
`MockLLMBackend` and `AsyncMockLLMBackend` (scripted, zero-dependency)
|
|
261
|
+
so downstream packages can unit-test hooks, tools, and backends
|
|
262
|
+
without a real LLM provider.
|
|
263
|
+
- **PyPI publish workflow** (`.github/workflows/publish.yml`) that
|
|
264
|
+
builds + publishes on version tags via PyPI trusted publishing.
|
|
265
|
+
- **README positioning matrix** comparing `looplet` to LangGraph,
|
|
266
|
+
DSPy, and smolagents; observability/OTel wiring example; stability &
|
|
267
|
+
versioning policy; real `AnthropicBackend` usage in quick-start.
|
|
268
|
+
|
|
269
|
+
### Fixed
|
|
270
|
+
- `resume_loop_state()` now restores the checkpointed `Conversation`
|
|
271
|
+
thread (was silently dropping multi-turn message history on resume).
|
|
272
|
+
- `RoutingLLMBackend.generate_with_tools` is now gated dynamically via
|
|
273
|
+
`__getattr__` so `hasattr(llm, "generate_with_tools")` returns a
|
|
274
|
+
truthful answer for the currently-selected backend (consistent with
|
|
275
|
+
`_FallbackLLM` and `CostTracker`).
|
|
276
|
+
- Async `__llm_error__` step is now recorded through `_history` to
|
|
277
|
+
match the sync loop (previously caused session-log/conversation
|
|
278
|
+
drift on LLM failure).
|
|
279
|
+
|
|
280
|
+
### Previously added in this release
|
|
281
|
+
- **`ToolError` taxonomy** — structured `ErrorKind` enum
|
|
282
|
+
(`PERMISSION_DENIED`, `TIMEOUT`, `VALIDATION`, `EXECUTION`, `PARSE`,
|
|
283
|
+
`CONTEXT_OVERFLOW`, `RATE_LIMIT`, `NETWORK`, `CANCELLED`) plus a
|
|
284
|
+
`ToolError` dataclass. `ToolResult` now carries both `error: str`
|
|
285
|
+
(for JSON-safe display) and `error_detail: ToolError` (for
|
|
286
|
+
introspection).
|
|
287
|
+
- **`PermissionEngine`** — declarative `ALLOW` / `DENY` / `ASK` /
|
|
288
|
+
`DEFAULT` rules with fail-closed `arg_matcher`, plug-in `ask_handler`
|
|
289
|
+
for human-in-the-loop, and an append-only denial audit log.
|
|
290
|
+
- **`CancelToken`** — cooperative cancellation is now threaded through
|
|
291
|
+
`LoopConfig` → `llm_call_with_retry` / `async_llm_call_with_retry`
|
|
292
|
+
→ `ToolContext.cancel_token`, so both the next LLM call and any
|
|
293
|
+
in-flight tool can stop cleanly.
|
|
294
|
+
- **`ToolContext.elicit`** — `LoopConfig.elicit_handler` surfaces a
|
|
295
|
+
generic `elicit(prompt) → str` protocol to tools for interactive
|
|
296
|
+
prompts.
|
|
297
|
+
- **Multi-block messages** — `Message.content` supports a `list` of
|
|
298
|
+
`ContentBlock(kind, data)` alongside plain `str`. `HEAVY_BLOCK_KINDS`
|
|
299
|
+
(`image` / `audio` / `video` / `binary`) are stripped before
|
|
300
|
+
summarization.
|
|
301
|
+
- **Async `build_trace`** — `async_composable_loop` now stashes the
|
|
302
|
+
built trace on `state.trace` at exit (async generators can't
|
|
303
|
+
`return` a value).
|
|
304
|
+
- **`SyncToAsyncAdapter.generate_with_tools`** — router-selected sync
|
|
305
|
+
backends keep native-tools support in the async loop.
|
|
306
|
+
- **Preflight context check** — async loop matches sync by skipping a
|
|
307
|
+
doomed LLM call when the prompt is already too long under
|
|
308
|
+
`FLAGS.reactive_recovery`.
|
|
309
|
+
- **Checkpoint state counters** — `resume_loop_state` now round-trips
|
|
310
|
+
`state.queries_used` and `state.budget_remaining` so budget
|
|
311
|
+
enforcement continues across resume.
|
|
312
|
+
|
|
313
|
+
### Changed
|
|
314
|
+
- `ToolResult.error` narrowed back to `str | None` (JSON-safe). Use
|
|
315
|
+
`ToolResult.error_detail` for structured introspection.
|
|
316
|
+
- `PermissionRule.matches()` now fails closed *per decision type*:
|
|
317
|
+
`DENY` rules match on matcher errors (block), `ALLOW` / `ASK` rules
|
|
318
|
+
do not (don't accidentally grant).
|
|
319
|
+
- `PermissionEngine._resolve_default` collapses ambiguous engine
|
|
320
|
+
defaults (`ASK` / `DEFAULT`) to `DENY` so a decision never leaks into
|
|
321
|
+
a `PermissionOutcome` where both `.allowed` and `.denied` are False.
|
|
322
|
+
- `ToolSpec._accepts_ctx` is computed eagerly at `register()` time (and
|
|
323
|
+
self-heals in `dispatch()` for specs inserted directly).
|
|
324
|
+
- `_backend_accepts_cancel_token` cache keyed by `(type, method_name)`
|
|
325
|
+
instead of `id()` (eliminates id-recycling hazard).
|
|
326
|
+
- `_classify_exception` broadened to detect `asyncio.CancelledError`,
|
|
327
|
+
rate-limit, context-overflow, and parse exceptions by class name /
|
|
328
|
+
message content.
|
|
329
|
+
- `SyncToAsyncAdapter._adapter_cache` now prefers the backend object
|
|
330
|
+
itself as the dict key, with `id()` as a fallback for unhashable
|
|
331
|
+
backends.
|
|
332
|
+
- `SessionLog.to_list()` includes `recall_key` for full round-trip
|
|
333
|
+
through checkpoints.
|
|
334
|
+
- `ToolError.context` now round-trips through `Conversation.serialize`
|
|
335
|
+
/ `deserialize`.
|
|
336
|
+
- Permission-denied results from hooks now populate `error_detail` with
|
|
337
|
+
`ErrorKind.PERMISSION_DENIED` (parity with the `PermissionEngine`
|
|
338
|
+
path) in both sync and async loops.
|
|
339
|
+
|
|
340
|
+
### Fixed
|
|
341
|
+
- `_rebuild_prompt` now renders `memory` and falls back to the
|
|
342
|
+
structured `build_prompt` from `looplet.prompts` instead of a
|
|
343
|
+
bare f-string, restoring parity with the first-pass build.
|
|
344
|
+
- `_deserialize_message` now reconstructs `ToolError` from serialized
|
|
345
|
+
`error_kind` / `error_retriable` / `error_context` fields.
|
|
346
|
+
- `_NullSessionLog` (async) gained the attributes the async loop
|
|
347
|
+
expects: `entries`, `current_theory`, `to_list()`, `compact()`.
|
|
348
|
+
|
|
349
|
+
## [0.1.5] - initial public import
|
|
350
|
+
|
|
351
|
+
- Initial release as a standalone package. See the extraction
|
|
352
|
+
commit history for the pre-extraction development timeline.
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
# Hook Authoring Guide
|
|
2
|
+
|
|
3
|
+
Hooks are the primary extension mechanism in `looplet`. They let you
|
|
4
|
+
inject domain-specific behavior into the generic loop without modifying
|
|
5
|
+
any framework code.
|
|
6
|
+
|
|
7
|
+
Hooks are [`@runtime_checkable`](https://docs.python.org/3/library/typing.html#typing.runtime_checkable)
|
|
8
|
+
Protocols — **any object with the right methods is a hook**. No base
|
|
9
|
+
class, no registry, no decorator:
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
class ConsolePrinter:
|
|
13
|
+
def post_dispatch(self, state, session_log, tool_call, tool_result, step_num):
|
|
14
|
+
print(f"#{step_num} {tool_call.tool} → {tool_result.data}")
|
|
15
|
+
return None
|
|
16
|
+
|
|
17
|
+
for step in composable_loop(..., hooks=[ConsolePrinter()]):
|
|
18
|
+
...
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
That's the entire contract. Include only the hook methods you need; the
|
|
22
|
+
loop calls the ones you define and ignores the rest.
|
|
23
|
+
|
|
24
|
+
## The Hook Protocol
|
|
25
|
+
|
|
26
|
+
A hook is any Python object that implements one or more of these methods:
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
class MyHook:
|
|
30
|
+
def pre_loop(self, state, session_log, context):
|
|
31
|
+
"""Called once at loop start. Setup, logging, initial state."""
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
def pre_prompt(self, state, session_log, context, step_num) -> str | None:
|
|
35
|
+
"""Called before each LLM prompt. Return text to inject into briefing."""
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
def pre_dispatch(self, state, session_log, tool_call, step_num) -> ToolResult | None:
|
|
39
|
+
"""Called before each tool. Return a ToolResult to skip execution (cache hit)."""
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
def post_dispatch(self, state, session_log, tool_call, tool_result, step_num) -> str | None:
|
|
43
|
+
"""Called after each tool. Return text to inject into next prompt."""
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
def check_done(self, state, session_log, context, step_num) -> str | None:
|
|
47
|
+
"""Called when agent calls done(). Return a string to reject (quality gate)."""
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
def should_stop(self, state, step_num, new_entities) -> bool:
|
|
51
|
+
"""Called after each step. Return True to force loop termination."""
|
|
52
|
+
return False
|
|
53
|
+
|
|
54
|
+
def on_loop_end(self, state, session_log, context, llm) -> int:
|
|
55
|
+
"""Called once after loop exits. Return count of extra LLM calls made."""
|
|
56
|
+
return 0
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
**All methods are optional.** Implement only the ones you need. The loop checks
|
|
60
|
+
for method existence with `hasattr()` before calling.
|
|
61
|
+
|
|
62
|
+
## Hook Composition
|
|
63
|
+
|
|
64
|
+
Hooks are passed as a list. All hooks fire in order for each hook point:
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
hooks = [LoggingHook(), QualityGateHook(), MetricsHook()]
|
|
68
|
+
composable_loop(llm, tools=reg, hooks=hooks, ...)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
- `pre_prompt`: All non-None returns are concatenated into the briefing
|
|
72
|
+
- `pre_dispatch`: First hook to return non-None wins (intercepts the call)
|
|
73
|
+
- `post_dispatch`: All non-None returns are accumulated for the next prompt
|
|
74
|
+
- `check_done`: First hook to return non-None rejects the done() call
|
|
75
|
+
- `should_stop`: First True stops the loop
|
|
76
|
+
|
|
77
|
+
## Common Hook Patterns
|
|
78
|
+
|
|
79
|
+
### 1. Progress Tracking
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
class ProgressHook:
|
|
83
|
+
def __init__(self):
|
|
84
|
+
self.tool_counts: dict[str, int] = {}
|
|
85
|
+
|
|
86
|
+
def post_dispatch(self, state, session_log, tool_call, tool_result, step_num):
|
|
87
|
+
name = tool_call.tool
|
|
88
|
+
self.tool_counts[name] = self.tool_counts.get(name, 0) + 1
|
|
89
|
+
|
|
90
|
+
if tool_result.error:
|
|
91
|
+
return f"⚠ {name} failed: {tool_result.error[:100]}"
|
|
92
|
+
return None # no injection when things go well
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### 2. Quality Gate
|
|
96
|
+
|
|
97
|
+
Prevent the agent from stopping prematurely:
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
class MinimumEvidenceGate:
|
|
101
|
+
def __init__(self, min_tools: int = 3):
|
|
102
|
+
self.min_tools = min_tools
|
|
103
|
+
|
|
104
|
+
def check_done(self, state, session_log, context, step_num):
|
|
105
|
+
if len(state.steps) < self.min_tools:
|
|
106
|
+
return (
|
|
107
|
+
f"Insufficient evidence: only {len(state.steps)} tools called. "
|
|
108
|
+
f"Call at least {self.min_tools} before concluding."
|
|
109
|
+
)
|
|
110
|
+
return None # allow done()
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### 3. Deduplication Warning
|
|
114
|
+
|
|
115
|
+
Warn when the agent repeats the same tool call:
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
class DedupHook:
|
|
119
|
+
def __init__(self):
|
|
120
|
+
self._seen: set[str] = set()
|
|
121
|
+
|
|
122
|
+
def post_dispatch(self, state, session_log, tool_call, tool_result, step_num):
|
|
123
|
+
key = f"{tool_call.tool}:{sorted(tool_call.args.items())}"
|
|
124
|
+
if key in self._seen:
|
|
125
|
+
return (
|
|
126
|
+
f"⚠ DUPLICATE: You already called {tool_call.tool} with "
|
|
127
|
+
f"these args. Try different parameters or a different tool."
|
|
128
|
+
)
|
|
129
|
+
self._seen.add(key)
|
|
130
|
+
return None
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### 4. Theory Tracking
|
|
134
|
+
|
|
135
|
+
Maintain and inject a running theory/hypothesis:
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
class TheoryHook:
|
|
139
|
+
def __init__(self):
|
|
140
|
+
self.current_theory = "No theory yet"
|
|
141
|
+
|
|
142
|
+
def pre_prompt(self, state, session_log, context, step_num):
|
|
143
|
+
return f"Current working theory: {self.current_theory}"
|
|
144
|
+
|
|
145
|
+
def post_dispatch(self, state, session_log, tool_call, tool_result, step_num):
|
|
146
|
+
# Update theory based on findings
|
|
147
|
+
theory = tool_call.args.get("__theory__")
|
|
148
|
+
if theory:
|
|
149
|
+
self.current_theory = theory
|
|
150
|
+
return None
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### 5. Timeout / Budget Warning
|
|
154
|
+
|
|
155
|
+
Inject urgency as the budget runs low:
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
class BudgetWarningHook:
|
|
159
|
+
def pre_prompt(self, state, session_log, context, step_num):
|
|
160
|
+
remaining = state.budget_remaining
|
|
161
|
+
if remaining <= 2:
|
|
162
|
+
return "⚠ CRITICAL: Only {remaining} steps left. Wrap up NOW."
|
|
163
|
+
if remaining <= 5:
|
|
164
|
+
return f"Budget alert: {remaining} steps remaining."
|
|
165
|
+
return None
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### 6. Domain-Specific Guidance
|
|
169
|
+
|
|
170
|
+
Inject domain knowledge at specific stages:
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
class CodeReviewGuidance:
|
|
174
|
+
def pre_prompt(self, state, session_log, context, step_num):
|
|
175
|
+
if step_num == 1:
|
|
176
|
+
return "Start by listing all changed files to understand scope."
|
|
177
|
+
if step_num == 3:
|
|
178
|
+
return "Focus on security-sensitive patterns: auth, crypto, SQL."
|
|
179
|
+
return None
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## Using Built-In Hooks
|
|
183
|
+
|
|
184
|
+
Cadence ships with several ready-to-use hooks:
|
|
185
|
+
|
|
186
|
+
```python
|
|
187
|
+
from cadence import (
|
|
188
|
+
ContextManagerHook, # Progressive context management
|
|
189
|
+
StreamingHook, # Event emission for observability
|
|
190
|
+
CheckpointHook, # Auto-save checkpoints
|
|
191
|
+
TracingHook, # Span-based tracing
|
|
192
|
+
MetricsHook, # Metrics collection
|
|
193
|
+
)
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### ContextManagerHook
|
|
197
|
+
|
|
198
|
+
Prevents context window overflow with three tiers:
|
|
199
|
+
|
|
200
|
+
```python
|
|
201
|
+
hook = ContextManagerHook(
|
|
202
|
+
llm, # for LLM-based compaction
|
|
203
|
+
context_window=128_000, # your model's window size
|
|
204
|
+
result_max_age_full=3, # steps before result aging
|
|
205
|
+
per_result_chars=50_000, # max chars per result
|
|
206
|
+
aggregate_chars=500_000, # max total chars
|
|
207
|
+
)
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
### StreamingHook
|
|
211
|
+
|
|
212
|
+
Emits typed events for real-time observability:
|
|
213
|
+
|
|
214
|
+
```python
|
|
215
|
+
from cadence import StreamingHook, CallbackEmitter
|
|
216
|
+
|
|
217
|
+
def on_event(event):
|
|
218
|
+
print(f"[{event.event_type}] {event}")
|
|
219
|
+
|
|
220
|
+
hook = StreamingHook(CallbackEmitter(on_event))
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
## Async Hooks
|
|
224
|
+
|
|
225
|
+
For the async loop, implement the same methods as `async def`:
|
|
226
|
+
|
|
227
|
+
```python
|
|
228
|
+
class AsyncProgressHook:
|
|
229
|
+
async def post_dispatch(self, state, session_log, tool_call, tool_result, step_num):
|
|
230
|
+
await save_to_database(tool_call, tool_result)
|
|
231
|
+
return None
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
The async loop checks `asyncio.iscoroutinefunction()` and properly `await`s
|
|
235
|
+
async hooks while also supporting sync hooks in the same hook list.
|
|
236
|
+
|
|
237
|
+
## Testing Hooks
|
|
238
|
+
|
|
239
|
+
Hooks are plain Python classes — test them in isolation:
|
|
240
|
+
|
|
241
|
+
```python
|
|
242
|
+
def test_quality_gate_rejects_early():
|
|
243
|
+
hook = MinimumEvidenceGate(min_tools=3)
|
|
244
|
+
state = MockState(steps=[1, 2]) # only 2 steps
|
|
245
|
+
result = hook.check_done(state, None, None, 2)
|
|
246
|
+
assert result is not None # should reject
|
|
247
|
+
assert "Insufficient" in result
|
|
248
|
+
|
|
249
|
+
def test_quality_gate_allows_enough():
|
|
250
|
+
hook = MinimumEvidenceGate(min_tools=3)
|
|
251
|
+
state = MockState(steps=[1, 2, 3]) # enough
|
|
252
|
+
result = hook.check_done(state, None, None, 3)
|
|
253
|
+
assert result is None # should allow
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
---
|
|
257
|
+
|
|
258
|
+
## Related: provenance hook
|
|
259
|
+
|
|
260
|
+
`TrajectoryRecorder` (in `looplet.provenance`) is a hook that
|
|
261
|
+
captures a complete structured record of every loop run —
|
|
262
|
+
`pre_loop` → `pre_prompt` → `post_dispatch` → `on_loop_end` — and
|
|
263
|
+
serialises it to disk alongside per-LLM-call prompt/response files.
|
|
264
|
+
Use it whenever you want a git-diffable audit trail of what your
|
|
265
|
+
agent did. See [PROVENANCE_GUIDE.md](PROVENANCE_GUIDE.md).
|