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.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 threadsCodexEngine(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 sessionsClaudeCodeEngine(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 modeCodexEngine(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. topgpt-5.6-sol, expensivegpt-5.6-terra, mediumgpt-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 - BetaDevelopment 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.Tasklazybridge.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.mcplazytools.connectors.mcp (pip install 'lazytoolkit[mcp]').
  • lazybridge.ext.gatewaylazytools.connectors.gateway.
  • lazybridge.external_tools.read_docslazytools.documents (pip install 'lazytoolkit[docs]').
  • lazybridge.external_tools.doc_skillslazytools.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 lazytoolslazybridge 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 fixedfrom_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 invariantsread() 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 bypassedacheck_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 argsTool.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_docslazybridge.external_tools.read_docs
  • lazybridge.ext.doc_skillslazybridge.external_tools.doc_skills
  • lazybridge.ext.data_downloaderlazybridge.external_tools.data_downloader
  • lazybridge.ext.stat_runtimelazybridge.external_tools.stat_runtime
  • lazybridge.ext.report_builderlazybridge.external_tools.report_builder
  • lazybridge.ext.external_toolslazybridge.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_cheapcheapmediumexpensivetop) 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.hilHumanEngine (approval gate) and SupervisorEngine (full REPL with tool calls, retry-with-feedback, store access).
  • ext.plannersmake_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.evalsEvalSuite / 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.