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¶
CodexEnginestructured output now goes throughturn/start's nativeoutputSchemawhen 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: falseplus every property inrequired— carrying an optional property intorequiredonly when it already acceptsnullas written; any other optional property, an object withadditionalPropertiesexplicitly left open (e.g.extra="allow"), or a fixed-length tuple (prefixItemshas 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) intorequiredwould let the model legally answernullwhere the destination model then rejects it onmodel_validate; anextra="allow"model was silently closed; aLiteral[..., None]enum needed the JSON-Schema-correct rule that a siblingtypeexcluding null overridesnulllisted inenum/const; andtuple[str, int]was kept instead of falling back, producing a schema that looked converted but thatturn/startstill 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 asCLAUDE_CODE_AUTO_COMPACT_WINDOWin 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 given137000echoes it back from its own environment. Codex'smodel_context_windowis 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).LLMEnginehas 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.tomlis 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'sRateLimitEventarrives free on every run but itsutilizationwasNoneon 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/usageslash command prints, sousage()spends one small turn sending it and parses the reply — every field it cannot extract isNonerather than guessed, andsnapshot.parsed/raw_textlet a caller detect and fall back from wording drift instead of trusting a silently empty result. Goes through the sameClaudeSdkClientboundary the engine'srun()/stream()already use, so a test engine built with an injected client never reaches the real SDK here either.fetch_claude_usage()andparse_usage_report()are exported standalone for use outside anEngine. 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 fromnow's own zone instead of the report's (an instant near midnight UTC can already be a different calendar date inAmerica/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 toresets_at=Nonerather than guess.tzdatais now a conditional dependency of theclaude-codeextra on Windows, which ships no system IANA database — without it, every reset would have quietly come back unparsed rather than erroring.Tool(timeout=N)andAgent(tool_timeout=N)— a per-tool deadline that works on synchronous tools.Agent(timeout=N)can only fire at anawait, and a blocking sync tool never yields one: measured, anAgent(timeout=8)whose tool called a blockingweb_searchwas still running two minutes later.LLMEngine(tool_timeout=N)did not help either — it wrappedtool.run()inasyncio.wait_for, and an executor future that has already started ignores cancellation, sowait_forwaited 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=)overAgent(tool_timeout=)overLLMEngine(tool_timeout=).Tool.wrap()andTool.from_schema()both accepttimeout=, andTool.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,_abandonemits aUserWarningnaming the tool.
Changed¶
LLMEngine(tool_timeout=)and the Claude Code / Codex tool bridges now dispatch through the sharedrun_tool_boundedhelper instead ofasyncio.wait_for, so all three of the places documented as bounding a tool call actually do. Behaviour for async tools is unchanged; theTOOL_TIMEOUTsession event and its payload keep their shape.- A bounded async tool is now cancelled, not raced. Every bounded path
applies the bound with
asyncio.waitrather thanwait_for, so aTimeoutErrorraised by the tool itself (an HTTP client reporting its own deadline) reaches the model as the tool failure it is, instead of being relabelledToolTimeoutError/TOOL_TIMEOUTand 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 catchCancelledErrorand 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 arun_sync()caller back the very hang itstimeout=had just spared it. ToolTimeoutErrormoved fromlazybridge.engines.llmtolazybridge.tools— it is no longer specific toLLMEngine. The top-levellazybridge.ToolTimeoutErrorimport is unchanged; it now carries.tool_nameand.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'stools=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 anapproval_gategives a writer agent per-call human/policy approval. Granting is not pre-approving — unless a name is also inallowed_tools, every call still routes throughcan_use_tool. Becausefile_rootsconfinement is a hook over the file tools only, the engine refuses at construction to grant an unconfinable name (Bash) without anapproval_gate: its only boundary is that policy.CodexEngine(thread_source=...)— sent as the App Server's ownThreadStartParams.threadSource("an optional client-supplied analytics source classification for this thread", verified against the generated protocol schema). Live-verified landing on disk assession_meta.payload.thread_sourcein the rollout file — a different field fromsession_meta.payload.source, which is something else and unaffected. Defaults to"lazybridge". Note that every LazyBridge-created Codex thread was already identifiable viasession_meta.payload.originator == "lazybridge"(unconditional, from theinitializecall'sclientInfo.name);thread_sourceadds a second, caller-chosen label on top — e.g. to tell two LazyBridge-based applications' threads apart. Creation-time only: sent onthread/start, never onthread/resume(the field cannot be changed after a thread exists).ClaudeCodeEngine(tag=...)— the Agent SDK has no creation-time equivalent ofthreadSource, but it does have a post-hoc, public tagging API (claude_agent_sdk.tag_session, appending a{"type":"tag",...}JSONL entrylist_sessions()reads back as.tag). Every NEW durable session (persist_session=Trueor asession_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 throughlist_sessions(). Passtag=Noneto skip it. A tagging failure warns (UserWarning) rather than failing the run.
Changed¶
- DeepSeek pricing (
lazybridge.core.providers.deepseek) — updated_PRICE_TABLEand_CACHE_HIT_PRICE_TABLEto 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 deprecateddeepseek-reasoner/deepseek-chataliases): $0.44 / $0.014 / $1.32, up from $0.14 / $0.0028 / $0.28.
[1.1.0] — 2026-08-16¶
Added¶
-
durable_blackboard_agentandDurableBlackboard(lazybridge.ext.planners) — the blackboard planner for agents that stay up. The to-do list lives in aStoreunder a stableplan_idinstead 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_nexthands 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 againstmax_attempts, after which the task is parked asfailedrather than stalling the plan forever. Verified live across three separate processes sharing only the SQLite file. Seedocs/recipes/durable-blackboard.md. -
ClaudeCodeEngine(lazybridge.engines.claude_code, re-exported aslazybridge.ClaudeCodeEngine) — a standardEnginethat runs the model/tool loop through the locally authenticated Claude Code runtime (Claude Agent SDK) instead of a raw provider API call, with the sameAgent/Memory/Session/tools=surface asLLMEngine: in-process MCP tool exposure, read-onlyRead/Glob/Grepscoped tofile_roots,WebSearch/WebFetch, retries/timeouts mirroringLLMEngine's policy,session_mode="runtime"for a persistent Claude Code session inside one LazyBridgeSession. Merged from the standalone feasibility prototype (live-verified against the real Claude Agent SDK); new optional extralazybridge[claude-code](claude-agent-sdk,mcp). Seedocs/guides/full/claude-code-engine.md. CodexEngine(lazybridge.engines.codex, re-exported aslazybridge.CodexEngine) — the sameEnginecontract driven by the locally authenticated Codex CLI, talking tocodex app-serverover JSON-RPC (nevercodex 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 mirrorLLMEngine, andreasoning_effortmaps toturn/start'seffort. Needs no Python extra — only thecodexCLI, which it also finds in the Codex desktop app's install directory when it is not onPATH(CODEX_BINoverrides). Verified live against codex-cli 0.148.0, which corrected three protocol assumptions carried over from the prototype:sandboxis the kebab-case"read-only"("readOnly"is rejected outright), token usage comes fromthread/tokenUsage/updatedrather thanturn/completed, and server→client request ids are numbered independently of the client's, so the read loop dispatches onmethod. Notecost_usdis structurally0.0: ChatGPT-plan auth reports rate-limit percentages, not per-turn prices. Seedocs/guides/full/codex-engine.md.- Multimodal input for both local-CLI engines.
Envelope.imagesnow reaches Claude Code (as Anthropic base64 image content blocks; URL-only images are dropped with a warning because the CLI rejectsurlsources) and Codex (asimageUserInput items, inline bytes sent as adata:URL).Envelope.audiois 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¶
ClaudeCodeEnginenow enforcesoutput=<type>natively through the Agent SDK'soutput_format(the CLI's--json-schema) and reads the parsed object back fromResultMessage.structured_output, instead of pasting the schema into the prompt and relying onAgent._validate_and_retryto repair prose answers — the same server-side guaranteeLLMEnginegets fromStructuredOutputConfig.CodexEnginekeeps the prompt-priming approach:turn/start's nativeoutputSchemaaccepts 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 exposesengine.thread_id;CodexEngine(thread_id=...)resumes it, from a different process (verified live), throughthread/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 LazyBridgeMemoryto the prompt (one chronology, not two); raises the newCodexTurnUncertaininstead of retrying a turn lost after the server accepted it (turn/startis 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. Seedocs/guides/full/codex-engine.md. - Durable Claude Code sessions —
ClaudeCodeEngine(persist_session=True)keeps the session and exposessession_id;ClaudeCodeEngine(session_id=...)resumes it, from a different process (verified live), whichsession_mode="runtime"could not do: that parks the id on a LazyBridgeSessionobject and never leaves the process. An explicit handle wins over the parked one. As with durable Codex threads, resuming stops prependingMemory(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 readsession_id. - Native review mode —
CodexEngine(review_target={"type": "baseBranch", "branch": "main"})(alsouncommittedChanges/commit) runs the App Server'sreview/startinstead 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 alwaysinline— measured: a detached review completes on a different thread and raises an approval request the parent never sees. Pair withpersist_thread=Trueand 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 (totalis cumulative over the thread, so a resumed turn used to be reported with the whole history's cost), and aturn/completednaming 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_forcancels withCancelledError, 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 throughstream()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 withCodex App Server reader failed: ValueError: Separator is found, but chunk is longer than limitas soon as the App Server sent one JSON-RPC line overStreamReader's 64 KiB default — which it does routinely, since whole file contents and command output (a realgit 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()usedstr(Path)instead of.as_posix(), producing backslash-separated ids/module names on Windows; combined with an example using@dataclassunderfrom __future__ import annotations, the backslash broke dataclasses' postponed-annotation resolution. Also register the synthetic module insys.modulesbeforeexec_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 beforeexamples/claude_code/. -
Anthropic Claude 5 family (
claude-fable-5,claude-opus-5,claude-sonnet-5) and restricted-accessclaude-mythos-5added toAnthropicProvider._PRICE_TABLE/_TIER_ALIASES/_FALLBACKS.topnow resolves toclaude-fable-5(Anthropic's most capable generally-available model),expensivetoclaude-opus-5,mediumtoclaude-sonnet-5.claude-mythos-5is 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 baregpt-5.6alias routing to Sol) added toOpenAIProvider._PRICE_TABLE/_TIER_ALIASES/_FALLBACKS, replacing the old flagship+-proshape.top→gpt-5.6-sol,expensive→gpt-5.6-terra,medium→gpt-5.6-luna(cheap/super_cheapunchanged — Luna isn't cheaper per-token thangpt-5.4-nano). OpenAI's realtime voice models (GPT-Live-1) are a separate Realtime API and out of scope forOpenAIProvider. - Anthropic
effortparameter (output_config.effort—"low"/"medium"/"high"/"xhigh"/"max") is now wired up inAnthropicProvider._build_effortfor 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) — previouslyThinkingConfig.effortwas read byOpenAIProvider/GoogleProviderbut silently ignored byAnthropicProvider. 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 forLLMEngine(model, thinking=ThinkingConfig(enabled=True, effort="low"))and works on both providers since both readThinkingConfig.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 thetestandallextras 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_paramsnow triesgriffefirst (whenlazybridge[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. Newtests/unit/test_docstring_multiline_params.pycovers 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 whenTool.wrap/.build()receives no explicitdescription=). Wrapped lines within that paragraph are collapsed to single spaces. A well-formed Google-style docstring (one-line summary, blank line, thenArgs:/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_hashalready 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¶
Plancrash-resume now feeds the first resumed step the previous step's output, not the plan's start input._run_implseededprev_envfrom the start envelope, so a resumed step whose implicitfrom_previnput came from a step that completed before the crash silently received the original task instead of the upstream result — a plainPlan(Step(a), Step(b), …)chain with nowrites=lost the link across the checkpoint boundary (no error, wrong data). It now reconstructsprev_envfrom the restored step-result history. Steps that pass data viawrites=/kv, or that referencefrom_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'shistoryand beforenext_stepadvances, so the resultingfailedcheckpoint'snext_stepstill points at that just-completed step. Reconstructingprev_envunconditionally fromhistory[-1]there fed the retried step its own recorded output instead of the upstream step's. Now only steps back tohistory[-2]when resuming afailed/cancelledcheckpoint whosenext_stepequals the last history entry (the retry-of-a-succeeded-step signature); a cleanrunningself-loop is unaffected. Regression test added:test_e2e_resume_after_post_success_routing_failure_uses_upstream. - Windows-only test failures resolved. e2e /
run_manytests now close theirStore(viacontextlib.closing) so the temp SQLite file unlocks beforeTemporaryDirectorycleanup, andtest_ext_core_boundaryreads package sources as UTF-8. Five tests that failed only on Windows (PermissionError [WinError 32]/UnicodeDecodeErrorunder cp1252) now pass; CI on Linux was already green.
Testing¶
ReplanEnginefailure→resume→recover contract pinned. Failure injection (a transient worker, a parallel-band branch, or the planner itself failing) confirmed the engine already recovers correctly viaresume=True— re-running only the failed round, never a completed one — and that an errorEnvelopefrom 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:LLMGuardresisted a deliberate tag-injection smuggling attempt,GuardChaincorrectly threads modifications and blocks across chained guards, andPlancheckpoint/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.iscoroutinefunctionreplaced withinspect.iscoroutinefunctioninlazybridge/tools.py,lazybridge/guardrails.py, andlazybridge/engines/plan/_plan.py. Theasyncioversion 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 calledverify_with_retry, which called back into the fullagent.run()— which re-entered the same verify branch, recursing untilRecursionErroron anyAgent(verify=...)invocation. The suite never caught it because the judge tests driveverify_with_retrywith mock agents that don't re-enter.verify_with_retrynow accepts arun=override andAgent._run_bodypasses 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+ feedbackcontext, silently dropping the original env'simages,audio, andpayload— 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):
Agent(output=Model)that exhaustsmax_output_retriesnow returnsok=Falsewitherror.type == "OutputValidationError"instead ofok=Truewith the raw string. Check the error type; the raw payload is preserved on the envelope.Agent.stream(timeout=)is now a total-stream deadline (was per-chunk); the stream also enforces the output guard on completion and fails over tofallback=when the engine dies before the first token.lazybridge.Task→lazybridge.ReplanTask(deprecated alias warns, removed in 1.0).CacheConfig→ import fromlazybridge.core.types.PROVIDER_ALIASES→ callLLMEngine.provider_aliases().- Routing (
routes=/routes_by=) into aparallel=Truestep is now aPlanCompileError(it silently lost the rejoin jump at runtime). Plan.to_dict()is now v2 (recordsStep.output/Step.inputby name); pass the types in thefrom_dictregistry ({"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.pyinstalls a MagicMockopenaistub before any lazybridge import (the per-filesys.modulesstubs came too late once the provider module was imported, leaving_openai = Nonebound forever — 15 failures), andtest_store_encryption.py's skip guard no longer crashes collection on hosts wherecryptography's Rust extension panics (the pyo3PanicExceptionis matched by name; the old import-then-catch bound()into the except clause and raisedTypeError).
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. $defsname collisions fail loud on flatten._flatten_refsmerged same-named definitions last-write-wins, silently inlining the wrong shape when two distinct models shared a class name. Conflicting shapes now raiseValueErrorwith a rename hint (identical duplicates still merge).- LLMGuard async timeout enforced once.
_ajudge's sync-callable fallback routed through_judge, which enforcestimeoutagain 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 outerasyncio.wait_for. DeduplicateGuardis silent by default.verbosedefaulted toTrueand wrote to stdout viaprint()from library code; it now defaults toFalseand routes throughlogging(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 withpyproject.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), byconclude(), or by an unexpected exception escaped past the per-step checkpointing and left the key stuck inclaimed/runningunder a deadrun_uid— every subsequenton_concurrent="fail"run raisedConcurrentPlanRunErroruntil the key was manually cleared. Both engines now write a best-effort terminal checkpoint on non-local exits (cancelledon cancellation,doneon conclude — with the conclude answer cached for Replan — andfailedon unexpected exceptions), and_claim_checkpointtreatscancelledas claimable by fresh runs and adoptable byresume=True(which continues from the recordednext_step/ round). - Plan serialization carries
Step.output/Step.input(to_dict v2).to_dict()silently dropped both, sofrom_dict()rebuilt every step withoutput=str: structured steps degraded to raw strings and anyroutes_by=plan failed recompilation (PlanCompileError) after a round-trip. Types are now recorded by name and rebound via thefrom_dictregistry ("type:<Name>"or bare"<Name>"key) with a loudKeyErrorwhen missing. v1 payloads still load (missing keys default tostr/Any). - Routing into a parallel band is now a compile error.
routes=/routes_by=targeting aparallel=Truestep compiled cleanly but the band dispatcher advances linearly and never consults theafter_branchesrejoin state — the jump was silently lost and a stale entry leaked.PlanCompilernow rejects it with a fix hint (wrap the parallel work in anAgent(engine=Plan(...))branch step). - Per-step checkpoint cost no longer quadratic.
_save_checkpointre-serialized the entire growing history (model_dumpof every envelope) on every step. The serialized history is now maintained incrementally alongside the in-memory one. EventLog.flush()afterclose()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.EncryptedStoreAdaptercontext manager + keyed bulk-read errors. The adapter now implements__enter__/__exit__(parity with the baseStore), andread_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__stampedengine._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):Agentbinds its name around eachengine.run()/engine.stream()call and all engines (LLM, Plan, Replan, Supervisor, Human) resolve the context-bound name first. The_agent_nameattribute is kept as a fallback for code that drives an engine directly. - Structured output on the streaming path.
LLMEngine._stream_turnrebuilt theCompletionResponsefrom stream chunks without ever readingchunk.parsed/chunk.validation_error/chunk.validated, so any streamed run withoutput=Modelsilently degraded to a raw string (and burned the output-validation retries). The reconstructed response now carries all three fields through. Agent.stream()pipeline parity withrun(). Streaming applied only the input guard. Now: the output guard runs on the accumulated text when the stream completes (a block raisesValueErrorand 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); andtimeout=is now a total-stream deadline, the same meaning it has inrun()(it was per-chunk, i.e. effectively unbounded — stall detection between chunks remainsLLMEngine(stream_idle_timeout=)).verify=andoutput=validation remain run()-only and are documented as such.- Executor retry classification. The last-resort string scan in
_is_retryablecould retry permanent client errors whose message merely contained "timeout" / "connection" (e.g. a 400invalid '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_retrynow amends the last turn via the newMemory.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
UserWarningexplaining where the child's events flow and how to choose explicitly.
Refactoring (no behaviour change)¶
_plan.pysplit 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, asCheckpointMixin),_resolve.py(sentinel resolution + band aggregation,ResolveMixin), and_fanout.py(run_many/arun_many,FanoutMixin).Planinherits all three, so every method keeps its original name and signature. The 170-line inline parallel-band block in_run_implis now the_run_parallel_bandmethod with an explicit state contract.- One provider registry. The provider-name → class map lived in two
hand-maintained copies (
Executor._resolve_providerandLLMEngine._provider_class) that had already drifted on thelitellmspecial case. Both now resolve throughlazybridge.core.providers._registry.provider_class(lazy per-provider import preserved). Executorretry loops deduplicated into a shared_next_retry_delay(classification + backoff + warning), keeping sync and async semantics in lock-step._parse_data_urishared byImageContent.from_data_uriandAudioContent.from_data_uri(byte-identical copies collapsed)._safe_register_agent/_safe_register_tool_edgecollapsed onto a single warn-on-failure_safe_graph_callhelper.
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→ renamedReplanTask(the bare name was too generic for a top-level export and collided with user code).lazybridge.engines.replan.Taskremains a plain alias;lazybridge.Taskstill resolves but emits aDeprecationWarning.CacheConfig→ import fromlazybridge.core.types(it is engine configuration, not primary API). Top-level access warns.PROVIDER_ALIASES→ callLLMEngine.provider_aliases(). The constant was an import-time snapshot that silently diverged from the live registry afterregister_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_atdocumented as informational metadata. The Store has no TTL/expiry mechanism and never consultswritten_at; the docstring now says so explicitly (agent_idcarries provenance stamps such as Plan'splan-run:<run_uid>).- BREAKING — exhausted output validation is now an error, not a silent
success.
Agent(output=Model)used to returnok=Truewith the raw, unvalidated string payload aftermax_output_retriesfailed correction attempts — callers could not distinguish "validated" from "gave up", andresult.payload.fieldblew up downstream. The final envelope now carrieserror.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 onok=Trueshould check forerror.type == "OutputValidationError"and readresult.payload(still the raw model output) from the error envelope. - Deduplicated the encrypted-Store CAS equality check.
EncryptedStoreAdapter.compare_and_swapcompared the decrypted plaintext againstexpectedthrough a private_plain_eqthat was a byte-for-byte copy oflazybridge.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_eqdirectly, keeping its CAS rule in lock-step withStore.compare_and_swapand 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__, andPlan.run_many. Only theAgentcopy handled nest_asyncio (Jupyter/Spyder) and suppressed httpx/anyio "Event loop is closed" GC noise; onlyMemoryhonoured a timeout; the others skipped the in-loop nest_asyncio branch — so the same call took a worker-thread path in a notebook whileAgent.__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__, andPlan.run_manynow take the in-loop path under nest_asyncio and suppress loop-closed cleanup noise;Memory's summariser path now also propagates the caller'scontextvars(OTel spans / request-ids / structured-logging context) into the worker loop.Memory'stimeoutcontract 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 itstimeoutis applied withasyncio.wait_forinside 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
PlanandReplanEngine.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) thatLLMEngine.run()adopts. Parallel bands are suppressed (no token interleaving), nested agents-as-tools stay silent (theLLMEngine.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. NewPlan(stream_buffer=N)bounds the token queue exactly likeLLMEngine(stream_buffer=N). TheReplanEngineplanner's structuredPlanRoundoutput 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 andLLMEngine.stream's loop), so a consumer that disconnects while the bounded queue is full can no longer deadlockaclose()onsink.put(None). - Checkpoint-epoch stamping +
Plan.store_write_is_current(). Every durableStep(writes=...)Store write (sequential, parallel band, and resume replay) now carriesagent_id="plan-run:<run_uid>", matching therun_uidpersisted in the checkpoint snapshot. Sidecar consumers reading the Store out-of-band can callPlan.store_write_is_current(store, checkpoint_key=..., key=...)to detect the documented crash-window staleness mechanically instead of diffing against the checkpointkvby hand. - Example-rot guard.
tests/unit/test_examples_integrity.pychecks that every file underexamples/compiles and that everylazybridgeimport 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 toPlanfor pipelines whose shape is decided at runtime by a planner agent. The planner is a tool in the parentAgent'stool_map(built withoutput=PlanRound, located byplanner_name); it is called every round and the tasks it emits are dispatched viatool.run(**task.kwargs)— agents, plain functions, and pool routes alike, with no special-casing. Tasks flaggedparallel=Truerun concurrently viaasyncio.gather. Passstore=+checkpoint_key=to persist round state after every round andresume=Trueto continue from the last checkpoint; same compare-and-swap single-writer semantics asPlan(ConcurrentPlanRunErroron a contended key).max_rounds(default20) caps the loop; adone=Trueround must carry afinal_answer.PlanRoundandTask(lazybridge.engines.replan, re-exported fromlazybridge) — the planner's structured output schema.PlanRoundcarriesreasoning, a list ofTask, adoneflag, and the terminalfinal_answer;Taskis one tool call (tool+kwargs+parallel). Added to the public API snapshot. New guide:docs/guides/full/replan-engine.md; reference entries indocs/reference/engines.md.
Fixed¶
ext.plannersDAG builder —add_stepnow exposesfrom_parallel_all. The incremental builder tool'stask_kindannotation wasLiteral["literal", "from_prev", "from_step", "from_parallel"], omittingfrom_parallel_alleven thoughStepSpec, the step validator,_resolve_task, andPLANNER_GUIDANCEall 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 theadd_stepLiteral 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 withprefixvia 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 usingstr.startswith. Passprefix=None(default) orprefix=""to iterate the full store. TheEncryptedStoreAdapterdelegates 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-levelstartswithfilter.
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→ uselazytools.connectors.mcp(pip install 'lazytoolkit[mcp]').lazybridge.ext.gateway→ uselazytools.connectors.gateway.lazybridge.external_tools.read_docs→ uselazytools.documents(pip install 'lazytoolkit[docs]').lazybridge.external_tools.doc_skills→ uselazytools.skills.- The whole
lazybridge.external_toolsnamespace 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_builderextracted to the sibling reposelvaz/LazyReport(PyPI:lazybridge-reports). Every import pathlazybridge.external_tools.report_builder.*is gone — replace withlazybridge_reports.*after installing the new package. The five optional extras[report],[report-charts],[report-citations],[report-fallback],[pdf]are gone fromlazybridge'spyproject.toml; their replacements live aslazybridge-reports[charts,citations,fallback,pdf]. No shim — there is no fallback import path.- New optional extra:
[encryption]→cryptography>=42,<46for the newlazybridge.store.encryption.EncryptedStoreAdapter(Fernet at-rest encryption forStorevalues, withMultiFernetkey 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 canonicalAgent(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 aflat kwarg > config object > defaultprecedence merge that required a private_UNSETsentinel on every kwarg. The precedence game and_UNSETare gone with them.CacheConfigis kept — it carries real semantic value (enabled/ttl) consumed byLLMEngine. mode="auto"graceful-fallback ladder removed fromTool/tool(). Both default tomode="signature"now; passmode="hybrid"ormode="llm"plusschema_llm=to opt into LLM-driven schema generation. Passingmode="auto"raisesValueError._ParallelAgentrenamed toParallelAgentand its return contract changed.ParallelAgent.__call__andrun()now return ONEEnvelopewhose.payloadis the labelled-text join across every branch (with transitive cost rollup inmetadata.nested_*and first-error short-circuit in.error) — restoring the framework invariant that every Agent returnsEnvelope. For typed per-branchlist[Envelope], call the newrun_branches(task)async helper.wrap_toolmade private (_wrap_tool). Use the publictool(...)factory instead.LLMEngine(tool_choice="parallel")raisesValueError(was a 0.7-eraDeprecationWarningthat 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 frompyproject.toml(unused).
Breaking — silent fallbacks → explicit errors¶
from_step("typo")/from_parallel("typo")no longer warn + fall back to the start envelope — they raisePlanRuntimeErrorwith the actual step history and a typo-aware "Did you mean?" hint.LLMEnginewith an unknown model raisesValueErrorinstead of silently routing to Anthropic. SetLLMEngine.set_default_provider("...")for the legacy behaviour.- MCP server emitting a non-
objectinputSchemaraisesValueError(was silently coerced to an empty parameter set). Envelope.text()on a non-JSON-serialisable payload raisesTypeError(wasstr(payload)fallback).Memory(summarizer_timeout < 5.0)warns at construction (timeout almost always fires for typical summariser shapes).BaseProvider._resolve_modelraisesValueErrorwhen nothing is configured (was empty string fallback).Agent(engine=<non-LLM>)requires an explicitname=(was silently named"agent"and collided when used as a tool).Agent(model=..., engine=<non-LLM>)raisesValueError(was silently dropped).
Added¶
lazybridge.matrix— declarative provider-capability lookup.provider_capabilities()andnative_tool_support()aggregate the per-providerClassVarflags into a single typed dict for docs / introspection / capability-aware error messages.BaseProvidercapabilityClassVarflags:supports_streaming/supports_structured_output/supports_thinking. Subclasses override when a backend doesn't.-
Standard error-message format — every
PlanCompileError/PlanRuntimeError/UnsupportedFeatureErrorfollows::Step '
' (# ) — = . Defined steps: [...].> Did you mean ' '? (when applicable) Fix: . -
OTel
gen_ai.agent.nesting_levelattribute on agent spans — dashboards filtering on=0get clean root-only views. Session.emitexporter-exception dedup — warn-once per(exporter class, exception class)pair with a count of suppressed identical failures.test_public_api_snapshot.py— pinslazybridge.__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_costacceptscached_input_tokens=0and 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_memoryunknown-target errors. - B7: Plan serialisation raises
ValueErroron unknown sentinelkind(was silently fallback tofrom_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
kvso 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.mdrewritten for the 0.7.9 surface; canonical Plan block, default-model fallback advice viafrom_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.mdrewritten — onlyCacheConfigremains documented; rest of file explains the deletion.docs/reference/engines.mdadds thethinking=knob.docs/reference/providers.mdadds thestop_reasonnormalisation table (GoogleMAX_TOKENSmapping fix).docs/guides/mid/parallel.mdrewritten 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 inexamples/daily_news_report.py.
Tooling¶
lazybridge.skill_docs._buildrecovered + wired into thetest.ymltypecheck job (drift gate that asserts every public symbol in__all__is mentioned in SKILL.md).docs.ymlassertssite/llms.txtandsite/llms-full.txtare non-empty (≥1 KB) aftermkdocs build --strict.- Top-level
permissions: contents: readadded to.github/workflows/test.yml(least-privilege baseline).
[Unreleased] — 2026-05-05 — bug-fix and routing hardening¶
Breaking¶
LLMEngine.stream_idle_timeoutdefault changed fromNoneto90.0s. 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 raisesStreamStallErrorafter 90 s of inter-chunk silence, which is large enough to absorb provider-side thinking pauses on Opus / Gemini Pro. Passstream_idle_timeout=Noneto opt out explicitly — a one-shotUserWarningflags 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 alongsideroutes/routes_byto route to exactly one branch and skip all sibling steps; execution resumes at the named step after the branch completes. Withoutafter_branches, routing is a detour (linear progression resumes from the routed-to step's declared position). SeeStepdocstring for the full example.
Hardening¶
MCP.stdio()now warns on unrestricted tool surface. When bothallow=anddeny=are omitted a one-shotUserWarningreminds 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 likeMCP.http); silence the warning by passingallow=["*"]once you've audited the surface, or restrict it with a glob. SeeSECURITY.mdfor the full guidance.- CI now enforces skill-doc drift.
test.ymlrunspython -m lazybridge.skill_docs._build --checkin 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 indocs/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.httpand the audit-on-init warning onMCP.stdio. - API reference —
_UNSETsentinels in generated signatures now have an explicit explanation at the top of the reference page, including theLLMEngine.stream_idle_timeoutsemantics. - Skill docs reference grouping fixed —
from_parallel_all,GuardError, andEventExporterare 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— theexcept sqlite3.Errorclause incompare_and_swapdid not catchjson.loadsfailures on corrupt rows, leavingBEGIN IMMEDIATEopen on the thread-local connection and poisoning every subsequent call on that thread. Widened toexcept (sqlite3.Error, ValueError)(json.JSONDecodeErroris aValueErrorsubclass). - Store in-memory: mutable references break CAS invariants —
read()andwrite()returned / stored the live Python object, so callers mutating the result could silently alter the stored value and defeatcompare_and_swap. Both paths now go through_deep_copy_safe(deep-copy with a non-copyable fallback). LLMEnginetool_choice="any"infinite loop — after the model satisfied the "must call at least one tool" contract on the first turn,provider_tcstayed"any", forcing every subsequent turn to also call a tool. The loop never exited untilmax_turnswas exhausted. Fixed:provider_tcis reset to"auto"immediately after the first tool-result turn.LLMEnginetool_choice="any"sent as literal tool name — Anthropic and OpenAI rejecttool_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_stepas the failing step rather than the band-start, soresume=Truere-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_retrywere called with the livememoryobject, so each failed attempt added a garbage turn to the agent's conversation history. Retries now passmemory=None; only the final accepted result reaches memory. Agent.stream(): input guard bypassed —acheck_inputwas not called in the streaming path, soguard=had no effect when callers usedasync for token in agent.stream(...). The guard check now runs before the first token is emitted.
Bug Fixes (High)¶
LLMGuardsync path:timeout=ignored —_judgeinvokedself._agent(prompt)directly on the calling thread without any deadline. Fixed by running the judge in a daemon thread and callingthread.join(timeout=self._timeout).- Memory
strategy="sliding"silently disabled withmax_tokens=None—_plan_compressiongated all compression onbool(self.max_tokens), sostrategy="sliding"with the defaultmax_tokens=Nonenever triggered. Fixed: only"auto"requires a token budget;"sliding"and"summary"compress by turn count independently ofmax_tokens. - Memory:
_overflow_warnedflag 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()treated0/Falseas empty — onlyNoneand zero-length containers (str,list,dict,tuple,set,frozenset) are now considered empty. Numerics and booleans are always non-empty; useeq(0)/eq(False)for those cases. - Google provider:
finish_reasonnever mapped to"max_tokens"— theMAX_TOKENSstop reason from the Google API was not translated, so callers inspectingstop_reasonalways sawNoneinstead of"max_tokens". - Tool schema:
model_dump()destroyed Pydantic model args —Tool.definition()calledmodel_dump()on the entire arguments dict, collapsing Pydantic model instances to plain dicts before the schema was built. Fixed:getattrper 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. Thestable / beta / alpha / domain4-tier taxonomy is removed. Per-module__stability__and__lazybridge_min__markers are removed; onlylazybridge.__stability__ = "alpha"remains. - Namespace reorganization — domain modules moved out of
lazybridge.ext.*: lazybridge.ext.read_docs→lazybridge.external_tools.read_docslazybridge.ext.doc_skills→lazybridge.external_tools.doc_skillslazybridge.ext.data_downloader→lazybridge.external_tools.data_downloaderlazybridge.ext.stat_runtime→lazybridge.external_tools.stat_runtimelazybridge.ext.report_builder→lazybridge.external_tools.report_builderlazybridge.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 (returnslist[Tool]).
Removed¶
lazybridge.ext.veoandlazybridge.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) andlazybridge.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 frompyproject.toml.
Tool factory shape (breaking)¶
- All surviving
external_tools/*factories standardize ondef 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, ...)(wasskill_tool(skill_dir, ...) -> Tool)skill_builder_tools(*, ...)(wasskill_builder_tool(...) -> Tool)read_docs_tools(*, base_dir=None)— new factory wrappingread_folder_docsas a Tool.
Boundary¶
- New CI check (
tools/check_ext_imports.py):ext/andexternal_tools/may only import from publiclazybridge.*, never from internallazybridge.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).
OTelExporternow emitsgen_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 viatool_use_idso N parallel invocations of the same tool no longer collide. Span registry is per-instance so multipleOTelExporters 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 blocksadd()— the keyword fallback runs and a one-shot warning surfaces. Compression also computes the summary OUTSIDEMemory._lock, so concurrentadd()calls progress while a slow summariser is in flight.- Per-event-type back-pressure in
EventLog(audit H-A). New defaulton_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 viaSession(critical_events=...)."block"and"drop"policies remain available unchanged. - MCP
_tools_cacheTTL + invalidation (audit H-E). Newcache_tools_ttlparameter onMCPServer/MCP.stdio/MCP.http(default 60 s) and aninvalidate_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_loadshelpers (OpenAI, LiteLLM) now tag the raw argument blob with_parse_erroron JSON decode failure or non-object payload.LLMEngine._exec_toolshort-circuits on the tag and emits a structuredTOOL_ERROR(type: "ToolArgumentParseError",parse_error,raw_arguments) instead of letting the tool fail downstream with a misleading "missing required field" message. Tool events also carrytool_use_idfor 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 undertests/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 onv*.*.*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, plusHumanEngine/SupervisorEnginefromlazybridge.ext.hil). One call surface (agent.run/agent(...)/agent.stream) regardless of engine.- Tool-is-Tool: plain functions,
Agentinstances,Agent.as_tool()results, and tool providers (e.g. an MCP server) all plug intotools=[...]with the same dispatch contract. Nested agents inherit the outer session for end-to-end observability. - Compile-time DAG validation:
PlanCompilerrejects duplicate step names, forward references, brokenfrom_step/from_parallel/from_parallel_allsentinels, and parallel-band misuse before any LLM call. - Crash-resume:
Plancheckpoints toStoreviacompare_and_swap. Concurrent runs on the samecheckpoint_keycollide at claim time;resume=Trueadopts 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=SomeBaseModelflips the engine into schema-validated mode with retry-with-feedback up tomax_output_retries. - Sources / Memory / Store / Session / Guard: composable
observability and state primitives.
Sessionis 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'scontextvarscontext 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
OpenAIProvidersubclass that targets the local server (http://localhost:1234/v1by 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) andSupervisorEngine(full REPL with tool calls, retry-with-feedback, store access).ext.planners—make_planner(DAG builder) andmake_blackboard_planner(todo-list).ext.mcp— Model Context Protocol integration at the tool boundary.MCP.stdio/MCP.http/MCP.from_transportbuild a tool provider that drops intoAgent(tools=[server]).ext.otel— OpenTelemetry exporter forSession.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 viapython -m lazybridge.skill_docs._build.