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.
Files changed (114) hide show
  1. looplet-0.1.7/.gitignore +63 -0
  2. looplet-0.1.7/CHANGELOG.md +352 -0
  3. looplet-0.1.7/HOOK_GUIDE.md +265 -0
  4. looplet-0.1.7/LICENSE +201 -0
  5. looplet-0.1.7/PKG-INFO +196 -0
  6. looplet-0.1.7/README.md +161 -0
  7. looplet-0.1.7/pyproject.toml +105 -0
  8. looplet-0.1.7/src/looplet/__init__.py +177 -0
  9. looplet-0.1.7/src/looplet/__main__.py +143 -0
  10. looplet-0.1.7/src/looplet/approval.py +135 -0
  11. looplet-0.1.7/src/looplet/backends.py +536 -0
  12. looplet-0.1.7/src/looplet/budget.py +235 -0
  13. looplet-0.1.7/src/looplet/cache.py +211 -0
  14. looplet-0.1.7/src/looplet/checkpoint.py +286 -0
  15. looplet-0.1.7/src/looplet/compact.py +598 -0
  16. looplet-0.1.7/src/looplet/context.py +386 -0
  17. looplet-0.1.7/src/looplet/conversation.py +492 -0
  18. looplet-0.1.7/src/looplet/evals.py +768 -0
  19. looplet-0.1.7/src/looplet/events.py +100 -0
  20. looplet-0.1.7/src/looplet/examples/__init__.py +1 -0
  21. looplet-0.1.7/src/looplet/examples/coding_agent.py +747 -0
  22. looplet-0.1.7/src/looplet/examples/hello_world.py +83 -0
  23. looplet-0.1.7/src/looplet/examples/ollama_hello.py +63 -0
  24. looplet-0.1.7/src/looplet/flags.py +98 -0
  25. looplet-0.1.7/src/looplet/history.py +160 -0
  26. looplet-0.1.7/src/looplet/hook_decision.py +233 -0
  27. looplet-0.1.7/src/looplet/loop.py +1850 -0
  28. looplet-0.1.7/src/looplet/mcp.py +253 -0
  29. looplet-0.1.7/src/looplet/memory.py +99 -0
  30. looplet-0.1.7/src/looplet/parse.py +171 -0
  31. looplet-0.1.7/src/looplet/permissions.py +260 -0
  32. looplet-0.1.7/src/looplet/presets.py +478 -0
  33. looplet-0.1.7/src/looplet/prompts.py +245 -0
  34. looplet-0.1.7/src/looplet/provenance.py +1016 -0
  35. looplet-0.1.7/src/looplet/py.typed +0 -0
  36. looplet-0.1.7/src/looplet/recovery.py +289 -0
  37. looplet-0.1.7/src/looplet/recovery_strategies.py +86 -0
  38. looplet-0.1.7/src/looplet/router.py +380 -0
  39. looplet-0.1.7/src/looplet/scaffolding.py +813 -0
  40. looplet-0.1.7/src/looplet/session.py +324 -0
  41. looplet-0.1.7/src/looplet/skills.py +84 -0
  42. looplet-0.1.7/src/looplet/streaming.py +446 -0
  43. looplet-0.1.7/src/looplet/subagent.py +233 -0
  44. looplet-0.1.7/src/looplet/telemetry.py +429 -0
  45. looplet-0.1.7/src/looplet/testing.py +126 -0
  46. looplet-0.1.7/src/looplet/tools.py +465 -0
  47. looplet-0.1.7/src/looplet/types.py +532 -0
  48. looplet-0.1.7/src/looplet/validation.py +297 -0
  49. looplet-0.1.7/tests/__init__.py +0 -0
  50. looplet-0.1.7/tests/conftest.py +47 -0
  51. looplet-0.1.7/tests/test_approval_hook_smoke.py +103 -0
  52. looplet-0.1.7/tests/test_approval_protocol.py +75 -0
  53. looplet-0.1.7/tests/test_backend_context_window.py +82 -0
  54. looplet-0.1.7/tests/test_backends.py +190 -0
  55. looplet-0.1.7/tests/test_budget_continuation_smoke.py +95 -0
  56. looplet-0.1.7/tests/test_budget_smoke.py +130 -0
  57. looplet-0.1.7/tests/test_cache_smoke.py +136 -0
  58. looplet-0.1.7/tests/test_checkpoint.py +434 -0
  59. looplet-0.1.7/tests/test_compact_smoke.py +109 -0
  60. looplet-0.1.7/tests/test_compact_strategies_smoke.py +277 -0
  61. looplet-0.1.7/tests/test_compaction_boundary.py +80 -0
  62. looplet-0.1.7/tests/test_context.py +479 -0
  63. looplet-0.1.7/tests/test_context_manager_boundary.py +69 -0
  64. looplet-0.1.7/tests/test_context_pressure_event.py +109 -0
  65. looplet-0.1.7/tests/test_conversation.py +446 -0
  66. looplet-0.1.7/tests/test_conversation_integration.py +159 -0
  67. looplet-0.1.7/tests/test_default_build_prompt.py +93 -0
  68. looplet-0.1.7/tests/test_domain_adapter_smoke.py +88 -0
  69. looplet-0.1.7/tests/test_eval_smoke.py +348 -0
  70. looplet-0.1.7/tests/test_examples.py +40 -0
  71. looplet-0.1.7/tests/test_flags.py +119 -0
  72. looplet-0.1.7/tests/test_history_recorder.py +126 -0
  73. looplet-0.1.7/tests/test_hook_build_slots_smoke.py +128 -0
  74. looplet-0.1.7/tests/test_hook_decision_smoke.py +349 -0
  75. looplet-0.1.7/tests/test_integration.py +402 -0
  76. looplet-0.1.7/tests/test_lifecycle_events_smoke.py +190 -0
  77. looplet-0.1.7/tests/test_llm_cancel_token.py +163 -0
  78. looplet-0.1.7/tests/test_llm_compact_smoke.py +78 -0
  79. looplet-0.1.7/tests/test_loop.py +566 -0
  80. looplet-0.1.7/tests/test_loop_memory_integration.py +60 -0
  81. looplet-0.1.7/tests/test_loop_wiring.py +563 -0
  82. looplet-0.1.7/tests/test_mcp_adapter_smoke.py +48 -0
  83. looplet-0.1.7/tests/test_multi_block_content.py +82 -0
  84. looplet-0.1.7/tests/test_multimodal_compaction.py +66 -0
  85. looplet-0.1.7/tests/test_native_tools.py +277 -0
  86. looplet-0.1.7/tests/test_package_smoke.py +53 -0
  87. looplet-0.1.7/tests/test_parse.py +201 -0
  88. looplet-0.1.7/tests/test_permission_engine.py +152 -0
  89. looplet-0.1.7/tests/test_permission_hook_smoke.py +110 -0
  90. looplet-0.1.7/tests/test_persistent_memory.py +114 -0
  91. looplet-0.1.7/tests/test_presets.py +203 -0
  92. looplet-0.1.7/tests/test_proactive_compact_smoke.py +97 -0
  93. looplet-0.1.7/tests/test_prompts.py +222 -0
  94. looplet-0.1.7/tests/test_provenance_smoke.py +248 -0
  95. looplet-0.1.7/tests/test_reactive_recovery_parity.py +119 -0
  96. looplet-0.1.7/tests/test_recovery.py +335 -0
  97. looplet-0.1.7/tests/test_render_override_smoke.py +94 -0
  98. looplet-0.1.7/tests/test_replay_and_cli_smoke.py +136 -0
  99. looplet-0.1.7/tests/test_router.py +331 -0
  100. looplet-0.1.7/tests/test_scaffolding.py +667 -0
  101. looplet-0.1.7/tests/test_session.py +284 -0
  102. looplet-0.1.7/tests/test_skill_smoke.py +49 -0
  103. looplet-0.1.7/tests/test_streaming.py +341 -0
  104. looplet-0.1.7/tests/test_subagent.py +533 -0
  105. looplet-0.1.7/tests/test_subagent_events_smoke.py +99 -0
  106. looplet-0.1.7/tests/test_telemetry.py +422 -0
  107. looplet-0.1.7/tests/test_testing_smoke.py +44 -0
  108. looplet-0.1.7/tests/test_tool_context.py +145 -0
  109. looplet-0.1.7/tests/test_tool_error_taxonomy.py +118 -0
  110. looplet-0.1.7/tests/test_tool_progress_smoke.py +85 -0
  111. looplet-0.1.7/tests/test_tools.py +287 -0
  112. looplet-0.1.7/tests/test_tools_json_schema.py +248 -0
  113. looplet-0.1.7/tests/test_types.py +312 -0
  114. looplet-0.1.7/tests/test_validation.py +364 -0
@@ -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).