Skip to content

Changelog

All notable changes to this project will be documented in this file. Format follows Keep a Changelog. Versioning follows Semantic Versioning.


[Unreleased]

[1.8.2] — 2026-10-15

Documentation

  • The bundled skill is now a short index plus reference files. lazybridge/skill/SKILL.md (was ~700 lines) is a ~130-line index with the mental model, golden rules and a routing table; the detail moved to lazybridge/skill/references/*.md (agents and engines, plans and pipelines, tools and nesting, sessions and memory, coding engines, providers and models, testing, extensions), which an assistant reads only when the task needs that area. Content brought up to 1.8.1: coding-engine profiles and write grants, durable sessions and SessionRegistry, native review, approval queues, background delegation, DurableBlackboard, knowledge base, control plane, current tier aliases. Corrected stale guidance: Plan(on_concurrent=...) accepts "fail"/"fork" (not "queue"), ClaudeCodeEngine can expose Write/Edit/Bash through ClaudeCodePolicy(extra_tools=...) since 1.2.0, and custom engines implement lazybridge.engines.base.Engine (there is no BaseEngine). lazybridge.skill.reference_paths() lists the reference files; the drift check (python -m lazybridge.skill_docs._build --check) now scans the whole bundle and fails on dead or unrouted reference files.

[1.8.1] — 2026-10-14

Added

  • examples/cross_review_benchmark/: a reproducible cross-provider review benchmark (frozen v2 suite, calibration records, regrading). Examples only; no library change.

Changed

  • DurableBlackboard.cancel_task accepts exhausted tasks. A task parked as failed after exhausting its attempt budget can now be reclassified as cancelled, with the reason recorded. Active claimed tasks and terminal done/already-cancelled tasks remain protected from cancellation.

Documentation

  • Realign the homepage, README, quickstart and engine guides with Claude Code / Codex collaboration, durable native conversations and LazyTools integration. Document verification exhaustion, write-tool grants, strict structured output, current session locking and the post-1.1 engine/approval/delegation sequence. Add tests executing changed Markdown examples with fake transports.

Fixed

  • Codex executable discovery selects the newest stable CLI by version and skips helper executables (#189).
  • Codex App Server retries once on thread/start or thread/resume transport failure before a turn was sent (#190); later uncertain turns are not blindly replayed.

[1.8.0]

Fixed

  • Store no longer fails with "database is locked" when several connections open the same database at once. The switch to WAL bypasses SQLite's busy handler, so busy_timeout never covered it; the pragma now retries with a short backoff for up to five seconds (reproduced: two in three concurrent first opens failed before).
  • Persistent consultant signatures and tool schemas expose only the enabled fresh/model/effort overrides, and dispatch rejects disabled arguments before recording or scheduling work. The question-only default signature is unchanged.

  • Per-call native writer session ids override a configured default alias for that call. Explicit per-call alias plus native id remains an error; omitting both still resumes the configured alias.

  • JobRunner sends safe failure notifications for exceptions in setup and execution. Its execution CAS atomically records unknown cost and prepared engine/model/effort identity; failures preserve that provenance. Default callbacks are installed as unbound instance callables, and plan cancellation cleanup explicitly re-raises.

  • Default parallel/plan workers refund admission when engine or Agent setup never reaches execution, and release it after actual execution. Cancellation before the first task step closes the inner coroutine, refunds the grant, and records a terminal job status; default plan claims also unwind on that cancellation.

Added

  • Whole-batch refusals from make_plan_delegate(structured_outcomes=True) carry batch_refused = {"code": "empty" | "batch_cap" | "in_flight_cap", "reason": ...}, so callers can tell them from per-item refusals without matching the wording.
  • Plan delegates accept an optional rollback_claim(JobContext) carrying captured attempts, claim owner, and job id. It runs once for every valid item that never starts, including later worker setup failure and pre-step cancellation. Optional structured_outcomes on plan/parallel delegates returns exact per-item started, refused, or failed outcomes; stop_on_refusal preserves already scheduled jobs while holding back later plan items. Defaults retain string output, continuing the batch, and the existing board failure behavior. Lifecycle cleanup protects records against a competing worker's execution CAS and preserves approval metadata.

  • Writers accept durable session_alias / SessionRegistry, separately from native session ids, with optional alias overrides, custom factories, extra parameters, guard, and lifecycle hooks. Exported Codex/Claude writer engine factories support per-call cwd/model/effort/alias and writable roots or max turns.

  • Optional JobRunner / JobContext worker phases (prepare, CAS register, execute, finalize) on background, parallel, and plan delegates, with one rollback callback on refusal/failure/cancellation and release/refund ownership. JobRegistry.begin_execution and update preserve unknown record fields through CAS transitions.

  • lazybridge.ext.delegation.track_background_task and schedule_with_admission_release: public names for the two scheduling primitives the delegate builders use, for callers that keep their own orchestration on top of this package instead of importing private names.

  • lazybridge.ext.delegation gains the generic hooks LazyCEO's own richer codex_write/claude_write/run_parallel/delegate_plan_tasks copies needed before they could be dropped in favour of this package. Every addition is optional and defaults to today's exact behaviour -- the pre-1.8 generated tools' signatures, JSON Schemas, and engine construction calls are all byte-identical when none of the new parameters are passed.
  • Per-call engine construction (make_background_delegate): extra_params: Mapping[str, ExtraParam] | None declares further per-call parameters (LazyCEO's motivating case: a repo argument) beyond objective, forwarded verbatim to engine_factory as keyword arguments; guard: Callable[[str, dict[str, Any]], Any] | None is a caller-supplied per-call validation hook (sync or async) that runs FIRST, before anything is recorded or a task spawned, and can reject with a "REJECTED: ..." string. Setting either builds the tool via Tool.from_schema with an explicit, hand-built JSON Schema instead of signature introspection, since the real Python callable underneath now accepts an arbitrary **extra.
  • Per-call model/effort/session overrides (writers and the persistent consultant): make_background_delegate/make_codex_writer/ make_claude_writer gain accept_model_override/accept_effort_override (plus accept_session_override on the two writers, forwarded as CodexEngine(thread_id=...)/ClaudeCodeEngine(session_id=...)); the resolved per-call value (falling back to the tool's own default) is what validate_model now runs against. make_persistent_consultant gains accept_fresh (a per-call fresh=True starts a new thread instead of continuing the remembered one -- the new thread's handle still becomes the one subsequent calls continue), plus the matching accept_model_override/accept_effort_override, each checked against the wrapped consultant function's own signature at BUILD time (TypeError immediately, not on the first call that tries to use it).
  • Admission reservation protocol (lazybridge.ext.delegation.admission): an admission_gate may now return a decision exposing, beyond allowed/rejection_text(), an optional release() (give back a reservation once the attempt it covered actually ran, success or failure) and refund() (give back the reservation AND anything else spent, e.g. a human's one-use approval, for an attempt that never ran at all -- falls back to release() when absent). Both may be sync or async. release_admission/ refund_admission/rejection_text are the generic helpers every delegate in this package now calls exactly once on every path, never more. make_parallel_delegate refunds every admission already granted earlier in the same batch when a later objective is refused or the post-check capacity race fires, and releases each job's admission once it finishes. make_plan_delegate gains admission_gate (checked per item, before that item's own board.claim_task -- a refusal costs nothing to unwind); a claim that loses the race, or a job that fails to even get scheduled after the claim succeeds, now refunds that item's admission alongside the existing board-claim unwind (board.mark_failed).
  • Engine factory knobs on the writers: make_claude_writer gains max_turns (default 60, unchanged -- a caller needing more headroom, as LazyCEO did after 20 proved too low live, passes a larger value). make_codex_writer gains writable_roots, wired straight through to CodexPolicy.writable_roots -- the prime use case is a workspace_root that is a git worktree, whose index lives outside it under the main repository's .git. Resolving which path(s) a given worktree needs is left to the caller; this package has no git-topology opinion of its own.

Changed

  • admission_gate is now consulted when there is no pre_confirm. Before, a background delegate ignored its admission_gate unless it also had a pre_confirm; now the gate runs before the job is scheduled and can refuse it (a gate that raises is recorded as a failed job and returned as a REJECTED: refusal). Callers that passed admission_gate without pre_confirm and relied on it being a no-op will see refusals. Jobs own the grant: released when the job ran, refunded on every not-started path, including immediate cancellation. make_claude_writer also accepts admission_gate.

[1.7.0] — 2026-10-07

Added

  • lazybridge.ext.approval.queue gains the generic improvements LazyCEO's own evolved copy made after promotion.
  • ApprovalQueue.claim_earlier_approval(task_id, prompt) lets a caller spend an approval a human gave after an earlier StoreApprovalChannel.ask() had already stopped waiting for it (see wait_budget below) -- checked automatically at the START of every ask() call, before a new ticket is even filed, so the same question is never asked twice while an earlier answer sits unconsumed.
  • ApprovalQueue.expire_ticket(approval_id) and a new "expired" TicketStatus give a ticket nobody ever answered a durable state distinct from "rejected" (a human said no) and from "still pending" (still listed, still actionable). StoreApprovalChannel.ask() now calls it when a ticket's own ttl genuinely elapses.
  • ApprovalQueue.record_denial(record) and ApprovalQueue.record_notify_failure(source=..., text=..., error=...) add durable, best-effort audit trails for refusals and swallowed notify() failures -- both configurable via denial_prefix=/ notify_failure_prefix= on ApprovalQueue.__init__. record_denial is generic enough to wire directly as TieredGate(..., on_record=queue.record_denial); StoreApprovalChannel now also calls it with the real reason when IT resolves a ticket (rejected by a human, or expired with nobody answering) -- a case TieredGate's own audit log can't distinguish, since both reach it as the same generic "denied by the human approver" sentence.
  • StoreApprovalChannel gains a wait_budget parameter, split from ttl: ttl is how long a ticket stays answerable, wait_budget is how long this one call blocks for it before giving up (defaulting to the full ttl, reproducing the previous behaviour exactly). Giving up on wait_budget leaves the ticket pending and answerable -- it does not expire it -- so a later ask() call with the identical prompt can pick up an answer given after the first caller moved on, via claim_earlier_approval.
  • ApprovalTicket.operator_only is a caller-defined metadata flag carried through ApprovalQueue.create_ticket(..., operator_only=...) and StoreApprovalChannel(..., operator_only=...); this package enforces nothing about it, leaving the policy (e.g. "only a specific human channel may resolve this one") entirely to the caller.
  • ticket_gist() now recognizes a TieredGate-rendered prompt and extracts the real tool/command text from its arguments: line instead of showing the generic "[TieredGate] agent asks to run tool 'Bash'" sentence for every pending Bash ticket.
  • ApprovalQueue.mark_approval_consumed(approval_id) is called automatically by StoreApprovalChannel.ask()'s own ordinary polling path once it observes "approved", so a LATER call with the identical (task_id, prompt) can never find that same approval still unconsumed and re-claim (and re-run) it a second time via claim_earlier_approval -- and ask() only reports True to whichever of two concurrent identical calls actually WINS that consumption, never both. The same protection now also applies to the branch where ask() loses the expire_ticket() CAS to a late approval.
  • claim_earlier_approval() only reuses a ticket created under THIS consumption protocol (one whose record has a consumed_at KEY at all, even if null -- a pre-1.7.0 record has none) and whose operator_only scope matches the request's own exactly; a ticket approved with operator_only=False can never satisfy a later request made with operator_only=True, or vice versa.
  • ask() now treats an externally expire_ticket()-ed ticket (one expired by some OTHER caller, before this call's own ttl would have elapsed on its own) as terminal immediately, instead of falling through to its own ttl check and polling (and re-notifying) a ticket that is already done.
  • The claim_earlier_approval check at the top of ask() is correctly bounded against cancellation. The "expired" and "stopped waiting" terminal notices are both bounded by notify_timeout alone, decoupled from the ticket's own (already-elapsed, for "expired") lifetime -- without this, a stalled notifier could cost a wait_budget that may be milliseconds up to a full extra notify_timeout just to report giving up (see StoreApprovalChannel._send's own docstring).
  • lazybridge.ext.delegation gains admission/validation hooks and richer job records.
  • make_background_delegate, make_codex_writer and make_claude_writer accept validate_model (a sync model -> rejection string | None hook run BEFORE anything is recorded or spawned); make_background_delegate and make_codex_writer also accept admission_gate (an async zero-argument hook, consulted only on the pre_confirm path, right after a human approves and right before the engine actually starts; a refusal is recorded as status="failed" with execution_started=False and nothing is started). Neither hook carries any model-validation or admission POLICY of its own -- both are entirely the caller's.
  • make_parallel_delegate accepts the same admission_gate, checked ONCE PER OBJECTIVE before any of them starts; the first refusal aborts the whole batch (nothing partially started), and the in-flight capacity cap is rechecked again right before scheduling (narrowing, not closing, the pre-existing cooperative-not-atomic race a concurrent call could win during admission_gate's own await).
  • A failed delegation whose result envelope carries an ambiguous 0.0 cost (the common shape for an engine-level failure, which never attaches real usage) is recorded cost_unknown=True rather than a confidently wrong cost_usd=0.0; a failure that preserves real, nonzero measured cost is still recorded as known.
  • Job records written through JobRegistry.write now carry engine/model/effort (read off the actual engine instance), execution_started, cost_usd/cost_unknown (direct + nested cost off the finished result, matching LazyPulse's own accounting), created_at/finished_at, and an open extra= passthrough dict for a caller's own fields -- without forking this method's signature.
  • JobRegistry.reclaim_interrupted() is now owner-aware: current_owner_fields() stamps owner_pid/owner_boot_id on a job a caller wants protected, and only a job whose owner process is confirmed DEAD (checked via tasklist on Windows, os.kill(pid, 0) elsewhere) is reclaimed -- more than one process can now share a Store and be concurrently alive without one wrongly reclaiming the other's in-flight job. A job with no owner stamp is reclaimed exactly as before this existed. A record whose owner_pid matches THIS process but whose owner_boot_id does not (pid reuse after a restart) is correctly treated as dead rather than falling through to a process-table check that would trivially see this process and report the old, dead owner as alive.

[1.6.2] — 2026-10-02

Fixed

  • Codex App Server transport failures are diagnosable and safely retried. The child's stderr was sent to DEVNULL, so a dying App Server surfaced only as "exited before completing the request". It is now piped and drained into a bounded 16 KiB tail, and the new CodexTransportError (a ConnectionError) reports the child's PID, return code, the RPC phase it died in and the stderr tail. A transport failure during startup, before any stateful request (thread/start, thread/resume, the turn) was written, is retried at most once after a short jittered pause; later failures are never retried (neither by the client nor by CodexEngine's retry loop), because a durable thread may exist or the turn may already have run, and replaying it could repeat workspace writes. Cleanup is bounded and cancellation-shielded: stdin is closed, the child gets a short grace period, then the same child is terminated, killed if needed, and reaped; a child that survives kill() is abandoned after a bounded wait.
  • ClaudeCodeEngine.stream() now takes the session lock. run() already held it for the whole turn, stream() did not, so two streams (or a run() and a stream()) on one persistent session (one engine, or two engines sharing a raw id or alias) overlapped: both read the session state before either stored the id, and aliased streams published their id early while another turn was mid-flight. The old body is now _stream_unlocked() and the public stream() holds the lock around it, closing it before the lock is released. An ephemeral engine still takes no lock.
  • SDK teardown completes inside the locked turn. The Claude engine's idle guard and AgentSdkClient.stream() iterated their inner async generators with a plain async for, which does not close them on an early exit; on an early aclose() or a cancellation the lock could be released while the SDK query was still being torn down. Both now close the inner iterator explicitly (lazybridge.engines.coding.closing_iter).
  • Own-lock to id-lock hand-off hole in ClaudeCodeEngine. A persistent engine with no id yet queued on its own lock; once an id was published, later calls keyed straight onto the id lock and bypassed it, so a queued first call and a newer one could run together. A persistent engine now always takes its own lock first, then the alias lock, then the id lock.
  • CodexEngine: two engines on one still-unknown session_alias could open two threads. The thread lock covered the engine and the thread id but not the alias, and the alias was refreshed after the lock was chosen. It now mirrors the Claude engine (own lock, alias lock, id lock). A streamed turn that is abandoned also records its thread id before releasing the lock, so the queued turn resumes that thread instead of opening another.

Known and not changed here: ClaudeCodeEngine's class-wide asyncio.Lock registries (_session_locks, _alias_locks) can bind to an earlier event loop when contention happens across successive loops (CodexEngine already scopes its locks per loop).

[1.6.1] — 2026-10-01

Added

  • OpenAI gpt-6.1-sol. The latest workhorse model, successor of gpt-6-sol, priced at $2 / $0.10 / $10 per 1M tokens (input / cached input / output). Capabilities mirror gpt-6-sol (128K default max tokens, reasoning model, vision, no audio, native effort max), and it has a _FALLBACKS entry (gpt-6-sol, gpt-5.6-sol, gpt-5.6-terra; declarative data, automatic fallback is not implemented, see BaseProvider._FALLBACKS). As for the rest of the GPT-6 family, the

    272K-input long-context surcharge is not modeled in cost. gpt-6-sol remains selectable by id.

Changed

  • OpenAI medium / expensive tier alias is now gpt-6.1-sol (was gpt-6-sol); same list price. gpt-6-astra falls back to gpt-6.1-sol first.

[1.6.0] — 2026-10-01

Added

  • SessionRegistry and session_alias= on CodexEngine and ClaudeCodeEngine. A JSON file store (lazybridge/engines/sessions.py, exported from lazybridge and lazybridge.engines) maps a caller-chosen name, per kind (codex/claude) and working directory, to the native thread/session id plus the model and effort it ran with, so a launcher can say "use session reviewer" and an operator can rename or rebind it by hand. API: resolve, bind, rename, forget, entries; location from the constructor, LAZYBRIDGE_SESSIONS_FILE, or ~/.lazybridge/sessions.json; atomic writes, cross-thread and cross-process locking, a corrupt file is quarantined to .corrupt rather than overwritten. Engines take session_alias= / session_registry= and expose session_alias: a known alias resumes its id, an unknown one opens a durable session bound after the first turn (failed turns included once an id exists), an explicit thread_id=/session_id= wins and rebinds. Without an alias behaviour is unchanged. ClaudeSdkOptions gains a trailing on_session_id callback and ClaudeSdkRequestError a session_id attribute to support early binding. Guides: session-registry.md, plus writer-configuration and timeout notes in the Codex and Claude Code engine guides.

  • CodexPolicy.writable_roots (#178). Extra directories the Codex sandbox may write besides cwd, sent to the App Server as sandbox_workspace_write.writable_roots (an explicit empty tuple also reaches it, clearing roots inherited from config.toml). Needed when cwd is a git worktree: git add/commit write the index under the main repository's .git/worktrees/<name>, which was otherwise rejected.

  • Rule.provider_pattern and stable Codex command names (#175). TieredGate rules can scope on the request's provider (default "*", so existing rules are unchanged). A Codex command approval's name is now the stable identifier codex-shell instead of the raw command line; match command content with arg_pattern against arguments["command"]. A rule written against the old behaviour still matches via a fallback on the command's own text, and session-wide approvals are scoped to the command's content (commandless ones fail closed).

Changed

  • Anthropic: medium tier now resolves to claude-sonnet-5-5 (GA 2026-09-28), replacing claude-sonnet-5. Same $2 / $10 pricing (added as its own _PRICE_TABLE row, ordered before the claude-sonnet-5 key per the substring-match rule). Fallback chains updated so claude-opus-5-5 / claude-opus-5 fall back to claude-sonnet-5-5 first, which itself falls back to claude-sonnet-5. No changes were needed to the sampling/adaptive-thinking/effort/vision/audio capability sets — they already substring-match claude-sonnet-5 and cover the point release automatically.
  • Tier aliases and price tables updated for September 2026 models.
  • Anthropic: top → claude-fable-5-1, expensive → claude-opus-5-5 ($4 / $20), super_cheap → claude-haiku-4-5 (claude-3-haiku is retired). Added claude-mythos-5-1 (price only). Sonnet 5 stays at $2 / $10 (the planned increase was cancelled). Per-model cache-read rates for Fable 5.1 / Mythos 5.1 ($0.25) and Opus 5.5 ($0.20). Retired models removed from fallback chains.
  • OpenAI: added gpt-6-sol ($2 / $0.20 / $10) and gpt-6-luna ($0.10 / $0.01 / $0.50); expensive / medium → gpt-6-sol, cheap / super_cheap → gpt-6-luna. Effort max is passed through for the whole GPT-6 family.
  • DeepSeek: deepseek-flash (V4.1-Flash, $0.30 / $0.006 / $1.20) replaces the retired deepseek-v4-flash as default model and for medium / cheap / super_cheap.

Fixed

  • Anthropic: forced tool_choice ("required" / "any" / a tool name) is sent as "auto" with a UserWarning on Opus 5.5 / Fable 5.1 / Mythos 5.1, which reject it with a 400.

[1.5.0] — 2026-09-18

Added

  • lazybridge.ext.delegation, a new extension promoting LazyCEO's generic background-delegation infrastructure: durable Store-backed job records, persistent consultant handles, fire-and-forget writer delegates, capped parallel delegation, and durable-plan-linked delegation. Like the prior ApprovalQueue/DurableKnowledgeBase extractions, Store key prefixes are configurable and default to neutral "delegation:" namespaces.
  • lazybridge.ext.knowledge, a new extension promoting LazyCEO's lazyceo.lessons module: DurableKnowledgeBase is a Store-backed collection of durable, cross-session "lessons" an agent records after genuinely non-obvious success, retrievable by keyword search across future sessions. Mirrors ApprovalQueue/DurableBlackboard's configurable Store key prefix (default "knowledge:").
  • OpenAI GPT-6 Astra (gpt-6-astra) is now supported throughout the OpenAI provider. The top tier resolves to Astra; GPT-5.6 Sol/Terra/Luna now occupy expensive/medium/cheap, and _FALLBACKS["gpt-6-astra"] lists Sol/Terra/gpt-5.5 as metadata for a future retry path -- like every other entry in _FALLBACKS, this is not executed automatically today (see BaseProvider._FALLBACKS's docstring). Reasoning effort (including Astra's native max), vision input, and the published 128K maximum output are recognised by the provider core. Cost tracking uses the Standard API rates of $10 input / $1 cached input / $50 output per 1M tokens. As with GPT-5.6, the >272K-input long-context multiplier is not modelled because LazyBridge's usage envelope does not expose the request's billable long-context tier.
  • lazybridge.control_plane, a SQLite-backed store for a fleet's work: an atomic claim (an item goes to exactly one worker, decided in a single write transaction), fencing (every claim carries a rising token, so a worker that stalls and comes back to report success is refused with FenceRejected), and an append-only ledger with verify_ledger() returning named LedgerProblems rather than a boolean. Visibility is scoped by the store itself (actor_id=, ScopeDenied), not by the caller. Ships with python -m lazybridge.control_plane.probe, which qualifies the storage engine with separate processes (concurrent-queue, kill-and-reclaim). Single-machine by design. See the new Control plane guide.
  • lazybridge.engines.codex.usage.fetch_codex_usage() reads the Codex subscription quota from the App Server (account/rateLimits/read) without starting a thread or spending a turn. Each CodexUsageWindow carries a percentage and its reset time; CodexUsageSnapshot.weekly() picks the seven-day bucket by duration rather than position. It raises RuntimeError when the server cannot be reached or refuses the method, which a caller must treat as unknown, never as unlimited.
  • lazybridge.ext.approval.ApprovalQueue, a durable approval/escalation ticket queue promoted from LazyCEO's lazyceo.approvals, with a configurable Store key prefix so independent queues can share one Store.
  • DurableBlackboard.claim_task(index, expected_text) claims a specific task instead of whichever is earliest-eligible, guarded by the same expected_text stale-index check as cancel_task.
  • Per-task scheduling on DurableBlackboard: optional planned_start_at / due_at on every task and a CAS-backed set_task_schedule(...). Each edit appends to an append-only schedule_events history stored in the same document and written in the same compare-and-swap as the task update, and snapshot().schedule_events exposes it. render() marks a task OVERDUE when due_at is past and it is still todo or claimed. A closing task now records completed_at, so a consumer can place closed work in time.
  • A portable approval_gate on every engine, not only the two coding ones. LLMEngine had no equivalent, so swapping a coding engine for a plain one in an application built around TieredGate silently dropped the whole safety layer.

Changed

  • DurableBlackboard.claim_task no longer refuses a holder its own task when the caller opts in. The default is unchanged (a claimed task is refused), but claim_task(..., renew=True) lets the same identified owner re-enter a claim it already holds. Renewal is deliberately narrow: owner= must be given and equal the holder, the lease must still be alive, and it succeeds once per claim -- checked inside the compare-and-swap, so two concurrent callers cannot both win. A fresh claim, by either claim_task or claim_next, always starts un-renewed. Previously the check compared only the lease clock, never the owner, so the holder was told another worker had its task when none existed.
  • Approval prompts keep both ends of a long value. A prompt now goes through one shared elide (in lazybridge/_display.py) with a budget derived from the transport rather than a magic number, keeps the head and the tail, and says how many characters were dropped. The old head-only cut at 400 characters showed an approver the harmless opening of cd repo && build && rm -rf artifacts and hid what followed. Redaction runs before elision, so a secret cannot survive in the kept tail.
  • TieredGate rules can say which kind of request they speak about. Rule gains kind_pattern (default "*", which is what every existing rule already meant). A tool arrives as a bare identifier and a command as the whole command line, so a pattern written for tool names matched no command and every escalation fell into the default deny. The kind is part of a rule's fingerprint, so a session grant given for a tool cannot satisfy a command of the same name; existing durable grants stop matching once a table starts naming kinds -- a grant must not outlive the wording the human was shown.

Fixed

  • Store.write, delete, clear, write_memory and delete_memory now roll back on failure. Only compare_and_swap did, so a failed write (for example "database is locked" under contention) left sqlite3's implicit transaction open on that connection forever, and in WAL mode every later read through it stayed pinned to the snapshot as of the failed write.
  • The Claude CLI subprocess's real stderr is captured instead of being lost, so a crash reports more than the SDK's generic "exit code N".

[1.4.0] — 2026-09-05

Added

  • Native Claude Code tool calls and results are now emitted as Session TOOL_CALL/TOOL_RESULT events (native: True in the payload).

Fixed

  • Step(context=...) now delivers a step's output whatever its type. The resolver appended a referenced step's payload only when it was already a str, so an agent declared with output=SomeModel contributed no context at all — and the adjacent line then forwarded that step's own input context in its place. Both halves were silent: a downstream step looked correctly wired while receiving either nothing or the wrong document. Payloads are now rendered through Envelope.text(), which already knows how to serialise Pydantic models, dicts and lists.

Behaviour change, by design. A str payload previously contributed both the upstream step's input context and its output; it now contributes only the output. The context fallback is reached only when a resolved envelope has no payload at all — from_start, whose meaning lives in context. An empty-string payload counts as an output and contributes nothing, rather than reviving the substitution.

Envelope.text() raises TypeError on a payload that is neither str, BaseModel, nor JSON-serialisable; a context= referencing such a step now surfaces that error instead of silently dropping the payload.

[1.3.0] — 2026-09-01

Added

  • CodexEngine's native web search is now configurable per agent, via CodexPolicy.web_search ("disabled" / "cached" / "indexed" / "live"). It is forwarded as a per-process -c web_search="<mode>" override, so it applies to that agent's subprocess and to nothing else; left as None — the default — the user's own Codex configuration is untouched, exactly as before. Until now an application that needed a Codex agent to browse had to rely on whatever the machine's global Codex config happened to say, which is a dependency that is invisible in the code and breaks silently when the machine changes.
  • TieredGate, a four-tier approval gate (lazybridge.ext.approval.tiered), promoted from approval-lab and conformant with the existing ApprovalGate protocol. Session grants are scoped to (provider, kind, name, cwd, policy fingerprint) rather than to the tool name alone, so a grant given for one working directory or one policy does not silently carry into another. Every decision lands in a structured AuditRecord log, with common secrets redacted from both previews and prompts. The known interaction with remembering_gate's outer cache is documented rather than papered over.

Changed

  • Provider pricing brought up to date against each provider's published table: OpenAI's gpt-5.6-terra ($2.50/$15 → $2.00/$12) and gpt-5.6-luna ($1.00/$6 → $0.20/$1.20), plus gpt-5.6-cyber, which is kept out of _TIER_ALIASES while it remains gated — the same treatment claude-mythos-5 already gets. Google's gemini-3.5/3.6/3.7 Flash were missing from the price table altogether and are now present, with tests that distinguish them from Flash-Lite: pricing is substring-matched, so their absence was a live risk of charging one model's rate for another's. A time-boxed promotional discount is deliberately not modelled, being a promotion on top of list price rather than a change to it.

Fixed

  • A bare list / tuple / set / frozenset annotation no longer compiles to a string-typed schema. _annotation_to_schema() special-cased list[X] but not an unsubscripted list — which has no __origin__ and so fell through to the {"type": "string"} fallback, the same gap already closed for bare dict. The tool then advertised a string parameter while the function underneath still required a real list, and every array a caller sent came back rejected as "Input should be a valid list". _COMPILER_VERSION is bumped alongside the fix so a persisted ArtifactStore cannot keep serving the stale pre-fix schema for a function compiled earlier.

[1.2.1] — 2026-08-25

Added

  • CodexEngine structured output now goes through turn/start's native outputSchema when the schema allows it, instead of always priming the prompt. to_openai_strict_schema() (lazybridge.core.structured) rewrites a Pydantic schema into OpenAI-strict form — additionalProperties: false plus every property in required — carrying an optional property into required only when it already accepts null as written; any other optional property, an object with additionalProperties explicitly left open (e.g. extra="allow"), or a fixed-length tuple (prefixItems has no strict-mode equivalent) makes the whole schema unrepresentable and falls back to the existing prompt-priming path exactly as before. Codex's own review harness caught four ways an earlier draft would have changed accepted semantics instead of degrading gracefully: forcing a non-nullable optional field (e.g. count: int = 5) into required would let the model legally answer null where the destination model then rejects it on model_validate; an extra="allow" model was silently closed; a Literal[..., None] enum needed the JSON-Schema-correct rule that a sibling type excluding null overrides null listed in enum/const; and tuple[str, int] was kept instead of falling back, producing a schema that looked converted but that turn/start still rejects.
  • ClaudeCodePolicy(auto_compact_window=N) / CodexPolicy(auto_compact_token_limit=N) — tell one coding agent when to compact its own context, without touching a machine-wide configuration file. The two numbers are not interchangeable: Claude Code's is a window it compacts within (the effective threshold is the minimum of it and the model's real context window), Codex's is the token count at which compaction starts. They travel as CLAUDE_CODE_AUTO_COMPACT_WINDOW in that agent's subprocess environment and as -c model_auto_compact_token_limit=<n> on that agent's own App Server, so neither leaks to any other agent on the machine. Verified end to end: an agent given 137000 echoes it back from its own environment. Codex's model_context_window is deliberately not exposed — it describes the budget rather than enlarging the model's limit, and setting it is reported upstream to break auto-compaction (openai/codex#16068). LLMEngine has no equivalent: an API-backed agent has no compaction to schedule. Review caught that without --strict-config, an override key a running Codex build does not recognise is a silent no-op with stderr discarded; the flag is now added, but only when an override is present, so it never changes whether an agent's own ~/.codex/config.toml is accepted.
  • ClaudeCodeEngine.usage() — how much of the account's weekly and session budget is used, and when each window resets. There is no typed field for this: the Agent SDK's RateLimitEvent arrives free on every run but its utilization was None on every account tested, and scanning every message type of a live run finds the percentage nowhere else. The weekly figures exist only in the prose Claude Code's own /usage slash command prints, so usage() spends one small turn sending it and parses the reply — every field it cannot extract is None rather than guessed, and snapshot.parsed/raw_text let a caller detect and fall back from wording drift instead of trusting a silently empty result. Goes through the same ClaudeSdkClient boundary the engine's run()/stream() already use, so a test engine built with an injected client never reaches the real SDK here either. fetch_claude_usage() and parse_usage_report() are exported standalone for use outside an Engine. The report carries no year, and review caught two ways the parser's own year-inference could produce a wrong timestamp rather than an absent one: deriving the year from now's own zone instead of the report's (an instant near midnight UTC can already be a different calendar date in America/Los_Angeles, turning "resets in 30 minutes" into "resets in a year") and silently picking the first of the two wall-clock occurrences during a DST fall-back, which the report's text has no offset to disambiguate. Both now fall back to resets_at=None rather than guess. tzdata is now a conditional dependency of the claude-code extra on Windows, which ships no system IANA database — without it, every reset would have quietly come back unparsed rather than erroring.
  • Tool(timeout=N) and Agent(tool_timeout=N) — a per-tool deadline that works on synchronous tools. Agent(timeout=N) can only fire at an await, and a blocking sync tool never yields one: measured, an Agent(timeout=8) whose tool called a blocking web_search was still running two minutes later. LLMEngine(tool_timeout=N) did not help either — it wrapped tool.run() in asyncio.wait_for, and an executor future that has already started ignores cancellation, so wait_for waited for a cancellation that never landed. A bounded sync tool now runs on a daemon thread and is abandoned at the deadline: the caller is freed, the work runs on until it returns by itself, and its result is discarded. Precedence is most-specific-wins — Tool(timeout=) over Agent(tool_timeout=) over LLMEngine(tool_timeout=). Tool.wrap() and Tool.from_schema() both accept timeout=, and Tool.wrap(agent, timeout=N) bounds that alias without bounding the agent elsewhere. run_sync() honours the bound too. A non-positive value is rejected at construction rather than firing on every call.
  • A warning when abandoned workers accumulate. A tool that times out on every call leaks one thread per attempt, and every caller still gets its timely timeout — which is exactly what makes the leak silent. Past Tool.abandoned_worker_warning_threshold (8) still-running abandoned workers, _abandon emits a UserWarning naming the tool.

Changed

  • LLMEngine(tool_timeout=) and the Claude Code / Codex tool bridges now dispatch through the shared run_tool_bounded helper instead of asyncio.wait_for, so all three of the places documented as bounding a tool call actually do. Behaviour for async tools is unchanged; the TOOL_TIMEOUT session event and its payload keep their shape.
  • A bounded async tool is now cancelled, not raced. Every bounded path applies the bound with asyncio.wait rather than wait_for, so a TimeoutError raised by the tool itself (an HTTP client reporting its own deadline) reaches the model as the tool failure it is, instead of being relabelled ToolTimeoutError/TOOL_TIMEOUT and inviting the wrong recovery. The tool task is cancelled and awaited both when the bound expires and when the caller is cancelled from outside.
  • Cancellation cleanup is bounded too (Tool.cancel_grace_seconds, 1.0s). Cancelling is a request, not a guarantee: a coroutine can catch CancelledError and carry on, or spend arbitrarily long in cleanup, and awaiting that unconditionally put the hang back exactly where the deadline was meant to remove it. Past the grace period the task is abandoned like a sync worker, and the sync bridge no longer gathers a task already abandoned — draining it would have handed a run_sync() caller back the very hang its timeout= had just spared it.
  • ToolTimeoutError moved from lazybridge.engines.llm to lazybridge.tools — it is no longer specific to LLMEngine. The top-level lazybridge.ToolTimeoutError import is unchanged; it now carries .tool_name and .timeout.

[1.2.0] — 2026-08-19

Added

  • ClaudeCodePolicy(extra_tools=...) — extra built-in tool names appended to the engine's derived set (which the Agent SDK's tools= option receives). The engine hardcoded that list to the read-only set, so a gated agent could never even be asked about a write: the model simply never had the tool. extra_tools=("Write", "Edit", "Bash") plus an approval_gate gives a writer agent per-call human/policy approval. Granting is not pre-approving — unless a name is also in allowed_tools, every call still routes through can_use_tool. Because file_roots confinement is a hook over the file tools only, the engine refuses at construction to grant an unconfinable name (Bash) without an approval_gate: its only boundary is that policy.
  • CodexEngine(thread_source=...) — sent as the App Server's own ThreadStartParams.threadSource ("an optional client-supplied analytics source classification for this thread", verified against the generated protocol schema). Live-verified landing on disk as session_meta.payload.thread_source in the rollout file — a different field from session_meta.payload.source, which is something else and unaffected. Defaults to "lazybridge". Note that every LazyBridge-created Codex thread was already identifiable via session_meta.payload.originator == "lazybridge" (unconditional, from the initialize call's clientInfo.name); thread_source adds a second, caller-chosen label on top — e.g. to tell two LazyBridge-based applications' threads apart. Creation-time only: sent on thread/start, never on thread/resume (the field cannot be changed after a thread exists).
  • ClaudeCodeEngine(tag=...) — the Agent SDK has no creation-time equivalent of threadSource, but it does have a post-hoc, public tagging API (claude_agent_sdk.tag_session, appending a {"type":"tag",...} JSONL entry list_sessions() reads back as .tag). Every NEW durable session (persist_session=True or a session_id) this engine creates is now tagged — default "lazybridge", once, never re-tagged on resume. Live-verified: the tag lands in the real session file and round-trips through list_sessions(). Pass tag=None to skip it. A tagging failure warns (UserWarning) rather than failing the run.

Changed

  • DeepSeek pricing (lazybridge.core.providers.deepseek) — updated _PRICE_TABLE and _CACHE_HIT_PRICE_TABLE to DeepSeek's current rates (api-docs.deepseek.com). DeepSeek now bills peak/off-peak (peak hours 01:00-04:00 and 06:00-10:00 UTC, off-peak is half); LazyBridge costs at the peak rate across the board for a conservative estimate. deepseek-v4-pro: $1.32 / $0.044 (cached) / $3.96 per 1M tokens (in / cached-in / out), up from $0.435 / $0.003625 / $0.87. deepseek-v4-flash (and the deprecated deepseek-reasoner / deepseek-chat aliases): $0.44 / $0.014 / $1.32, up from $0.14 / $0.0028 / $0.28.

[1.1.0] — 2026-08-16

Added

  • durable_blackboard_agent and DurableBlackboard (lazybridge.ext.planners) — the blackboard planner for agents that stay up. The to-do list lives in a Store under a stable plan_id instead of a closure, so it survives the run, the process, and the crash. Adds the three things a resumable worker needs on top of the flat list: claim_next hands out exactly one task under compare-and-swap (two workers never take the same item); a claimed task carries a lease, so work interrupted by a crash returns to the queue instead of staying "in progress" forever; and each claim counts against max_attempts, after which the task is parked as failed rather than stalling the plan forever. Verified live across three separate processes sharing only the SQLite file. See docs/recipes/durable-blackboard.md.

  • ClaudeCodeEngine (lazybridge.engines.claude_code, re-exported as lazybridge.ClaudeCodeEngine) — a standard Engine that runs the model/tool loop through the locally authenticated Claude Code runtime (Claude Agent SDK) instead of a raw provider API call, with the same Agent/Memory/Session/tools= surface as LLMEngine: in-process MCP tool exposure, read-only Read/Glob/Grep scoped to file_roots, WebSearch/WebFetch, retries/timeouts mirroring LLMEngine's policy, session_mode="runtime" for a persistent Claude Code session inside one LazyBridge Session. Merged from the standalone feasibility prototype (live-verified against the real Claude Agent SDK); new optional extra lazybridge[claude-code] (claude-agent-sdk, mcp). See docs/guides/full/claude-code-engine.md.

  • CodexEngine (lazybridge.engines.codex, re-exported as lazybridge.CodexEngine) — the same Engine contract driven by the locally authenticated Codex CLI, talking to codex app-server over JSON-RPC (never codex exec, which cancels non-interactive MCP tool calls unless the sandbox-removing bypass flag is used). LazyBridge tools are exposed as App Server dynamic tools on one ephemeral, read-only, approval-free thread per run; retries/timeouts/tool_timeout/streaming mirror LLMEngine, and reasoning_effort maps to turn/start's effort. Needs no Python extra — only the codex CLI, which it also finds in the Codex desktop app's install directory when it is not on PATH (CODEX_BIN overrides). Verified live against codex-cli 0.148.0, which corrected three protocol assumptions carried over from the prototype: sandbox is the kebab-case "read-only" ("readOnly" is rejected outright), token usage comes from thread/tokenUsage/updated rather than turn/completed, and server→client request ids are numbered independently of the client's, so the read loop dispatches on method. Note cost_usd is structurally 0.0: ChatGPT-plan auth reports rate-limit percentages, not per-turn prices. See docs/guides/full/codex-engine.md.
  • Multimodal input for both local-CLI engines. Envelope.images now reaches Claude Code (as Anthropic base64 image content blocks; URL-only images are dropped with a warning because the CLI rejects url sources) and Codex (as image UserInput items, inline bytes sent as a data: URL). Envelope.audio is still dropped, now with an accurate reason per engine: Claude accepts no audio input, and Codex accepts the shape but the model cannot read it.

Changed

  • ClaudeCodeEngine now enforces output=<type> natively through the Agent SDK's output_format (the CLI's --json-schema) and reads the parsed object back from ResultMessage.structured_output, instead of pasting the schema into the prompt and relying on Agent._validate_and_retry to repair prose answers — the same server-side guarantee LLMEngine gets from StructuredOutputConfig. CodexEngine keeps the prompt-priming approach: turn/start's native outputSchema accepts only OpenAI-strict schemas, which a plain Pydantic schema does not satisfy.

Added

  • Durable Codex threads — CodexEngine(persist_thread=True) keeps the thread alive past the subprocess and exposes engine.thread_id; CodexEngine(thread_id=...) resumes it, from a different process (verified live), through thread/resume. Codex' own transcript then carries the history — the files it read, the reasoning it did — so a follow-up question is not a cold start. Everything is re-supplied on resume (cwd/sandbox/model/developerInstructions/dynamicTools), since the tool callbacks live in the new subprocess. Because the conversation's home moves, resuming also: stops prepending LazyBridge Memory to the prompt (one chronology, not two); raises the new CodexTurnUncertain instead of retrying a turn lost after the server accepted it (turn/start is not idempotent, so a durable turn may already be committed with its side effects); and serialises runs per thread id within the process. Default behaviour is unchanged: one ephemeral, unresumable thread per run. See docs/guides/full/codex-engine.md.
  • Durable Claude Code sessions — ClaudeCodeEngine(persist_session=True) keeps the session and exposes session_id; ClaudeCodeEngine(session_id=...) resumes it, from a different process (verified live), which session_mode="runtime" could not do: that parks the id on a LazyBridge Session object and never leaves the process. An explicit handle wins over the parked one. As with durable Codex threads, resuming stops prepending Memory (Claude holds the history) and serialises runs per session id — including the run that creates the session, since two concurrent first runs would otherwise open two sessions and race to store the id. Prompt and options are now built inside that lock, because both read session_id.
  • Native review mode — CodexEngine(review_target={"type": "baseBranch", "branch": "main"}) (also uncommittedChanges / commit) runs the App Server's review/start instead of a prompted turn, returning Codex' own review harness output: severity-tagged findings with file:line. The protocol has no prompt slot there, so the agent's prompt is not sent and the review cannot be steered. Delivery is always inline — measured: a detached review completes on a different thread and raises an approval request the parent never sees. Pair with persist_thread=True and the review lands in the thread, so a following turn can ask about it.
  • CodexRunResult.thread_id, and turn-id attribution throughout the App Server client: usage is now the delta for this turn (total is cumulative over the thread, so a resumed turn used to be reported with the whole history's cost), and a turn/completed naming a different turn is ignored rather than returned as this call's answer. The turn id arrives in a response, while notifications about that turn do not wait for it, so attribution is resolved at the end from per-turn records rather than guessed on arrival, completions are not accepted on a resumed thread until the id is known, and the "outcome unknown" boundary opens when the request is sent rather than when it is answered. A timeout on a durable thread is likewise reported as uncertain and not retryable — asyncio.wait_for cancels with CancelledError, which unwinds past the client's own handling — and the thread id survives an uncertain turn, since inspecting the thread is the recovery path. A native review streamed through stream() now delivers its findings as one chunk instead of nothing (an inline review emits no deltas). (Every one of these windows was found by Codex reviewing this diff through the very tool this change enables.)

Fixed

  • CodexEngine: a turn died with Codex App Server reader failed: ValueError: Separator is found, but chunk is longer than limit as soon as the App Server sent one JSON-RPC line over StreamReader's 64 KiB default — which it does routinely, since whole file contents and command output (a real git diff) arrive in a single notification. The subprocess is now created with an explicit 64 MiB line limit. Found live, on the first diff-scoped code review run through the engine.
  • tests/unit/test_examples_import.py: _example_id() used str(Path) instead of .as_posix(), producing backslash-separated ids/module names on Windows; combined with an example using @dataclass under from __future__ import annotations, the backslash broke dataclasses' postponed-annotation resolution. Also register the synthetic module in sys.modules before exec_module() (required for that same resolution path), removing it afterward. No prior example both lived in a subdirectory and imported cleanly under this test, so this never surfaced before examples/claude_code/.

  • Anthropic Claude 5 family (claude-fable-5, claude-opus-5, claude-sonnet-5) and restricted-access claude-mythos-5 added to AnthropicProvider._PRICE_TABLE / _TIER_ALIASES / _FALLBACKS. top now resolves to claude-fable-5 (Anthropic's most capable generally-available model), expensive to claude-opus-5, medium to claude-sonnet-5. claude-mythos-5 is priced but deliberately not tier-aliased — it's restricted to vetted partners (Project Glasswing) and unreachable with an ordinary API key.

  • OpenAI GPT-5.6 family (gpt-5.6-sol / gpt-5.6-terra / gpt-5.6-luna, plus the bare gpt-5.6 alias routing to Sol) added to OpenAIProvider._PRICE_TABLE / _TIER_ALIASES / _FALLBACKS, replacing the old flagship+-pro shape. top → gpt-5.6-sol, expensive → gpt-5.6-terra, medium → gpt-5.6-luna (cheap / super_cheap unchanged — Luna isn't cheaper per-token than gpt-5.4-nano). OpenAI's realtime voice models (GPT-Live-1) are a separate Realtime API and out of scope for OpenAIProvider.
  • Anthropic effort parameter (output_config.effort — "low"/"medium"/"high"/"xhigh"/"max") is now wired up in AnthropicProvider._build_effort for every model that supports it (Fable 5, Mythos 5, Opus 5, Opus 4.8, Opus 4.7, Opus 4.6, Sonnet 5, Sonnet 4.6, Opus 4.5) — previously ThinkingConfig.effort was read by OpenAIProvider/GoogleProvider but silently ignored by AnthropicProvider. Unsupported models get a warning instead of a 400; "xhigh" on a model that only goes up to "max" (Opus 4.6 / Sonnet 4.6 / Opus 4.5) is downgraded to "max" with a warning. LLMEngine(model, thinking="low") is new shorthand for LLMEngine(model, thinking=ThinkingConfig(enabled=True, effort="low")) and works on both providers since both read ThinkingConfig.effort.
  • New extra lazybridge[docparse] (griffe) for multi-line-aware docstring parameter parsing, same library used by PydanticAI and the OpenAI Agents SDK. Auto-detects Google/NumPy/Sphinx style. Included in the test and all extras so CI exercises it, not just its fallback.

Fixed

  • A parameter description wrapped across multiple physical lines is no longer silently truncated to its first line. _parse_docstring_params now tries griffe first (when lazybridge[docparse] is installed), which correctly joins wrapped continuation lines (collapsed to single spaces, consistent with the first-paragraph tool description below); the previous regex-only parser — kept as the fallback when griffe isn't installed — only ever captured a single physical line per parameter and dropped the rest with no warning. New tests/unit/test_docstring_multiline_params.py covers Google/NumPy/ Sphinx wrapped params, the griffe-unavailable fallback path, and a regression guard for short/ambiguous docstrings where style auto-detection itself returns nothing (falls back to the same :param-marker heuristic the regex parser already used, not blindly to Google).

Changed

  • Auto-derived tool description now uses the docstring's first paragraph, not just its first physical line (ToolSchemaBuilder.build_artifact, only when Tool.wrap/.build() receives no explicit description=). Wrapped lines within that paragraph are collapsed to single spaces. A well-formed Google-style docstring (one-line summary, blank line, then Args:/extended prose) is unaffected — this only changes tools whose summary itself wraps across multiple physical lines before the blank line. Text after the first blank line (extended description, Args:, internal notes) was never included and still isn't. The old comment claiming this protected the compile-artifact fingerprint was incorrect: func_source_hash already hashes the full function source, docstring included, so any docstring edit already invalidates the fingerprint regardless of how much of it becomes the description.

[1.0.2] — 2026-07-13

Found by a targeted cross-process stress-testing pass over the checkpoint / resume machinery (Plan crash-resume, ReplanEngine failure-recovery).

Fixed

  • Plan crash-resume now feeds the first resumed step the previous step's output, not the plan's start input. _run_impl seeded prev_env from the start envelope, so a resumed step whose implicit from_prev input came from a step that completed before the crash silently received the original task instead of the upstream result — a plain Plan(Step(a), Step(b), …) chain with no writes= lost the link across the checkpoint boundary (no error, wrong data). It now reconstructs prev_env from the restored step-result history. Steps that pass data via writes=/kv, or that reference from_start/from_step/from_parallel, were never affected. This complements the 1.0.1 fix (which stopped resume from re-invoking completed steps but did not restore the chain value). Regression test added: test_e2e_resume_preserves_from_prev_chain_value.
  • Resuming a step that failed after it already succeeded no longer reprocesses its own output. Edge of the fix above: _routing() (or a durable-write) can raise after a step is appended to the checkpoint's history and before next_step advances, so the resulting failed checkpoint's next_step still points at that just-completed step. Reconstructing prev_env unconditionally from history[-1] there fed the retried step its own recorded output instead of the upstream step's. Now only steps back to history[-2] when resuming a failed/ cancelled checkpoint whose next_step equals the last history entry (the retry-of-a-succeeded-step signature); a clean running self-loop is unaffected. Regression test added: test_e2e_resume_after_post_success_routing_failure_uses_upstream.
  • Windows-only test failures resolved. e2e / run_many tests now close their Store (via contextlib.closing) so the temp SQLite file unlocks before TemporaryDirectory cleanup, and test_ext_core_boundary reads package sources as UTF-8. Five tests that failed only on Windows (PermissionError [WinError 32] / UnicodeDecodeError under cp1252) now pass; CI on Linux was already green.

Testing

  • ReplanEngine failure→resume→recover contract pinned. Failure injection (a transient worker, a parallel-band branch, or the planner itself failing) confirmed the engine already recovers correctly via resume=True — re-running only the failed round, never a completed one — and that an error Envelope from a worker surfaces as an error rather than a phantom-empty success. No behaviour change; added as regression coverage since no existing test exercised this path.

[1.0.1] — 2026-07-06 — first Stable release

Version starts at 1.0.1, not 1.0.0: an earlier 1.0.0 shipped in April 2026 under the old LazyAgent/LazyTool namespace and was rolled back (see Migrating from 1.0.0). That version number is retired for good — this release starts clean at 1.0.1 rather than reusing it.

lazybridge.__stability__ moves "beta" → "stable"; PyPI classifier moves Development Status :: 4 - Beta → Development Status :: 5 - Production/Stable. The core public API contract (Agent, Plan, Tool, Envelope, Guardrails, Checkpoint/resume) will not break without a major version bump going forward.

Changed

  • Guardrails and Checkpoint/resume promoted Alpha → Stable in the maturity table (docs/index.md), backed by a live adversarial/load stress-testing pass: LLMGuard resisted a deliberate tag-injection smuggling attempt, GuardChain correctly threads modifications and blocks across chained guards, and Plan checkpoint/resume correctly resumed after a forced step failure without re-invoking (re-billing) the already-completed step. Native tools, HumanEngine/ SupervisorEngine, Evals, and the Visualizer remain Alpha/Experimental — not exercised by this pass, unchanged. The two Planned items (provider fallback chains, automatic PII redaction beyond credential shapes) remain unimplemented; still explicitly listed rather than quietly dropped.

Fixed

  • asyncio.iscoroutinefunction replaced with inspect.iscoroutinefunction in lazybridge/tools.py, lazybridge/guardrails.py, and lazybridge/engines/plan/_plan.py. The asyncio version is deprecated and slated for removal in Python 3.16; behavior is identical. Found via DeprecationWarning surfaced by the live stress-test suite.
  • Agent(verify=...) no longer recurses infinitely. Found live by the pre-v1 stress notebook: _run_body's verify branch called verify_with_retry, which called back into the full agent.run() — which re-entered the same verify branch, recursing until RecursionError on any Agent(verify=...) invocation. The suite never caught it because the judge tests drive verify_with_retry with mock agents that don't re-enter. verify_with_retry now accepts a run= override and Agent._run_body passes its engine-only runner (_run_engine), so each verify attempt re-executes the engine (plus structured-output validation) without re-entering the guard/verify pipeline. Regression tests cover the callable-judge, Agent-judge, and retry-with-feedback paths.
  • Verify retries keep the original attachments and payload. The rebuilt post-rejection envelope carried only task + feedback context, silently dropping the original env's images, audio, and payload — every retry ran without the input the first attempt had (Codex review finding on the recursion-fix PR).

[0.10.0] — 2026-07-02 — v1 stabilization bridge

The bridge release before 1.0: every finding from the v1 deep audit of the core is fixed here, the plan runtime is decomposed into focused modules, and the public API gets its final pre-1.0 cleanup. Package stability moves alpha → beta (Development Status :: 4). The plan: this release settles across the dependent Lazy* projects, then 1.0.0 is tagged from it without further changes.

Migration summary (breaking / deprecated):

  1. Agent(output=Model) that exhausts max_output_retries now returns ok=False with error.type == "OutputValidationError" instead of ok=True with the raw string. Check the error type; the raw payload is preserved on the envelope.
  2. Agent.stream(timeout=) is now a total-stream deadline (was per-chunk); the stream also enforces the output guard on completion and fails over to fallback= when the engine dies before the first token.
  3. lazybridge.Task → lazybridge.ReplanTask (deprecated alias warns, removed in 1.0). CacheConfig → import from lazybridge.core.types. PROVIDER_ALIASES → call LLMEngine.provider_aliases().
  4. Routing (routes= / routes_by=) into a parallel=True step is now a PlanCompileError (it silently lost the rejoin jump at runtime).
  5. Plan.to_dict() is now v2 (records Step.output / Step.input by name); pass the types in the from_dict registry ({"type:<Name>": <class>}). v1 payloads still load.

Added

  • No-extras test environment is green. The suite now passes with no provider SDK installed: tests/conftest.py installs a MagicMock openai stub before any lazybridge import (the per-file sys.modules stubs came too late once the provider module was imported, leaving _openai = None bound forever — 15 failures), and test_store_encryption.py's skip guard no longer crashes collection on hosts where cryptography's Rust extension panics (the pyo3 PanicException is matched by name; the old import-then-catch bound () into the except clause and raised TypeError).

Fixed

  • Memory summaries now accumulate across compressions. Repeated compression overwrote the previous summary — the summarizer never saw it, so the second compression permanently discarded everything the first had captured (the oldest context), silently. Both the LLM path and the keyword-extraction fallback now fold the prior summary into the new one.
  • Unannotated tool params survive strict mode. In signature mode an unannotated parameter produced an empty {} subschema (bypassing _annotation_to_schema's documented {"type": "string"} fallback); strict-mode validators on OpenAI/Gemini reject or drop {}, making the parameter vanish from the tool signature.
  • $defs name collisions fail loud on flatten. _flatten_refs merged same-named definitions last-write-wins, silently inlining the wrong shape when two distinct models shared a class name. Conflicting shapes now raise ValueError with a rename hint (identical duplicates still merge).
  • LLMGuard async timeout enforced once. _ajudge's sync-callable fallback routed through _judge, which enforces timeout again on its own daemon thread — double enforcement, plus a leaked daemon thread per call whenever the outer deadline fired first. The async path now calls a single untimed judging round-trip under the outer asyncio.wait_for.
  • DeduplicateGuard is silent by default. verbose defaulted to True and wrote to stdout via print() from library code; it now defaults to False and routes through logging (INFO when verbose, DEBUG otherwise). The module also gains behavioral test coverage (block splitting, near-dup prefixes, short-block preservation).
  • __version__ source-tree fallback re-aligned with pyproject.toml (was stale at 0.9.0), with a test guarding the sync.
  • Cancelled Plan/Replan runs no longer poison the checkpoint key. A run unwound by cancellation (e.g. a consumer breaking out of plan.stream() early), by conclude(), or by an unexpected exception escaped past the per-step checkpointing and left the key stuck in claimed/running under a dead run_uid — every subsequent on_concurrent="fail" run raised ConcurrentPlanRunError until the key was manually cleared. Both engines now write a best-effort terminal checkpoint on non-local exits (cancelled on cancellation, done on conclude — with the conclude answer cached for Replan — and failed on unexpected exceptions), and _claim_checkpoint treats cancelled as claimable by fresh runs and adoptable by resume=True (which continues from the recorded next_step / round).
  • Plan serialization carries Step.output / Step.input (to_dict v2). to_dict() silently dropped both, so from_dict() rebuilt every step with output=str: structured steps degraded to raw strings and any routes_by= plan failed recompilation (PlanCompileError) after a round-trip. Types are now recorded by name and rebound via the from_dict registry ("type:<Name>" or bare "<Name>" key) with a loud KeyError when missing. v1 payloads still load (missing keys default to str / Any).
  • Routing into a parallel band is now a compile error. routes= / routes_by= targeting a parallel=True step compiled cleanly but the band dispatcher advances linearly and never consults the after_branches rejoin state — the jump was silently lost and a stale entry leaked. PlanCompiler now rejects it with a fix hint (wrap the parallel work in an Agent(engine=Plan(...)) branch step).
  • Per-step checkpoint cost no longer quadratic. _save_checkpoint re-serialized the entire growing history (model_dump of every envelope) on every step. The serialized history is now maintained incrementally alongside the in-memory one.
  • EventLog.flush() after close() no longer stalls. Pushing a flush sentinel to a queue whose writer thread has exited blocked for the full timeout; flush() is now a no-op once closed or when the writer thread is not alive.
  • EncryptedStoreAdapter context manager + keyed bulk-read errors. The adapter now implements __enter__/__exit__ (parity with the base Store), and read_all() / items() name the offending key when they hit a plaintext row in a mixed store.
  • Shared-engine event misattribution. An engine is a shareable object, but Agent.__init__ stamped engine._agent_name = self.name — so with two Agents on one engine, every event and usage row was attributed to whichever agent was constructed last, deterministically. The identity is now bound per-invocation via a context variable (lazybridge.engines.base.bind_agent_name / resolve_agent_name): Agent binds its name around each engine.run() / engine.stream() call and all engines (LLM, Plan, Replan, Supervisor, Human) resolve the context-bound name first. The _agent_name attribute is kept as a fallback for code that drives an engine directly.
  • Structured output on the streaming path. LLMEngine._stream_turn rebuilt the CompletionResponse from stream chunks without ever reading chunk.parsed / chunk.validation_error / chunk.validated, so any streamed run with output=Model silently degraded to a raw string (and burned the output-validation retries). The reconstructed response now carries all three fields through.
  • Agent.stream() pipeline parity with run(). Streaming applied only the input guard. Now: the output guard runs on the accumulated text when the stream completes (a block raises ValueError and skips the Store write — tokens already delivered cannot be retracted, but buffering consumers can discard); the fallback agent takes over when the engine fails before the first token (after tokens, the error propagates); and timeout= is now a total-stream deadline, the same meaning it has in run() (it was per-chunk, i.e. effectively unbounded — stall detection between chunks remains LLMEngine(stream_idle_timeout=)). verify= and output= validation remain run()-only and are documented as such.
  • Executor retry classification. The last-resort string scan in _is_retryable could retry permanent client errors whose message merely contained "timeout" / "connection" (e.g. a 400 invalid 'timeout' parameter). A structured 4xx status (other than 408/429) now short-circuits to non-retryable before the string scan.
  • Memory records the answer actually returned. When a structured-output correction retry produced the accepted answer, memory kept the first (rejected) draft — history diverged from the returned Envelope. Agent._validate_and_retry now amends the last turn via the new Memory.amend_last(assistant).
  • Cross-session sub-agent pinning is now visible. A sub-agent that inherited its session from one orchestrator and is then passed to a second orchestrator with a different session stays pinned to the first (unchanged — we never steal a session), but the second construction now emits a UserWarning explaining where the child's events flow and how to choose explicitly.

Refactoring (no behaviour change)

  • _plan.py split into focused submodules. The 1,700-line runtime monolith is now _plan.py (scheduler/orchestration, ~1,180 lines) plus _checkpoint.py (the CAS checkpoint state machine, as CheckpointMixin), _resolve.py (sentinel resolution + band aggregation, ResolveMixin), and _fanout.py (run_many / arun_many, FanoutMixin). Plan inherits all three, so every method keeps its original name and signature. The 170-line inline parallel-band block in _run_impl is now the _run_parallel_band method with an explicit state contract.
  • One provider registry. The provider-name → class map lived in two hand-maintained copies (Executor._resolve_provider and LLMEngine._provider_class) that had already drifted on the litellm special case. Both now resolve through lazybridge.core.providers._registry.provider_class (lazy per-provider import preserved).
  • Executor retry loops deduplicated into a shared _next_retry_delay (classification + backoff + warning), keeping sync and async semantics in lock-step.
  • _parse_data_uri shared by ImageContent.from_data_uri and AudioContent.from_data_uri (byte-identical copies collapsed).
  • _safe_register_agent / _safe_register_tool_edge collapsed onto a single warn-on-failure _safe_graph_call helper.

Documentation

  • ReplanEngine checkpoint granularity made explicit. Checkpoints are per-ROUND, not per-task: a crash mid-round re-executes the entire round on resume=True (planner re-asked, every task re-dispatched), so tasks with external side effects must be idempotent. This was always the behaviour; it is now documented on the engine.

Changed

  • v1 API pass — three top-level names deprecated (removal in 1.0).
  • Task → renamed ReplanTask (the bare name was too generic for a top-level export and collided with user code). lazybridge.engines.replan.Task remains a plain alias; lazybridge.Task still resolves but emits a DeprecationWarning.
  • CacheConfig → import from lazybridge.core.types (it is engine configuration, not primary API). Top-level access warns.
  • PROVIDER_ALIASES → call LLMEngine.provider_aliases(). The constant was an import-time snapshot that silently diverged from the live registry after register_provider_alias. Top-level access warns and now returns a fresh snapshot. All three are out of __all__ (star-imports no longer pick them up); the public-API snapshot test, SKILL.md, and reference docs are updated.
  • StoreEntry.written_at documented as informational metadata. The Store has no TTL/expiry mechanism and never consults written_at; the docstring now says so explicitly (agent_id carries provenance stamps such as Plan's plan-run:<run_uid>).
  • BREAKING — exhausted output validation is now an error, not a silent success. Agent(output=Model) used to return ok=True with the raw, unvalidated string payload after max_output_retries failed correction attempts — callers could not distinguish "validated" from "gave up", and result.payload.field blew up downstream. The final envelope now carries error.type == "OutputValidationError" (ok=False, retryable=False) with the raw payload preserved on the envelope for inspection. Migration: code that relied on receiving the unvalidated string on ok=True should check for error.type == "OutputValidationError" and read result.payload (still the raw model output) from the error envelope.
  • Deduplicated the encrypted-Store CAS equality check. EncryptedStoreAdapter.compare_and_swap compared the decrypted plaintext against expected through a private _plain_eq that was a byte-for-byte copy of lazybridge.store._json_eq (JSON-shape equality via _to_jsonable, so a Pydantic model compares equal to the dict it round-trips to). The adapter now calls the shared _json_eq directly, keeping its CAS rule in lock-step with Store.compare_and_swap and removing the copy. Behaviour is unchanged.
  • Unified the synchronous→async bridge. The logic that runs a coroutine to completion from synchronous code (detect the event-loop state, then run on a fresh loop / in-loop under nest_asyncio / on a worker thread) lived in six near-identical, subtly-divergent copies: Agent.__call__, ParallelAgent.__call__, Tool.run_sync, Memory._drive_to_completion, MockAgent.__call__, and Plan.run_many. Only the Agent copy handled nest_asyncio (Jupyter/Spyder) and suppressed httpx/anyio "Event loop is closed" GC noise; only Memory honoured a timeout; the others skipped the in-loop nest_asyncio branch — so the same call took a worker-thread path in a notebook while Agent.__call__ ran in-loop, a source of intermittent, path-dependent behaviour. All six now delegate to a single private helper, lazybridge._asyncbridge.run_coroutine_blocking, so every synchronous entry point crosses the boundary with identical semantics. Observable effects: Tool.run_sync, MockAgent.__call__, and Plan.run_many now take the in-loop path under nest_asyncio and suppress loop-closed cleanup noise; Memory's summariser path now also propagates the caller's contextvars (OTel spans / request-ids / structured-logging context) into the worker loop. Memory's timeout contract is unchanged. The helper takes a coroutine factory rather than a live coroutine, so a failure anywhere in dispatch can never strand a "coroutine was never awaited" object, and its timeout is applied with asyncio.wait_for inside the executing loop (the coroutine is actually cancelled on expiry, not left running detached).

[0.9.2] — 2026-06-12

Added

  • True token streaming for Plan and ReplanEngine. Agent(engine=plan).stream(...) previously awaited the entire plan and yielded the final text once; it now streams tokens live from each sequential step's LLM engine via an ambient token sink (lazybridge/core/streaming.py) that LLMEngine.run() adopts. Parallel bands are suppressed (no token interleaving), nested agents-as-tools stay silent (the LLMEngine.stream() contract), plans with no streaming-capable step fall back to yielding the final text once, and closing the stream early cancels the in-flight run. New Plan(stream_buffer=N) bounds the token queue exactly like LLMEngine(stream_buffer=N). The ReplanEngine planner's structured PlanRound output is loop control and is kept out of the stream. See Guides → Full → Plan → Streaming. Post-review hardening: the closing sentinel is skipped on cancellation (in both the ambient-sink runner and LLMEngine.stream's loop), so a consumer that disconnects while the bounded queue is full can no longer deadlock aclose() on sink.put(None).
  • Checkpoint-epoch stamping + Plan.store_write_is_current(). Every durable Step(writes=...) Store write (sequential, parallel band, and resume replay) now carries agent_id="plan-run:<run_uid>", matching the run_uid persisted in the checkpoint snapshot. Sidecar consumers reading the Store out-of-band can call Plan.store_write_is_current(store, checkpoint_key=..., key=...) to detect the documented crash-window staleness mechanically instead of diffing against the checkpoint kv by hand.
  • Example-rot guard. tests/unit/test_examples_integrity.py checks that every file under examples/ compiles and that every lazybridge import in it resolves against the installed package, so a public API rename can no longer silently break the examples.
  • ReplanEngine — guardian of the dynamic replan loop. The adaptive counterpart to Plan for pipelines whose shape is decided at runtime by a planner agent. The planner is a tool in the parent Agent's tool_map (built with output=PlanRound, located by planner_name); it is called every round and the tasks it emits are dispatched via tool.run(**task.kwargs) — agents, plain functions, and pool routes alike, with no special-casing. Tasks flagged parallel=True run concurrently via asyncio.gather. Pass store= + checkpoint_key= to persist round state after every round and resume=True to continue from the last checkpoint; same compare-and-swap single-writer semantics as Plan (ConcurrentPlanRunError on a contended key). max_rounds (default 20) caps the loop; a done=True round must carry a final_answer.
  • PlanRound and Task (lazybridge.engines.replan, re-exported from lazybridge) — the planner's structured output schema. PlanRound carries reasoning, a list of Task, a done flag, and the terminal final_answer; Task is one tool call (tool + kwargs + parallel). Added to the public API snapshot. New guide: docs/guides/full/replan-engine.md; reference entries in docs/reference/engines.md.

Fixed

  • ext.planners DAG builder — add_step now exposes from_parallel_all. The incremental builder tool's task_kind annotation was Literal["literal", "from_prev", "from_step", "from_parallel"], omitting from_parallel_all even though StepSpec, the step validator, _resolve_task, and PLANNER_GUIDANCE all already supported it — so the value the guidance steers the planner toward was not selectable through the generated tool schema. Added "from_parallel_all" to the add_step Literal and documented it in the tool docstring. Additive; no existing behaviour changes.

[0.9.1] — 2026-05-28 — Store.items(prefix=) range scan

Added

  • Store.items(prefix=) — returns (key, value) pairs restricted to keys starting with prefix via a single indexed B-tree range scan (WHERE key >= ? AND key < ?). Sub-linear in total keyspace size; O(M) in the number of matching keys. The in-memory path filters under the store lock using str.startswith. Pass prefix=None (default) or prefix="" to iterate the full store. The EncryptedStoreAdapter delegates to the inner store and decrypts each returned value.
  • _prefix_upper_bound(prefix) — private helper that computes the exclusive upper bound for the B-tree scan. Handles the U+10FFFF edge case by falling back to a Python-level startswith filter.

Compatibility

Additive — no existing behaviour changed. LazyPulse 0.2.0 uses this method to replace its O(N+1) _scan_records implementation.


[0.9.0] — 2026-05-24 — lazytoolkit extraction (Phase 3: shims removed)

Removed (breaking)

The lazy deprecation shims left behind by the 0.8 extraction are gone. Import from lazytools directly.

  • lazybridge.ext.mcp → use lazytools.connectors.mcp (pip install 'lazytoolkit[mcp]').
  • lazybridge.ext.gateway → use lazytools.connectors.gateway.
  • lazybridge.external_tools.read_docs → use lazytools.documents (pip install 'lazytoolkit[docs]').
  • lazybridge.external_tools.doc_skills → use lazytools.skills.
  • The whole lazybridge.external_tools namespace is deleted.

The old paths now raise ModuleNotFoundError instead of emitting a DeprecationWarning. lazybridge still has no runtime dependency on lazytools.


[0.8.0] — 2026-05-24 — lazytoolkit extraction (Phases 0–2)

The concrete, dependency-carrying tools moved to the new sibling package lazytoolkit (repo: selvaz/LazyTools). LazyBridge keeps only the minimal runtime + framework extensions.

Moved (lazy deprecation shims left behind; removed in 0.9)

  • lazybridge.ext.mcp → lazytools.connectors.mcp (pip install 'lazytoolkit[mcp]').
  • lazybridge.ext.gateway → lazytools.connectors.gateway.
  • lazybridge.external_tools.read_docs → lazytools.documents (pip install 'lazytoolkit[docs]').
  • lazybridge.external_tools.doc_skills → lazytools.skills.

Old import paths still work and emit a DeprecationWarning pointing at the new location. The shims are lazy (PEP 562 __getattr__) so import lazybridge never imports lazytools — lazybridge has no runtime dependency on the toolkit. The mcp and tools extras were removed (use lazytoolkit[mcp] / lazytoolkit[docs]).


[0.7.9] — 2026-05-10 — simplification release

The headline change: deletion-led simplification. The framework had no users yet, so we ship breaking changes without deprecation paths or shims. Net public surface change: −1 in lazybridge.__all__ (50 → 49), 5 deleted Agent.from_* class methods, 9 silent-fallback paths converted to explicit errors, and the entire report_builder subsystem extracted to its own repo. Zero new public concept.

The single LLM-friendliness lever is consistency: one canonical form per concept, errors always raise, no opt-in modes.

See docs/migrations/0.7-to-0.79.md for per-deletion before/after codemod snippets.

Breaking — extraction

  • lazybridge.external_tools.report_builder extracted to the sibling repo selvaz/LazyReport (PyPI: lazybridge-reports). Every import path lazybridge.external_tools.report_builder.* is gone — replace with lazybridge_reports.* after installing the new package. The five optional extras [report], [report-charts], [report-citations], [report-fallback], [pdf] are gone from lazybridge's pyproject.toml; their replacements live as lazybridge-reports[charts,citations,fallback,pdf]. No shim — there is no fallback import path.
  • New optional extra: [encryption] → cryptography>=42,<46 for the new lazybridge.store.encryption.EncryptedStoreAdapter (Fernet at-rest encryption for Store values, with MultiFernet key rotation).

Breaking — deletions

  • 5 Agent.from_* factories deleted: from_model, from_engine, from_chain, from_plan, from_parallel. All five were pure-alias forwarders (verified by audit). Use the canonical Agent(engine=...) ctor or the kept-because-non-trivial factories (Agent.chain, Agent.parallel, Agent.from_provider).
  • 3 config dataclasses deleted: AgentRuntimeConfig, ResilienceConfig, ObservabilityConfig. These were wrapper-of-flat-kwargs configs whose only behaviour was a flat kwarg > config object > default precedence merge that required a private _UNSET sentinel on every kwarg. The precedence game and _UNSET are gone with them. CacheConfig is kept — it carries real semantic value (enabled / ttl) consumed by LLMEngine.
  • mode="auto" graceful-fallback ladder removed from Tool / tool(). Both default to mode="signature" now; pass mode="hybrid" or mode="llm" plus schema_llm= to opt into LLM-driven schema generation. Passing mode="auto" raises ValueError.
  • _ParallelAgent renamed to ParallelAgent and its return contract changed. ParallelAgent.__call__ and run() now return ONE Envelope whose .payload is the labelled-text join across every branch (with transitive cost rollup in metadata.nested_* and first-error short-circuit in .error) — restoring the framework invariant that every Agent returns Envelope. For typed per-branch list[Envelope], call the new run_branches(task) async helper.
  • wrap_tool made private (_wrap_tool). Use the public tool(...) factory instead.
  • LLMEngine(tool_choice="parallel") raises ValueError (was a 0.7-era DeprecationWarning that downgraded to "auto").\n Concurrent tool execution is the default and not configurable.
  • Old doc/ directory deleted (1.2 MB, zero references).
  • pythonpath = ["lazybridge"] removed from pyproject.toml (unused).

Breaking — silent fallbacks → explicit errors

  • from_step("typo") / from_parallel("typo") no longer warn + fall back to the start envelope — they raise PlanRuntimeError with the actual step history and a typo-aware "Did you mean?" hint.
  • LLMEngine with an unknown model raises ValueError instead of silently routing to Anthropic. Set LLMEngine.set_default_provider("...") for the legacy behaviour.
  • MCP server emitting a non-object inputSchema raises ValueError (was silently coerced to an empty parameter set).
  • Envelope.text() on a non-JSON-serialisable payload raises TypeError (was str(payload) fallback).
  • Memory(summarizer_timeout < 5.0) warns at construction (timeout almost always fires for typical summariser shapes).
  • BaseProvider._resolve_model raises ValueError when nothing is configured (was empty string fallback).
  • Agent(engine=<non-LLM>) requires an explicit name= (was silently named "agent" and collided when used as a tool).
  • Agent(model=..., engine=<non-LLM>) raises ValueError (was silently dropped).

Added

  • lazybridge.matrix — declarative provider-capability lookup. provider_capabilities() and native_tool_support() aggregate the per-provider ClassVar flags into a single typed dict for docs / introspection / capability-aware error messages.
  • BaseProvider capability ClassVar flags: supports_streaming / supports_structured_output / supports_thinking. Subclasses override when a backend doesn't.
  • Standard error-message format — every PlanCompileError / PlanRuntimeError / UnsupportedFeatureError follows::

    Step '' (#) — = . Defined steps: [...].> Did you mean ''? (when applicable) Fix: .

  • OTel gen_ai.agent.nesting_level attribute on agent spans — dashboards filtering on =0 get clean root-only views.

  • Session.emit exporter-exception dedup — warn-once per (exporter class, exception class) pair with a count of suppressed identical failures.
  • test_public_api_snapshot.py — pins lazybridge.__all__ and locks the deleted-in-0.7.9 names as permanently gone.

Fixed (bug fixes from Phase 1)

  • B1: DeepSeek provider — defensively rebuild params['messages'] rather than mutating in place.
  • B2: Anthropic _compute_cost accepts cached_input_tokens=0 and applies the standard 10% cache-read rate; cost telemetry was over-counted on cached calls pre-fix.
  • B4: Anthropic provider warns on URL-source AudioContent (was silently dropped).
  • B6: Plan compiler typo-aware "Did you mean?" suggestions on from_step / from_agent / from_memory unknown-target errors.
  • B7: Plan serialisation raises ValueError on unknown sentinel kind (was silently fallback to from_prev).
  • B8: OTel exporter logs SDK exceptions at WARNING (was swallowed).
  • B9: Blackboard planner closure state resets per run() call (was leaking between successive runs).
  • B10: Plan compiler rejects sentinels referencing auto-named _anon_<id> steps (LLMs cannot meaningfully produce that name).
  • B11: Plan resume replays Store sidecar writes from the checkpoint kv so external consumers see complete state after a crash in the checkpoint→Store-write window.
  • I5: Anthropic adaptive-thinking warning corrected for Opus 4.7 (pre-fix said "use 'effort'" but Opus 4.7 only accepts display).

Documentation

  • SKILL.md rewritten for the 0.7.9 surface; canonical Plan block, default-model fallback advice via from_provider(tier=...), anti-pattern entries for every deletion.
  • docs/migrations/0.7-to-0.79.md (new) — per-deletion before/after snippets and a TL;DR table.
  • docs/reference/configs.md rewritten — only CacheConfig remains documented; rest of file explains the deletion.
  • docs/reference/engines.md adds the thinking= knob.
  • docs/reference/providers.md adds the stop_reason normalisation table (Google MAX_TOKENS mapping fix).
  • docs/guides/mid/parallel.md rewritten for the new single-Envelope return contract.
  • the MCP guide example now shows allow= filtering as best practice.
  • examples/verify_judge_loop.py (new), examples/guardrails_demo.py (new), env preflight in examples/daily_news_report.py.

Tooling

  • lazybridge.skill_docs._build recovered + wired into the test.yml typecheck job (drift gate that asserts every public symbol in __all__ is mentioned in SKILL.md).
  • docs.yml asserts site/llms.txt and site/llms-full.txt are non-empty (≥1 KB) after mkdocs build --strict.
  • Top-level permissions: contents: read added to .github/workflows/test.yml (least-privilege baseline).

[Unreleased] — 2026-05-05 — bug-fix and routing hardening

Breaking

  • LLMEngine.stream_idle_timeout default changed from None to 90.0 s. Old default left provider streams unbounded — a half-open HTTP/2 connection (TCP RST never delivered, PING dropped) would pin a worker indefinitely. New default raises StreamStallError after 90 s of inter-chunk silence, which is large enough to absorb provider-side thinking pauses on Opus / Gemini Pro. Pass stream_idle_timeout=None to opt out explicitly — a one-shot UserWarning flags the choice because the failure mode (worker pinned forever) is silent and hard to diagnose. The class-level default exposed for __new__ / subclass paths shifts the same way. No code change is required for callers that already pass an explicit value.

Features

  • Step.after_branches — exclusive-branch rejoin point. Set alongside routes / routes_by to route to exactly one branch and skip all sibling steps; execution resumes at the named step after the branch completes. Without after_branches, routing is a detour (linear progression resumes from the routed-to step's declared position). See Step docstring for the full example.

Hardening

  • MCP.stdio() now warns on unrestricted tool surface. When both allow= and deny= are omitted a one-shot UserWarning reminds the caller that every tool the subprocess advertises will reach the LLM. Trust model is unchanged (stdio is still audit-on-init, not deny-by-default like MCP.http); silence the warning by passing allow=["*"] once you've audited the surface, or restrict it with a glob. See SECURITY.md for the full guidance.
  • CI now enforces skill-doc drift. test.yml runs python -m lazybridge.skill_docs._build --check in the typecheck job: a fragment edit without a re-render now fails the PR instead of slipping through (this was the documented contract; CI just hadn't been wired).
  • mkdocs build --strict. A missing nav target or unresolved cross-reference in docs/ now fails the docs workflow instead of shipping an empty page.

Documentation

  • README — added an "alpha (0.7.x)" status callout up top, and expanded the Full tier sentinel list to cover all five exports (from_prev / from_start / from_step / from_parallel / from_parallel_all).
  • SECURITY.md — new "MCP Servers — Tool Surface Audit" section documenting the deny-by-default contract on MCP.http and the audit-on-init warning on MCP.stdio.
  • API reference — _UNSET sentinels in generated signatures now have an explicit explanation at the top of the reference page, including the LLMEngine.stream_idle_timeout semantics.
  • Skill docs reference grouping fixed — from_parallel_all, GuardError, and EventExporter are now classified under their proper categories instead of falling through to "Core types".
  • CHANGELOG hygiene — the second [Unreleased] section was renamed to [0.7.0 — short-term audit hardening] so the file no longer carries two unreleased headers.

Bug Fixes (Critical)

  • Store SQLite CAS: open transaction on JSONDecodeError — the except sqlite3.Error clause in compare_and_swap did not catch json.loads failures on corrupt rows, leaving BEGIN IMMEDIATE open on the thread-local connection and poisoning every subsequent call on that thread. Widened to except (sqlite3.Error, ValueError) (json.JSONDecodeError is a ValueError subclass).
  • Store in-memory: mutable references break CAS invariants — read() and write() returned / stored the live Python object, so callers mutating the result could silently alter the stored value and defeat compare_and_swap. Both paths now go through _deep_copy_safe (deep-copy with a non-copyable fallback).
  • LLMEngine tool_choice="any" infinite loop — after the model satisfied the "must call at least one tool" contract on the first turn, provider_tc stayed "any", forcing every subsequent turn to also call a tool. The loop never exited until max_turns was exhausted. Fixed: provider_tc is reset to "auto" immediately after the first tool-result turn.
  • LLMEngine tool_choice="any" sent as literal tool name — Anthropic and OpenAI reject tool_choice="any" as an unknown tool name. The framework now maps "any" → "required" when building the provider request so the wire value is always a recognised constant.
  • Plan: parallel-band failure checkpoint pointed at failing step — when a step inside a parallel band failed, the checkpoint recorded current_step as the failing step rather than the band-start, so resume=True re-entered mid-band in an inconsistent state. Now points at the band-start step.
  • Agent: failed structured-output retries contaminated memory — correction retries in _validate_and_retry were called with the live memory object, so each failed attempt added a garbage turn to the agent's conversation history. Retries now pass memory=None; only the final accepted result reaches memory.
  • Agent.stream(): input guard bypassed — acheck_input was not called in the streaming path, so guard= had no effect when callers used async for token in agent.stream(...). The guard check now runs before the first token is emitted.

Bug Fixes (High)

  • LLMGuard sync path: timeout= ignored — _judge invoked self._agent(prompt) directly on the calling thread without any deadline. Fixed by running the judge in a daemon thread and calling thread.join(timeout=self._timeout).
  • Memory strategy="sliding" silently disabled with max_tokens=None — _plan_compression gated all compression on bool(self.max_tokens), so strategy="sliding" with the default max_tokens=None never triggered. Fixed: only "auto" requires a token budget; "sliding" and "summary" compress by turn count independently of max_tokens.
  • Memory: _overflow_warned flag shared between turn-cap and summarizer timeout — a summariser timeout silenced the turn-cap warning (or vice versa) because both used the same flag. Split into _overflow_warned (turn cap) and _summarizer_warned (summariser timeout).
  • Predicates: empty() / not_empty() treated 0 / False as empty — only None and zero-length containers (str, list, dict, tuple, set, frozenset) are now considered empty. Numerics and booleans are always non-empty; use eq(0) / eq(False) for those cases.
  • Google provider: finish_reason never mapped to "max_tokens" — the MAX_TOKENS stop reason from the Google API was not translated, so callers inspecting stop_reason always saw None instead of "max_tokens".
  • Tool schema: model_dump() destroyed Pydantic model args — Tool.definition() called model_dump() on the entire arguments dict, collapsing Pydantic model instances to plain dicts before the schema was built. Fixed: getattr per field preserves the original objects.

[0.7.0] — pre-1.0 reset, simplified namespace layout

Major reorganization. The framework is dropping back to pre-1.0 and reshaping its namespace boundaries before stabilizing. Everything is alpha.

Breaking

  • Version downgrade: 1.0.0 → 0.7.0. The 1.0 release was premature given the API churn since; 0.7.x is the honest baseline.
  • Single stability tier: every surface is alpha. The stable / beta / alpha / domain 4-tier taxonomy is removed. Per-module __stability__ and __lazybridge_min__ markers are removed; only lazybridge.__stability__ = "alpha" remains.
  • Namespace reorganization — domain modules moved out of lazybridge.ext.*:
  • lazybridge.ext.read_docs → lazybridge.external_tools.read_docs
  • lazybridge.ext.doc_skills → lazybridge.external_tools.doc_skills
  • lazybridge.ext.data_downloader → lazybridge.external_tools.data_downloader
  • lazybridge.ext.stat_runtime → lazybridge.external_tools.stat_runtime
  • lazybridge.ext.report_builder → lazybridge.external_tools.report_builder
  • lazybridge.ext.external_tools → lazybridge.ext.gateway (file rename to free the namespace)
  • lazybridge.ext.* is now reserved for framework extensions that augment the agent runtime (mcp, otel, hil, evals, gateway, planners, viz).
  • New namespace lazybridge.external_tools.* — domain tool packages (returns list[Tool]).

Removed

  • lazybridge.ext.veo and lazybridge.ext.quant_agent — neither was ready for use and they only created confusion. Re-introduce later if the underlying integrations stabilize.
  • lazybridge.external_tools.stat_runtime (statistical / econometrics sandbox) and lazybridge.external_tools.data_downloader (Yahoo / FRED / ECB market-data ingestion) — same rationale: scope-creep domain examples that distract from the framework's actual surface. The matching [stats] and [downloader] optional-deps extras are also removed from pyproject.toml.

Tool factory shape (breaking)

  • All surviving external_tools/* factories standardize on def X_tools(*, ...) -> list[Tool] — keyword-only arguments, always returning a list. Single-tool cases return a 1-element list.
  • report_tools(*, output_dir=...)
  • fragment_tools(*, bus, default_section=None, step_name=None)
  • skill_tools(*, skill_dir, ...) (was skill_tool(skill_dir, ...) -> Tool)
  • skill_builder_tools(*, ...) (was skill_builder_tool(...) -> Tool)
  • read_docs_tools(*, base_dir=None) — new factory wrapping read_folder_docs as a Tool.

Boundary

  • New CI check (tools/check_ext_imports.py): ext/ and external_tools/ may only import from public lazybridge.*, never from internal lazybridge.core.* or other private submodules.

Migration

# before
from lazybridge.ext.read_docs import read_docs_tools
from lazybridge.ext.external_tools import ExternalToolGateway

# after
from lazybridge.external_tools.read_docs import read_docs_tools
from lazybridge.ext.gateway import ExternalToolGateway

[0.7.0 — short-term audit hardening] — bundled into the 0.7.0 cut

Closes the high-severity findings from the deep architecture audit (plan §5.1). All changes are additive; defaults shift only on Session(batched=True) (on_full="hybrid" instead of "drop") which strictly improves the safety of the existing path — critical events that previously could be dropped under saturation now block the producer. Pass on_full="drop" to opt back into the legacy policy.

Hardening

  • OTel GenAI conventions (audit H-D). OTelExporter now emits gen_ai.system / gen_ai.request.model / gen_ai.usage.* / gen_ai.tool.* attributes per the OpenTelemetry Semantic Conventions for Generative AI, and constructs a real parent-child span hierarchy (invoke_agent → chat, invoke_agent → execute_tool) with cross-agent context propagation through OTel contextvars. Tool spans correlate via tool_use_id so N parallel invocations of the same tool no longer collide. Span registry is per-instance so multiple OTelExporters in a process don't fight over the global tracer provider.
  • Memory.summarizer_timeout= (audit H-B). Default 30 s. An LLM summariser that hangs no longer blocks add() — the keyword fallback runs and a one-shot warning surfaces. Compression also computes the summary OUTSIDE Memory._lock, so concurrent add() calls progress while a slow summariser is in flight.
  • Per-event-type back-pressure in EventLog (audit H-A). New default on_full="hybrid" — the writer queue blocks the producer for audit-critical events (AGENT_* / TOOL_* / HIL_DECISION) but drops cheap telemetry (LOOP_STEP / MODEL_REQUEST / MODEL_RESPONSE) under saturation. Override the set via Session(critical_events=...). "block" and "drop" policies remain available unchanged.
  • MCP _tools_cache TTL + invalidation (audit H-E). New cache_tools_ttl parameter on MCPServer / MCP.stdio / MCP.http (default 60 s) and an invalidate_tools_cache() method. An MCP server that hot-loads or unloads tools is eventually reflected in the agent's tool list instead of forever-stale.
  • Loud surfacing of malformed tool-call arguments (audit M-A). Provider _safe_json_loads helpers (OpenAI, LiteLLM) now tag the raw argument blob with _parse_error on JSON decode failure or non-object payload. LLMEngine._exec_tool short-circuits on the tag and emits a structured TOOL_ERROR (type: "ToolArgumentParseError", parse_error, raw_arguments) instead of letting the tool fail downstream with a misleading "missing required field" message. Tool events also carry tool_use_id for downstream correlation.

Tests / CI

  • New tests/unit/test_audit_short_term.py (17 tests) covering each of the above plus the streaming + tool-call accumulation regression for Gemini / DeepSeek shape (audit M-B).
  • Coverage policy widened (audit M-I): lazybridge/ext/{otel,mcp,hil, planners,evals} are now in scope for the gate (previously omitted wholesale). Domain extensions (stat_runtime, data_downloader, doc_skills, veo, quant_agent, read_docs, external_tools) remain omitted because their dedicated test suites live under tests/unit/ext/ and are skipped by the default run. Gate stays at 70 with broader coverage; target for 1.1 is 80.
  • New CI workflows (audit M-J): release.yml (PyPI Trusted Publishing on v*.*.* tags), codeql.yml (weekly scheduled SAST + per-PR), dependabot.yml (weekly action + pip updates with major SDK pins preserved). Pre-commit hooks now run as a CI job.

[1.0.0] — 2026-04-26 — initial public release

Historical: this entry describes the deleted 4-tier stability taxonomy (stable / beta / alpha / domain) and the original namespace layout. Both were removed in 0.7.0; entries below are kept for historical accuracy only.

Core

  • Agent — universal façade with swappable engines (LLMEngine, Plan, plus HumanEngine / SupervisorEngine from lazybridge.ext.hil). One call surface (agent.run / agent(...) / agent.stream) regardless of engine.
  • Tool-is-Tool: plain functions, Agent instances, Agent.as_tool() results, and tool providers (e.g. an MCP server) all plug into tools=[...] with the same dispatch contract. Nested agents inherit the outer session for end-to-end observability.
  • Compile-time DAG validation: PlanCompiler rejects duplicate step names, forward references, broken from_step / from_parallel / from_parallel_all sentinels, and parallel-band misuse before any LLM call.
  • Crash-resume: Plan checkpoints to Store via compare_and_swap. Concurrent runs on the same checkpoint_key collide at claim time; resume=True adopts an in-flight checkpoint.
  • Parallel tool dispatch: when an LLM emits multiple tool calls in one turn, the engine runs them concurrently via asyncio.gather.
  • Structured output: output=SomeBaseModel flips the engine into schema-validated mode with retry-with-feedback up to max_output_retries.
  • Sources / Memory / Store / Session / Guard: composable observability and state primitives. Session is SQLite-backed with thread-local connections and a batched event-log writer.
  • Sync façade: agent("…") works inside or outside a running event loop. When invoked from inside one, the worker-thread loop inherits the caller's contextvars context so OTel spans / request IDs / structured-logging context flow through.

Providers

  • AnthropicProvider, OpenAIProvider, GoogleProvider, DeepSeekProvider, LiteLLMProvider, LMStudioProvider.
  • Provider-tier aliasing (super_cheap → cheap → medium → expensive → top) keeps preview / date-pinned model strings in one place per provider.
  • LMStudio adapter is a thin OpenAIProvider subclass that targets the local server (http://localhost:1234/v1 by default), pinned to Chat Completions, with zero cost and no native tools.
  • Native server-side tools per provider: WEB_SEARCH / CODE_EXECUTION / FILE_SEARCH / COMPUTER_USE / GOOGLE_SEARCH / GOOGLE_MAPS.
  • Prompt-caching forwarded via cache=True (Anthropic explicit, OpenAI / DeepSeek auto, Google via separate API).

Extensions (lazybridge.ext)

Extensions ship at __stability__ = "alpha" by default and may break between minor releases. Promotion: alpha → beta → stable → core. See docs/guides/core-vs-ext.md for the policy.

  • ext.hil — HumanEngine (approval gate) and SupervisorEngine (full REPL with tool calls, retry-with-feedback, store access).
  • ext.planners — make_planner (DAG builder) and make_blackboard_planner (todo-list).
  • ext.mcp — Model Context Protocol integration at the tool boundary. MCP.stdio / MCP.http / MCP.from_transport build a tool provider that drops into Agent(tools=[server]).
  • ext.otel — OpenTelemetry exporter for Session.
  • ext.evals — EvalSuite / EvalCase / llm_judge / built-in matchers.
  • ext.stat_runtime — sandboxed DuckDB query engine with AST-validated SQL (sqlglot DuckDB dialect; defence-in-depth regex layer for environments without sqlglot).
  • ext.data_downloader, ext.quant_agent, ext.doc_skills, ext.read_docs, ext.veo — domain extensions.

Documentation

  • Per-tier guides (docs/tiers/{basic,mid,full,advanced}.md).
  • Decision trees (docs/decisions/) — when to use which primitive.
  • Recipes (docs/recipes/) — tool calling, structured output, pipeline with resume, human-in-the-loop, MCP, orchestration tools.
  • LLM-assistant skill (lazybridge/skill_docs/) ships with the package; same content as the site, signature-first for LLM consumption. Single-source via python -m lazybridge.skill_docs._build.