Skip to content

Changelog

v0.28.0 (2026-09-02): a sub-agent roster can give one specialist a longer leash, and the loop says when it is the one talking

Tools

  • New A sub-agent spec may declare its own max_iterations. SUBAGENT_SPEC_KEYS gains a sixth entry, so an agent_types roster can hand one specialist a different tool-loop cap from its peers: a search-heavy researcher that spends every round calling tools gets the rounds it needs while the rest of the roster stays on the factory default. Before this, max_iterations was a single value per spawn tool, so the only way to raise one worker's cap was to raise everyone's. The key is the second, after "model", whose missing-key behavior is to fall back rather than inherit nothing: a spec omitting it gets whatever make_subagent_tool(max_iterations=...) was called with, which is what lets a roster share one default without writing the key into every spec. "thinking" and "generate_kwargs" cannot work that way, having no factory-level tier beneath them. Validated at factory-call time, alongside max_depth < 1 and the closed key set: an int below 1 is a loop that makes no model call, and bool is an int subclass, so an unchecked True would have been accepted as a cap of 1. A nested spawn tool (max_depth > 1) still carries the factory's tier rather than the spec that built it, since it serves the whole roster again. Both surfaces: make_subagent_tool and make_async_subagent_tool. Tests: tests/test_subagent_tools.py, tests/test_aio_subagent_tools.py. How-to: docs/how-to/spawn-subagents.md.

Models

  • New A CONTINUING stream phase, carrying the prompt the loop injected. The agent loop puts words in the model's mouth twice: a nudge after an empty turn, and the forced tools-disabled wrap-up at the round cap. Until now a streamed consumer saw only chunk.iteration rise, which is also what an ordinary tool round does, so the two injected rounds were indistinguishable from each other and from a tool round. content is {"kind", "prompt"}, where kind is the same PROVENANCE_CONTINUATION / PROVENANCE_FINAL_ANSWER the injected message is tagged with, and prompt is the string actually sent (a configured continuation_prompt / final_answer_prompt reports itself). StreamChunk.is_continuing() dispatches on it.
  • Change StreamingContentType gains a member, so an exhaustive dispatch over it needs a new arm. A match chunk.phase (or an if / elif chain) written to cover every phase will now fall through on a CONTINUING chunk, and a consumer that treats an unrecognized phase as text prints the repr of a dict. Only the streamed agent-loop drivers emit it, so a plain client.chat(stream=True) never sees one, and there is no way to filter it out of a streamed Agent.run: include=[...] is a chat() / generate() argument, and this chunk comes from the loop above them. A consumer that does not want it drops it on chunk.phase.

Agents

  • New Both streamed drivers emit that chunk immediately before an injected round's own chunks, from one factory on the shared loop base, so the sync and async drivers cannot drift. tests/test_loop_iteration_parity.py pins that they announce the same boundaries.

Console output

  • New pretty_print() shows the injected round as a [continuing: <kind>] <prompt> line. Not gated by show_thinking / show_tools: those control volume, and this is one line per injected round.

Channels

  • New CLIChannel writes the same [continuing: <kind>] <prompt> line, and WebChannel sends a {"type": "loop", "reason", "text"} frame, where reason is the chunk's kind and text is the prompt. Both are unconditional rather than behind stream_thinking / stream_tools, for the reason above.

Examples

  • Fixed examples/personal-assistant's page renders the loop frame instead of dropping it on the floor of a frame chain with no default arm, and resets its answer bubble when one arrives, so an injected round's tokens start a new bubble rather than growing the previous round's.

Documentation

  • New docs/explanation/streamchunk-model.md argues why this needs a phase of its own rather than a reading of chunk.iteration; docs/reference/stream-phases.md carries the phase, the predicate, and a sequences row for an injected round, and now says what the iteration counter does not tell you; docs/how-to/build-personal-assistant.md says why this frame is not behind the channel flags. CLAUDE.md's frame protocol, relay list, enum members, and dispatch helpers all name it, the frame protocol most of all: that enumeration is the contract a page is written against.

v0.27.0 (2026-08-28): every provider says how a turn ended, and a refusal that no longer reads as an empty answer

Models

  • Fixed Every provider now reports how a turn ended, so TruncatedTurnError actually fires. client.last_output_truncated has been part of the client contract for a while, and the agent loop turns it into a typed error naming the remedy (_ToolLoop._raise_if_truncated, 8 call sites) -- but only Ollama ever set it. Its default of False means "nobody looked", not "not truncated", so on Anthropic, OpenAI, Gemini, every local OpenAI-compatible server, llama.cpp and HuggingFace the check silently no-opped and a turn cut off before it produced an answer came back as a bare empty string. The consumer was fine; the producer was missing on four of five provider families, and the tests only ever set the flag by hand on a mock. _ChatStateMixin._record_stop_reason is now the single seam, mandatory on every request path for the same reason _record_request is, and enforced the same way -- by test_every_client_records_how_the_turn_ended, parametrized over every installed client x chat/generate x stream/non-stream, rather than by convention. Anthropic reads response.stop_reason, the OpenAI-compatible family reads choices[0].finish_reason (streaming splits that from usage across different chunks, so each is recorded only when present), llama.cpp indexes the same field out of its dicts, and HuggingFace infers it from generated length against max_new_tokens since Transformers reports no reason at all. New: client.last_stop_reason carries the provider's own word for it, so the raw signal is inspectable rather than only its derived bool. None means the provider said nothing, which is deliberately distinct from "finished normally".

  • New ModelRefusalError, raised when a model's safety classifiers decline a request. Anthropic returns that as HTTP 200 with stop_reason: "refusal" and no text block, so AIMU returned an empty string and said nothing; inside an agent loop it read as a degenerate turn, so the continuation nudge fired and the run spent its iterations being refused again. Opus 5 and Fable 5 ship the classifiers that produce it, and Anthropic notes that benign security and life-sciences work trips them, so it is reachable in ordinary use. The error carries the stop_details category and explanation when present, and is raised after usage is recorded so a caller can still see what the declined attempt cost. Being its own class it composes with FallbackClient(retry_on=(ModelRefusalError,)) -- routing to another model is the vendor's own recommended recovery. Exported from aimu, aimu.models and aimu.aio alongside ModelConnectionError and ContextOverflowError. Tests: tests/test_stop_reason_api.py, tests/test_request_legibility.py.

v0.26.0 (2026-08-28): the Claude 5 line on anthropic 1.x, reasoning effort that lands, and an output cap that stops truncating

Agents

  • Fixed The forced wrap-up no longer strands an un-dispatched tool call, which made every Anthropic run that reached its round cap mid-search fail with a 400 instead of an answer. chat() is single-turn: it records the assistant turn's tool_calls and leaves execution to the loop. So exhausting max_iterations on a turn that requested tools left those calls unanswered, and the wrap-up's prompt is a user message, appended directly on top of them. That transcript is invalid for every tool-calling provider: Anthropic rejects it with messages.N: `tool_use` ids were found without `tool_result` blocks immediately after, and OpenAI requires an assistant tool_calls message be followed by tool messages. All four wrap-up sites were affected (run and run_streamed, sync and async), so the bug was invisible to the surface-parity tests: both surfaces were equally wrong. Search-heavy sub-agents are what hit it in practice, being the shape of run that spends every round calling tools and so the one still holding a pending call when the cap lands. _BaseToolLoop._settle_pending_tools now closes the stranded calls with results stating they were not executed and why, logged at WARNING rather than emitted as an event (ToolDenied is the nearest fit in shape but means an approval policy refused the call, and a sink cannot tell a real denial from a synthesized one if both use it). The result says the call did not run rather than standing in for a plausible one, since the model is about to write a final answer and a blank or invented result would let it report as gathered what was never fetched. The fix is in the transcript, not in a provider's adapter, because the transcript is what is persisted, exported, and resumed: patching one converter at request time would leave the same latent defect for every other provider. Tests: tests/test_pending_tools_wrap_up.py (all four surfaces, plus the empty-terminal-turn case that must not gain a duplicate result).

Models

  • Changed Opus 4.6 and Sonnet 4.6 now use adaptive thinking, not the deprecated {"type": "enabled", "budget_tokens": N} shape. Anthropic deprecated manual extended thinking on that line, says it "will be removed in a future release", and reports adaptive as measurably better; AIMU was sending the shape with the removal date on it by default. What changes on the wire, for Sonnet 4.6:
Call Before After
thinking=None (the default) {"type": "enabled", "budget_tokens": 8000} {"type": "adaptive", "display": "summarized"}
thinking="low" budget_tokens: 2048 adaptive + output_config {"effort": "low"}
thinking="high" budget_tokens: 16000 adaptive + output_config {"effort": "high"}
thinking=False no thinking key {"type": "disabled"}
thinking=False, temperature=0.2 extra_body {"temperature": 0.2} unchanged
generate_kwargs={"thinking_budget_tokens": N} honoured dropped, with a warning

The first row is the behavioral change worth reading twice: the model now decides how much to think rather than always reasoning up to 8000 tokens. "high" maps to "high" rather than xhigh on this line, which has no xhigh -- the mapping rule is "the rung below max", and here that is high. Haiku 4.5 is untouched and is now the only ThinkingStyle.ENABLED member, so it is also the only place thinking_budget_tokens still does anything; elsewhere it is dropped with a warning naming thinking= as the replacement, rather than silently as before. One internal fact had to be split for this: _route_sampling_kwargs used "is adaptive" as a proxy for "rejects temperature/top_p/top_k", and the 4.6 line is adaptive and accepts sampling parameters. The proxy is now an explicit _REJECTS_SAMPLING set, so moving a model's request shape can no longer silently change which sampling parameters it sends. Design: docs/superpowers/specs/2026-08-28-anthropic-4-6-adaptive-design.md. Tests: tests/test_thinking_control.py.

  • New thinking="low"/"medium"/"high" now steers Anthropic's adaptive models, via output_config.effort. Five of the eight AnthropicModel members declared thinking_levels=True and then dropped the level with a warning -- the declaration existed only to stop the generic resolver stripping it before the provider saw it, and the provider discarded it anyway. So AIMU's one portable reasoning dial did nothing on its newest and most expensive Anthropic models, which are exactly the ones where effort has the largest cost and quality swing. low/medium map straight through; high maps to the vendor's xhigh, because high is what Anthropic already uses when the parameter is unset and sending it would be a silent no-op (the same reasoning as QWEN_REASONING_EFFORT). max stays out of reach of the three-value portable vocabulary; pass generate_kwargs={"output_config": {"effort": "max"}} for it, which has always worked and now takes precedence over a derived level. The vocabulary is declared per model as ModelSpec.effort_levels, a tuple rather than a bool because effort support does not follow ThinkingStyle: xhigh exists on Opus 4.7+ but not on the 4.6 line, and Haiku 4.5 rejects effort outright. Following the under-declare rule from the original thinking-control design, only the five adaptive members declare one; Opus 4.6, Sonnet 4.6 and Haiku 4.5 keep budget_tokens unchanged, so this release is additive. One guard comes with it: Opus 5 rejects thinking: {"type": "disabled"} combined with xhigh or max effort, validated independently on every request. AIMU cannot build that pair itself, but a caller passing output_config through generate_kwargs can, so the disable path lowers such an effort to high and warns rather than reversing the disable -- silently re-enabling reasoning the caller turned off is invisible in the response and shows up only on the bill. Design: docs/superpowers/specs/2026-08-28-anthropic-effort-control-design.md. Tests: tests/test_thinking_control.py, tests/test_models_api.py.

  • Changed The tier-1 max_tokens fallback is no longer 1024. It is the weakest of the four generation-kwarg tiers -- a model card or either caller tier still overrides it -- so what it has to get right is the value nobody sets, and 1024 was low enough to truncate an ordinary answer: a silent, mid-sentence failure whose only symptom is a retry. Two values now, in one place (CLOUD_MAX_TOKENS = 16000 and LOCAL_MAX_TOKENS = 4096 in aimu/models/_internal/generate_kwargs.py), because the two deployments fail differently. A cloud endpoint stops at EOS and bills per token, so it can afford the vendors' documented non-streaming guidance; a local server spends wall-clock on every token it is allowed, and a quantized model that never emits EOS spends all of them. Anthropic, OpenAI and Gemini take the cloud cap; llama.cpp, the local OpenAI-compat servers and HuggingFace (already at 4096, for this exact reason) take the local one. Ollama is unchanged -- it declares no tier-1 fallbacks at all and falls through to the server's own defaults. Both constants are shared by import rather than restated per client, so the families and the sync/async surfaces cannot drift. The sharpest effect is on Anthropic's thinking models, where the old default was worse than it looked: a default Sonnet 4.6 turn resolved to max_tokens=9024 against a budget_tokens of 8000, leaving ~1024 tokens for the answer after a full thinking budget. It now resolves to 16000, so thinking and the answer get about half each. Tests: tests/test_generate_kwargs_merge.py (per-family caps, the cloud/local split across both surfaces, and that the cap is still overridable in both directions -- a caller asking for a smaller budget must win).

  • Changed The anthropic extra now requires SDK 1.x (anthropic>=1,<2, resolved 1.2.0). The upgrade itself is small -- AIMU used none of the removed Text Completions API, no with_raw_response, no output_format dicts, no Bedrock client, and already required Python 3.11 -- but one removal bites: temperature, top_p and top_k are gone from the messages.create() / .stream() signatures, so passing one is a TypeError raised before any request is made. Two AIMU paths still carried one: a thinking-capable model called with thinking=False, and every structured-output call, which had temperature=1 forced into it by _rewrite_generate_kwargs and never stripped (the structured path routes around _thinking_kwargs). The sampling decision now lives in one hook, _route_sampling_kwargs, reached from _rewrite_generate_kwargs so it runs on every request path: all three keys are dropped when thinking is in effect (the API fixes temperature at 1 there) or the model is ADAPTIVE (Opus 4.7+/Sonnet 5/Fable 5 reject them outright), and otherwise moved into extra_body, which is merged into the request JSON as-is. So temperature=0.2 still reaches Opus 4.6 / Sonnet 4.6 / Haiku 4.5, and ANTHROPIC_GENERATE_KWARGS still declares all three supported; only the transport changed. One behavior change beyond the fix: top_k used to survive alongside extended thinking (only temperature and top_p were stripped) and is now dropped with the other two, matching Anthropic's documented restriction. httpx2 (the SDK's new HTTP layer, the maintained fork of httpx by its original author, published by Pydantic at github.com/pydantic/httpx2) arrives transitively; the only direct use is in tests/test_context_overflow_providers.py, whose Anthropic section builds httpx2 request and response objects while its OpenAI section stays on httpx, since the openai SDK has not moved. New guard: tests/test_anthropic_sdk_contract.py binds the payloads AIMU builds -- every model x every thinking= value x both request paths -- against the installed SDK's real signature. Every other Anthropic test monkeypatches messages.create, which is exactly why this removal could have shipped green.

  • New AnthropicModel members CLAUDE_OPUS_5 (claude-opus-5) and CLAUDE_SONNET_5 (claude-sonnet-5), both tools=True, thinking=True, vision=True, structured_output=True and both ThinkingStyle.ADAPTIVE. Addressable as aimu.client("anthropic:claude-opus-5"). claude-mythos-5 is deliberately absent: it is invitation-only, and the catalog is curated to models a caller can actually reach.

  • Fixed thinking=False now really turns thinking off on Anthropic's adaptive models. AIMU disabled reasoning by omitting the thinking parameter, which is correct for the ENABLED-style models but not for the 5-series: Opus 5 and Sonnet 5 run adaptive thinking when the parameter is absent, so thinking=False would have quietly bought reasoning tokens on the two models this release adds. The adaptive path (now _adaptive_thinking_kwargs, shared by the sync and async clients) sends an explicit {"type": "disabled"} instead. Two consequences worth naming: temperature/top_p/top_k are stripped on the adaptive models whether or not the request asks them to think (they are rejected outright there, and this client's own DEFAULT_GENERATE_KWARGS supplies a temperature, so a thinking=False call to Opus 4.7/4.8 was already 400ing before this release); and CLAUDE_FABLE_5 now declares thinking_optional=False, since it always reasons and 400s on an explicit disable -- so thinking=False there warns at the resolver and never reaches the wire, the same warn-and-continue path as any other model that cannot honour the request. Tests: tests/test_thinking_control.py, tests/test_models_api.py.

  • Fixed The Anthropic adapter keeps prose a model emitted alongside a tool call. _openai_messages_to_anthropic took its "tool_calls" in msg branch and built only tool_use blocks, never reading msg["content"] -- which _append_assistant_tool_calls stores deliberately, because a single generation can carry both. Every later request in the conversation therefore dropped the model's own stated reason for the call, silently and with nothing raised. The branch now emits a leading text block when that content is non-blank, and still omits it when blank, since the API rejects an empty text block. Anthropic was the only provider affected, and that is now checked rather than asserted: the providers that forward OpenAI-shaped messages (openai_compat and everything built on it, including Gemini via Google's compatible endpoint, plus Ollama, whose adapter only rewrites vision blocks) carry the prose for free, and only a provider that re-formats messages can drop it. Tests: tests/test_request_legibility.py::test_prose_beside_a_tool_call_reaches_the_wire, over every installed client on both surfaces and both chat paths, so a provider added later is covered without a list to remember; tests/test_pending_tools_wrap_up.py::test_anthropic_keeps_assistant_prose_emitted_alongside_tool_calls and ::test_anthropic_omits_an_empty_text_block_when_a_tool_call_carries_no_prose for the adapter's two branches directly.

Memory

  • Fixed A persistent DocumentStore now reads through to its directory, so a document copied into persist_path by a user or another process is visible immediately. The store used to walk the directory exactly once, in __init__, and treat the resulting dict as the source of truth with disk as a write-through mirror that was never re-read. A file placed in the directory after construction was therefore invisible to read, list_paths, and search_full_text for the lifetime of the process, with nothing raised or logged to say so: list_documents reported "No documents stored." while the file sat in the directory. The aimu.memory.document_mcp server made this worse by building its store at import time, so the snapshot was taken before the server could ever be handed anything. Because the store advertises a path-addressed, filesystem-backed namespace, "drop a file in the folder" is the mental model it invites, and it silently did not work. The fix removes the second source of truth rather than adding a refresh() a host would have to remember to call (and an agent could never trigger): with persist_path set, read opens the file, and list_paths / search_full_text scan the directory, on every call. The in-memory dict remains the backing store only for the ephemeral (persist_path=None) mode, where it is the sole source of truth. document_mcp needed no change. Tests: tests/test_document_store.py::test_list_paths_sees_file_copied_in_after_construction and the six read-through cases beside it.
  • Changed An unreadable file in a persistent store's directory is logged at WARNING naming the file, instead of being skipped in silence (except (UnicodeDecodeError, OSError): continue). Documents are UTF-8 text; a PDF or binary artifact sharing the directory still cannot become one, but a file that will never appear now says so, per the "failures are apparent" principle. The warning fires once per path per store instance, since the scan now runs on every list/search call. Dot-files and dot-directories (.DS_Store, .git/, .gitkeep) are excluded silently instead: they are not documents anyone placed there, and warning about them on every scan would be noise. Tests: tests/test_document_store.py::test_unreadable_file_is_skipped_with_a_warning, ::test_dot_files_are_ignored_without_warning.

Tools

  • Changed list_documents reports files the store could not read. The store skips anything that is not UTF-8 text, which is correct, but the agent had no way to know it had happened: a research paper dropped in as a PDF produced the same "No documents stored." as an empty directory, and the model would tell the user there was nothing there. The tool now appends a note naming the unreadable files and saying to export them to Markdown, backed by a new DocumentStore.unreadable_paths(). Its docstring also now tells the model to list before concluding a document is absent, since the store is a directory a user can add files to directly. Making the failure apparent in the log (see Memory, above) is not enough when the only party who can act on it is on the other side of the model. Tests: tests/test_memory_tools.py::test_list_documents_reports_unreadable_files.
  • Changed search_documents returns an excerpt per match, not the whole document. It joined the full text of up to n_results (default 5) documents into one tool result, so a single query against a corpus of papers could put hundreds of KB into the context window in one call. Matches longer than 1500 characters are now truncated with their total size and a pointer to read_document for the full text, and the docstring directs the model to read_document when it needs to analyze rather than locate. Short documents are unchanged. Tests: tests/test_memory_tools.py::test_search_documents_truncates_a_long_document.
  • Changed read_file's line cap is 2000 (was 200), and its truncation notice names the total. 200 lines is under a fifth of a typical research paper or design doc, so the common case was a model reading an introduction and synthesizing from it with no sign anything was missing -- a confident, plausible, wrong answer, which is worse than an error. The notice now reads truncated: showing N of M lines; call read_file with a larger max_lines to read the rest, so the model can see the size of the gap and how to close it, and the docstring says not to draw conclusions from a truncated read. Tests: tests/test_tools.py::test_read_file_reports_how_much_it_truncated, ::test_read_file_default_reads_a_document_sized_file_whole.

v0.25.0 (2026-08-26): a spawned sub-agent that can report to its caller's sink

Tools

  • New make_subagent_tool and make_async_subagent_tool take an events= parameter. A spawn tool builds a fresh client per call so that concurrent spawns share no state, and that same freshness is what keeps it outside the client family a run's scoped events= override reaches (see docs/how-to/observe-a-run.md's "shared client under concurrent workers" section): a spawned child's model turns went unreported to any caller-attached sink, with nothing raised anywhere to say so. A caller measuring what a whole turn cost (in tokens, in requests, in wall time) silently under-counted every delegation. The new parameter is forwarded onto each spawned child's Agent.events field, which is what run() already falls back to when its own per-call events= is left at None (both surfaces already had this field; this factory just wasn't setting it), so no change was needed to either factory's dispatch call site, and it covers the streamed/observed spawn path on the async surface too, since that path calls run() internally. It also threads through the recursive make_async_subagent_tool / make_subagent_tool call each factory makes for its own nested spawn tool (the max_depth mechanism), so a worker that spawns its own worker reports to the same sink its caller does. Opt-in: omitting it leaves a spawned child reporting nowhere, exactly as before. Tests: tests/test_aio_subagent_tools.py::test_spawn_forwards_events_to_the_child_agents_sink / test_spawn_without_events_reports_nowhere (sync mirrors in tests/test_subagent_tools.py).

v0.24.0 (2026-08-25): a command tool that shares the supervisor execute_python earned

Tools

  • New run_command, in builtin.compute. Runs a command line through /bin/sh -c (COMSPEC /c on Windows) and returns its exit code with stdout and stderr labelled separately. run_command(command, cwd="", timeout=30), with the timeout clamped to 600 seconds. It shares execute_python's supervision rather than reimplementing it. The new _run_supervised owns what was expensive to get right in that tool: output captured to files rather than pipes, so a backgrounded grandchild holding the inherited fds cannot keep a finished run hostage; start_new_session=True with a process-group SIGKILL on timeout and on any interruption, so a cancelled turn cannot orphan a child or a grandchild; capped reads, so an output bomb cannot inflate the parent's RSS. execute_python's behavior is unchanged, and its existing tests are the proof. Three deliberate divergences, where a snippet and a command genuinely differ. A nonzero exit returns the output instead of "Error: subprocess exited abnormally", because pytest exits 1 with the answer on stdout and git diff --exit-code exits 1 to mean "yes, there is a diff". A timed-out command returns whatever it printed first, which the file-based capture already has on disk, because a test run killed at its ceiling has usually already printed the failure that mattered. And there is no memory cap: 512 MB of address space breaks compilers and test suites, and imposing one on a shell child needs preexec_fn, which is neither portable nor safe alongside threads. The same disclosure, sharpened. This is isolation and not containment, with one fewer step in between than execute_python has: the command reaches credentials sitting in files (.env, ~/.aws/credentials) as the calling user, and process signalling is unconfined, so kill -9 against the host process is one command away. Gate it with tool_approval for untrusted callers and reach for a container when you need real containment.
  • New make_command_tool(env_passthrough=...). The child's environment is an allowlist (execute_python's, plus SHELL, TERM, TZ, USER, LOGNAME), so no API key in this process reaches a command and run_command("env") cannot lift a credential into a model's context. That default also makes gh, ssh, and git push over ssh fail, which is a policy rather than a bug, so this factory lets a host name the variables it wants admitted. An allowlist rather than a denylist of secret-looking names, and a name that is unset produces no key rather than an empty-string value, since SSH_AUTH_SOCK="" misleads ssh in a way a missing variable does not. Tests: tests/test_code_execution.py.

Internal

  • Change _kill_execute_python_process_group is now _kill_process_group, since both tools launch through the shared supervisor. Private, so no caller outside this package is affected.

v0.23.0 (2026-08-25): channels that relay the whole loop by default, reasoning that is read, and tool calls that survive the round trip

Renumbered from v0.22.1: the channel rename below is a breaking change, which cannot ride a patch release. Breaking changes are free before 1.0 and expensive after, so it ships now.

Channels

  • Change (breaking) show_thinking / show_tools are now stream_thinking / stream_tools, and both default to True (aio.CLIChannel, aio.WebChannel). The rename fixes a name that claimed a decision the channel does not make. A channel is a transport: the flag decides whether THINKING and TOOL_CALLING content reaches the far side, not whether the far side draws it. A page that received a thinking frame is free to collapse it, fold it behind a header, or drop it, and routinely does; a channel that never emitted one has taken that choice away from the front end and left it nothing to decide with. show_ read as a display policy set two layers below the display. The default flip follows from the same reading. Off-by-default meant the legible path was the one you had to know two keyword arguments existed to ask for, while the default hid the two chunk phases that show what the model actually did. Making a model's real behavior legible is the reason the library exists, so relaying those phases is now what a bare channel does, and suppressing them is the opt-in. The break is loud, not silent. Both are keyword-only, so a caller still passing show_thinking= / show_tools= gets a TypeError at construction naming the argument. A subclass that reads self.show_thinking while streaming (an overridden send, a replay path) raises AttributeError on the first stream rather than quietly treating the frames as suppressed. Rename the arguments at the construction site; a caller that was passing True can drop them. aimu.pretty_print deliberately keeps show_thinking / show_tools. It writes to a TextIO and so is the display: there is no transport and no front end downstream of it to decide anything, which is exactly the case the channel flags do not describe. The two spellings now mark a real difference rather than an inconsistency. examples/personal-assistant drops AssistantConfig.show_thinking / show_tools and constructs both channels bare, since those fields only ever carried the value that is now the default. Tests: tests/test_aio_channels.py and tests/test_aio_web_channel.py (the two "by default" tests invert: the frames are asserted present with no arguments, and absent only when the flags are explicitly off), plus examples/personal-assistant/tests/test_web_assistant.py.

Models

  • Fix Reasoning is no longer dropped when the server names the field reasoning (_reasoning_text in aimu.models.providers._thinking, now read by every OpenAI-compatible client and by llama.cpp). A server that strips <think> tags itself returns the reasoning in a field of its own, and the family has never agreed on that field's name: llama-server, vLLM and SGLang use reasoning_content (the DeepSeek spelling), while mlx-lm and OpenRouter use reasoning. AIMU read only the first, so against an mlx-lm server every reasoning block vanished: client.last_thinking stayed empty, no THINKING chunks were yielded, no "thinking" key reached the assistant message, and nothing was raised anywhere. Measured against mlx-community/Qwen3.8-27B-8bit on mlx-lm 0.31.3, with the reasoning plainly present on the wire, a streamed turn produced {'GENERATING': 3} and no thinking at all; it now produces {'THINKING': 14, 'GENERATING': 9} for the same question. The inline-tag fallback could not have covered this, which is why it went unnoticed: that path only fires when the tags are still in content, and a server doing its own parsing has already removed them. Both spellings are extra fields on an otherwise standard message, so neither is detectable any way but by name. Twelve call sites now share one helper: _iter_stream, _generate, _chat and _chat_streamed in each of providers/openai_compat.py, aio/providers/openai_compat.py, and providers/llamacpp.py (async llama.cpp wraps the sync client, so there is no fourth set). reasoning_content wins when a server sends both, so a gateway echoing the same text into an alias cannot have it counted twice. A non-text value under either name (a summary object, a list of parts) is ignored rather than surfaced, since callers concatenate the result and a dict would raise mid-stream. The helper takes a mapping as well as an attribute-bearing object, because llama.cpp hands back plain dicts where the OpenAI SDK hands back models. Tests: tests/test_models_api.py and tests/test_aio_models_api.py (fifteen new, one per call site plus the helper's precedence, non-text and mapping rules). The four existing reasoning_content tests are unchanged and still pass, so the DeepSeek spelling keeps its behavior exactly.

  • Fix A tool call's arguments now reach an OpenAI-compatible server as a JSON string rather than a dict (encode_tool_call_arguments in aimu.models._internal.message_meta, called at all four openai-compat request sites: _chat and _chat_streamed in each of providers/openai_compat.py and aio/providers/openai_compat.py). self.messages stores a tool call's arguments parsed, which is what Ollama's and Anthropic's request paths want on the wire and what a UI or a transcript reads, but OpenAI's schema types tool_calls[].function.arguments as a string and nothing re-serialized it at the request boundary. Sending the dict is not merely off-schema, it raises server-side: a server rendering its chat template calls json.loads on that field, so mlx-lm answered 404 {'error': 'the JSON object must be str, bytes or bytearray, not dict'}. Measured against mlx-community/Qwen3.8-27B-8bit on mlx-lm, an aio.Agent holding a single tool failed every run; it now completes, as does a parent agent delegating through spawn_subagent to a sub-agent that calls web_search. The failure landed one request later than its cause, which is what made it look like a routing problem rather than a payload problem. The round that requests a tool succeeds, and only the next request, the one carrying the tool result back, is rejected; so the first tool call of a conversation appeared to work and the turn died immediately after it. The status code compounds it: a 404 whose body is a Python TypeError message names neither the field nor the message that carried it. Ollama and Anthropic were never affected, since both want the parsed object, and that is precisely why this could not be fixed by changing what the store holds. Every provider sharing the OpenAI-format request paths was affected, cloud OpenAI and Gemini included (AsyncOpenAIClient and AsyncGeminiClient subclass the compat client and override neither method), so the fix reaches them too; it is verified against a local mlx-lm server, not against those APIs. Deliberately not folded into strip_inert_keys, which Ollama's request path also calls: a dict is correct there, so encoding for every caller would have moved the bug rather than fixed it. A value that is already a string passes through, so history a caller hand-built in OpenAI's own shape is not double-encoded, and a tool call carrying no arguments key does not acquire one. encode_tool_call_arguments is exported alongside its sibling for the reason its sibling is: a caller writing a custom OpenAI-format request path needs it, and tests/test_public_surface.py records the name entering the surface as a deliberate edit. Tests: tests/test_request_legibility.py (five new: one per request path, plus a guard that Ollama's path still receives the parsed dict, which is the half of this reasoning a later refactor is most likely to lose).

Documentation

  • Docs docs/explanation/thinking-and-context.md names both spellings where it lists how each provider family separates reasoning from the answer. The page previously described only two routes (inline <think> tags, and a dedicated field on the native-thinking providers), which left the case above undocumented as well as unhandled.

  • Docs docs/explanation/architecture.md and docs/how-to/observe-a-run.md name the tool-call arguments adaptation where each already lists what changes between chat() and the wire. The architecture page's list of request-time adaptations covered only providers needing a different wire format (Anthropic blocks, Ollama image fields, HF PIL images), which read as though the OpenAI-format paths adapt nothing.

v0.22.0 (2026-08-24): one meaning for max_iterations, and a sink that survives concurrency

Two pre-1.0 breaking corrections, both deferred out of v0.21.0 and recorded in that release's ledger. Breaking changes are free before 1.0 and expensive after, so they ship now rather than after the stability promise.

Agents

  • Change (breaking) An async agent now makes the same number of model calls as a sync one for the same max_iterations. The two async tool-loop drivers let the bounded loop run one round past the cap, so aio.Agent(max_iterations=3) made four model calls where Agent(max_iterations=3) made three -- at every value, measured. CLAUDE.md states the async surface mirrors the sync one one-for-one, so this was a documented contract that did not hold, and it cost a real extra model call on every async run. Async is aligned down to sync's convention, not the reverse: that is the direction that makes the name true, and it reduces cost rather than raising it. An async agent that was completing a multi-step plan on its final licensed round may now reach the forced wrap-up instead; raise max_iterations by one to restore the old behavior exactly. The definition, now written down where the parameter is set (Agent.max_iterations on both surfaces, both run() docstrings, CLAUDE.md): max_iterations is the maximum number of model calls the bounded loop itself makes. The final_answer_prompt wrap-up is one additional call beyond that cap -- the single deliberate exception, since its whole purpose is to guarantee a final answer when the cap is reached.

Observability

  • Change (breaking) A scoped event sink no longer leaks between agents sharing one client. v0.21.0 delivered a per-run sink by swapping client.events for the duration of the run, and shipped with a known gap pinned by test_KNOWN_GAP_parallel_from_client_shared_events_sink_drops_events: Parallel.from_client -- the documented quick-start -- builds every worker Agent over one shared client and runs them concurrently, so their swap/restore sequences interleaved. Reproduced: 11 of an expected 12 events, and a worker's RunFinished arriving before its own ModelTurnFinished. The scoped override now lives in a module-level contextvars.ContextVar read through one _effective_sink() helper at every emit site, so each OS thread and each asyncio Task carries its own. The known-gap test is replaced by one asserting the isolation holds, on both surfaces. The override is scoped to the client it was installed for, not to "any client called while the scope is open." A ContextVar read unconditionally would trade the race for a worse ambient leak -- most visibly on make_subagent_tool, whose sub-agent turns would land in the parent's sink stamped with the parent's name. The payload is therefore (sink, family), where the family is the client plus whatever it delegates state to or from (ModelClient's _client, FallbackClient's clients, _AgenticView's _inner_client, _AsyncInProcessClient's _sync), matched by identity. A different client called from inside a tool never receives the run's sink, on either surface and regardless of concurrent_tool_calls; it falls back to its own self.events, so give it an explicit events= if it should report anywhere. What is still surface-dependent, stated rather than papered over: a tool that calls a client already in the run's family (reusing ctx.deps, say) sees the override under sequential dispatch on both surfaces, and under concurrent_tool_calls=True only on async -- asyncio.TaskGroup.create_task copies the current context, ThreadPoolExecutor.submit does not. One consequence worth knowing if you stream. The scope is held open across the run generator's yields, so it is torn down when that generator finishes, is closed, or is dropped. On the sync surface all three are immediate. On async, a generator dropped without aclose() leaves finalization to the event loop's asyncgen hook a few iterations later, and until then that run's sink is still the active one for its client family -- bounded and self-healing, never permanent. A consumer that abandons a streamed run (a UI stop button, say) should close it: await stream.aclose(), or async with contextlib.aclosing(...). Teardown deliberately does not reset a contextvars.Token: a token can only be reset in the context that created it, and the asyncgen finalizer runs in a different one -- which raised, and leaked the scope permanently. It also cannot be reset out of LIFO order without silently dropping every scope opened after it. The stack is torn down by flag instead, on an object every context shares, so nothing can fail and nothing is swallowed.

client.events set directly is unchanged: still durable, still shared by every caller of that client, by design. Only the scoped per-run override became per-context.

v0.21.0 (2026-08-24): a run you can see, and a context window you can manage

Observability

  • New aimu.events: a run event vocabulary, with zero cost until a sink is attached. A frozen-dataclass union -- RunStarted, ModelTurnStarted, RequestPrepared, ModelTurnFinished, ToolCalled, ToolDenied, ContextCompacted, RunFinished -- delivered to one EventSink (Callable[[RunEvent], None]) via emit(). A union rather than a Protocol of named methods, deliberately: a Protocol grows a method and every implementation is suddenly incomplete; a union grows a member and an existing sink just ignores it. The same reasoning that keeps self.messages a list[dict] rather than a Message class hierarchy keeps telemetry plain data too.
    import logging
    from aimu.events import log_events
    
    reply = aimu.chat("hi", model="ollama:qwen3:8b", events=log_events(logging.getLogger("aimu")))
    
    Turn events (ModelTurnStarted/RequestPrepared/ModelTurnFinished) fire from BaseModelClient.chat()/generate() themselves, so a bare aimu.chat(events=sink) is observable with no agent involved. Run and tool events (RunStarted/ToolCalled/ToolDenied/ContextCompacted/RunFinished) fire from the tool-loop engine, which stamps agent and iteration onto everything passing through so one sink attributes events correctly inside a nested workflow. Wired in: Agent(events=...) plus a per-run run(events=...) override; Chain.from_client(..., events=...), Router.from_client, Parallel.from_client, and EvaluatorOptimizer's equivalent; OrchestratorAgent._init_orchestrator(events=...) / assemble(..., events=...) and the three prebuilt orchestrators. emit() logs rather than propagates a sink's own exception -- observation must not change what it observes, the same contract SubagentObserver already followed. log_events(logger) is the sink the docs lead with: one line per event, the shortest path to the payoff. A sink must be thread-safe. With concurrent_tool_calls=True, ToolCalled/ToolDenied are emitted from worker threads (sync) or concurrent tasks (async) in nondeterministic order; a sink that appends to a plain list or accumulates per-tool state needs its own lock. Turn and run events are emitted from the calling thread/task and stay ordered.
  • New client.last_request and RequestPrepared show the payload as it actually left the process. Between a caller's chat() and the wire sit the four-tier generate_kwargs merge, the GENERATE_KWARG_SUPPORT renames and drops, thinking-effort resolution, strip_inert_keys, and per-provider format adaptation -- none of it previously visible at runtime, so a model's odd behavior and AIMU's own request-shaping were indistinguishable from the outside. Recorded through one _record_request seam every provider calls (guarded by tests/test_events.py::test_every_client_records_its_request, so a new provider can't skip it), this is what makes "when a model surprises you, the surprise should be the model's" a checkable claim rather than an aspiration. The payload is unredacted -- it is the request, including whatever the caller put in the conversation; a sink that ships events off the machine is the right place to filter, not this recording site.
  • Change (breaking) ContextCompacted.dropped no longer shares object identity with client.messages. The event now carries copies of the removed messages. A prior test asserted event.dropped[0] is old_messages[0] with a comment calling the aliasing deliberate; it wasn't safe to keep once compaction and turn-tagging could interleave -- a later in-place write (the provenance key an agent loop adds to a message after the fact) could otherwise mutate an event a sink had already received and logged. If anything correlated dropped messages by identity rather than content/equality, that comparison now fails; compare on content instead.
  • Known gap, pinned by test: an event sink attached to agents that share one model_client and run concurrently drops and misorders events -- Parallel.from_client builds every worker over one shared client, so it hits this. Give each worker its own client to avoid it. See test_KNOWN_GAP_parallel_from_client_shared_events_sink_drops_events.
  • Known gap: a client.chat(schema=...) call emits only RequestPrepared; turn events are not emitted on the structured-output path, since that path makes exactly one call and returns before ModelTurnStarted would fire. Inside an Agent.run(schema=...), the run is still bracketed by RunStarted/RunFinished.
  • Docs docs/how-to/observe-a-run.md plus reference pages for aimu.events (docs/reference/api/events.md). Sending events to an OpenTelemetry collector is documented as an adapter a caller writes over EventSink -- OTel is not a new AIMU dependency. notebooks/27-observing-runs.qmd is the runnable companion, since aimu.events was otherwise the one subsystem in the notebooks/ collection without a demo: log_events first (the fastest payoff), then the event vocabulary, client-level emission with no agent involved, client.last_request, agent and workflow wiring, a custom token-tallying sink, and an OpenTelemetry adapter labelled an example rather than a dependency -- plus both known gaps above, as callouts rather than omissions.

Context Management

  • New aimu.context: three plain functions over list[dict], plus an Agent field that runs one of them automatically. count_tokens(messages, counter=None), trim_messages(messages, max_tokens, ...), and summarize_messages(client, messages, ...) mirror aimu.rag's shape deliberately: conversation state is already a plain list a caller can print and edit, so the tools for reshaping it are plain functions, not a ContextPolicy class applied invisibly inside a client.
    from aimu.context import trim_messages
    agent = aimu.Agent(client, compaction=lambda msgs: trim_messages(msgs, max_tokens=4000))
    
    Agent(compaction=...) (plus a per-run run(compaction=...) override) calls the given callable with client.messages before each model turn and adopts its result if changed. The invariant that justifies all of this existing: compaction must never orphan a tool message from the assistant message carrying its tool_calls -- every provider rejects that shape, and it is exactly what a naive messages[-n:] slice produces. trim_messages and summarize_messages both treat a tool-call turn and its results as one indivisible group, extending a keep_last boundary outward to the group edge rather than splitting it. An applied compaction announces itself twice: a ContextCompacted event carrying the removed messages (see above), and a WARNING log for callers who attached no sink at all -- compaction rewriting history under a caller who isn't watching is exactly the kind of silent behavior this release's own theme argues against.
  • Known limitation, stated rather than hidden: count_tokens's default counter is an estimate (len(text) // 4 over each message JSON-serialized, AIMU's own inert bookkeeping keys stripped first), typically wrong by 20-30% for any specific model's real tokenizer. Exact counts exist only after the fact, via client.last_usage following a real call. Pass counter= with a real tokenizer when accuracy matters more than a zero-dependency default.
  • Docs docs/how-to/manage-context.md plus docs/reference/api/context.md. notebooks/28-managing-context.qmd is the runnable companion, for the same reason aimu.context needed one: count_tokens and its honesty caveat, trim_messages and the tool-pairing invariant, keep_last counting messages rather than exchanges, summarize_messages, Agent(compaction=...) announcing a drop twice, and ContextOverflowError with a recovery recipe.

Models

  • New ContextOverflowError is now portable across every text backend, not Ollama-only. Each backend's own overflow signal is mapped to the same exception, chained via raise ... from exc so the original cause survives: OpenAI-compat's machine-readable context_length_exceeded error code (matched on the code, not the message text or "it was a 400", so an unrelated bad request still propagates as itself); Anthropic's 400 "prompt is too long" (text-matched, since Anthropic's 400s carry no distinguishing code) and its separate RequestTooLargeError 413 (a sibling exception class, not a subclass of BadRequestError, so it needed its own catch); and a pre-flight token count against the model's own known window on the two in-process backends, HuggingFace (config.max_position_embeddings, checked against the tokenizer's exact rendered length) and llama-cpp (an approximation, since llama-cpp-python doesn't expose the exact chat-template-rendered count without reaching into internal chat-format machinery). Every message names the fix: shorten the conversation, advertise fewer tools, or compact history with aimu.context.trim_messages / summarize_messages.
  • New resolve_default_text_model is public (aimu.models, aimu.models.model_client, top-level aimu; credit to a sibling session's contribution, merged from e76f3c9). Its enum twin, resolve_default_text_model_enum, was already public, and its own docs pointed a reader at the string resolver for anything an enum can't carry -- an endpoint, ad-hoc flags -- but that resolver lived in aimu.models._internal.model_defaults and could not actually be imported. A host that builds a second client on the same default the first one resolved had no public way to ask what that default was: client.model is an enum, and an enum is exactly the form that can't hold an @base_url. A real instance of the gap: a host with AIMU_LANGUAGE_MODEL pointed at a remote Ollama server had its sub-agents rebuilt against localhost, silently, because the only public surface for "what did the default resolve to" dropped the endpoint on the way out.

Tools

  • Change (breaking) execute_python runs in a fresh subprocess. v0.19.0 corrected the false "sandboxed" claim on this tool across nine files but deliberately deferred the behavior change; this release makes it. What a subprocess buys is isolation, not containment: a hard timeout (an in-process hang could previously block the host process indefinitely), crash isolation (a crash or unhandled exit in the code brings down only the child), no mutation of this process's imports or global state, a memory cap enforced via RLIMIT_AS on Linux (best-effort on other POSIX platforms, absent on Windows, with a one-time WARNING logged rather than a silent no-op when the cap can't be applied), and -- specifically -- no access to this process's environment variables: ANTHROPIC_API_KEY and anything else in os.environ is invisible to the child, which starts from a minimal allowlisted environment instead. What did not change, and matters: this is not a filesystem or network sandbox. The child runs as the same OS user this process runs as, so it can read, write, and make requests exactly as this process can -- a .env file, ~/.aws/credentials, ~/.config/gh/hosts.yml, or any other on-disk credential is exactly as readable to the child as to the parent's own account. Treat any code reaching this tool as code you have chosen to run; gate untrusted callers with tool_approval, and reach for a real container when actual containment is required. What you must do: code that relied on execute_python sharing this process's memory, imports, or environment (deliberately or by accident) will behave differently now -- it runs isolated by design. execute_python_in_process(code) is the explicit, clearly-named opt-in that preserves the old in-process behavior (same restricted-builtins/import-allowlist accident guard, weaker isolation, no subprocess startup cost) for trusted code that needs it.

v0.20.1 (2026-08-23): a docs build that completes, and releases that publish themselves

Agents

  • Docs The skill-script input guidance names SkillAgent(script_env=...), the field v0.20.0 shipped without updating the prose around it. Two places routed a reader to build_skills_server(manager, env=...), and one of them (docs/how-to/build-personal-assistant.md) did so in a section whose own example constructs an aio.SkillAgent -- which builds that server internally, so the argument named there was unreachable from the setup being described. Corrected there and in notebooks/08-agent-skills.qmd, with build_skills_server(manager, env=...) kept for a host that builds the server itself; docs/how-to/use-skills.md now names how the environment arrives rather than only that a script can read one. Notebook 08 gains a runnable subsection: a script reading REPORT_DIR, called through a bare skills server (REPORT_DIR unset, the quiet failure the field prevents), then through env=, then through an agent carrying script_env=.

Documentation

  • Fix The docs build no longer aborts on a lazily-exported symbol. Every deploy since v0.19.0 failed with mkdocstrings: aimu.aio.MCPClient could not be found / Aborted with a BuildError!, so the published site had been three releases stale. Five packages resolve public names through a module-level __getattr__ (PEP 562) to keep an optional dependency's import cost off import aimu.*; griffe, which mkdocstrings uses, reads the source without importing it, so a name that only exists at runtime is invisible to it. Being listed in __all__ is not enough -- there is no assignment for a static reader to follow. Each package gains an if TYPE_CHECKING: block importing the names it lazily exports. This is the mirror image of the aimu.Agent bug v0.19.0 fixed, and safe for the opposite reason: there the name was importable only under TYPE_CHECKING, so it raised AttributeError at runtime; here the runtime path is the __getattr__ that was always there, and the block that never executes exists purely so a static reader can see the name too. Verified rather than assumed: no heavy module (torch, transformers, diffusers, fastmcp, llama_cpp, pandas) appears in sys.modules after an import, and import aimu.models / from aimu import aio stay at 0.26s / 0.82s. mkdocs aborts on the first bad reference, so CI only ever named one symbol. There were 24: aimu.models (17), aimu.prompts (4), aimu.tools (2), aimu.skills (1). All are fixed together rather than one red deploy at a time. aimu.models uses the X as X redundant-alias re-export form, since it builds __all__ dynamically inside its HAS_* guards -- invisible to ruff for the same reason the names were invisible to griffe.

Packaging

  • New Releases publish from CI on a version tag (.github/workflows/publish.yml). Pushing vX.Y.Z now builds, tests, and uploads; previously a release meant building locally and running twine upload against a token in ~/.pypirc, so shipping depended on one machine's credential. Two jobs: test installs [all,dev] and runs ruff plus the full suite, and publish needs it, so a red suite blocks the upload. Authentication is PyPI trusted publishing over OIDC -- no token is stored in the repository, and only the publish job holds id-token: write (the workflow's top-level default is no permissions at all). The tag is also asserted against pyproject's version, which catches a forgotten dev suffix before it becomes a version number that cannot be reused. The gate is ruff, not the test suite. It was designed to run the full suite first, and that is still the right shape -- but the suite does not currently survive a hosted Linux runner: it dies at the same test every time (tests/test_images_api.py, ~45% in) with exit code 143, SIGTERM, no assertion failure and no exit of its own. Something terminates the process. That reproduces across four runs and is unrelated to packaging, so blocking releases on it would mean not releasing at all. Two candidate causes were measured and ruled out: disk (the runner has 145 GB and was 28% used) and a dependency-resolution difference (fixed separately -- pip had been backtracking to deepeval 2.6.6, which imports langchain without declaring it, and since deepeval registers a pytest11 plugin that killed the suite at startup; CI now installs the committed uv.lock). So the tests are the open item, not the workflow. Running them on Linux at all is new information -- they have only ever run on macOS -- and the SIGTERM is worth understanding on its own merits, since it may say something about the suite rather than about CI. This is currently the only workflow that runs the tests, and only at release. A tests.yml on push and PR is the natural follow-up.

v0.20.0 (2026-08-22): catalog parity across every local runtime, capabilities stated once, and a remote Ollama

Models

  • New host= points the native Ollama clients at a remote server (OllamaClient, AsyncOllamaClient, OllamaEmbeddingClient; plus the string form "ollama:qwen3.5:9b@http://gpu-box:11434"). Reaching an Ollama box on the LAN previously meant the process-wide OLLAMA_HOST env var or nothing: the native clients built ollama.Client() with no host, and ollama:<id>@<endpoint> raised, because the endpoint suffix was accepted only by the OpenAI-compatible providers. One env var also cannot address two servers from one process.
    client = aimu.client("ollama:qwen3.5:9b", host="http://gpu-box:11434")
    client = aimu.client("ollama:qwen3.5:9b@http://gpu-box:11434")   # same thing, string form
    
    The kwarg is the ollama SDK's own spelling, forwarded verbatim (the rule timeout/max_retries already follow), so a bare host, host:port, and scheme://host:port all work. An unset host is omitted rather than passed as None, leaving the SDK's own OLLAMA_HOST-else-localhost resolution in charge. A /v1 suffix raises: the native API is served from the server root, so the OpenAI-compat habit would 404 at request time on .../v1/api/chat, and the message names ollama-openai as the provider that does want /v1.
  • Fix OllamaEmbeddingClient stops silently embedding against localhost, and gains timeout= on the way. It called module-level ollama.pull / ollama.embed, so it had no way to honour a host at all -- pairing a remote text client with a local embedder produced vectors from a different model than the one the caller thought they had chosen, with nothing to indicate it. It now holds an ollama.Client like the text clients do. Give it the same host=.
  • Change Accepting a remote endpoint and accepting an uncatalogued model id are now separate policies (_ENDPOINT_PROVIDERS and _ADHOC_PROVIDERS in aimu.models.model_client, split out of the single _BASE_URL_PROVIDERS). One set had been doing both jobs, so admitting ollama to the endpoint list would also have opened the ad-hoc form to it -- and Ollama's ids are registry tags whose capabilities AIMU knows, which is exactly the case the curated catalog exists for. ollama:some-unknown-tag still raises, with or without an endpoint or capability flags. The endpoint-to-kwarg mapping (base_url= for the OpenAI-compatible providers, host= for ollama) lives in one shared endpoint_kwargs() called by both the sync and async factories.
  • Fix The modality factories stop swallowing a client's own constructor kwargs into model_kwargs (ProviderEntry.direct_kwargs in aimu.models._internal.factory). All five bundled every kwarg into model_kwargs, which is right for a weight-loading client (device= belongs to from_pretrained) but wrong for a parameter the client declares itself: aimu.embedding_client("ollama:nomic-embed-text", host="gpu-box") accepted the kwarg and then quietly embedded against localhost. Each provider entry now names the params its client declares, and those are forwarded as real keyword arguments. Listing them in the table beats inspecting the signature: the split is visible where the providers are described, and a mismatch fails loudly instead of vanishing.
  • Fix AIMU_LANGUAGE_MODEL accepts the full model string, endpoint and flags included (resolve_default_text_model in aimu.models._internal.model_defaults). It validated the env var with resolve_model_string(), which reads only provider:model_id, so AIMU_LANGUAGE_MODEL=ollama:qwen3.5:9b@http://gpu-box:11434 died on Provider 'ollama' has no model id 'qwen3.5:9b@http://gpu-box:11434' -- the endpoint was reachable by an explicit aimu.client("...") argument but never by the env var. Validation now uses the extended resolve_model(), so anything the client factory accepts, the env var accepts. This affects ;<flags> ad-hoc ids and every @base_url provider, not just the ollama endpoint support new in this release.
    AIMU_LANGUAGE_MODEL=ollama:qwen3.5:9b@http://gpu-box:11434
    
    The two enum-returning resolvers, resolve_default_text_model_enum() and the public resolve_model_enum(), still refuse both extended forms, since a Model member names a catalogued id and can carry neither an endpoint nor ad-hoc capability flags. What changed is that they now refuse them by name and point at the string form, instead of reporting a catalogued id as unknown and printing a ~100-entry "Available:" list containing the very id you passed -- which sent the reader hunting for a typo that was not there while never naming the suffix that was. An unknown id is still an error with or without an endpoint: a curated-catalog provider does not become ad-hoc by gaining one. Tests: tests/test_default_model.py, tests/test_models_api.py. Third instance of one defect: resolve_model_string() standing in for resolve_model() on a path that later hands the string to a full parser. See the sub-agent fix under Tools below.
  • Change An uncatalogued modality id now points at the client instead of only listing the catalog (the shared resolve_model_string in aimu.models._internal.factory, so image, audio, speech, transcription, and embedding all get it). These resolvers return an enum member and match catalogued values exactly, but the concrete clients parse a string themselves, so ImageClient("hf:<any repo>") works where resolve_image_model_enum("hf:<any repo>") raised -- reporting the id as simply unknown, next to a catalog it was never going to be in. The message now names what is actually true and what to do:
    Provider 'hf' has no catalogued image model id 'some-org/not-in-the-catalog'. Available: [...].
    An uncatalogued id has no enum member to return, but may still be usable: pass the whole
    string to ImageClient instead.
    
    This is the modality-side match for the text resolve_model_enum() wording above. It is deliberately weaker: that one parses the string and can say which unrepresentable form it found (endpoint vs ad-hoc), while the modality path has no extended grammar to parse and cannot tell an ad-hoc id from a typo, so it offers both readings. Behavior is unchanged; every one of these calls raised before and raises now. Tests: tests/test_images_api.py, tests/test_embeddings_api.py.
  • Docs The local-discovery probes are documented as endpoint-blind. available_text_models(), the omitted-model default, and ambiguous-bare-name resolution all run before any client exists, so a per-client host= or base_url= is invisible to them: Ollama discovery reads OLLAMA_HOST (else 127.0.0.1:11434) and the OpenAI-compat probes try each provider's default base_url. Stated in the available_text_models / resolve_default_text_model docstrings, the _ollama_installed_names probe itself, and docs/how-to/switch-providers.md. Export OLLAMA_HOST when discovery should consider a remote server too.
  • Change Intrinsic model capabilities move out of nine per-provider catalogs into one shared MODEL_FACTS table (aimu/models/_catalog.py, private). tools/thinking/vision, thinking_levels/thinking_optional, and a card's generation_kwargs/nonthinking_generation_kwargs are properties of the weights, not of who serves them, so restating them per catalog was how they drifted (see the PHI_4_MINI rename below for a real instance this migration would have caught). Every local-runtime catalog (OllamaModel, HuggingFaceModel, LlamaCppModel, and the seven *OpenAIModel local-server catalogs) now declares a Wire(id) per member instead of a full ModelSpec(...); Wire resolves against MODEL_FACTS, keyed on the cross-provider enum-member name, at class-construction time. A serving path that cannot deliver an intrinsic capability the facts declare overrides it explicitly with a why= (e.g. Wire(id, why="no mmproj projector loaded by default", vision=False)) -- an override without why= raises at import time, so a silent capability mismatch is no longer possible to introduce by hand. Cloud catalogs (AnthropicModel, OpenAIModel, GeminiModel) are single-provider and unaffected; they still declare a bare ModelSpec(...) directly. See the PHI_4_MINI rename below for the characterization test this migration let retire, and the "Adding New Models" section in CLAUDE.md for the new two-step procedure (add the facts once, then wire each catalog that serves the model).
  • Change (breaking) Sampling profiles now propagate to catalogs that previously declared none of their own. MODEL_FACTS holds one generation_kwargs / nonthinking_generation_kwargs pair per model name, and every local-runtime catalog now resolves through it -- so a catalog that never wrote a card profile (every *OpenAIModel local-server catalog, for every thinking model they serve) now merges the same values a catalog that did declare one already used. This is the goal of the migration, not a side effect of it, but it changes the request every affected model sends, silently, and deserves calling out on its own: 58 members across nine catalogs gained a profile they did not have on the previous commit (74 individual field changes, since 16 of those gained both a thinking-mode and an instruct-mode profile). Counted by resolving every non-cloud catalog member at 2be71b6 and at this commit and diffing the two. The sharpest case: VLLMOpenAIModel.QWEN_3_5_9B (a member the parity fill below also adds new) draws its facts from a catalog that, like every OpenAI-compat local-server catalog, never declared a sampling profile at all -- so absent this migration it would merge to the library's own tier-1 fallback, {max_tokens: 1024, temperature: 0.1}, near-deterministic sampling nobody asked for. It now merges to
    {"max_tokens": 1024, "temperature": 1.0, "top_p": 0.95, "top_k": 20, "min_p": 0.0, "presence_penalty": 1.5, "repetition_penalty": 1.0}
    
    the card's own thinking-mode row, verified against merge_generate_kwargs() directly. Three existing OllamaModel members are affected the same way: QWEN_3_8B gains {temperature: 0.6, top_p: 0.95, top_k: 20, min_p: 0}, GPT_OSS_20B gains {temperature: 1.0, top_p: 1.0, top_k: 0}, and DEEPSEEK_R1_8B gains {temperature: 0.6} -- Ollama's native catalog had simply never transcribed these three cards' rows, so those models ran on the server's generic defaults until now. What you must do: if you pinned a client.default_generate_kwargs value specifically to counteract a model running on generic/tier-1 sampling, that override may now be redundant (harmless) or may now fight a card value it previously had nothing to override (also harmless, since caller tiers still win, but worth checking if you tuned it empirically). If you were relying on a specific local-server model's previous silence -- e.g. testing against exact deterministic output -- pin the values explicitly via generate_kwargs= or client.default_generate_kwargs rather than depending on the catalog omitting a profile.
  • New Every local-runtime catalog is filled to parity with what its runtime can actually serve, each id verified against the HuggingFace Hub (scripts/verify_model_builds.py) rather than guessed. Before this fill, a model available on Ollama or HuggingFace commonly had no entry at all on the OpenAI-compatible local servers, llama-cpp, or the MLX runtimes, even though the identical weights run there. Per-catalog member counts: | catalog | before | after | |---|---:|---:| | OllamaModel | 24 | 26 | | OllamaOpenAIModel | 16 | 26 | | VLLMOpenAIModel | 13 | 27 | | SGLangOpenAIModel | 12 | 26 | | HFOpenAIModel | 12 | 26 | | LlamaServerOpenAIModel | 12 | 26 | | LlamaCppModel | 8 | 26 | | LMStudioOpenAIModel | 15 | 95 | | OMLXOpenAIModel | 12 | 77 | The MLX-serving catalogs (OMLXOpenAIModel, and LMStudioOpenAIModel's MLX-engine ids) grew the most because each MLX quantization is a separate mlx-community repo -- and therefore a separate enum member -- rather than a single id the client resolves at load time the way an Ollama tag or llama-cpp's model_path= does. docs/reference/model-matrix.md documents the full set, including the handful of intentional per-server capability disagreements (e.g. GEMMA_3_12B's tools flag on Ollama, vision on the three GGUF-serving catalogs) as footnotes.
  • Docs docs/reference/model-matrix.md's tables are now generated, not hand-maintained (scripts/generate_model_matrix.py). The doc's own test docstring recorded two prior silent drifts -- a new catalog member with no row, and a corrected flag fixed in the enum but not the doc -- and the parity fill above added roughly 130 rows, past the point hand-maintenance could keep up. Run python scripts/generate_model_matrix.py --write after any catalog change; tests/test_docs_model_matrix.py::test_matrix_tables_match_the_generator fails the suite if the committed file drifts from the generator's output. The surrounding prose (the legend, the ThinkingStyle discussion, the footnote paragraphs) stays hand-written; only the marker-delimited tables regenerate.
  • Change (breaking) PHI_4_MINI is renamed PHI_4_MINI_3_8B on every catalog that carried it (LMStudioOpenAIModel, VLLMOpenAIModel, HFOpenAIModel, LlamaServerOpenAIModel, OllamaOpenAIModel, SGLangOpenAIModel, LlamaCppModel). It is the same weights as OllamaModel.PHI_4_MINI_3_8B and HuggingFaceModel.PHI_4_MINI_3_8B -- OllamaOpenAIModel.PHI_4_MINI even carried the identical id phi4-mini:3.8b -- so two names meant resolve_model_enum("PHI_4_MINI") and resolve_model_enum("PHI_4_MINI_3_8B") resolved to disjoint provider sets, and the duplicate had let the two entries drift to disagree on tool support. Resolved against the model card: microsoft/Phi-4-mini-instruct's chat template defines a <|tool|>{tools}<|/tool|> block for a system message carrying tools, and Ollama's own registry page for phi4-mini carries the "tools" capability badge, so the shared intrinsic fact is tools=True. OllamaModel.PHI_4_MINI_3_8B now carries it (its prior False was a stale entry, not a serving-path limitation). HuggingFaceModel.PHI_4_MINI_3_8B keeps tools=False, but now as an explicit, why=-documented override rather than an undocumented disagreement: the in-process HF client has no ToolCallFormat and no processor parse path for this model, so it genuinely cannot surface a tool call -- a real serving-path limitation, not a stale value. What you must do: replace PHI_4_MINI with PHI_4_MINI_3_8B in any code referencing the enum member by name on the seven catalogs listed above; the wire id each catalog resolves to is unchanged. This also retires tests/test_catalog_snapshot.py, the characterization test that pinned every catalog member's resolved spec while the shared-facts migration (moving intrinsic capabilities out of nine per-provider catalogs into one MODEL_FACTS table) proved itself behaviorally inert. This rename is the first intentional capability change since that migration landed, which is exactly where a characterization test's job ends -- one task earlier than originally planned, since the plan's own Task 7 was going to delete it before the catalogs gained any new members anyway.

Tools

  • Fix A sub-agent spawned from the async surface honours an @base_url model string (_fresh_async_subagent_client in aimu.aio.tools.builtin). It pre-resolved a string model through resolve_model_string, which reads only provider:model_id, so every spawn from a parent configured with an endpoint died on Provider 'ollama' has no model id 'qwen3.8:27b@http://gpu-box:11434' while the parent itself ran fine (AsyncModelClient parses the full grammar). Fixing the parse alone would not have been enough: the resolved enum was then handed to the constructor, and no enum can carry an endpoint or capability flags, so the sub-agent would have gone on talking to the provider default while its parent talked to the override. A string is now passed through to AsyncModelClient unresolved, and resolved locally only to answer the in-process-provider question. The sync twin (make_subagent_tool) never had the bug: it hands its string to ModelClient directly. This affects ;<flags> ad-hoc ids and every @base_url provider, not just the ollama endpoint support new in this release. Tests: tests/test_aio_subagent_tools.py. Still resolved-to-enum, deliberately unchanged: a spawn tool built from a live client rather than a string (make_async_subagent_tool(some_client)) normalizes to client.model, which drops the endpoint the same way. That path has no string to preserve.

Agents

  • New SkillAgent(script_env=...) hands host context to the skill scripts that agent runs (both the sync aimu.agents.skill_agent.SkillAgent and the async aimu.aio.SkillAgent). build_skills_server(manager, env=...) has carried a host environment since 0.14.1, but a SkillAgent builds that server itself, on first run and again in reload_skills(), so a host holding only the agent had no way to reach it. Anything a script cannot discover for itself (where to write output, which account to send from) therefore had to travel as a process-wide variable, which makes one agent's context every subprocess's context, or not travel at all: the script would run with the settings simply missing, report itself unconfigured, and raise nothing anywhere.
    agent = aio.SkillAgent(client, skill_manager=manager, script_env={"REPORT_DIR": "/srv/reports"})
    
    Merged over the inherited environment by run_script_file, like the build_skills_server argument it mirrors, so PATH survives. Default None leaves the previous behavior exactly. Both build sites take it, since reload_skills() rebuilding the server was the second place the environment could be dropped. Tests: tests/test_skills.py, tests/test_aio_skill_authoring.py.

v0.19.0 (2026-08-21): a fifth-of-a-second import, lighter installs, and a truthful sandbox claim

Models

  • Change import aimu drops from 8.5s / 8282 modules to ~0.2s / 415 modules (aimu.models, aimu.aio, aimu.tools). Measured on this machine across three runs: 0.31s / 0.21s / 0.15s, 415 modules loaded. The package used to import every provider SDK eagerly just to publish its symbols and compute its HAS_* flags -- sentence_transformers alone was 3.9s of the 8.5s, and a caller who only wanted aimu.chat(..., model="anthropic:...") paid for torch, transformers, diffusers, and everything else in the catalog before the first line of their own code ran. Providers are now described in tables of module and symbol names, not classes, and loaded on demand: the five modality factories (image, audio, speech, transcription, embedding), the text factory, aimu/models/__init__.py, aimu/aio/__init__.py, aimu/aio/_model_client.py, and aimu/tools/__init__.py all made this conversion. HAS_* flags now answer from importlib.util.find_spec instead of the success of a real import, and both aimu.aio and aimu.Agent -- previously imported eagerly at the top of aimu/__init__.py -- now resolve through a module-level __getattr__ on first touch. A follow-on fix in the same stream: resolve_model() and the OpenAI-compat _sync_compat_client() path were still routed through the eager _provider_registry(), so the first client() call reloaded everything the import itself had just avoided -- asking for anthropic:... pulled in torch, transformers, ollama, and llama_cpp regardless. Both now load only the named provider; the registry-search path (bare model names, which must scan every provider by contract) is unchanged. Tests: tests/test_import_weight.py (subprocess-isolated, since the pytest process itself has already imported torch via other test modules). import aimu.aio itself still cost 6.42s / 4375 modules after all of the above, because aimu/aio/__init__.py imported its five modality modules (.audio, .embedding, .image, .speech, .transcription) eagerly, and each of those still had its own module-level try/except ImportError around the real provider SDK -- so torch, diffusers, and soundfile all loaded before import aimu.aio returned, regardless of the lazy-symbol table sitting unused below them. Converted to the same installed() + import-on-demand shape as the rest of this entry (aimu/aio/audio.py, .embedding.py, .image.py, .speech.py, .transcription.py); two more eager loads that were dragging in fastmcp (and its own dependency tree -- mcp, jsonschema, rfc3987_syntax) regardless of whether MCP was ever touched were fixed alongside it: aio.MCPClient now resolves lazily off aio/__init__.py (mirroring the plain-lazy pattern aimu/tools/__init__.py already used for the sync MCPClient), and aimu.skills.build_skills_server now resolves lazily off aimu/skills/__init__.py (aio.SkillAgent pulls in SkillManager, which shares a package __init__.py with build_skills_server). import aimu.aio is now ~0.5s / ~1,100 modules.
  • Change (breaking) HAS_* now means "installed," not "imported cleanly." Checking find_spec instead of actually importing is what makes the flags cheap, but it changes what a True promises. A dependency that is present but broken (an ABI clash among onnxruntime/grpcio/protobuf, say) used to fail its import silently and leave the flag False, so AIMU treated that provider as simply not installed and any caller checking the flag skipped it without incident. The same broken install now reports True and raises at first real use instead. An absent dependency is unaffected -- it still yields None for the provider symbol -- only the installed-but-broken case moves from a quiet skip to a loud failure. What you must do: if any of your code relied on a HAS_* flag going False to paper over a provider whose install had quietly rotted, that no longer happens. Fix the install, or if you genuinely want to keep skipping that provider without repairing it, wrap the specific call site in its own try/except ImportError. The change is deliberate: reporting a broken dependency as merely "absent" hid a real failure behind a state that looks identical to "never installed," and a provider that used to work should not go on looking healthy once it can't.

Packaging

  • Change (breaking) chromadb and sqlalchemy move to new [memory] and [prompts] extras, both folded into [all]. pip install aimu no longer pulls onnxruntime, grpcio, opentelemetry, and posthog for a vector store most callers never touch. Core is now fastmcp, tinydb, requests, python-dotenv, pydantic>=2, plus Windows-only tzdata. nest_asyncio and watchdog were declared dependencies that nothing in the package imported; both are dropped outright. What you must do: if your code touches aimu.memory.SemanticMemoryStore, install aimu[memory]; if it touches aimu.prompts.catalog.PromptCatalog, install aimu[prompts]. Without the extra, importing either now raises ImportError naming exactly what to install. The guard checks the failing import's own module name (exc.name), not a bare except ImportError, so a chromadb or sqlalchemy that is installed but broken reports its own error instead of the useless "install the extra you already have." aimu.memory.DocumentStore -- a plain path-based store that never touches a vector database -- stays importable without [memory]; SemanticMemoryStore is resolved lazily inside aimu/memory/__init__.py so it no longer forces chromadb on everyone who imports the package. This bullet's claim -- that only code touching PromptCatalog needs [prompts] -- did not hold at first: aimu.prompts.__init__ still imported Prompt/PromptCatalog eagerly, and aimu.agents.workflows.plan_execute_evaluator imports aimu.prompts.tuners.scorers unconditionally, so a bare import aimu.agents (and therefore import aimu.aio, which imports aimu.agents) raised ImportError without sqlalchemy installed, regardless of whether PromptCatalog was ever touched. Fixed by moving Prompt/PromptCatalog into aimu/prompts/__init__.py's existing lazy table (the same mechanism the tuner classes already used for [tuning]), so the sentence above is now actually true. Guarded by a new parametrized test (tests/test_optional_extras.py::test_core_namespaces_import_without_optional_extras) that imports each of aimu, aimu.models, aimu.tools, aimu.agents, and aimu.aio with chromadb/sqlalchemy simulated absent. Tests: tests/test_optional_extras.py.

Tools

  • Change (breaking) execute_python stops claiming to be a sandbox (aimu.tools.builtin). Its docstring -- the text the model itself reads -- said "File system and subprocess access are not available." Both are one-liners: ().__class__.__mro__[-1].__subclasses__() reaches subprocess.Popen, and json.codecs.sys.modules["os"].getcwd() reaches the filesystem. The restricted builtins and import allowlist stop accidents, not a deliberate attempt, and the docs now say exactly that. Nine files carried the old claim and are corrected: CLAUDE.md, README.md, docs/how-to/build-personal-assistant.md, docs/reference/api/tools.md, examples/news-summarizer/news_summarizer.py, examples/personal-assistant/README.md, notebooks/07-agents.qmd, and notebooks/24-personal-assistants.qmd, alongside the tool itself. Three of those -- the personal-assistant how-to, its example README, and notebook 24 -- had been recommending execute_python as the safe option for untrusted input, which is precisely the one case it does not handle. What you must do: make_tools(python_sandbox=True) is now make_tools(allow_code_execution=True), with no back-compat alias, because the old name asserted the thing that was untrue. The tool's actual behavior is unchanged -- this release is truth-in-labelling, not a capability change. Real process isolation (timeout, scrubbed env, memory cap) is planned for v0.20. Tests: tests/test_tools.py.

Documentation

  • New The public surface is pinned by test (tests/test_public_surface.py). import aimu, aimu.models, aimu.agents, aimu.tools, and aimu.aio each get a captured __all__ baseline, asserted as REQUIRED ⊆ actual ⊆ REQUIRED ∪ CONDITIONAL rather than set equality -- aimu.models builds its __all__ from fifteen HAS_*-guarded blocks, and equality would bake one machine's installed extras into the test. A name entering or leaving the surface is now a deliberate edit to this file, not a side effect of some other change. It caught a real bug on its first run: "Agent" had been listed in aimu.__all__ since the aimu.agent() shortcut was added, but the only import of Agent in aimu/__init__.py was under if TYPE_CHECKING (for a return annotation), so aimu.Agent had been raising AttributeError at runtime since May. Fixed with a real binding through the same lazy __getattr__ that now resolves aimu.aio.
  • Fix Three stale claims, each contradicted by the code, are corrected. CLAUDE.md called continuation_prompt a deprecated no-op; it is live -- it recovers a degenerate empty turn (one that returns no content and no tool calls). CLAUDE.md also called PROVENANCE_CONTINUATION no longer produced; it still tags that same degenerate-turn recovery nudge, at four call sites across aimu/agents/_tool_loop.py and aimu/aio/_tool_loop.py (sync and async). The nuance to keep straight: the tag appears only on that recovery path -- never between successful tool rounds, where the loop injects nothing at all (that half of the old behavior really is gone; only the doc's blanket "no longer produced" was wrong). docs/explanation/design-principles.md documented Chain.of() / Router.of() / Parallel.of(), which have never existed; the API is from_client(). Tests: tests/test_provenance.py (new: test_degenerate_empty_turn_recovery_nudge_tagged, driving a real degenerate turn and asserting the tag survives), tests/test_public_surface.py.

v0.18.0 (2026-08-18): generation parameters a provider can actually take, and per-sub-agent generation

Models

  • New Every client declares what it does with each portable generation parameter (aimu.models, aimu.aio; new PORTABLE_GENERATE_KWARGS, Unsupported, apply_kwarg_support, and a GENERATE_KWARG_SUPPORT class attribute in aimu.models._internal.generate_kwargs). Eight generation parameters have one portable name each and eight backends that spell them differently or lack them outright, and until now three different mechanisms answered that one question: a per-provider max_tokens rename inside each rewrite hook, a PROVIDER_CONTEXT_LENGTH_KWARG / CONTEXT_LENGTH_REMEDY pair for the context window, and nothing at all for the six sampling keys except one private Anthropic set. So a sampling key a backend could not take either went on the wire and failed the request, or vanished with nothing said. Ollama was the sharp case, because it vanished silently. Its SDK types options as a pydantic Options model with no min_p field and the repetition knob spelled repeat_penalty; unknown keys are discarded on validation, so Options(min_p=0.05, repetition_penalty=1.0, temperature=0.7) dumps as {'temperature': 0.7}. Every Qwen card in the catalog carries both of those keys, so neither had ever reached the model there. HuggingFace's generate() raising on presence_penalty and Anthropic's Messages API rejecting min_p were the two visible failures on the other side, both previously popped by one private, undocumented set; both now warn instead of dropping the caller's value in silence. Each client now declares GENERATE_KWARG_SUPPORT: the backend's own spelling for each portable key it accepts, an Unsupported (carrying the remedy) for the rest. Applied on the base, in _GenerateKwargsMixin._resolve_generate_kwargs, between the tier merge and the provider's _rewrite_generate_kwargs hook, because dropping a key is a rule that must hold everywhere and an opt-in hook cannot carry one; a test fails if a shipped client leaves any of the eight keys undeclared. Only the caller's own two tiers (client.default_generate_kwargs and the per-call generate_kwargs) trigger the warning, so a card profile's unsupported key is dropped as quietly as it is ignored today rather than reported once per client for a value the user never chose. On the wire, this also means an OpenAI-compatible request now carries top_k / min_p / repetition_penalty under extra_body rather than at the top level, since the OpenAI schema has no top-level place for them. Gemini's top_k is declared unsupported on an unresolved question, not a settled one. Google's OpenAI-compatibility reference documents no top-level top_k and no place for it under extra_body, but it does document extra_body={"generation_config": ...}, and the native Gemini API has topK inside generationConfig. That route could be neither confirmed nor disproved without a live key, so treat it as an open question for a future contributor rather than a verified impossibility. Docs: Generation parameters. Tests: tests/test_generate_kwargs_merge.py.
  • Change repetition_penalty is renamed into repeat_penalty on Ollama and llama.cpp (aimu.models.providers.ollama, aimu.models.providers.llamacpp, and the aio twins), so the portable spelling reaches the knob both backends actually have -- and so do the card profiles that carry it. Behavior change on Ollama: all four Qwen cards in the catalog carry repetition_penalty: 1.0. That key used to be discarded silently by the SDK's Options model, so Ollama's own server default of 1.1 applied instead; now that the portable spelling is renamed into repeat_penalty, every Qwen request on Ollama ships repeat_penalty: 1.0, and the repetition penalty effectively turns off for those models. This is the card's own explicit recommendation finally reaching the wire -- the failure this change set out to fix -- but it is a real change in what a Qwen call on Ollama does today, not a no-op.
  • Fix Two OpenAI-compatible servers whose sampling surface is not the family's (aimu.models.providers.openai_compat: OllamaOpenAIClient, LlamaServerOpenAIClient, and the aio twins). "OpenAI-compatible" describes the endpoint, not the sampling surface behind it, and the family verdict above assumed otherwise for two of its members. Ollama's /v1/chat/completions shim maps a fixed OpenAI field set onto its native call and reads none of top_k, min_p, or repetition_penalty, so inheriting the family's "supported" verdict routed all three into extra_body for the server to discard without a word -- exactly the silent loss the declared table exists to eliminate. OllamaOpenAIClient and AsyncOllamaOpenAIClient now declare all three Unsupported, each remedy naming the native ollama provider (which accepts top_k and, as repeat_penalty, the repetition knob) or the Modelfile (for min_p, which the native SDK's Options model cannot carry either). LlamaServerOpenAIClient and its async twin get a narrower, one-key fix: llama-server does read all three but spells the repetition knob repeat_penalty, as llama.cpp's own /completion endpoint does, where vLLM and SGLang use repetition_penalty. The rename and the extra_body routing now come off the same table (_EXTRA_BODY_PORTABLE_KWARGS, renamed from _EXTRA_BODY_KWARGS), so a client's rename cannot drift out of step with what gets routed -- the OpenAI SDK's create() accepts no arbitrary keywords, so a renamed key left at the top level would raise TypeError rather than reach the server. Tests: tests/test_generate_kwargs_merge.py.
  • Change A None value means unset for every portable key, not just context_length. Assigning client.default_generate_kwargs = {"temperature": None} or passing generate_kwargs={"top_p": None} cancels a standing default rather than being reported as a value the backend cannot support; without this rule, cancelling a client default on a provider that declares the key Unsupported would have logged a spurious warning for a key the caller never actually set. Tests: tests/test_generate_kwargs_merge.py.
  • Change (breaking) PROVIDER_CONTEXT_LENGTH_KWARG, CONTEXT_LENGTH_REMEDY, and apply_context_length are gone, absorbed into the single declaration above along with each provider's max_tokens rename. A BaseModelClient subclass outside this repository that set either attribute declares one GENERATE_KWARG_SUPPORT entry for context_length instead: the backend's own spelling as a string if it takes the window per request, or an Unsupported(remedy) naming where to set it instead. No in-tree caller is affected, and CONTEXT_LENGTH_KWARG (the portable key's own name, unrelated to the deleted attributes) is unchanged. This is a breaking change only for code outside this repository; v0.17.0's own changelog entry above describes the mechanism as it shipped and is left as published history.

Tools

  • New A "generate_kwargs" key on each agent_types spec, applied to the sub-agent's client (aimu.tools.builtin.make_subagent_tool, aimu.aio.tools.builtin.make_async_subagent_tool; SUBAGENT_SPEC_KEYS grows to five). A spawned sub-agent's client is constructed inside the spawn tool, so a caller wanting per-worker sampling parameters had no call site to set them on. The spec now carries generate_kwargs, assigned to the fresh client's default_generate_kwargs, so a roster can pair one specialist with a cold temperature and another with a long context window:
    spawn = make_subagent_tool(
        "ollama:qwen3.8:27b",
        agent_types={
            "extractor": {"system_message": "Extract facts literally.", "generate_kwargs": {"temperature": 0.1}},
            "brainstormer": {"system_message": "Generate options freely.", "generate_kwargs": {"temperature": 1.0}},
            "generalist": {"system_message": "Handle the task."},   # default_generate_kwargs stays empty
        },
    )
    
    Only the keys a spec names are set. That matters because this tier sits above the model card in the precedence chain, so filling in a default would shadow a card's own tuned profile. Like "thinking" and unlike "model", an omitted key inherits nothing: there is no factory-level generation tier to fall back to, so a caller wanting one default across a whole roster writes the resolved value into each spec. The dict is copied per spawn rather than aliased, so a client that later mutates its own default_generate_kwargs cannot edit the roster every subsequent spawn of that type reads. Docs: Control thinking effort, Spawn sub-agents. Tests: tests/test_subagent_tools.py, tests/test_aio_subagent_tools.py.

v0.17.0 (2026-08-18): a portable context length, thinking effort on a spawned sub-agent, and a closed spec

Models

  • New context_length sets the model's context window from generate_kwargs, as a client default or per request (aimu.models, aimu.aio; new CONTEXT_LENGTH_KWARG and apply_context_length() in aimu.models._internal.generate_kwargs). Sizing the context window was the one generation parameter with no portable name: on Ollama it worked only by accident, because generate_kwargs becomes that provider's options dict verbatim, so {"num_ctx": 32768} reached the wire while the same dict against Anthropic or OpenAI would have been rejected as an unknown parameter. There was no key that meant "the context window" whatever the backend, which made the one setting a local-model user tunes most often the one setting a client default could not portably carry. It is now a key like any other, layered by the same four tiers, so both scopes come for free and no signature changed:
    client = aimu.client("ollama:qwen3.8:27b")
    
    client.default_generate_kwargs = {"context_length": 32768}   # every call on this client
    client.chat("and now the long one", generate_kwargs={"context_length": 131072})   # just this call
    client.chat("this one can be small", generate_kwargs={"context_length": None})    # cancel the default
    
    Translation runs on the base, in _GenerateKwargsMixin._resolve_generate_kwargs between the merge and the provider's _rewrite_generate_kwargs hook, for the same reason the merge itself moved there in v0.16.0: a provider that forgot the step would put an unknown parameter on the wire. Each client declares one of two class attributes -- PROVIDER_CONTEXT_LENGTH_KWARG (the backend's own name, which the base renames into) or CONTEXT_LENGTH_REMEDY (where to set it instead) -- and a test fails if a shipped client declares neither. Ollama's native API is the only backend that sizes the window per request (num_ctx). Everywhere else the window is fixed at load time, at server launch, or by the vendor, so the key is dropped and a warning names the remedy: llama.cpp's n_ctx= constructor argument, OLLAMA_CONTEXT_LENGTH for ollama-openai, --ctx-size / --max-model-len for the other OpenAI-compatible servers, the weights' own max_position_embeddings for HuggingFace, and "fixed by the provider" for Anthropic / OpenAI / Gemini. Dropping rather than raising is the same rule thinking= follows -- validate the argument, never the model -- so moving a working client default to another provider never turns into an exception, and the warning fires once per client rather than once per round of an agent loop. Passing a backend's own key (num_ctx) still works unchanged. Docs: Set the context length. Tests: tests/test_generate_kwargs_merge.py.
  • Change ContextOverflowError and TruncatedTurnError name context_length rather than num_ctx (aimu.models.providers.ollama, aimu.agents._tool_loop). Both messages are read back by a delegating agent as a tool result, so they should name the knob a caller can actually turn from Python; OLLAMA_CONTEXT_LENGTH is still named where it applies. No behavior change beyond the wording.

Tools

  • New A "thinking" key on each agent_types spec, applied to the sub-agent it spawns (aimu.tools.builtin.make_subagent_tool, aimu.aio.tools.builtin.make_async_subagent_tool). v0.16.0 made thinking= a standing field on Agent, but a spawned sub-agent is constructed inside the spawn tool, so there was no call site to set it on: a roster where one specialist should reason hard and another should not reason at all had no way to say so. Typed mode now reads "thinking" from each spec alongside "system_message", "tools", and "model":
    spawn = make_subagent_tool(
        "ollama:qwen3.8:27b",
        agent_types={
            "researcher": {"system_message": "Research thoroughly.", "thinking": "high"},
            "formatter":  {"system_message": "Reformat text.", "thinking": False},
            "generalist": {"system_message": "Handle the task."},   # field stays None
        },
    )
    
    The key is read with .get(), so False is carried rather than swallowed by a truthiness test, and it takes the same four value forms the Agent field does. Nested spawns (max_depth > 1) rebuild the tool with the same agent_types, so a spec's value reaches every level it spawns at. Note the deliberate asymmetry with "model": an omitted "model" falls back to the model the factory was built with, while an omitted "thinking" leaves the spawned agent at None, because there is no factory-level thinking tier to fall back to. A caller with one default across a whole roster should write the resolved value into each spec rather than expect inheritance. Docs: Control thinking effort. Tests: tests/test_subagent_tools.py, tests/test_aio_subagent_tools.py.
  • Change An agent_types spec's keys are a closed set, and an unrecognized one raises (aimu.tools.builtin, aimu.aio.tools.builtin; new SUBAGENT_SPEC_KEYS). A spec may carry system_message, tools, model, thinking, and nothing else; anything else is a ValueError at factory-call time, naming the bad key, the agent_type it came from, and the keys that are accepted. Previously an unrecognized key was ignored in silence, which is the wrong default here because an ignored key reads exactly like an applied one: a misspelled "thinkng" or a hopeful "temperature" left the spawned agent at its default with nothing raised anywhere and the caller believing otherwise. Adding the "thinking" key above is what made this concrete -- it is the failure mode a caller reaching for per-worker effort is most likely to hit, and the same one thinking="xhigh" already raises for rather than quietly accepting. This is a breaking change for a spec carrying an extra key, which previously worked by being ignored. It fails loudly and immediately (at the make_subagent_tool() call, not at spawn time), so the fix is to delete the key. An unknown agent_type is deliberately not treated this way and is still returned to the model as a tool result: that one is the model's mistake to self-correct, where a bad spec key is the programmer's. One corollary worth knowing for version pinning: because an older AIMU ignores an unknown spec key rather than raising, a "thinking" key against a pre-0.17.0 install is silently dropped, and no runtime check can detect it -- pin aimu>=0.17.0 if you depend on per-spec thinking. Tests: tests/test_subagent_tools.py, tests/test_aio_subagent_tools.py.

v0.16.0 (2026-08-18): one precedence chain for generation parameters, and thinking effort for a whole run

Models

  • New ContextOverflowError: an over-long request surfaces as a typed error instead of Ollama's raw 500 (aimu.models, aimu.models.providers.ollama, aimu.aio.providers.ollama; chat, streaming and not). Ollama trims a chat request's messages to fit the runner's context window before rendering the prompt. When the trim reaches the user turn, a model whose renderer requires one (the qwen3.5 family, which qwen3.8 belongs to) refuses with 500 no user query found in messages -- wording that names the missing user turn rather than the overflow that caused it. An agent whose tool results are large hits this routinely: a research worker whose web_fetch results fill a 32k window fails on its next round, and left raw the 500 reaches the delegating agent as a tool result it reads as transient, so it re-runs the same over-long task. The chat paths now raise ContextOverflowError naming the cause and the knob (num_ctx / OLLAMA_CONTEXT_LENGTH), with the provider's own error preserved on __cause__. It is the input-side counterpart of TruncatedTurnError, which reports an output that ran out of room, and is exported from aimu.models and aimu.aio. Translation is conditional on the request having carried a user message, which is why the check reads the messages and not just the exception: a request that genuinely has no user turn produces the same 500, and there the server's own wording is already the accurate diagnosis, so the original ollama.ResponseError propagates untouched. This does not prevent the overflow -- the request still fails -- it makes the failure legible to whoever reads it. Tests: tests/test_ollama_context_overflow.py.
  • New client.default_generate_kwargs sets generation parameters for every call on a client, on every provider (aimu.models, aimu.aio). Previously there was no way to say "use these sampling parameters for this whole conversation" short of repeating generate_kwargs= on every chat(). The attribute existed but meant something different on each provider: an honoured input on Anthropic / the OpenAI-compatible family / llama.cpp, a read-only report of the model card on Ollama, and an empty dict that nothing read on HuggingFace. It is now one thing everywhere, an input, starting empty:
    client = aimu.client("ollama:qwen3.5:9b")
    client.default_generate_kwargs = {"temperature": 0.2, "num_ctx": 16384}
    client.chat("summarise this")                                         # temperature 0.2
    client.chat("now be creative", generate_kwargs={"temperature": 1.0})  # 1.0, this call only
    
    Assigning a whole dict and mutating in place both work, and both now propagate through the ModelClient / AsyncModelClient wrapper that aimu.client() returns, through agent.as_model_client(), and down a FallbackClient's chain, all of which previously copied the dict on construction (so mutation happened to work while reassignment silently detached). Behavior change on Ollama: reading default_generate_kwargs used to return the model card's profile and now returns {} until you write to it. Use Model.generation_kwargs to read a card's profile.
  • Fix The model card's sampling profile now reaches Anthropic, the OpenAI-compatible family, and llama.cpp (sync and async). v0.15.0 fixed generate_kwargs clobbering the card's profile on Ollama and HuggingFace, the two providers whose catalogs carry one. The other three never read ModelSpec.generation_kwargs at all: their _resolve_generate_kwargs merged only their class-level DEFAULT_GENERATE_KWARGS with the caller's dict, so a generation_kwargs= profile on one of their members was discarded silently, as was the nonthinking_generation_kwargs instruct-mode switch that thinking=False selects. No member of those catalogs carries a profile yet, so no request changes today; it was a trap set for whoever adds the first one.
  • Change One precedence chain for generation parameters, shared by every provider (aimu.models._internal.generate_kwargs.merge_generate_kwargs). Five providers each spread the tiers by hand, which is how three of them came to drop a tier. There is now one merge, lowest precedence first: the client's DEFAULT_GENERATE_KWARGS fallbacks, then the model card's profile, then client.default_generate_kwargs, then the per-call generate_kwargs. The library's own fallbacks sit at the bottom, below the card, so a generic temperature=0.1 cannot quietly beat a card's tuned value; the two caller-supplied tiers sit on top. Verified byte-for-byte identical against every current catalog member, since the two tiers that moved are empty by default. Provider-specific rewrites still run after the merge and may override the caller where an API requires it (Anthropic's forced temperature=1 under extended thinking, the o-series max_completion_tokens rename, HuggingFace dropping presence_penalty). Docs: Generation parameters.
  • Change Providers declare their generation-kwarg rewrites instead of driving the merge (aimu.models, aimu.aio). Resolving a request's generation kwargs is now a single unskippable path: the new aimu.models._internal.generate_kwargs owns the tier merge (merge_generate_kwargs(), select_profile(), moved out of _internal.thinking) and a _GenerateKwargsMixin that both base clients inherit. Its _resolve_generate_kwargs() (renamed from _update_generate_kwargs, since it resolves tiers rather than updating anything) layers the four tiers and hands the merged dict to a new _rewrite_generate_kwargs(kwargs) hook whose default is no rewrite. Every provider override dropped its self._merge_generate_kwargs(...) first line and now only declares what its API needs reshaped (Ollama's num_predict rename, HuggingFace's max_new_tokens rename and presence_penalty drop, Anthropic's forced temperature=1 under extended thinking, the o-series max_completion_tokens rename, the OpenAI-compatible thinking translation). Behavior is unchanged; what changed is that a provider can no longer skip the merge by forgetting a line, which is how three of them lost a tier, and _ChatStateMixin is back to message/system/tool state only (kwarg resolution serves generate() as much as chat(), so it never belonged there). All three names are private, so this reaches only out-of-tree BaseModelClient subclasses: move such a subclass's _update_generate_kwargs body to _rewrite_generate_kwargs, minus the merge call. A tests/test_generate_kwargs_merge.py guard now fails if any shipped client overrides the resolve entrypoint. Contributor guide: Add a provider.

Agents

  • New thinking= on Agent, applied to every model turn of a run (aimu.agents.Agent, aimu.aio.Agent, and both SkillAgents). thinking= reached chat() and generate() in v0.15.0, but an Agent had no seam to pass it down, so a caller wanting a specific reasoning effort inside an agentic loop had no way to ask for one. It is now a standing field on the agent plus a per-run run(thinking=...) override, mirroring tools= / deps= / tool_approval=:
    agent = Agent(client, "You are a careful analyst.", tools=[...], thinking="high")
    agent.run("audit this contract")                    # high effort
    agent.run("what time is it?", thinking=False)       # off, this run only
    
    It is threaded through the tool-loop engine to every turn of the run: each tool round, the continuation turn, the forced tools-disabled wrap-up (final_answer_prompt), and the schema= structured-output turn. Effort is therefore uniform across a run rather than applying to the opening turn and decaying afterwards. The override tests is None rather than truthiness, so thinking=False genuinely overrides a configured level instead of being swallowed by it. The agent forwards the public argument rather than a pre-resolved request, so validation and the deduplicated warning stay on the model client where the model is known: the agent makes no capability decisions, and a model without effort-level control warns once per run instead of once per round. Not added to the Runner ABC or the workflow classes, which compose sub-runners and have no single model turn to apply it to; set it on the agents they wrap. Docs: Control thinking effort. Tests: tests/test_thinking_control.py, tests/test_aio_thinking_control.py.

v0.15.0 (2026-08-17): portable thinking control, and a sampling profile that survives your kwargs

Models

  • New thinking= on chat() and generate() (sync and async, plus the top-level aimu.chat() / aio.chat() helpers): a portable knob to turn reasoning on or off, or request an effort level, without a provider- or model-specific branch. Four forms: None (default, byte-for-byte unchanged), False (off, and switches to the model's instruct-mode sampling profile when the card specifies one), True (on, at the model's own default effort), and "low"/"medium"/"high" (on, at that effort). One rule governs resolution: validate the argument, never the model. An unrecognised value raises ValueError before any request is built; a value the model cannot honour (no effort-level control, or a model that cannot disable reasoning) logs a deduplicated warning and the call proceeds, so a model swap never turns working code into an exception. This reaches Ollama (think=) and HuggingFace (enable_thinking=/reasoning_effort template kwargs) for the first time: both had no reachable mechanism at all before this, since their existing hardcoded knobs (think=self.is_thinking_model, enable_thinking=self.model.supports_thinking) took no caller input. OpenAI-compat local servers (vLLM, SGLang, LM Studio, Ollama-OpenAI, oMLX, HF-Serve, llama-server) get extra_body={"chat_template_kwargs": {"enable_thinking": ...}} plus reasoning_effort, with "high" sent as Qwen's own "xhigh". Anthropic maps a level to budget_tokens (low 2048, medium 8000, high 16000), and all six AnthropicModel members now declare thinking_levels=True (previously none did, since a level had nowhere to go without the flag); the three ThinkingStyle.ADAPTIVE models (Opus 4.7+, Fable 5) warn and ignore a level instead, since that request shape carries no budget parameter. llama.cpp and the OpenAI/Gemini cloud clients pop the request and emit nothing on the wire, a deferred scope boundary rather than a limitation in Gemini's case specifically: Google's OpenAI-compatible endpoint does accept reasoning_effort, but its vocabulary excludes the "xhigh" value the shared Qwen mapping sends for "high", and a correct Gemini mapping needs its own effort vocabulary (see the how-to). New ModelSpec fields carry the capability: thinking_levels (accepts an effort level), thinking_optional (False means the model always reasons and cannot be disabled), and nonthinking_generation_kwargs (the instruct-mode sampling profile). Among the providers that share Qwen's effort vocabulary (Ollama, the OpenAI-compatible family, HuggingFace), only Qwen 3.8 declares thinking_levels=True today; Anthropic's six models declare it too, through the budget_tokens mechanism above. GEMINI_2_5_PRO is the only model with thinking_optional=False: thinking=False against it warns and the call still proceeds at full reasoning cost, billed as such and visible on client.last_usage. On the HuggingFace tokenizer path, enable_thinking now follows the model's declared supports_thinking capability instead of a hardcoded True when thinking=None; this is inert for every current non-thinking member (each either takes the processor branch, which never reads enable_thinking, or is otherwise not driven by this flag), and it corrects a latent inconsistency (sending enable_thinking=True to a model that cannot think was never meaningful) rather than introducing one. Docs: Control thinking effort, Thinking content and the model context. Tests: tests/test_thinking_control.py, tests/test_aio_thinking_control.py.
  • Fix generate_kwargs no longer discards the model's tuned sampling profile (aimu.models.providers.ollama, aimu.models.providers.hf.text; sync and async). _update_generate_kwargs on both providers used to replace rather than merge: if not generate_kwargs: use the spec's tuned profile; else: use the caller's dict verbatim. So chat("hi", generate_kwargs={"max_tokens": 2000}) silently discarded temperature/top_p/top_k/min_p/presence_penalty, every value the model's card recommends, keeping only the one key the caller happened to pass. This was silent, which is why it survived: nothing raised, nothing warned, the call simply ran with the library's own untuned defaults instead of the catalog's. Both providers now merge per key, with the spec's profile as the base and the caller's keys winning:
    chat("hi", generate_kwargs={"max_tokens": 2000})
    # before: {"max_tokens": 2000}
    # after:  {temperature: 1.0, top_p: 0.95, top_k: 20, min_p: 0.0, presence_penalty: 0.0, max_tokens: 2000}
    
    This is a genuine behavior change for anyone currently passing partial generate_kwargs on Ollama or HuggingFace: a caller relying on the old (buggy) replace behavior to drop the model's profile entirely now gets that profile merged back in underneath their own keys.
  • Fix Qwen 3.6's thinking-mode presence_penalty corrected against the model cards (aimu.models.providers.ollama, aimu.models.providers.hf.text). Ollama's _QWEN_3_6_KWARGS used 0.9 and HuggingFace's _QWEN_KWARGS used 1.5 (HuggingFace's own instruct-mode value, misapplied to thinking mode); the 27B card specifies 0.0, and both are now corrected to it. The 35B-A3B MoE variant's card specifies a different value again, 1.5, so it keeps its own constant (_QWEN_3_6_35B_THINKING_KWARGS) rather than sharing the 27B's: Ollama's old 0.9 was wrong for both 3.6 members, in opposite directions, not just under- or over-shooting one shared correct value. On HuggingFace, _update_generate_kwargs drops presence_penalty before generation (Transformers' generate() has no such parameter), so that surface's error was inert; on Ollama it reached the server on every thinking-mode call. Every Qwen 3.5 / 3.6 / 3.8 member across both providers also gains a nonthinking_generation_kwargs instruct-mode profile now, selected automatically when thinking=False resolves off.

  • Fix HuggingFace Qwen 3.8 loads through the multimodal path (aimu.models.providers.hf.text). QWEN_3_8_27B and QWEN_3_8_27B_FP8 shipped in v0.13.2 with vision=True, but both routing prefixes still listed only Qwen 3.5 and 3.6, so "Qwen/Qwen3.8-27B" matched neither and fell to the causal-LM branch. That branch loads no AutoProcessor, so image input could not work at all despite the declared capability, and it builds a text-only module tree while the FP8 checkpoint's quantization_config skip-list is written against the multimodal tree, the same mis-quantization the 3.5/3.6 comment already warned about. Silent in the common case, since text generation works on the wrong branch. _load_profile also feeds the weight-cache key, so this additionally stops a 3.8 member from sharing a cache entry with a differently-loaded member of the same repo id. A new guard asserts the invariant rather than the instance: any HuggingFace member declaring vision=True must route to a profile whose loader builds a processor, which catches the next catalog addition that forgets a prefix.

  • Change HuggingFaceModel.generate_kwargs removed (aimu.models.providers.hf.text). This per-enum-member dict merged DEFAULT_GENERATE_KWARGS with the member's generation_kwargs at class-definition time. Nothing read it: _update_generate_kwargs recomputes the merge per call, because the profile now depends on whether thinking resolved on or off, which a value baked at definition time cannot express. Undocumented and provider-internal; the documented accessor for the same data, Model.generation_kwargs, is unchanged. Removed rather than left in place because a stale duplicate of a merge invites someone to fix a sampling bug by editing a dict nothing reads.
  • Change The warning for a model AIMU cannot steer no longer claims the model cannot reason (aimu.models._internal.thinking). It read "o3 is not a thinking model", which is false about a well-known reasoning family: supports_thinking means "reasoning is visible through AIMU", not "the model reasons". It now reads "AIMU does not expose reasoning control for o3", which is also true of models that genuinely do not reason. The two ValueError paths for an invalid value also now render the valid level set identically.

Documentation

  • Fix The model matrix is synced with the catalogs and guarded by a test (docs/reference/model-matrix.md). It claimed to be kept up to date with the enums and was not. Missing rows: HuggingFace GEMMA_4_12B and NEMOTRON_H_8B, llama-cpp GEMMA_4_12B, and the whole Qwen 3.8 family. Wrong flags: HuggingFace GEMMA_4_E4B was thinking=✗ where the catalog says , and llama-cpp LLAMA_3_1_8B / LLAMA_3_2_3B were tools=✗ where the catalog says , contradicting a footnote in the same file. The Servers column called twelve members "all" when oMLX carries none of them, which told a reader OMLXOpenAIModel.GEMMA_4_12B exists; that column now defines "all" as the six non-MLX servers once and explains that oMLX ships only MLX conversions. tests/test_docs_model_matrix.py now parses the file and asserts, per table, that every catalog member has a row, that every documented tools/thinking/vision flag matches the enum, and that the Servers column matches which server enums actually contain each member.
  • Fix generation_kwargs merge semantics corrected in the add-a-model guide (docs/how-to/add-new-model.md). It described the replace-not-merge behavior this release fixes. The guide also documents the three new thinking-control ModelSpec fields, including why thinking_levels should be under-declared.

v0.14.2 (2026-08-16): a turn cut off by the context window is no longer mistaken for a degenerate one

Agents

  • Change An empty turn the provider reports as truncated raises TruncatedTurnError instead of being nudged (aimu.agents / aimu.aio; a subclass of DegenerateTurnError, so an existing handler keeps working). The tool loop treats an assistant turn with no content and no tool calls as degenerate and injects its continuation prompt to recover. That is right when a small model simply fumbled a turn, and exactly wrong when the turn was cut off for want of output room: the nudge adds tokens to a request that had none to spare, so each retry gets a shorter turn than the last and the loop spends its whole round budget before the forced wrap-up. Observed against a 26B model on Ollama with a long conversation and 26 advertised tools: a prompt of 32,693 tokens in a 32,768 window left 75 tokens to generate, and seven rounds of reasoning shrank from ~330 to ~70 tokens with nothing usable at any point. The loop now stops at the first such turn and says what happened, naming the input-token count and the three ways out (shorter conversation, fewer tools, bigger context window). A truncated turn that does carry an answer is untouched, since that is a caller's own max_tokens doing its job.
  • New client.last_output_truncated (bool, alongside last_usage on both the sync and async clients). True when the provider reports the generation stopped for want of room rather than at the model's own stopping point. Set by the Ollama provider from done_reason == "length" on every response path (chat, generate, and both streamed forms); every other provider leaves it False, so nothing else changes behavior.

v0.14.1 (2026-08-16): host-provided script environment, and a shutdown hang

Skills

  • New A host can hand a script environment variables (run_script_file(..., env=...) and build_skills_server(manager, env=...)). Environment variables are one of the three input channels the Agent Skills script guidance names, and the one a host uses to tell a script something it cannot discover for itself: where to write output, which account to send from. Without it, a skill needing host context has to re-derive the host's own configuration, duplicating logic that will drift. env is merged over the inherited environment rather than replacing it, because a replacement would strip PATH, which the interpreter lookup itself needs. Set once when the server is built, not per call.

Tools

  • New MCPClient.close(), and __del__ no longer hangs the interpreter at exit (aimu.tools.client). The sync client runs its FastMCP client in an anyio blocking portal on a background thread, and teardown is a cross-thread call. __del__ made that call unconditionally, so a client still referenced when the interpreter began finalizing blocked forever: the portal's loop is gone by then and the call never returns. Verified with a faulthandler stack pointing at client.py's __del__. __del__ now does nothing while sys.is_finalizing(), and close() is the explicit, idempotent teardown. A client held for the life of the process must call close(). Skipping teardown in __del__ is necessary but not sufficient on its own: if nobody releases the portal, Python's finalization of start_blocking_portal's own context-manager generator tries to stop and join the portal thread, and blocks there instead. A short-lived client collected during normal operation is unaffected, since __del__ still closes it then.

v0.14.0 (2026-08-16): Agent Skills spec compliance, and per-agent skill selection

Skills

  • New SKILL.md frontmatter is validated against the Agent Skills specification (aimu.skills.validate, exporting validate_frontmatter and SkillSpecError; both re-exported from aimu.skills). Discovery now enforces every rule the spec states: name is required (1-64 characters, lowercase alphanumerics and single hyphens, no leading or trailing hyphen, and must match its parent directory), description is required and at most 1024 characters, compatibility at most 500, and metadata must be a mapping of string to string. A violation raises SkillLoadError naming the rule and the fix, which is the "failures are apparent" principle applied to the one identifier everything else uses to address a skill. The validator is a pure function over parsed frontmatter, so the rules are testable without a filesystem and an author can call it directly the way skills-ref validate is.
  • Change (breaking) name no longer defaults to the skill's directory name. _parse previously fell back to skill_md.parent.name when the frontmatter omitted name; the spec makes the field required, so an omission is an error rather than a silent substitution. A SKILL.md with no name:, a name: that is not a spec-valid slug, or a name: that disagrees with its directory now fails discovery where it used to load. Nothing in AIMU's own tests, examples, or notebooks was affected (all use single-word or kebab-case names matching their directories), and write_skill already enforced the slug on the authoring path, so only hand-written skills can reach this.
  • New allowed-tools is parsed and carried (AgentSkill.allowed_tools, a tuple[str, ...] split on whitespace, empty when absent). AIMU exposes it and acts on it nowhere: the spec marks the field experimental, and which tools an agent may run is a host's policy rather than a library's. The attribute-versus-key rename follows the existing licenselicense_info precedent.
  • New A skill's .py scripts may declare their dependencies inline (PEP 723), which the Agent Skills script guidance recommends as the way to make a skill self-contained. A .py script containing a # /// script block now runs through uv run --script, which resolves the declared dependencies into an isolated environment; previously every .py ran on sys.executable, so a spec-recommended script failed on ModuleNotFoundError at its first import (verified: a script declaring humanize raised, while uv run on the same file installed and ran it). --script rather than a bare uv run, so the file is treated as standalone instead of resolving whatever project surrounds the skill directory. A script with no inline block still runs on sys.executable, so a skill relying on packages installed in the host environment is unaffected. Inline dependencies with no uv on PATH return a message naming uv and the alternative, rather than the import error.
  • Fix A script's stdin is closed rather than inherited (stdin=subprocess.DEVNULL). The spec calls a non-interactive shell a hard requirement of the execution environment, and an inherited stdin meant a script that prompts blocked until the 30-second timeout and held the event loop with it, stalling every concurrent conversation on the async path, then reported only "Script timed out". Closed stdin turns the same script into an immediate EOFError (measured: 30.0s to 0.05s) that names the real problem. Nothing regresses: run_script_file has no parameter for supplying stdin, so no caller could pipe input. Supplying stdin as input is deliberately still not implemented, since nothing in AIMU needs it and the script tool's signature carries only args.
  • New SkillManager(include=[...]) narrows discovery to the named skills, so a host giving one agent a subset does not have to filter in three places -- catalog_prompt() and build_skills_server() both read skills. A name in include that no search path provides raises SkillLoadError listing what was discovered, because the alternative is an agent quietly holding fewer skills than it asked for. include=None (the default) discovers everything, so every existing caller is unaffected.

  • Change (breaking) Skill script tool names are slugified (aimu.skills.skill.script_tool_name, new and exported). A skill's scripts/*.py / *.sh were registered as f"{skill_name}__{script.stem}" with both halves verbatim, so the pdf-processing skill produced pdf-processing__extract_pages. Both halves are now lowercased with every run of characters outside [a-z0-9] collapsed to _, giving pdf_processing__extract_pages and leaving the __ as the only separator. Any prompt, config, or caller that hardcodes an old hyphenated name must be updated; a skill whose name and script stems are already single lowercase words (every skill in the tests, the examples, and notebook 24) is unaffected, which is why this went unnoticed. The concrete motivation was not cosmetic: at the time, SkillManager._parse took name: from the frontmatter unvalidated (only write_skill enforced the kebab-case slug, and only on the authoring path), so a hand-written SKILL.md reading name: My Skill produced the tool My Skill__go — an identifier no provider accepts, failing at call time with a schema error rather than at discovery. Spec validation, added above in this same release, now rejects that skill outright, so slugification's remaining job is narrower: a spec-valid name is [a-z0-9-], and its hyphens are still not legal in an identifier. Hyphens were legal for every provider AIMU targets (^[a-zA-Z0-9_-]{1,64}$), so the rename is also about not asking a model to reproduce a mixed -/__ identifier from the catalogue.

  • Fix The catalogue and the skills server can no longer disagree about a tool name. Three sites formatted this string independently: AgentSkill.script_tool_names (which the catalogue injected into the system prompt advertises), build_skills_server (which registers it), and add_skill_script's success message (which tells the model what it may now call). Nothing held them in agreement, so slugifying any one of them would have advertised names that were not registered -- a tool call that cannot succeed. All three now call script_tool_name, and a new test asserts every advertised name is present both in catalog_prompt() and among the server's registered tools.
  • Fix Script registration dedupes on the tool name rather than the file stem (aimu.skills.mcp._register_script_tools, AgentSkill.script_tool_names). foo.py / foo.sh already collided on one name and were deduped by stem, .py winning. Slugifying adds a second collision the stem check could not see: _SCRIPT_STEM deliberately allows both separators, so backup-db.py and backup_db.py are both valid in one skill and now map to ops__backup_db. Dedupe is on the resulting name, first sorted path winning (the hyphen sorts before the underscore), so the second file is skipped rather than silently shadowing the first on the server. add_skill_script's docstring now tells the model the two spellings are equivalent.
  • Docs: use skills documents the spec's frontmatter rules and how a violation is reported, the slugging rule and its collisions, and include; notebook 08-agent-skills called unit-converter__now and would have broken. Tests: tests/test_skills.py.

v0.13.2 (2026-08-15): Qwen 3.8 27B, and a silent thinking-truncation fix

Models

  • New Qwen 3.8 27B across the four providers that can actually execute it: OllamaModel / OllamaOpenAIModel (qwen3.8:27b), HuggingFaceModel (QWEN_3_8_27B = Qwen/Qwen3.8-27B plus QWEN_3_8_27B_FP8), OMLXOpenAIModel (bare Qwen3.8-27B plus _4BIT / _8BIT / _BF16), and LMStudioOpenAIModel (_4BIT / _8BIT) -- the same spread Qwen 3.6 uses. All entries are tools=True, thinking=True, vision=True, and none of those flags is a guess: the config carries a vision_config with image_token_id/video_token_id (a dense unified vision-language model, Qwen3_5ForConditionalGeneration, so no separate -VL variant), the chat template defines the XML <tool_call><function=…> framing the existing ToolCallFormat.XML parser already handles, and it emits <think> with thinking on by default. Ollama's registry page lists the tag with matching vision/tools/thinking capability chips. The native Ollama entry also sets structured_output=True (Ollama grammar-enforces JSON for any model).
  • New _QWEN_3_8_KWARGS sampling defaults (aimu.models.providers.ollama and aimu.models.providers.hf.text). Qwen 3.8's card recommends presence_penalty=0.0 in thinking mode, so 3.8 cannot reuse either existing constant -- _QWEN_3_6_KWARGS uses 0.9 and 3.5 / the HF-side _QWEN_KWARGS use 1.5 (the instruct-mode value). Sharing one of those would have silently applied a repetition penalty the model is not tuned for, so each surface derives a 3.8 variant off its existing constant rather than being hand-copied.
  • New think_opener_in_prompt=True on both HuggingFace 3.8 entries. Qwen 3.8's chat template appends <think>\n to the generation prompt (verified against the published template, byte-identical to 3.5's and 3.6's in that respect), so the model generates inside the thinking block and emits only the closing </think>. The flag tells the in-process parser not to expect a literal opener; without it, a thinking block truncated by the token budget (no </think> in the output) is returned as if it were the answer. This matches QWEN_3_5_9B. (The same latent bug in the existing QWEN_3_6_27B / _FP8 and DEEPSEEK_R1_8B entries is fixed separately below.)
  • Note on naming: QWEN_3_8_27B is Qwen 3.8 at 27B, one underscore away from the pre-existing QWEN_3_8B, which is Qwen 3 at 8B (text-only). Both follow the catalog's "version parts, then size" scheme, and their ids (qwen3.8:27b vs qwen3:8b) are unambiguous, but the pair is easy to misread, so both catalogs now carry a comment pointing it out. The 2.4T-A95B sibling (Qwen/Qwen3.8-2.4T-A95B, a 512-expert / 10-active text-only MoE) is deliberately absent: it has no Ollama tag, no MLX conversion, and will not load in-process, so there is no provider in this spread that can serve it.
  • No test changes needed for the catalog addition itself: tests/test_model_catalog_consistency.py is data-driven, so it already holds the new members' intrinsic flags in agreement across the four catalogs and guards the shared-ModelSpec.id alias trap for the per-quantization members (166 → 175 assertions).

  • Fix think_opener_in_prompt was False on three HuggingFace thinking models whose chat templates prefill the opener (aimu.models.providers.hf.text): QWEN_3_6_27B, QWEN_3_6_27B_FP8, and DEEPSEEK_R1_8B. All three templates append a bare <think> to the generation prompt -- Qwen 3.6's tail is byte-identical to 3.5's and 3.8's, and R1's ends {{'<|Assistant|><think>\n'}} unconditionally -- so the model generates inside the thinking block and emits only the closing </think>. The failure was silent in the common case, which is why it survived: the parser in _chat/_generate splits correctly whenever </think> is present (response.startswith("<think>") is False either way, and the start offset is computed independently), so the flag only bites when a thinking block is truncated before its close -- token budget exhausted mid-reasoning. There, the elif opened branch that exists precisely to report "the whole output is thinking, not an answer" was skipped, so raw chain-of-thought was returned to the caller as the assistant's answer and last_thinking was left empty. On the streaming path the flag is the only signal (opened = self.model.think_opener_in_prompt with no startswith fallback), so the same truncation leaked reasoning tokens as GENERATING chunks. Now True on all three, matching QWEN_3_5_9B.

  • New Guard against the same flag drifting again (tests/test_model_catalog_consistency.py). _EXPECTED_THINK_OPENER pins the expected value for every HuggingFace thinking model against its published chat template, with the verification recipe and the reason each False entry is correct (QWEN_3_8B / SMOLLM3_3B emit only the closed <think>\n\n</think>; GEMMA_4_* use <|channel>thought framing rather than <think> at all; GPT_OSS_20B emits no opener). A companion test fails when a newly added thinking model has no pinned entry, so the False default cannot be inherited unexamined -- which is exactly how the 3.6 entries went wrong. This is pinned in the catalog suite rather than tested behaviourally because the flag is a claim about an upstream template, and the in-process parser it feeds needs loaded weights to exercise.

v0.13.1 (2026-08-13): MLX-optimized models via oMLX, LM Studio, and Ollama

Models

  • New MLX-optimized model support on Apple Silicon, reached through servers rather than a new in-process client. MLX is Apple's ML framework and now has real coverage in the local-inference ecosystem (Ollama 0.19+ swapped its Mac backend from llama.cpp to MLX, LM Studio ships an MLX engine, and oMLX is a dedicated MLX server), typically generating 20-40% faster than the llama.cpp Metal backend on the same hardware. Three providers execute MLX weights: a new omlx provider (OMLXOpenAIClient + OMLXOpenAIModel in aimu.models.providers.openai_compat, async twin AsyncOMLXOpenAIClient) targeting oMLX at http://localhost:8000/v1; lmstudio, which gains MLX catalog entries (its MLX engine is auto-selected for MLX weights); and ollama, which needs no code change at all, since 0.19+ picks MLX automatically on Apple Silicon behind unchanged tags (it wants >32 GB of unified memory). Everything oMLX needs already exists on OpenAICompatClient -- OpenAI-shaped tool_calls, streaming with stream_options.include_usage, vision content blocks, and reasoning_content -- so the client is the same five-line subclass as LlamaServerOpenAIClient and no behavioural code was added. No new extra either: oMLX and LM Studio are external server processes reached through the already-declared openai SDK, so both ride aimu[openai_compat]. hf and llamacpp are deliberately excluded rather than given guessed entries: HuggingFaceClient is torch/transformers and LlamaCppClient is GGML/GGUF, and neither can load MLX's quantized safetensors layout -- mlx-community repos are hosted on the HuggingFace Hub but only mlx-lm/mlx-vlm can execute them, so an in-process MLX client would be a separate, deliberate decision (and a substantial one, needing chat templating, tool-call parsing, and a weight registry) rather than a gap in this change.
  • New Qwen 3.6 35B-A3B MLX catalog, with per-quantization members (OMLXOpenAIModel, LMStudioOpenAIModel). Qwen 3.6 35B-A3B is a unified vision-language MoE (Qwen3_5MoeForConditionalGeneration, with an image_token_id and an image-text-to-text pipeline tag), so all entries are tools=True, thinking=True, vision=True, matching the existing OllamaModel.QWEN_3_6_35B. Because each MLX quantization is a separate mlx-community repo, the catalog carries QWEN_3_6_35B_4BIT / _8BIT / _BF16 alongside the bare QWEN_3_6_35B (the quant-agnostic layout, and the name shared with the Ollama catalogs so resolve_model_enum("QWEN_3_6_35B") and the cross-provider consistency guard keep working). LM Studio gets only the two quantized members: a quant-free key there would be the GGUF build (not an MLX path), and bf16 is impractical at 35B. OllamaOpenAIModel also gains the previously-missing QWEN_3_6_35B (qwen3.6:35b) for parity with the native OllamaModel, which already had both 35B and 27B. oMLX ids are --model-dir subdirectory names (oMLX discovers models from subdirectories), so like LlamaServerOpenAIModel's GGUF filenames they are conventions, not contracts; the entries follow "directory name == the mlx-community repo's model segment", which is what a copy-pasted download produces.
  • New omlx joins _BASE_URL_PROVIDERS (aimu.models.model_client), and _ASYNC_COMPAT_CLIENTS gains the matching "omlx" entry. Membership grants both an @<base_url> override and ad-hoc (not-in-catalog) id resolution with ;<flags>. For oMLX this is a primary addressing mechanism rather than an escape hatch, since its ids are user-chosen directory names: aimu.client("omlx:my-own-conversion-4bit;tools,thinking,vision") must work for any local layout, and aimu.client("omlx:Qwen3.6-35B-A3B-4bit@http://mac-studio:8000/v1") covers the canonical deployment, a headless Mac on the LAN driven from a laptop. Note AdHocModel capability flags default to False, so they have to be spelled out. The async ad-hoc path routes by provider prefix through the hand-maintained _ASYNC_COMPAT_CLIENTS dict (the sync side reads _provider_registry() and cannot have this gap), so a missing entry there would have raised a bare KeyError; a regression test now covers it.
  • New oMLX local-availability probe (aimu.models._internal.model_defaults._OPENAI_COMPAT_PROBES), so a served oMLX model is discovered by available_text_models() and can be auto-selected as the default. oMLX's default port is shared with vllm and hf-openai, which is safe because each probe keeps only enum members whose .value appears in that server's /v1/models response, and the id namespaces are disjoint (HuggingFace repo paths contain a /; oMLX ids are bare directory names), so a server can only ever claim ids from its own catalog. The entry is appended last, leaving existing discovery priority untouched: a machine with qwen3.6:35b pulled still resolves the bare name to Ollama.
  • New Enum-alias guard for every model catalog (tests/test_model_catalog_consistency.py::test_no_silent_enum_aliases). Model.__init__ assigns _value_ = spec.id before enum's duplicate-value scan runs, and ModelSpec.__eq__/__hash__ are id-only, so two members of one catalog sharing a ModelSpec.id silently become an alias: the second vanishes from iteration (and therefore from TOOL_MODELS/VISION_MODELS, the local-availability probes, and every check in that file) and its own ModelSpec -- including its capability flags -- is discarded, with no warning. This is exactly the trap a bare-plus-per-quantization catalog invites, so the new guard asserts set(enum.__members__) == {m.name for m in enum} for every auto-discovered text-model enum. It is green across the existing catalogs (no enum aliases today) and is the canary for any future id collision. The consistency guard itself needed no edits: OMLXOpenAIModel is auto-discovered, and because QWEN_3_6_35B is now shared by three catalogs and QWEN_3_6_35B_4BIT/_8BIT by two, their intrinsic flags are held in agreement for free -- no _INTENTIONAL_DIVERGENCES entry was needed.
  • Tests: tests/test_model_catalog_consistency.py (alias guard), tests/test_model_client_base_url.py and tests/test_aio_model_client_base_url.py (the sync/async wiring canaries: default endpoint, @base_url override, ad-hoc directory ids), tests/test_default_model.py (port-8000 probe coexistence in both directions). tests/helpers.py, tests/helpers_aio.py, and tests/conftest.py are wired for pytest tests/test_models.py --client=omlx_openai.

  • New Muse Glimmer 30B on oMLX (OMLXOpenAIModel: MUSE_GLIMMER_30B plus _4BIT / _8BIT / _BF16), tools=True, thinking=True, vision=True, matching the existing OllamaModel / OllamaOpenAIModel / VLLMOpenAIModel entries. Meta's Muse Glimmer emits channel-scoped reasoning and ATEM-style XML tool calls (<atem:function_calls>) instead of <think> tags and JSON, so a serving path only exposes those capabilities if it parses that framing -- which is why the model was previously absent from every catalog except Ollama and vLLM. oMLX 0.5.8.dev3 added Muse Glimmer 30B with channel-scoped output parsing for ATEM tool calls, so reasoning arrives as reasoning_content and tool calls in the standard OpenAI shape, and the flags are honest rather than guessed. Caveat now documented in the catalog and the model matrix: use an mlx-community checkpoint, because oMLX's own Jundot/Muse-Glimmer-30B-oQ4e was quantized by 0.5.8.dev1, before the embedding-normalization fix, and silently breaks tool calling on it -- the model emits <|eot|> right after the reasoning block and never produces an <atem:function_calls> block (jundot/omlx#2589). That is a stale-quantization bug rather than a parser one, so it is a checkpoint to avoid rather than a capability to downgrade. The upstream nvfp4/mxfp4 conversions are omitted (NVIDIA / microscaling formats, not the Apple Silicon path), and LM Studio deliberately gets no Glimmer entry for two independent reasons recorded in its enum: LM Studio distributes the model as GGUF only (so it is not an MLX path at all), and whether its llama.cpp engine parses the channel/ATEM framing is undocumented, so entries there would mean guessing tools/thinking.

  • Fix HuggingFaceModel.QWEN_3_6_27B silently pointed at an FP8 checkpoint (aimu.models.providers.hf.text). The member's id was Qwen/Qwen3.6-27B-FP8 while its name said nothing about quantization, so selecting it as "Qwen 3.6 27B" handed you an e4m3 FP8 checkpoint with dynamic activation scaling -- which needs Ada/Hopper-class tensor cores (compute capability ≥ 8.9) and has no native path on Ampere, MPS, or CPU. Since HuggingFaceClient loads weights in-process, that surfaced as a load or performance failure rather than a clear "unsupported" message, and it made the entry an outlier in a catalog whose every other member is unquantized. It also meant the name shared with OllamaModel.QWEN_3_6_27B (Ollama's default ~Q4 tag) described materially different numerics, which the cross-provider consistency guard cannot detect because it only compares tools/thinking/vision. QWEN_3_6_27B now resolves to the unquantized Qwen/Qwen3.6-27B, and the FP8 checkpoint is reachable as a new explicit QWEN_3_6_27B_FP8 member -- the same bare-plus-quantization shape used for the MLX catalogs above, and with distinct ids so neither becomes a silent enum alias. Both still route to the qwen-multimodal load profile (_load_profile prefix-matches Qwen/Qwen3.6), so the vision loader and the weight-cache key are unaffected. The general rule, now documented in the model matrix: a quantization belongs in the member name when the id pins a specific non-default quantization the caller must choose between (this entry, the MLX _4BIT/_8BIT/_BF16 members, GLM_4_7_FLASH_31B_Q4), and is left out when the provider resolves it (Ollama default tags, LM Studio keys, llama-cpp model_path=). Note the bare member now downloads an unquantized 27B (~54 GB in bf16); use QWEN_3_6_27B_FP8 on supported hardware, or bitsandbytes load-time quantization via model_kwargs (already in the [hf] extra).

Testing

  • Fix --client=llamaserver_openai and --client=sglang_openai silently tested Ollama (tests/helpers.py::_resolve_client, tests/helpers_aio.py::_resolve_async_client_for_type). Both resolvers ended in return OllamaClient # default, and neither had a branch for these two options, so the invocations documented in CLAUDE.md ran the Ollama catalog against Ollama and reported a pass -- the worst kind of green. A real member of either catalog would then have died in create_real_model_client with ValueError: Unknown model. Both options now resolve to LlamaServerOpenAIClient / SGLangOpenAIClient, both catalogs construct in the live-client fixtures (sync and async), and both are included in the --client=all matrix so it matches its "full cross-provider" docstring. The silent fallback itself is gone: ollama is now an explicit branch and an unrecognised value raises with the list of valid options, mirroring the existing _resolve_image_client_cls. So a typo (--client=sglang rather than sglang_openai) fails loudly instead of quietly measuring the wrong provider. Tests: tests/test_client_option_dispatch.py (every documented option resolves to its own class; near-miss values raise).

Channels

  • New The web tool frame carries the call's result (aimu.aio.channels.web.WebChannel.send). The frame is now {"type": "tool", "name", "arguments", "response"}. A TOOL_CALLING chunk is yielded after the call has been dispatched and already carries the tool result on content["response"] (see aimu.aio._tool_loop._dispatch_streamed), so the channel was dropping information a page needs: what the call returned, not only what it was asked to do. An error, an argument-binding failure, and a denied approval are results too and arrive on the same key, so a page needs no separate failure path. response is None only for a chunk that omits it (a provider or a test that builds TOOL_CALLING content by hand). Additive and backwards-compatible on the wire: a page that ignores the key renders as before. CLIChannel is unchanged and still shows the call alone. Tests: tests/test_aio_web_channel.py.

v0.13.0 (2026-08-11): extended model strings, timezone-aware time tools, web form tools, agentic-loop and streaming fixes

Models

  • New Muse Glimmer 30B (MUSE_GLIMMER_30B in OllamaModel, OllamaOpenAIModel, and VLLMOpenAIModel). Meta's Apache-2.0 agentic model (dense causal transformer plus a ViT-G/14 perception encoder, ~29.6B params, 128K+ context, sized for a single consumer GPU). tools=True, thinking=True, vision=True on all three, with Meta's recommended sampling defaults (temperature=1.0, top_p=0.95, top_k=64) on the native Ollama entry; ids are muse-glimmer:30b (Ollama) and meta-models/Muse-Glimmer-30B (vLLM). The model emits channel-scoped reasoning and ATEM-style XML tool calls (<atem:function_calls>) rather than <think> tags and JSON, so a serving path only exposes those capabilities if it parses that framing: Ollama does, and vLLM does when --tool-call-parser muse_glimmer and --reasoning-parser muse_glimmer are enabled together (they key off the same markers, and the reasoning parser forces skip_special_tokens=False; without both, reasoning and answer collapse into one content stream and tool calls never surface). The other catalogs are deliberately left out rather than given guessed flags: SGLang support exists only on a PR branch, llama.cpp/LM Studio tool parsing for this format is undocumented, and the in-process HuggingFaceClient would need both a new load profile (the weights load via AutoModelForMultimodalLM, which the client doesn't import) and a parser for the channel/ATEM markup that no ToolCallFormat covers. Because MUSE_GLIMMER_30B is now a shared name, tests/test_model_catalog_consistency.py holds the three entries' intrinsic flags in agreement.
  • Fix Cross-provider model capability flags reconciled (aimu.models.providers.{hf.text,llamacpp,ollama,openai_compat}). Audited every model shipped under more than one provider and corrected intrinsic capability flags (tools/thinking/vision) that disagreed across providers for the same model. (1) Gemma 4 thinking: GEMMA_4_E4B/GEMMA_4_12B were missing thinking=True on the HuggingFace catalog and GEMMA_4_12B on LlamaCpp, while every server catalog set it; now consistent. (2) Qwen 3.5/3.6 vision: QWEN_3_5_9B/QWEN_3_6_27B (and the Ollama-only QWEN_3_6_35B) were marked vision=True only on HuggingFace; confirmed via the Qwen release notes and the Ollama registry that these are a unified vision-language family (vision built into the base weights), so vision=True is now set on the Ollama, Ollama-OpenAI, and LM Studio entries too. (3) Llama 3.1/3.2 tools: LLAMA_3_1_8B/LLAMA_3_2_3B had tools=False on the consumer-runtime catalogs (Ollama, Ollama-OpenAI, LM Studio, LlamaCpp) from an era when those runtimes emitted unreliable tool calls; live-tested tool use on current Ollama builds (correct tool + arguments across every trial) and enabled tools=True across all four, matching the already-True server catalogs. Two divergences are kept and now documented as intentional (they reflect a real serving-path limitation, not a bug): GEMMA_3_12B tools=False on the in-process HuggingFace/native-Ollama clients (no tool-call parse format assigned; OpenAI-compat servers parse server-side), and GEMMA_4_12B vision=False on LlamaCpp (the default GGUF path loads no mmproj projector). structured_output and audio remain deliberately provider-specific (serving-path flags). See the new consistency guard below and Add a new model.
  • New Cross-provider catalog consistency guard (tests/test_model_catalog_consistency.py). A model shipped under multiple providers uses a provider-specific ModelSpec.id (the wire identifier: qwen3:8b vs Qwen/Qwen3-8B vs qwen3-8b.gguf), but shares one enum-member name (QWEN_3_8B) that resolve_model_enum searches for a bare name. This test enforces that a shared name describes the same model: its intrinsic capability flags (tools/thinking/vision) must agree across every provider that ships it, so a new provider entry that forgets e.g. vision=True fails the suite. structured_output/audio are excluded as serving-path flags; genuine serving-path divergences are registered in an explicit, rationale-carrying allowlist (_INTENTIONAL_DIVERGENCES), and a companion test forces reconciled allowlist entries to be removed. Documented in Add a new model.

  • New Model strings carry an endpoint and capabilities inline (aimu.models.model_client.resolve_model, mirrored on the async path). The text model string grammar is now provider:model_id[@base_url][;flags]. Appending @<base_url> overrides the endpoint for the OpenAI-compatible local-server providers (llamaserver, lmstudio, vllm, hf-openai, sglang, ollama-openai), so a single string can target a remote llama.cpp / vLLM server. A new generic openai-compat:<model_id>@<base_url> prefix reaches any OpenAI-compatible server not tied to a known provider (the @<base_url> is required there). A model id not in the provider catalog is allowed for these providers when its capabilities are declared with ;<flags> (comma-separated from tools,thinking,vision,audio,structured); such ids resolve to a new AdHocModel (exported from aimu.models) instead of raising. Known ids keep their catalog spec and reject ;flags. Cloud providers (openai, gemini) and non-OpenAI-compat providers (anthropic, ollama, hf, llamacpp) reject @<base_url> with an actionable error. No authentication is added; api_key stays unset. Tests: tests/test_model_string.py, tests/test_adhoc_model.py, tests/test_resolve_model.py, tests/test_model_client_base_url.py, tests/test_aio_model_client_base_url.py.

  • New ModelConnectionError when an inference server is unreachable (aimu.models.base, exported from aimu.models and aimu.aio). The OpenAI-compatible clients (aimu.models.providers.openai_compat, aimu.aio.providers.openai_compat; sync + async, streaming + non-streaming) now catch the OpenAI SDK's APIConnectionError at the chat.completions.create call (and during stream consumption, where a mid-stream drop can surface it) and re-raise it as ModelConnectionError from the original error, so the specific transport cause (e.g. httpx.ConnectError: [Errno 61] Connection refused) is preserved on the exception chain. This mirrors the existing MCPConnectionError / A2AConnectionError wrappers and lets a front end distinguish "server is down" from a generic failure instead of receiving a raw, provider-specific exception. Only APIConnectionError is wrapped; genuine HTTP/API errors still propagate with their own detail. Tests: tests/test_openai_compat_connection_error.py.
  • New Messages are timestamped at append time (aimu.models._internal.chat_state._ChatStateMixin._append_message). Every message appended to self.messages now carries an inert timestamp (ISO-8601, append time) via a single _append_message seam that every content-bearing append routes through, on both surfaces: the shared mixin's user-turn / system-seed / tool-call-record paths, each concrete provider's assistant-response append (sync aimu.models.providers.{anthropic,ollama,openai_compat,llamacpp,hf.text}, async aimu.aio.providers.{anthropic,ollama,openai_compat}), and the tool-loop engines' tool-result appends (aimu.agents._tool_loop, aimu.aio._tool_loop, streaming and non-streaming). The user message is stamped at request time and the assistant/tool messages when they arrive, so a consumer gets accurate per-message times without its own bookkeeping. timestamp was already in INERT_MESSAGE_KEYS, so it is still stripped from every provider request; stamping never changes the payload sent to a model. Previously only the sync ConversationManager set the key; the client now fills it on every path (sync and async), and ConversationManager.update_conversation setdefaults it so a client-stamped value wins. _append_message uses setdefault, so a restore replay's existing timestamps are preserved. Tests: tests/test_message_timestamps.py.
  • New Gemma 4 model catalog for the OpenAI-compatible providers (aimu.models.providers.openai_compat). Added the full suite (GEMMA_4_E4B, GEMMA_4_12B, GEMMA_4_26B, GEMMA_4_31B) to every local-server enum (OllamaOpenAIModel, LMStudioOpenAIModel, VLLMOpenAIModel, HFOpenAIModel, LlamaServerOpenAIModel, SGLangOpenAIModel), replacing the lone GEMMA_4_12B entry each previously carried. Capabilities are set from Google's Gemma 4 model card: tools=True, thinking=True, vision=True on all four (thinking surfaces over OpenAI-compat via <think>-tag parsing). Provider-appropriate ids include the MoE google/gemma-4-26B-A4B-it and the dense google/gemma-4-31B-it for the HuggingFace-repo servers. vision=True was also backfilled onto the existing Gemma 3/4 entries. Audio is deliberately left off (only E4B/12B are natively audio-capable, and audio input isn't reliably exposed by these local servers); each enum carries an inline comment recording the transport-specific reason. The async providers inherit these enums, so aimu.aio picks up the new members automatically.

Agents and workflows

  • New SubagentObserver reports sub-agent activity to a display hook (aimu.aio.tools.builtin.make_async_subagent_tool(observer=...)). Passing an observer (a SubagentObserver: spawned/chunk/finished) switches that spawn to a streamed child run and reports it as it happens, while the spawn_subagent tool itself stays non-streaming, so concurrent_tool_calls still lets multiple spawns overlap. The accumulator that builds the returned answer resets on every loop iteration, so the tool's return value is unchanged from the non-observed path: the final answer, not every intermediate tools-only response concatenated. finished fires from a finally, exactly once per spawn, including when the spawn is cancelled. Nested spawns (max_depth > 1) inherit the same observer. An observer callback is display-only: an exception it raises is logged and swallowed rather than failing the spawn, a deliberate, narrow exception to "failures are apparent" because a broken display hook must not break the underlying work it is merely reporting on. That covers a callback whose signature has drifted from the protocol (where the failure happens as the call is made, not as it is awaited) as well as one that raises in its body, and the spawn's remaining callbacks still fire. Because the protocol is satisfied structurally, a partial observer is a legitimate input too: a missing spawned/chunk/finished is logged once per call and skipped rather than raising AttributeError into the spawn. Attaching an observer is not purely additive, though: an observed spawn issues its model calls through the provider's streaming request path where an unobserved one uses the non-streaming path. Tests: tests/test_aio_subagent_tools.py.
  • Fix Agentic loop no longer ends silently on a degenerate turn (aimu.agents._tool_loop, aimu.aio._tool_loop; both the plain and streamed paths). The loop treated any turn without tool calls as the final answer, so a model that returned an empty turn (no content and no tool calls — common with small local models, e.g. after a tool result mid multi-step plan) ended the run with an empty string and abandoned the plan; and hitting max_iterations with a tool call still pending returned the dangling tool-only turn. The loop now classifies its terminal turn (classify_terminal_turn → pending-tools / empty / healthy) and guards both cases: an empty turn is nudged with continuation_prompt (tools still enabled, so the model can resume its plan), bounded by max_iterations; at the cap with tools pending it forces one tools-disabled wrap-up turn (use_tools=False). The forced wrap-up is now unconditional — previously it required an opt-in final_answer_prompt, which now only customizes the wrap-up prompt (a built-in DEFAULT_WRAP_UP_PROMPT is used when unset). If even the wrap-up yields no answer, the loop raises the new DegenerateTurnError (exported from aimu.agents and aimu.aio) instead of returning empty output. Injected continuation nudges are tagged PROVENANCE_CONTINUATION (revived) so a UI can hide them. Behavior change: an agent left at the default final_answer_prompt=None that reaches the cap with pending tools now performs a wrap-up turn rather than returning the dangling turn. continuation_prompt (previously wired but never invoked) is now threaded into the loop and used for empty-turn recovery. Tests: tests/test_agents.py, tests/test_aio_agents.py, tests/test_vision.py.

Memory

  • New Thread-safe memory stores (aimu.memory.base.synchronized, applied across every public method of SemanticMemoryStore and DocumentStore). A store's methods run in worker threads when an async agent dispatches sync memory tools via asyncio.to_thread, so a store shared across concurrent turns (e.g. a multi-session assistant answering two conversations at once) could be entered from several threads simultaneously — interleaving DocumentStore's in-memory dict + on-disk writes, or provoking ChromaDB "database is locked". Each store now guards its public methods with a re-entrant per-store lock (threading.RLock), so calls serialize per store; the lock is re-entrant because methods call each other (DocumentStore.editread + write, storewrite). Reads are serialized too, so a read never observes a half-applied write. Single-threaded use is unaffected. Concrete stores set self._lock = threading.RLock() in __init__; synchronized is exported from aimu.memory.base for future stores. Tests: tests/test_memory.py.

Fixes

  • Fix OpenAI-compat and llama-cpp streaming chat is now incremental (aimu.models.providers.openai_compat, aimu.aio.providers.openai_compat, aimu.models.providers.llamacpp; _chat_streamed). chat(stream=True) drained the entire upstream stream into a buffer before yielding any chunk, so every OpenAI-compatible provider (llama-server, LM Studio, vLLM, HF-Serve, SGLang, ollama-openai) and in-process llama-cpp delivered the whole response as one end-of-generation burst instead of token-by-token; native OllamaClient, which yields per part, was unaffected (which is why streaming appeared to work there but not via the OpenAI-compat path). The buffering existed to detect tool calls before yielding content, but content and tool-call deltas are separate in the OpenAI streaming protocol, so content/thinking chunks are now yielded as they arrive while tool-call deltas accumulate independently (matching the already-incremental _iter_stream used by generate(stream=True)). No caller-visible change beyond incremental delivery; message recording, thinking-key attachment, tool handling, and usage capture are preserved. Tests: tests/test_models_api.py, tests/test_aio_models_api.py.
  • Fix Thinking from reasoning_content is no longer dropped on OpenAI-compat / llama-cpp (same modules; _chat, _generate, _chat_streamed, _iter_stream, sync + async). Servers that parse reasoning tags server-side (llama-server with the default --reasoning-format deepseek/auto, vLLM/SGLang reasoning parsers) return reasoning in a separate reasoning_content field and strip <think> tags from content. The clients only parsed inline <think> tags, so on these servers thinking was silently lost (e.g. gemma-4-31b-it on llama-server emitted no THINKING chunks and left last_thinking empty). The clients now read reasoning_content off the delta/message and surface it as THINKING (streaming) or store it in last_thinking (non-streaming); when present it takes precedence over the <think> parser (which stays for servers that inline tags) and is not gated on supports_thinking (if the server sent it, it is reasoning). Tests: tests/test_models_api.py, tests/test_aio_models_api.py.
  • Fix HAS_LLAMACPP no longer reports installed when llama-cpp-python is absent (aimu.models.providers.llamacpp). The module deferred from llama_cpp import Llama into LlamaCppClient.__init__, so the provider module imported cleanly without the dep and every guarded import (aimu.models.model_client, aimu.models.__init__, aimu.aio.providers.llamacpp) set HAS_LLAMACPP = True as a false positive. llamacpp then appeared in resolve_model's "available providers" list and in _provider_registry(), only to fail later at client construction. The module now does a hard top-level import llama_cpp (matching the diffusers/soundfile convention that keeps HAS_HF_IMAGE/HAS_HF_AUDIO truthful); the Llama weights are still loaded lazily in __init__. With the dep uninstalled, llamacpp correctly drops out of the registry.
  • Fix resolve_model stops advertising openai-compat when its extra is missing (aimu.models.model_client; the async path reuses the same resolver). The "unknown provider" error unconditionally appended openai-compat to the "available providers" list even when HAS_OPENAI_COMPAT was False, so the message contradicted itself: it named openai-compat as available, and using it then failed with a different ImportError ("requires the openai-compatible extra"). The list now includes openai-compat only when the openai_compat extra is installed.
  • Fix Ollama thinking + multi-tool-call turn crash (aimu.models.providers.ollama and aimu.aio.providers.ollama). Non-streaming _chat recorded the turn's thinking onto self.messages[-1 - len(tool_calls)], but _record_tool_calls appends exactly one assistant message, so the offset pointed len(tool_calls) messages too far back: it wrote thinking onto an earlier message, and on a short history (e.g. a freshly spawned sub-agent's first turn, messages=[user, assistant]) with two or more tool calls it raised IndexError: list index out of range, surfacing as Tool call 'spawn_subagent' failed: list index out of range. Now indexes the just-appended assistant message (self.messages[-1]), matching the streaming path and the OpenAI-compat provider. Tests: tests/test_ollama_streaming.py.

Tools

  • New web-interaction tools in aimu.tools.builtin. get_webpage_html(url) is a stateless @tool (added to builtin.web + ALL_TOOLS + the MCP server) that returns a page's raw HTML markup (truncated), complementing the existing text-stripping get_webpage. make_web_tools(*, session=None, timeout=15, max_content_chars=20000, user_agent=...) is a factory returning [find_forms, submit_form] closing over a shared requests.Session, so cookies persist across calls and a GET-then-POST form flow works: find_forms(url) parses every <form> (stdlib html.parser; no new dependency) into a listing of resolved-absolute action / method / fields including type=hidden (CSRF tokens surface), and submit_form(url, method="POST", data=None) submits via POST (form body) or GET (query params), returning status + final URL + truncated body — method="GET" doubles as a session-aware raw fetch for pages behind a login. Pass them together: Agent(client, tools=[get_webpage_html, *make_web_tools()]). Server-rendered HTML only (no JavaScript execution): JS-rendered SPAs and anti-bot-protected pages are out of scope; a headless-browser backend is a possible future addition. submit_form is the mutating tool — gate it via the tool_approval hook when confirmation is wanted. Both are re-exported from aimu.aio.tools.builtin (dispatched via asyncio.to_thread). How-to: Fetch HTML and submit web forms. Tests: tests/test_web_tools.py.
  • Change @tool docstring and schema generation (aimu.tools.decorator). The model-facing description is now the full prose before the first Google-style section header (Args:, Returns:, ...) rather than only the first paragraph, so guidance placed in later paragraphs is no longer silently dropped. An Args:/Arguments:/Parameters: section is parsed into per-parameter descriptions (name: text or name (type): text entries, with more-indented continuation lines joined), and a Literal[...] parameter now emits a JSON Schema enum (with the element type when the literals are homogeneous) advertising the exact allowed values instead of a bare "string". Previously an opaque dict/Literal parameter reached the model as a structureless {"type": "object"} / "string" and multi-paragraph guidance was truncated, so models routinely guessed wrong argument shapes. _json_type_for (used by structured-output schema generation) is unchanged; the tool path uses a new Literal-aware _schema_for. Tests: tests/test_tool_decorator.py.
  • Change get_current_date_and_time is timezone-aware (aimu.tools.builtin). The tool returned str(datetime.datetime.now()) — a naive timestamp (2026-08-11 02:21:54.586824) carrying neither a UTC offset nor a zone name, so a model could not tell whether it was PDT or UTC and every cross-location calculation rested on an unstated guess. It now returns an offset-aware ISO-8601 timestamp annotated with its IANA zone, abbreviation, UTC offset, and UTC equivalent (2026-08-11T02:48:29-07:00 (America/Los_Angeles, PDT, UTC-07:00; 2026-08-11T09:48:29Z)), at seconds precision. The zone name is the part that matters beyond disambiguating "now": an offset alone cannot tell a model what the offset will be at some other date, so DST reasoning needs the key. A new optional timezone= parameter (an IANA name) reports the current time in any location, which answers "what time is it there" without arithmetic at all. Because the stdlib exposes no API for "which IANA zone is this machine in", the local key is derived from the TZ environment variable and then the /etc/localtime symlink target; when neither yields one (notably on Windows) the zone name is omitted and only the abbreviation and offset are reported — degraded, never wrong. An unknown name returns a teaching string (Unknown timezone: 'Tokyo'. Use an IANA name such as 'Asia/Tokyo' ...) rather than raising, matching get_weather, so the model self-corrects from the tool result. City names and abbreviations are deliberately not accepted: a mapping table is a maintenance burden and genuinely ambiguous ("Portland"). Behavior change: the return string's shape changed. Nothing in the library parses it (it exists to be read by a model), so the only exposure is a caller's own prompt that assumed the old format. Tests: tests/test_time_tools.py.
  • New convert_time(datetime_str, from_timezone, to_timezone) (aimu.tools.builtin, added to builtin.time + ALL_TOOLS + the MCP server, re-exported from aimu.aio.tools.builtin). Converts an ISO-8601 timestamp between IANA zones for dates other than now ("the meeting is 3pm Nov 2 in Berlin — what is that in Denver?"), which the anchor tool above cannot express. The value it adds over letting a model do the arithmetic is the two cases zoneinfo otherwise resolves silently (defaulting to fold=0), which are precisely the errors a cross-zone calculation needs surfaced: a nonexistent wall-clock time in the spring-forward gap, and an ambiguous one in the fall-back overlap. Both are detected and reported as a note: line, and a nonexistent time is displayed as the instant it actually resolves to, so the output never shows a reading that did not occur. An input that already carries a UTC offset keeps it and the output notes that from_timezone was ignored — models routinely emit aware strings, so rejecting them would cost a round trip for no gain, and the note keeps the override visible rather than silent. Unparseable timestamps and unknown zones return teaching strings. Tests: tests/test_time_tools.py.
  • Change convert_time accepts the wall-clock formats a model actually emits (aimu.tools.builtin._parse_datetime). fromisoformat requires a zero-padded 24-hour time, so a model asked for ISO 8601 that produced 2026-08-11T5:00:00 (unpadded hour) or 2026-08-11 5:00 PM (12-hour clock) got a teaching string and had to spend a round trip reformatting — the most common way the tool failed in practice. Both are unambiguous once a date is present, so they are now parsed: the hour is padded, a meridiem is resolved (with 12 AM00 and 12 PM12, the two a naive +12 gets wrong), and an optional Z/±HH:MM offset is preserved. Normalization rebuilds a strict ISO string and defers to fromisoformat, so calendar validation stays in one place. Deliberately still rejected, because each would require a silent guess: a bare time with no date (10:00 AM — inferring today defeats the tool's purpose, which is times other than now; get_current_date_and_time is the tool for now, and the failure string now says so), a contradiction (13:00 PM), and prose dates (November 2, 2026). Timezone arguments are unchanged and still IANA-only — PST and Pacific Time remain rejected for the ambiguity reasons noted above, and the tool description now states that requirement and carries a full example call in its prose, where the @tool parser puts it in the model-facing description (an Example: section would be parsed as a section header and dropped). Tests: tests/test_time_tools.py.
  • Change New builtin.time subgroup; the time tools leave builtin.misc (aimu.tools.builtin, re-exported from aimu.aio.tools.builtin). time = [get_current_date_and_time, convert_time] and misc narrows to [echo]. The two tools were only in misc because that is where ungrouped built-ins landed, which made "grant this agent a clock" inseparable from "grant it echo" — a real constraint for a caller composing a narrow toolset, since an agent scoped to filesystem or compute work still has to resolve "by tomorrow morning" and had no group to draw the clock from. Grouping by domain also matches how the other subgroups are drawn, and gives the pair a home as more time tools are added. ALL_TOOLS gains *time where it previously picked both up via *misc, so the default set and the MCP server (which registers every ALL_TOOLS entry) are unchanged. Behavior change: a caller passing tools=builtin.misc and expecting get_current_date_and_time now gets only echo and must pass builtin.time (or builtin.misc + builtin.time) — the one break, and it is import-time visible only as a missing tool at runtime, not an AttributeError, so check any call site that names misc. In-tree callers updated: examples/personal-assistant (web + time + misc) and tutorial 02, which uses the group for a date question and now asks for compute + time. The name deliberately shadows the stdlib time module as a builtin attribute; nothing in the module imports it (date work uses datetime), and a comment at the definition records that adding such an import would be silently rebound. Tests: tests/test_time_tools.py.
  • Change zoneinfo added to the execute_python sandbox allowlist (aimu.tools.builtin._SANDBOX_ALLOWLIST). The sandbox permitted datetime but not zoneinfo, so the one general-purpose escape hatch for date math could only do fixed-offset arithmetic and had no way to be DST-correct either. It is a complement to the two tools above rather than a substitute, since execute_python is opt-in and not in ALL_TOOLS by default.
  • Change tzdata is now a Windows-only dependency (pyproject.toml, tzdata; sys_platform == 'win32'). Windows ships no system tz database, so zoneinfo — which the time tools above depend on — has no data to read and raises ZoneInfoNotFoundError for every zone key without it. Confined by the marker to the one platform that needs it; other platforms are unaffected.

Internal

  • Change De-duplicated identical sync/async code into shared homes (no public API or behavior change). Three blocks that were byte-for-byte identical between the sync surface and its aimu.aio twin now live in one place, continuing the established sharing pattern (_ChatStateMixin, _internal/streaming.py, the composed provider format adapters, the shared tool-loop terminal classification) rather than inverting the sync/async dependency:
  • The tool-call recording helpers (_prepare_tool_calls, _append_assistant_tool_calls, _record_tool_calls) and structured-request resolution (_structured_request) moved from BaseModelClient (aimu/models/_base/text.py) and AsyncBaseModelClient (aimu/aio/_base.py) into the shared _ChatStateMixin (aimu/models/_internal/chat_state.py) that both already inherit.
  • The async-free members of the tool-loop engine (__init__, _current_tools, _pending, _tag_injected, _wrap_up_prompt, _tool_call_kwargs, _not_approved) extracted into a new _BaseToolLoop in aimu/agents/_tool_loop.py, subclassed by both _ToolLoop and aimu.aio._tool_loop._AsyncToolLoop; only the loop drivers and dispatch (threads vs asyncio.TaskGroup, await) stay per-surface.
  • The near-identical in-process async wrappers AsyncHuggingFaceClient / AsyncLlamaCppClient reduced to ~5-line subclasses of a new _AsyncInProcessClient (aimu/aio/providers/_inprocess.py) that holds the state-sharing properties, _generate/_chat, and _stream_via_thread (each subclass sets only MODELS + _SYNC_CLASS; the wrap-refusal error is preserved).

Net −339 lines. Verified by the existing mock suites (models, structured, agents, tools, decorator, approval, provenance, checkpointing — sync + async).

Documentation

  • Change The notebooks/ tutorial collection migrated from Jupyter .ipynb to plain-text Quarto .qmd (markdown with executable python cells), so notebooks diff cleanly and are easy to edit or hand to an AI assistant. Files are renamed to kebab-case (01 - Model Client.ipynb01-model-client.qmd), and a notebooks/_quarto.yml makes the set a browsable Quarto website (quarto preview notebooks/). Notebooks are not executed at render time (execute: eval: false): most need a live backend (Ollama, a cloud API key, or a GPU) and gracefully skip; opt a cheap notebook into eval: true + freeze: auto per file. The docs site (MkDocs + Material) is unchanged; how-to/tutorial deep-links now point at the .qmd files. The [notebooks] extra is now jupyter + jupytext (the latter lets JupyterLab open the .qmd files as native notebooks); the Quarto CLI installs separately (a standalone binary). Convention documented in Contributing.

v0.12.0 (2026-07-14): tool-calling refactor, dynamic sub-agent spawning

Agents and workflows

  • New dynamic sub-agent spawning: aimu.tools.builtin.make_subagent_tool(model, *, system_message=, tools=, agent_types=, max_depth=1, ...) (async twin aimu.aio.tools.builtin.make_async_subagent_tool) returns a spawn_subagent @tool that lets an agent delegate an independent subtask to a fresh sub-agent with its own isolated context at runtime — AIMU's answer to the Claude-Code-style Task pattern. It is the dynamic complement to OrchestratorAgent: an orchestrator dispatches to a fixed roster wired up front, while spawn_subagent lets the LLM decide the fan-out. Two shapes: generic spawn_subagent(task) (a general-purpose sub-agent) or, with agent_types=, typed spawn_subagent(agent_type, task) over a registry of named specialists (unknown types are returned to the model to self-correct; the menu is listed in the tool description). Each spawn builds a fresh ModelClient (isolated history, the make_workers idiom); parallelism is free — give the parent concurrent_tool_calls=True and multiple spawns in one turn run concurrently (ThreadPoolExecutor sync / asyncio.TaskGroup async). max_depth (default 1) bounds recursion. Non-streaming by design (keeps the concurrent path and avoids interleaving). Composes as a plain tool (no new Runner subclass). How-to: Spawn sub-agents; demo: examples/news-summarizer --method spawn.
  • New approval gate for spawned sub-agents: make_subagent_tool / make_async_subagent_tool gained a tool_approval= parameter — the same (name, arguments) -> bool hook Agent accepts — forwarded into every spawned sub-agent (and, with max_depth > 1, into the sub-agents they spawn). A parent can route a delegated sub-agent's tool calls through its own approval policy instead of letting them run unattended; the default is unchanged (approve_all). How-to: Gate a sub-agent's tools.
  • Change Tool calling is now split into three layers, one responsibility each, so the boundaries are apparent:
  • Model client — a pure provider adapter. chat() is a single model turn: it advertises the tools= it's given, issues one request, parses any tool calls, and stores them on the assistant message (content + tool_calls + thinking) without executing them. It no longer holds a persistent tool registry or any approval/deps/concurrency state (self.tools is an internal per-call transient defaulting to []).
  • Tool-loop engine (internal)aimu.agents._tool_loop._ToolLoop (sync) / aimu.aio._tool_loop._AsyncToolLoop (async) owns the iterative tool-calling logic: when a turn requests tools it dispatches them (arg coercion, approval, ToolContext(deps) injection, concurrent_tool_calls), appends the role:"tool" results, and calls the client again until a turn makes no tool calls (bounded by max_rounds), then the optional final_answer_prompt wrap-up. Not public API; the ladder stays chat()Agent → workflows.
  • Agent — autonomy + composition. Configures and drives the engine (tool callables, deps, tool_approval, concurrent_tool_calls, max_iterations, final_answer_prompt), and adds identity, Runner, as_tool(), as_model_client(), restore(), from_config(), and the schema= short-circuit. Tool config lives on the Agent (fields + per-run run(tools=/deps=/tool_approval=) overrides); the Agent never pushes it onto the model client.

This removes the old double-generation (a tool-using turn used to produce the answer twice) and the muddiness of the client both parsing and executing tools. chat() gained an optional user_message (default None = "run a turn on the current messages, appending no new user turn" — the continuation primitive the engine uses). Applies to sync + aimu.aio, non-streaming + streaming, every provider, and FallbackClient. Behavior change: a bare client.chat("q", tools=[...]) now parses and stores the tool call but does not execute it — use Agent(client, tools=...).run("q") (or agent.as_model_client()) for a full tool-using answer. Removed from the model client: _handle_tool_calls / _handle_tool_calls_streamed / _call_plain_tool and the tool_approval / tool_context_deps / concurrent_tool_calls attributes (they live on the Agent + engine now). Anthropic stores the real tool_use block IDs directly (folding in the old _patch_tool_ids). The async→sync tool bridge (aimu.aio.providers._sync_tool_bridge, added in v0.9.0) is removed: it existed only so the sync _chat's in-thread tool dispatcher could call async tools, but the sync _chat no longer dispatches (it just advertises + stores), so wrapped in-process async clients (AsyncHuggingFaceClient / AsyncLlamaCppClient) now pass async tools straight through and the async engine dispatches them. Deprecated (kept as accepted no-ops): Agent.continuation_prompt / DEFAULT_CONTINUATION_PROMPT; PROVENANCE_CONTINUATION is no longer produced (kept for legacy transcripts). max_iterations and final_answer_prompt are unchanged.

v0.11.0 (2026-07-05): personal-assistant primitives, streaming structured output, sessions

Models

  • New streaming structured output: schema= now combines with stream=True on chat() / generate() (sync + aimu.aio), lifting the previous ValueError. The call returns a StreamChunk iterator so thinking / generation stream live, then a terminal DONE chunk carries {"result": <validated object>}; the object is also stored on client.last_structured once the stream is consumed (mirrors last_usage; proxied through ModelClient / _AgenticView / FallbackClient and their async twins, cleared by reset()). An include= filter still applies to the thinking/generation phases, but the terminal result chunk is always emitted. Ollama and OpenAI-compatible thinking models stream thinking alongside the schema-constrained answer (Ollama now threads format= into its streamed call); Anthropic streams the answer JSON as it is built (GENERATING via input_json_delta) with no thinking, because its structured mode is a forced tool_choice the API forbids alongside extended thinking (no regression: Anthropic structured output never produced thinking). Also threaded through Agent.run(schema=..., stream=True) (sync + async Agent / SkillAgent). Docs: Get structured output.
  • Fix Ollama streaming (OllamaClient + AsyncOllamaClient, chat(stream=True)) sometimes dropped a tool call and streamed an empty response instead. The logic decided tool-vs-answer from the single part that ended the thinking stream, so when Ollama emitted an empty transitional part (content="", no tool_calls) before the part carrying tool_calls, the tool call was missed and one or more empty GENERATING chunks were yielded (a stray/empty response bubble in the web UI). _chat_streamed now consumes each turn fully, collecting tool_calls from any part and yielding only non-empty GENERATING chunks (which also drops the cosmetic empty trailing done chunk). As defense-in-depth, the personal-assistant WebChannel and aimu.aio.CLIChannel skip empty GENERATING chunks.
  • Fix local thinking models now record their reasoning in self.messages consistently. The llama-cpp and OpenAI-compat local-server clients (sync + aimu.aio) previously dropped a turn's reasoning from the conversation entirely, keeping it only on the (overwritten-each-call) last_thinking, while HuggingFace and Ollama attached it to the assistant message under a "thinking" key. All four now attach it under the same key (omitted when a turn produced no reasoning), so per-turn reasoning is uniformly available for UI display (examples/web/streamlit_chatbot.py) and ConversationManager persistence. This also covers the tool-call turn in an agentic loop: the reasoning that precedes a tool call is attached to the assistant message carrying tool_calls (matching the existing HuggingFace/Ollama behavior), so every assistant message that had reasoning carries its own. The "thinking" key is inert metadata: chat templates and request adapters read only role/content/tool_calls, so prior-turn reasoning is not re-fed to the model on subsequent turns (the recommended behavior for Qwen3/Gemma/DeepSeek-R1). New explanation page Thinking and the model context.

Sessions and persistence

  • New aimu.sessions: a multi-user session store keyed by channel:sender, so one process can serve many users/chats. Session holds a conversation's list[dict] history (OpenAI format) + an optional memory_namespace + metadata; SessionStore (ABC) has InMemorySessionStore (non-durable) and TinyDBSessionStore (durable, reusing ConversationManager's TinyDB mechanics, no new dep). session_key(channel, sender) collapses single-user to "default:default", and SessionLocks gives a lazy per-key asyncio.Lock (serialize a session's turns; run different sessions concurrently). Generalizes the single-conversation ConversationManager using the existing reset()+restore() per-turn seam (agents never share a live messages list). First piece of the personal-assistant substrate roadmap (network channel adapters and run-safety hooks are separate follow-ups).

Memory

  • Fix DocumentStore now canonicalizes every path through a single _normalize helper (single leading slash, forward slashes, posixpath.normpath to collapse redundant separators and contain ..). Previously write("foo.md", ...) stored the key verbatim while _load_from_disk() always re-keyed it with a leading slash, so in persistent mode a document written without a leading slash became unreadable by its original key after reload. write/read/delete and the list_paths(prefix=...) filter now normalize their inputs, so "foo.md" and "/foo.md" address the same document consistently across ephemeral and persistent stores.

Agents and workflows

  • New async-first channel transport under aimu.aio.channels: a Channel ABC (receive() async-generator, async send(), aclose()) and ChannelMessage plain-data type, plus a CLIChannel stdin/stdout adapter. A new uniform interface alongside AsyncRunner / MemoryStore for talking to a user over a transport; network adapters (Telegram/Slack) are a deferred follow-up behind an optional extra + HAS_* guard, kept out of core. Exported from aimu.aio.
  • New WebChannel (aimu.aio.channels.web, exported from aimu.aio): the WebSocket twin of CLIChannel. Bridges one browser WebSocket onto the Channel ABC (a server pump feed()s inbound text into a queue receive() drains; send() relays a finished string or a streamed reply as JSON frames). The frame protocol is {"type": "message"|"token"|"thinking"|"tool"|"done", ...} (a finished message carries proactive when there is no reply_to); a public send_frame(frame) is the subclass seam for apps adding their own frame types (conversation lists, approval prompts). The websocket is duck-typed (send_json/close), so the adapter needs no starlette import and is unit-testable with a fake. The Starlette server, route, and HTML page stay app-side (see examples/personal-assistant/web_assistant.py); only the reusable adapter moved into the library.
  • New aimu.aio.Scheduler: runs interval (every) and one-shot (at) async jobs concurrently under one asyncio.TaskGroup, for proactive assistant triggers (reminders, check-ins). A job that raises is logged and the loop continues (one bad reminder can't kill the daemon); run() is single-use and honors a stop() signalled before it started (no lost-stop race). Persistence is intentionally out of scope. Exported from aimu.aio.
  • New aimu.aio.RunHandle: cooperative cancellation for an in-flight aio.Agent.run(...). RunHandle.start(coro) schedules the run as a task; cancel() stops it at the next await, await result() returns the result or raises asyncio.CancelledError. The async Agent loop now snapshots its messages in a finally, so a cancelled run still records its partial turn for resume via restore(). Async-only (asyncio cancellation; no threaded token). The personal-assistant example gains a /stop that cancels the current reply. How-to: Cancel a run.

Skills

  • New runtime skill authoring: aimu.skills.write_skill(name, description, body, *, skills_dir, ...) writes a discoverable SKILL.md (slug validation + traversal guard + no-clobber + parser round-trip), and aimu.skills.make_skill_authoring_tool(manager, skills_dir) returns an async author_skill @tool for the Hermes-style self-improvement loop. New SkillManager.refresh() invalidates the discovery cache so a skill authored mid-run is visible.
  • New skill scripts (Python + shell), authored and runnable mid-turn: a skill's scripts/*.py and scripts/*.sh are each registered as a {skill}__{stem} tool that runs the script as a subprocess (.py via the current Python, .sh via bash), now with an optional args string forwarded to the script's argv (shlex-split; backward-compatible). write_skill(..., scripts={"name.py"|"name.sh": source}) writes them (.sh marked executable); aimu.skills.make_skill_script_tool(agent, manager, skills_dir) returns an async add_skill_script @tool. New SkillAgent.reload_skills() (sync + aimu.aio) rebuilds the skills server, re-snapshots the skill tools (surfaced through the Agent's _effective_tools, re-read each engine round), and re-injects the catalog in place, so a script the assistant authors is callable in the same turn and a newly authored skill now appears in the catalog mid-conversation (retiring the prior "catalog injected once" limitation). Scripts run with full access (no sandbox), matching OpenClaw/Hermes; builtin.execute_python remains the sandboxed alternative.

Tools

  • New MCPClient (sync aimu.tools.MCPClient + async aimu.aio.MCPClient) accepts a remote server by url=, plus auth= (a bearer-token string or "oauth") and headers=. A url= is folded into a single-server mcpServers config so FastMCP infers SSE vs streamable-HTTP and applies auth/headers in one path (shared _build_transport helper); auth/headers without url raises. This makes hosted MCP services usable through the existing as_tools() path with no config-dict boilerplate. auth= also accepts a configured provider object (a FastMCP OAuth / httpx.Auth instance) for persistent OAuth token storage or a custom redirect handler; it is forwarded straight to the fastmcp.Client (and cannot be combined with headers=).
  • New make_document_tools(store) in aimu.tools.builtin (parallel to make_memory_tools): wraps a DocumentStore's path API as save_document / read_document / list_documents / search_documents @tools. The names are distinct from make_memory_tools' triad, so one agent can carry both a SemanticMemoryStore (facts) and a DocumentStore (documents). make_memory_tools, make_document_tools, and make_retrieval_tool are now re-exported from aimu.aio.tools.builtin for async discoverability.
  • New tool-call approval hook (aimu.ToolApproval + aimu.approve_all): an optional gate (tool_name, arguments) -> bool run right before each tool call; deny appends a refusal tool message so the model can react. Additive and off by default (approves everything). Set it on a client (client.tool_approval = policy) for bare chat(), or on an Agent (Agent(tool_approval=...) / per-run run(tool_approval=...)), on both the sync and aimu.aio surfaces (async policies may be coroutines). It gates every dispatch path (non-streaming, streaming, concurrent). The personal-assistant example uses it to confirm the full-access add_skill_script tool in the terminal by default. How-to: Gate tool calls.

Examples & docs

  • New examples/personal-assistant/: a single-user, always-on assistant (OpenClaw / Hermes Agent style) assembled from the primitives above (CLIChannel + Scheduler for a proactive reminder + a SkillAgent that authors skills via author_skill and runnable Python/shell scripts via add_skill_script, persisted via ConversationManager, with a small fixed set of built-in tools builtin.web + builtin.misc). A deliberately minimal teaching reference: selectable tool groups, remote MCP servers, and persistent memory are capabilities AIMU ships (see the how-to guides) but the example leaves out. Includes a CLI entry point and mock-only tests.
  • New web front end for the personal assistant: examples/personal-assistant/web_assistant.py (a Starlette + uvicorn WebSocket server) with an example-local WebChannel (a Channel over a browser WebSocket) and a dependency-free static page. Streams replies and pushes proactive scheduler messages to the browser, with no change to the Assistant loop, a worked example of extending the Channel ABC.
  • New both personal-assistant channels can surface per-turn reasoning and tool calls, not just the final answer. CLIChannel gains opt-in show_thinking / show_tools flags (off by default, preserving the minimal library default); the example-local WebChannel emits thinking / tool frames the page renders as distinct blocks. The example enables both via AssistantConfig.show_thinking / show_tools.
  • New how-to guide Build a personal assistant (incl. a "Web front end" section); aimu.aio and aimu.skills API references extended with the new symbols.

Packaging (breaking)

  • Moved the Streamlit/Gradio chat apps from web/ to examples/web/, consolidating all runnable programs under examples/.
  • Breaking streamlit and gradio are no longer core dependencies; they (with starlette/uvicorn for the personal-assistant web UI) moved to a new optional [web] extra. Install the web UIs with pip install aimu[web]. aimu[all] now includes web.
  • New [tuning] extra (pandas, tqdm) for the prompt-tuning subsystem and the evals Benchmark harness, which previously imported these without declaring them. aimu.prompts now imports the PromptTuner subclasses lazily, so import aimu and from aimu.prompts import PromptCatalog / Scorer work without the extra; touching a tuner class raises ModuleNotFoundError only if [tuning] isn't installed. Included in aimu[all].
  • Breaking the [deepeval] extra is renamed to [evals] (pip install aimu[evals]); the DeepEval adapters, module paths, and HAS_DEEPEVAL flag are unchanged. Extras are now documented in two groups, provider backends (ollama, anthropic, openai_compat, google, llamacpp, hf) and capabilities (web, tuning, evals, a2a), with dev / notebooks / docs as development tooling.

v0.10.1 (2026-06-24): cleanup: unified modality factory kwargs, keyword-only restore(), async SkillAgent parity + import-guard hardening

Models

  • Change the modality factory classes (ImageClient, AudioClient, SpeechClient, TranscriptionClient, EmbeddingClient) now take provider construction kwargs directly as **kwargs, matching ModelClient(model, base_url=...) and the top-level aimu.image_client(model, variant="fp16") helpers: ImageClient(HuggingFaceImageModel.SDXL_BASE, variant="fp16"). The old model_kwargs={...} argument is removed (pass the kwargs directly instead). The concrete provider clients (HuggingFaceImageClient, etc.) are unchanged and still take model_kwargs=.
  • Fix optional-provider import guards (aimu.models, ModelClient, and their aimu.aio mirrors) now catch ImportError instead of bare Exception. A real error inside a provider module (a SyntaxError, an AttributeError, a broken transitive dependency) was previously swallowed and the provider silently reported as "dependency not installed," surfacing later as a confusing "no client for …" message; the real cause now propagates at import time.

Agents and workflows

  • Change the composite-runner restore() selectors are now keyword-only and give clear errors on a bad selector (sync + aimu.aio): Chain.restore(messages, *, step=0), Parallel.restore(messages, *, worker=0), Router.restore(messages, *, route=None). step / worker out of range now raise IndexError with a descriptive message (Router already raised KeyError on an unknown route). Existing keyword calls are unaffected; only positional selector calls (e.g. chain.restore(msgs, 1)) need updating to step=1. The semantic names are kept rather than collapsed to a generic target=.
  • Fix async SkillAgent.run() (aimu.aio) ignored deps= and schema=, which its sync twin and aio.Agent.run() both accept; async skill users silently lost ToolContext dependency injection and structured output. The async override now mirrors aio.Agent.run() in full: deps=, schema= (mutually exclusive with stream=True), and the final_answer_prompt forced-wrap-up on both the streamed and non-streamed paths.

v0.10.0 (2026-06-23): A2A interop + resilience (fallback, timeout/retry), Anthropic prompt caching, streaming usage, uniform restore

Models

  • New streaming token usage: client.last_usage now populates after a fully-consumed chat(stream=True) / generate(stream=True), where before it was reset to None. OpenAI-compat clients request it via stream_options={"include_usage": True} and read the terminal usage chunk; Ollama reads the final streamed part's eval counts; Anthropic reads stream.get_final_message().usage (which also carries the P1-A cache-token fields). Usage is set once the stream is drained (reading mid-stream still yields None), and matches the non-streaming semantics (final turn's counts). Hardened the OpenAI-compat stream loop against empty-choices chunks. In-process providers (HuggingFace, LlamaCpp) expose no streaming counts and still leave it None.
  • New opt-in Anthropic prompt caching: AnthropicClient / AsyncAnthropicClient accept cache_prompt=True (threads through aimu.client("anthropic:...", cache_prompt=True)), which marks the system prompt and the tool definitions with cache_control: {"type":"ephemeral"} breakpoints at request time (the large, unchanging prefix an agent resends every turn). Markers are injected in the two format adapters, so all request paths (chat, tool-follow-up, streaming, structured) are covered. Below Anthropic's minimum cacheable size the API silently skips caching, so the flag is safe to leave on. usage_from_anthropic now also surfaces cache_creation_input_tokens / cache_read_input_tokens in client.last_usage when the response reports them, so cache creation/hits are observable (the base input/output/total_tokens keys are unchanged). Pure passthrough; no AIMU-side caching layer.
  • New FallbackClient (sync) / aio.AsyncFallbackClient (async): wrap an ordered list of BaseModelClients and fail over to the next on error. The first client that answers wins; a raising client (by default any Exception, narrowable via retry_on=) hands off to the next with the same conversation state, so multi-turn history is preserved across a failover; when all fail, FallbackExhaustedError is raised with the last error chained as __cause__ (and all errors on .errors). Because it is a BaseModelClient, it drops into Agent, workflows, Benchmark, and agent.as_model_client() with no failover-specific wiring. Streaming fails over only before the first chunk is emitted. Pure policy layer (no backoff/sleep); pair with per-client timeout/max_retries for in-SDK retry plus cross-provider failover. Exported from aimu, aimu.models, and aimu.aio.
  • New timeout and max_retries on the networked model clients (sync + aimu.aio), forwarded verbatim to the underlying SDK so requests get a bounded timeout and automatic retry on transient failures: aimu.client("anthropic:claude-sonnet-4-6", timeout=30, max_retries=5). Supported by Anthropic, OpenAI, Gemini, and every local OpenAI-compat server (LM Studio, vLLM, llama-server, SGLang, Ollama-OpenAI, HF-Serve) via the anthropic/openai SDKs' native support. Ollama's native client supports timeout (the sync OllamaClient now holds an ollama.Client instance rather than calling module-level functions) but has no request-retry, so passing max_retries to it raises ValueError pointing at the ollama-openai provider. In-process providers (HuggingFace, LlamaCpp) are not networked and don't accept these kwargs. No retry/backoff machinery is implemented in AIMU; this is pure passthrough to the SDKs.

Tools

  • New runtime tool-argument validation. Model-supplied tool-call arguments are now validated and lax-coerced against each @tool function's type hints before the tool runs (sync, aimu.aio, and the streaming / concurrent dispatch paths alike, via the shared _ChatStateMixin._tool_call_kwargs). A coercible mismatch is coerced ("5"5 for an int param); an uncoercible value, a missing required argument, or an unknown argument raises the new ToolArgumentError, which the dispatcher reports back to the model as a tool result so it can self-correct (distinct from a tool that runs and crashes). A Pydantic TypeAdapter per parameter is built once at decoration time, so dispatch stays cheap. The validator is exposed as aimu.tools.coerce_tool_arguments(fn, arguments). MCP as_tools() wrappers carry no local type hints and pass through unchanged (their server validates). pydantic>=2, previously a transitive dependency, is now a declared core dependency.

Agents and workflows

  • New restore() on every composite runner and full aimu.aio parity. The save/restore pattern (persist a failed run's list[dict], reload, resume) now covers Router.restore(messages, route=None) (route key selects a handler; None restores the routing classifier), Parallel.restore(messages, worker=0) (index selects a worker), and OrchestratorAgent.restore(messages) (delegates to the inner orchestrator agent), in addition to the existing Agent / Chain / EvaluatorOptimizer. The async surface previously had no restore(); all six aio runners now mirror their sync twins. restore() stays per-class (signatures vary by selector), not on the Runner ABC.
  • New Runner.as_tool(*, name=None, description=None) (sync and aimu.aio): wraps any agent or workflow as a @tool-style callable (tool(task: str) -> str) that delegates to run(). This is the seam that lets an autonomous Agent call any Runner (including a Chain / Router / Parallel workflow or a remote A2A agent), not just other agents. The name defaults to the runner's name (sanitised), the description to the first line of its system_message (or a generic fallback for workflows).
  • Change OrchestratorAgent.assemble(workers=...) now accepts list[Runner] (was list[Agent]) on both surfaces, wrapping each worker via Runner.as_tool(). Worker dispatch can now target a workflow or a remote agent, not only an Agent. Existing Agent-only call sites are unaffected; the internal _wrap_worker_as_tool helper is removed in favour of as_tool().

A2A interop (new optional a2a extra)

  • New aimu.agents.a2a: Agent2Agent protocol interop, the agent-level analog of the MCP tool surface (aimu.tools.MCPClient / python -m aimu.tools.mcp). Install with pip install 'aimu[a2a]'; aimu.agents.HAS_A2A reports availability. A2A types never leak into Runner / Agent core; they adapt at the boundary.
  • Consume: RemoteAgent.connect(url) resolves a remote agent card and returns a local Runner. Because it is a Runner, a remote A2A agent composes like any local one (into Chain / Router / Parallel / OrchestratorAgent.assemble(workers=[...]), or into an Agent's tool list via remote.as_tool()), with no A2A-specific wiring. The sync client drives the async a2a-sdk through an anyio portal (mirroring MCPClient); aimu.aio.a2a.RemoteAgent uses it natively and supports incremental message/stream streaming.
  • Expose: serve_a2a(runner) (blocking) / build_a2a_app(runner) (returns a Starlette ASGI app) wrap any Runner as an A2A server with an agent card at /.well-known/agent-card.json. CLI: python -m aimu.agents.a2a --model ... --system ... --port 9000.
  • Pinned to the a2a-sdk 0.3.x line (pydantic-native API matching the A2A ecosystem); the protobuf 1.x line is a tracked future migration. Connection / call failures raise A2AConnectionError.

Documentation

  • New notebook 23 - Composing Agents (A2A), explanation page A2A vs MCP, and how-to Connect agents (A2A).

v0.9.1 (2026-06-16): EvaluatorOptimizer revision-prompt fix

Agents and workflows

  • Fix EvaluatorOptimizer (sync and aimu.aio) lost the draft it was revising. The revision prompt carried only the evaluator's feedback and the original task, so when the generator was an Agent with a system prompt (which resets its conversation on every run()) it could not see its prior response and effectively regenerated from scratch each round instead of revising. The revision prompt now re-supplies the previous output alongside the task and feedback.

v0.9.0 (2026-06-16): Tool dependency injection, structured-output agents, configurable evaluator & pretty_print

Tools

  • New aimu.ToolContext: dependency injection for tools. A tool parameter annotated ToolContext (or ToolContext[Deps]) is filled by the agent at call time and excluded from the model-facing JSON schema, so the model never supplies it. This lets a tool reach shared state (a document store, cache, configuration) without module-level globals. @aimu.tool records the injected parameter names on func.__tool_injected__; both sync and async dispatch fill them via _tool_call_kwargs() from the client's tool_context_deps. Exported from aimu and aimu.tools.

Agents and workflows

  • New Agent.deps field + per-run Agent.run(..., deps=...) override (sync and aimu.aio): supplies the value injected as ctx.deps into tools that declare a ToolContext parameter. The per-run deps= takes precedence over the agent's deps= field; _prepare_run() publishes the effective value to the model client before each run. None (bare client.chat()) means ctx.deps is None. Forwarded by SkillAgent.
  • New Agent.run(..., schema=...) (sync and aimu.aio): pass a dataclass or Pydantic v2 model to make the run a single structured-output turn that returns a validated instance instead of running the tool-calling loop. Useful for an agent whose job is to return a typed object (e.g. a critic's verdict). Mutually exclusive with stream=True.
  • New EvaluatorOptimizer typed-verdict acceptance, replacing brittle substring matching. Acceptance is now decided by one of three mechanisms in priority order: stop_when (a predicate over the evaluator's output, either the raw text or the typed verdict when verdict_schema is set), verdict_schema (a dataclass / Pydantic model the evaluator must return via structured output; acceptance reads its passed bool and revision uses its feedback str, passed_attr / feedback_attr are configurable, and a malformed verdict raises rather than silently continuing), or pass_keyword (the default, unchanged; accept when the substring appears in the evaluator's text). Leaving the new fields unset preserves prior behaviour exactly.

Console output

  • New aimu.pretty_print(stream, *, file=None, show_thinking=False, show_tools=True): render the StreamChunk iterator from client.chat(stream=True), Agent.run(stream=True), or any workflow run to a readable transcript (tool calls flagged, generated text streamed inline, thinking optional), and return the concatenated generated text. Saves callers from re-implementing the chunk.is_tool_call() / chunk.is_text() dispatch loop. Exported from aimu.

Documentation

  • New README "Agents and workflows", "Tools", "Output and utilities", and quick-start sections cover ToolContext injection, the configurable EvaluatorOptimizer acceptance (pass_keyword / stop_when / verdict_schema), and pretty_print, with a runnable example combining all three.

Examples

  • Change Consolidated the loose scripts/ directory and the data/skills/ demo skills into a single top-level examples/ tree, organized by theme: examples/text-refinement/ (the epic_* family), examples/image-refinement/ (the hotdog_* family), examples/news-summarizer/, and examples/skills/ (haiku-poet, unit-converter). Each example directory has its own README.md, and examples/README.md indexes them. Files were moved with git mv (history preserved); scripts/ and data/ are removed.
  • New aimu.paths.examples constant pointing at the examples/ directory. aimu.paths.skills now resolves to examples/skills (was data/skills); the unused aimu.paths.data constant is removed.
  • Change The example test suites (test_epic_scripts.py, test_hotdog_scripts.py) are now scoped out of the default pytest run via testpaths = ["tests"]. Run them explicitly with pytest examples/. The two refinement directories are on pythonpath so their shared-helper imports resolve.
  • New Examples are surfaced from the README (## Examples section), the docs site (docs/examples.md + nav entry), and cross-linked from notebooks 07, 08, and 09. The two iterative-refinement how-to guides and generate-images.md now reference the examples/ paths.

Models

  • Fix HuggingFaceModel.QWEN_3_6_27B (and the Qwen 3.5/3.6 family) crashed at generation with RuntimeError: expected mat1 and mat2 to have the same dtype, but got: c10::BFloat16 != c10::Float8_e4m3fn. These are unified multimodal FP8 checkpoints whose quantization_config.modules_to_not_convert skip-list is written against the multimodal module tree (model.language_model.* / model.visual.*). The text-only entries loaded via AutoModelForCausalLM, which builds a text-only tree (model.layers.*) the skip-list can't match, so layers meant to stay bf16 (router mlp.gate, lm_head, linear_attn projections) mis-quantized. Qwen 3.5/3.6 now always load via AutoModelForImageTextToText.
  • Change Merged the Qwen 3.5/3.6 text-only and _VL enum members into single vision=True entries (QWEN_3_6_27B, QWEN_3_5_9B); removed QWEN_3_6_27B_VL and QWEN_3_5_9B_VL. The two variants loaded the identical checkpoint via the identical loader (vision tower included either way), so the split no longer backed any loader or VRAM difference.
  • Fix HuggingFaceClient's module-level weight cache could collide: two enum members sharing a repo id and model_kwargs but loading via different classes (AutoModelForCausalLM vs AutoModelForImageTextToText) produced the same cache key, so the second silently received the first's model object. _make_cache_key now folds in a load-profile tag (mirroring how the image/audio/speech clients key on pipeline_class / pipeline_type).

v0.8.0 (2026-06-12): Embeddings, transcription, structured output, RAG & audio input

Models

  • New audio: bool = False field on ModelSpec. Audio-capable text models expose supports_audio on their enum members, is_audio_model on their client instances, and an AUDIO_MODELS classproperty (parallel to TOOL_MODELS, THINKING_MODELS, VISION_MODELS).
  • New Audio-capable models added to the catalog: OpenAIModel GPT-4o, GPT-4o-mini, GPT-4.1, GPT-4.1-mini, GPT-4.1-nano; GeminiModel 2.0 Flash, 2.0 Flash Lite, 2.5 Pro, 2.5 Flash; HuggingFaceModel.GEMMA_4_E4B, GEMMA_4_12B, NEMOTRON_H_8B. Ollama models remain audio=False with inline comments noting where the underlying weights support audio (upgrade path once the Ollama API adds audio input).

ModelClient.chat() and ModelClient.generate()

  • New audio= parameter on both chat() (stateful; turn persists in self.messages) and generate() (stateless one-shot; no history touched). Accepts any mix of: file path strings, pathlib.Path, raw bytes (WAV assumed), https:// URLs (fetched eagerly), and data:audio/...;base64,... data URLs. Supported format strings: wav, mp3, ogg, flac, m4a, webm, inferred from file extension or MIME type.
  • New Passing audio= to a model with supports_audio=False raises ValueError before any API call.
  • New images= and audio= are mutually exclusive per turn; passing both raises ValueError.
  • Internally normalised to OpenAI input_audio content blocks ({"type": "input_audio", "input_audio": {"data": "<b64>", "format": "wav"}}). Provider adaptation happens at request time: OpenAI/Gemini/OpenAI-compat pass through; Anthropic converts to {"type": "audio", "source": {"type": "base64", ...}}; HuggingFace decodes to float32 numpy arrays via soundfile and passes them to the AutoProcessor; Ollama raises with a clear message (API does not yet support audio).
  • Mirrored on the async surface (aimu.aio): same signature on aio.chat() and aio.generate().
  • Fix ModelClient._generate (and the async AsyncModelClient._generate / _chat) now accept and forward audio=. They were missing the parameter while the base generate()/chat() always pass it, so every aimu.client().generate() / aimu.chat(...) call through the factory raised TypeError: _generate() got an unexpected keyword argument 'audio'. (Concrete provider clients were unaffected, which is why the live test suite, which constructs them directly, didn't surface it.)

Documentation

  • New docs/how-to/handle-audio-input.md: accepted input forms, model selection, stateful vs. stateless, async surface, per-provider adaptation.
  • New notebooks/05 - Audio Input.ipynb: capability flags, all input forms, multiple clips per turn, stateful/stateless split, multi-turn conversations, capability check, mutual-exclusion demo, Gemini and HuggingFace sections, async surface.

Transcription (speech-to-text)

  • New aimu.transcription_client() / aimu.transcribe() + TranscriptionClient factory + BaseTranscriptionClient ABC: a dedicated speech-to-text surface, parallel to TTS (BaseSpeechClient). Disjoint from the audio= parameter on text models, which handles audio analysis/QA by audio-capable chat models; this surface uses dedicated ASR models (Whisper family, gpt-4o-transcribe) optimised for transcription.
  • New OpenAITranscriptionClient + OpenAITranscriptionModel: cloud ASR backed by openai.audio.transcriptions.create(). Models: WHISPER_1, GPT_4O_TRANSCRIBE, GPT_4O_MINI_TRANSCRIBE. Auth via OPENAI_API_KEY. Uses the same openai SDK already required by the [openai_compat] extra.
  • New HuggingFaceTranscriptionClient + HuggingFaceTranscriptionModel: local ASR backed by transformers.pipeline("automatic-speech-recognition"). Models: WHISPER_TINY, WHISPER_BASE, WHISPER_SMALL, WHISPER_MEDIUM, WHISPER_LARGE_V3, DISTIL_WHISPER_LARGE_V3. Weight caching via module-level registry (same pattern as other HF clients).
  • New transcribe(audio, language=None, response_format="text", prompt=None, temperature=None) -> str | dict. Accepted audio forms: file path, raw bytes, https:// URL, data:audio/... URL, the same set as audio= on chat(). response_format="verbose_json" returns a dict with text, segments (start/end/text), language, duration. response_format defaults to "text" (plain string).
  • New AIMU_TRANSCRIPTION_MODEL env var: sets the default model for aimu.transcription_client() and aimu.transcribe() when model= is omitted.
  • New Async mirror under aimu.aio: AsyncTranscriptionClient, aio.transcription_client(sync_client), await aio.transcribe(audio, *, model, ...). Wraps sync via asyncio.to_thread (Decision 7, same as every other aio modality).
  • New Built-in transcribe_audio(audio_path: str) -> str @tool in aimu.tools.builtin; builtin.transcription subgroup; included in ALL_TOOLS. Backed by a lazy _transcription_client singleton via AIMU_TRANSCRIPTION_MODEL. make_transcription_tool(client) binds a fresh tool to a caller-supplied client.
  • New docs/how-to/transcribe-audio.md and notebooks/21 - Transcription.ipynb.

Embeddings (text-to-vector)

  • New aimu.embedding_client() / aimu.embed() + EmbeddingClient factory + BaseEmbeddingClient ABC: a dedicated text-embedding surface, parallel to the other modality clients. embed() takes one string (returns list[float]) or a list (returns list[list[float]], order preserved); an empty list returns [] without a provider call. client.dimensions reports the spec's vector width.
  • New OpenAIEmbeddingClient + OpenAIEmbeddingModel (text-embedding-3-small/large, text-embedding-ada-002) via openai.embeddings.create(); auth via OPENAI_API_KEY.
  • New OllamaEmbeddingClient + OllamaEmbeddingModel (nomic-embed-text, mxbai-embed-large, bge-m3, all-minilm) via ollama.embed().
  • New HuggingFaceEmbeddingClient + HuggingFaceEmbeddingModel (MiniLM-L6-v2, BGE small/base/large-en-v1.5, GTE-large, E5-large-v2, mxbai-embed-large-v1) backed by sentence-transformers so each model's own pooling/normalization config is honoured; lazy load + module-level weight cache (freed by aimu.clear_hf_cache()). Adds sentence-transformers>=3 to the [hf] extra.
  • New SemanticMemoryStore(embedding_client=...): pluggable embedding model; default None keeps ChromaDB's built-in embedder (unchanged behaviour).
  • New AIMU_EMBEDDING_MODEL env var sets the default model for aimu.embedding_client() / aimu.embed() when model= is omitted (raises if unset; no implicit download).
  • New Async mirror: aio.embedding_client(sync_client) / aio.embed() wrap a sync client via asyncio.to_thread.
  • Docs docs/how-to/use-embeddings.md, notebooks/11 - Embeddings.ipynb, API reference, and env-var reference.

Structured output

  • New schema= on chat() and generate() (sync and async). Pass a dataclass type or a Pydantic v2 model; the call returns a validated instance of that type instead of a string. Mutually exclusive with stream=True.
  • New ModelSpec.structured_output flag → client.supports_structured_output property and a STRUCTURED_MODELS classproperty (parallel to tools/thinking/vision/audio). Set on the OpenAI, Gemini, Ollama (all models), and Anthropic catalogs.
  • Auto-escalate semantics: native provider enforcement when supports_structured_output=True (OpenAI response_format json_schema; Ollama format=; Anthropic forced-tool), otherwise the schema is appended to the prompt and the response is parsed. The branch is on the static capability flag, not on catching a runtime error, so a genuine provider failure surfaces rather than silently downgrading; parse failure raises ValueError.
  • self.messages stays plain strings; the typed object is a return value only, so conversation history remains provider-portable.
  • Composition: schema= works alongside tools= on OpenAI-compatible and parse-path providers. On Anthropic (native structured output is a forced tool) combining schema= with active tools raises ValueError.
  • New schema_to_json_schema() (internal) converts a dataclass/Pydantic model to a JSON Schema, reusing the @tool decorator's Python-type → JSON-Schema mapping.
  • Docs docs/how-to/use-structured-output.md.
  • Deferred: Agent.run(schema=...), a strict=True (native-or-raise) knob, and native HuggingFace/llama-cpp enforcement (those use the parse path).

RAG primitives (retrieval-augmented generation)

  • New aimu.rag: chunk/retrieve/rerank helpers as plain functions over the MemoryStore interface (no retriever/splitter/loader class hierarchy).
  • New split_text(text, *, chunk_size=1000, chunk_overlap=200, separators=None, length_function=len): recursive separator-based chunking (paragraphs → lines → sentences → words → characters) with overlap. length_function defaults to character count; pass a tokenizer's counter for token-aware chunking. Oversized unsplittable text hard-cuts at chunk_size.
  • New ingest(store, documents, *, chunk_size, chunk_overlap, separators, length_function) -> int: splits one or many documents and stores each chunk via store.store(); returns the chunk count. retrieve(store, query, *, n_results=5, **search_kwargs) -> list[str] is a RAG-named pass-through to store.search() (forwards e.g. max_distance=). format_context(chunks, *, separator="\n\n", numbered=False) -> str joins chunks for prompt augmentation.
  • New rerank(query, documents, *, model="cross-encoder/ms-marco-MiniLM-L-6-v2", top_n=None): cross-encoder reranking via sentence-transformers (the [hf] extra); lazy-loaded and cached. Empty input returns [] without loading the model.
  • New make_retrieval_tool(store, *, n_results=5) in aimu.tools.builtin: wraps retrieve + format_context as a retrieve_context(query) agent tool (returns numbered context).
  • Docs docs/how-to/use-rag.md and the aimu.rag API reference.
  • Loaders and per-chunk metadata are intentionally out of scope: ingestion sources are covered by read_file / get_webpage (or any text-returning library), and chunks are stored as plain strings per the MemoryStore contract.

Token usage surfacing

  • New client.last_usage: token counts for the most recent non-streaming chat() / generate(), as {"input_tokens", "output_tokens", "total_tokens"} (or None when the provider/server omits usage). Captured for Anthropic, OpenAI-compat (incl. OpenAI/Gemini/local servers), and Ollama, on both sync and async surfaces, and delegated through the ModelClient / AsyncModelClient wrappers. Reset to None on streaming calls (streaming usage capture is a separate follow-up) and by reset(). Token counts only; dollar cost is derivable but intentionally not computed (no maintained price table).

Anthropic models & adaptive thinking

  • New AnthropicModel members: CLAUDE_FABLE_5 (claude-fable-5), CLAUDE_OPUS_4_8 (claude-opus-4-8), CLAUDE_OPUS_4_7 (claude-opus-4-7), all tools=True, thinking=True, vision=True.
  • New ThinkingStyle enum (ENABLED / ADAPTIVE) carried as a per-member extra on AnthropicModel (analogous to HuggingFace's ToolCallFormat). AnthropicClient._thinking_kwargs() builds the request accordingly: ENABLED{"type": "enabled", "budget_tokens": N}; ADAPTIVE{"type": "adaptive", "display": "summarized"} with temperature/top_p/top_k dropped. Opus 4.7+ and Fable 5 are adaptive-only (the enabled form 400s on them); Opus 4.6, Sonnet 4.6, and Haiku 4.5 use the budget form.
  • Fix CLAUDE_HAIKU_4_5 now correctly has thinking=True. Haiku 4.5 supports extended thinking via the enabled/budget_tokens form (previously omitted, so thinking tests silently skipped).
  • Adaptive models decide per request whether to think and may emit none on simple prompts; the thinking tests use a multi-step reasoning prompt and assert thinking emission rather than an exact answer.
  • Docs Updated the model matrix, provider matrix, add a new model, and CLAUDE.md (Thinking Models, AnthropicClient notes) to cover the two thinking styles and the new models.
  • Docs Added an "Adaptive vs. budget thinking" section to notebooks/01 - Model Client.ipynb (section C) demonstrating ThinkingStyle and adaptive models skipping thinking on trivial prompts.

Dependencies

  • Fix Pinned the [hf] extra's kernels to >=0.12,<0.13. It was unconstrained and resolved to kernels 0.15.2, which is outside the range transformers supports (<0.13); transformers constructs kernels.LayerRepository(...) at import time and 0.13+ made revision/version mandatory, so from transformers import AutoProcessor raised ValueError, silently flipping HAS_HF to False (HuggingFace clients unavailable) and erroring every HF test on import.
  • Pinned the [hf] extra's transformers to >=5,<6 (the major the model catalog targets: Qwen 3.6, Gemma 4, GPT-OSS) so a future major can't reintroduce this class of import-time breakage on resolve.

Async surface

  • New Async→sync tool bridging for wrapped in-process clients. AsyncHuggingFaceClient / AsyncLlamaCppClient run their sync client's _chat tool-dispatch loop in a worker thread (via asyncio.to_thread), and that sync dispatcher refuses async def tools, but the async surface routinely attaches them (e.g. await aio.MCPClient.as_tools()). Each async tool is now wrapped as a sync callable that drives the coroutine back on the main event loop (run_coroutine_threadsafe) and blocks only the worker thread (no deadlock, the main loop is free, awaiting the to_thread future). Async-generator (streaming) tools bridge to sync generators; the OpenAI tool spec and dispatch name are preserved. Async agents using in-process models can now mix sync and async tools (including MCP) transparently.

Fixes

  • Change HuggingFace default max_new_tokens raised from 1024 to 4096. The previous default truncated reasoning models (e.g. Qwen 3.5/3.6) mid-thinking, before the closing </think>, which left no room for the answer; the higher default leaves headroom for thinking plus a response. Override per call with generate_kwargs={"max_tokens": N}.
  • Fix Streamed chat() on a HuggingFace thinking model no longer raises RuntimeError: generator raised StopIteration when a turn produces reasoning but is truncated before emitting an answer. The streaming path now mirrors the non-streaming one: it surfaces the buffered thinking and finishes with empty generated content instead of doing an unguarded next() on an empty token stream.
  • Fix (tests) Mock-only audio/speech/image API tests previously replaced transformers / soundfile / diffusers in sys.modules with bare stubs at collection time and never restored them, breaking any live model test that ran later in the same session (ModuleNotFoundError: Could not import module 'Qwen3_5ForCausalLM'). The stubs are now installed via auto-restoring, monkeypatch-scoped fixtures, and the permanent install is skipped whenever the real dependency is importable.

v0.7.0 (2026-06-08): MCP tool unification, model resolvers, and agent improvements

Breaking changes

  • Breaking Removed the model_client.mcp_client attribute. MCP tools now integrate through the single model_client.tools registry: call MCPClient(...).as_tools() (sync) or await aio.MCPClient.connect(...).as_tools() (async) to turn a server's tools into @tool-style callables, then add them to tools (constructor Agent(tools=...), client.tools = ..., or the per-call chat(tools=...) / run(tools=...) override). Migration: replace client.mcp_client = mcp with client.tools = mcp.as_tools() (concatenate with @tool functions as needed, e.g. builtin.web + mcp.as_tools()). Two consequences: dispatch is now one by-name lookup over tools, so on a name collision the last entry wins (previously Python @tool always beat a same-named MCP tool; to preserve that, append the Python tool after mcp.as_tools()); and MCPClient.get_tools() is no longer called on every chat() (the tool list is snapshotted by as_tools()), so call as_tools() again to pick up server-side tool changes. SkillAgent and the internal dispatch (_handle_tool_calls(tool_calls), _call_plain_tool(tc, tc_id), both of which lost their tools parameter) were updated accordingly. The MCPClient class, its get_tools() / call_tool() / ping(), and the aio.MCPClient parallel are unchanged.
  • Breaking system_message is no longer immutable after the first chat(). The setter is now always live: assigning it mid-conversation rewrites the {"role": "system"} entry in messages in place (re-conditioning the model on the new prompt while preserving history), inserts one if absent, or removes it on None. Before the first chat it still just seeds the value. The previous behaviour raised RuntimeError; code that caught that error to gate a reset() can now assign directly. To change the prompt and drop history, use reset(system_message="new"). Two consequences are accepted by design: the transcript becomes counterfactual (prior assistant turns predate the new prompt), and there is no longer a guard against silently re-conditioning a ModelClient shared by another agent's in-flight conversation, so don't share a live-conversation client across agents that each set system_message. The _system_message_locked flag has been removed. See System message lifecycle.

Models

  • New aimu.resolve_model_enum(model) and aimu.resolve_image_model_enum(model): resolve a model to its Model / ImageModel enum member from any of three input forms: an enum member (returned unchanged), a "provider:model_id" string (delegates to resolve_model_string / resolve_image_model_string), or a bare enum-member name (e.g. "QWEN_3_8B", "FLUX_2_KLEIN_4B", "NANO_BANANA") looked up across every installed provider enum. Useful for CLIs/scripts that accept "enum, name, or string" uniformly. For text, an ambiguous bare name (the same id ships under many providers) is disambiguated the way the omitted-model default is: prefer a provider where the model is actually available locally (running Ollama → cached HuggingFace → reachable local OpenAI-compat server, tool-capable first), logged at WARNING; if it isn't available under any provider, ValueError lists the "provider:model_id" options. This availability probe runs only on the ambiguous path. resolve_image_model_enum has no local-availability notion (image catalogs don't collide) and raises on the rare ambiguity. Exported from aimu.models and top-level aimu.
  • New aimu.available_text_models(*, include_hf_cache=True) for discovery: return locally available text models as Model enum members (running Ollama → cached HuggingFace → reachable local OpenAI-compat servers), in provider-priority order. Download-free and cloud-free. aimu.resolve_default_text_model_enum(*, include_hf_cache=True) returns the single auto-pick (env var → first available, tool-capable preferred) as an enum member, the enum-returning twin of the internal default resolver that backs client()/chat()/agent() when model= is omitted.
  • New Gemma 4 12B added to every provider that can run it: OllamaModel.GEMMA_4_12B (gemma4:12b, tools/thinking/vision, with the shared Gemma sampling kwargs), HuggingFaceModel.GEMMA_4_12B (the instruction-tuned google/gemma-4-12b-it, tools/vision, processor parse_response path), and a GEMMA_4_12B member on every OpenAI-compat server enum (OllamaOpenAIModel, LMStudioOpenAIModel, VLLMOpenAIModel, HFOpenAIModel, LlamaServerOpenAIModel, SGLangOpenAIModel) plus LlamaCppModel. The server/llama.cpp entries are tools=True, matching the established GEMMA_3_12B convention for those catalogs. Resolvable via the usual "provider:model_id" strings (e.g. "ollama:gemma4:12b", "hf:google/gemma-4-12b-it", "vllm:google/gemma-4-12b-it").

Tools

  • New The tool decorator is re-exported at the top level as aimu.tool. @aimu.tool is now the single recommended/documented form across the README, tutorials, how-tos, and notebook examples. It's namespaced, so it can't be silently shadowed by another library's same-named tool decorator (LangChain, smolagents, etc.). from aimu.tools import tool remains valid and unchanged (same object); it's the natural form for code already inside aimu.tools. The ToolSignatureError message prefix is now @aimu.tool: to match. No behaviour change to decoration or dispatch.
  • New MCPClient.as_tools() (sync) and aio.MCPClient.as_tools() (async) return a server's tools as @tool-style callables, each closing over the client, invoking call_tool() cross-process, and carrying __tool_spec__ / __tool_is_async__ / __tool_is_streaming__. Drop them straight into tools (client.tools = mcp.as_tools(), Agent(tools=builtin.web + mcp.as_tools())). This unifies MCP and in-process tools onto the single self.tools registry and one dispatch path; see the breaking-change note above for the migration from model_client.mcp_client. New shared helper aimu.tools.mcp_format.mcp_content_to_text(tool_response) flattens a call_tool result to a string.
  • New Per-call tool override: chat(..., tools=None) and Agent.run(..., tools=None) (both sync and aimu.aio) accept a tools= list that replaces the client's configured self.tools for a single call/run, restored afterward. tools=None (default) keeps the existing behaviour; tools=[] disables tools for the call (MCP tools, being callables in self.tools via as_tools(), are included in the swap). On an Agent, the override applies to every turn of the agentic loop. Implemented as a scoped self.tools swap (_ChatStateMixin._tools_override) covering both request-spec building and dispatch; the agent threads it through each loop chat() call so no new agent state is introduced. Not safe across concurrent chat() calls on a shared client; same contract as self.messages. Not added to the Runner ABC / workflow classes.

Agents and workflows

  • New Agent.final_answer_prompt (opt-in, default None; sync and aimu.aio): guarantees a final answer when the agentic loop exhausts max_iterations while the model is still calling tools. Instead of returning whatever the last (possibly tool-only) turn produced (an empty or stub result), the agent sends this prompt once with tools disabled (chat(..., tools=[])), forcing the model to synthesize an answer from the context it has gathered. The trigger is the post-loop _last_turn_called_tools() check (no new counter); it fires only on the cap-with-pending-tools path (a natural finish, a turn with no tool calls, is unaffected) and the wrap-up turn is not counted against max_iterations. OrchestratorAgent._init_orchestrator() and OrchestratorAgent.assemble(..., final_answer_prompt=...) (sync + aio) forward it to the inner orchestrator agent, and it is accepted as a from_config key. Leaving it None preserves prior behaviour exactly.

Fixes

  • Fix SkillAgent skill injection no longer wipes conversation history when applied to an already-used client. It previously called reset() to unlock the setter (clearing messages); it now assigns system_message directly, which swaps the system entry in place.

v0.6.0 (2026-06-04): Output utilities, model weight caching, and experiment checkpointing

Breaking changes

  • Breaking Renamed HuggingFaceImageModel.FLUX_DEVFLUX_1_DEV and FLUX_SCHNELLFLUX_1_SCHNELL for naming consistency with the FLUX_2_KLEIN_4B/FLUX_2_KLEIN_9B members. The underlying model id strings (black-forest-labs/FLUX.1-dev, black-forest-labs/FLUX.1-schnell) are unchanged. Update enum references; "hf:black-forest-labs/FLUX.1-dev" string-form usage is unaffected.
  • Behavior change builtin.compute now includes execute_python alongside calculate. If you were passing tools=builtin.compute and want to exclude the sandboxed REPL, switch to tools=[builtin.calculate] explicitly. ALL_TOOLS and make_tools() are unchanged (opt-in only via python_sandbox=True).

Output utilities

  • New aimu.parse_json_response(text, schema=None): extract JSON from any LLM response string using three extraction strategies (raw parse, fenced code block, {…} substring). Pass a dataclass class or Pydantic v2 BaseModel as schema to coerce the parsed dict into a typed object. Raises ValueError on all-strategy failure with the first 200 characters of the response included. Exported from aimu.models._json, aimu.models, and top-level aimu.
  • New aimu.generate_json(client, prompt, schema=None, *, retries=2, generate_kwargs=None): call client.generate() and parse the result as JSON, retrying up to retries times on parse failure. Convenience wrapper around parse_json_response.
  • New aimu.extract_tool_calls(messages): convert an OpenAI-format message list (e.g. agent.model_client.messages) into a flat list[dict] of {iteration, tool, arguments, result} records. Handles both arguments and parameters key names for cross-model compatibility. Replaces manual reconstruction boilerplate common in agentic scripts.

Model weight caching

  • New All four in-process HuggingFace clients (HuggingFaceClient, HuggingFaceImageClient, HuggingFaceAudioClient, HuggingFaceSpeechClient) now maintain a module-level weight registry keyed on (spec.id, *sorted_model_kwargs). A second client instance with the same model and construction kwargs reuses already-loaded weights rather than calling from_pretrained() again. The text client checks on construction; the lazy-loading modality clients check on first load. LlamaCppClient has the same pattern with key (model_path, n_ctx, n_gpu_layers, chat_format).
  • New aimu.clear_hf_cache(model=None): evict HuggingFace weight entries from all four modality registries and call gc.collect() + cuda.empty_cache(). Pass a model enum member to clear just that model; pass None to clear all.
  • New aimu.clear_llamacpp_cache(model=None): same for LlamaCppClient.

Tools

  • New execute_python(code) built-in tool in builtin.compute. Executes sandboxed Python in a fresh namespace per call, captures stdout, and returns the last expression value. Allowed imports: math, statistics, json, re, itertools, functools, datetime, and numpy/pandas/scipy/matplotlib when installed. Filesystem (open, os, pathlib) and subprocess access are blocked. Not included in ALL_TOOLS; opt in via tools=builtin.compute or make_tools(python_sandbox=True).
  • New make_tools(..., python_sandbox=False): new python_sandbox= kwarg appends execute_python when True.
  • New make_memory_tools(store) in aimu.tools.builtin: wraps any MemoryStore instance as three @tool-decorated functions (store_memory, search_memories, list_memories) for direct in-process agent use. Unlike the image/audio/speech built-in tools, there is no lazy singleton: the store is always explicit because persistence semantics (persist_path, backend, collection name) are meaningful caller choices. Works with SemanticMemoryStore, DocumentStore, or any MemoryStore subclass. For cross-process or multi-agent memory, the existing FastMCP servers (aimu.memory.mcp / aimu.memory.document_mcp) remain the recommended path.
  • New builtin.make_tools(..., memory_store=None): new memory_store= kwarg appends make_memory_tools(store) to the assembled tool list when provided.

Agents and workflows

  • New Agent.restore(messages): restore an agent from a saved list[dict] (OpenAI message format) for resuming after failure. Calls model_client.reset(), strips the leading system message to prevent duplication on the next chat(), and sets model_client.messages. The live partial state after a failed run is on agent.model_client.messages (not the post-run snapshot from agent.messages).
  • New EvaluatorOptimizer.restore(messages): delegates to generator.restore().
  • New Chain.restore(messages, step=0): restores the specified step's agent client.

Documentation

  • New docs/how-to/using-llms-inside-tools.md: covers the history pollution problem, generate() for stateless in-tool LLM calls, the HuggingFace weight caching model (including clear_hf_cache() / clear_llamacpp_cache()), and the save/restore checkpointing pattern with a full try/except example.

v0.5.1 (2026-06-01): Image-to-image, FLUX.2 Klein, and curated model catalog

Image generation

  • New Image-to-image (img2img) support: pass reference_image= to BaseImageClient.generate() (and all subclasses). Accepts a file path string, pathlib.Path, raw bytes, data URL, http(s) URL, or PIL Image. HuggingFace derives the img2img pipeline from the loaded txt2img pipeline via from_pipe() (shared weights, no extra VRAM). strength= (default 0.75) controls deviation from the reference for FLUX.1-style pipelines. width/height are ignored; output size is derived from the reference image. Gemini passes the reference as inline PNG data in a multipart request, enabling image editing.
  • New HuggingFaceImageModel.FLUX_2_KLEIN_4B and FLUX_2_KLEIN_9B: FLUX.2 Klein by Black Forest Labs. 4-step distilled model with improved text rendering, better hand/face quality, and higher resolution support. Uses Flux2KleinPipeline (diffusers 0.37+), a unified pipeline that handles both txt2img and img2img natively (image= parameter, no strength). img2img_uses_strength=False on the spec distinguishes it from FLUX.1-style img2img.
  • New HuggingFaceImageSpec.img2img_pipeline_class: diffusers class name for the img2img variant (e.g. "StableDiffusionImg2ImgPipeline"); None for ad-hoc "hf:<repo>" strings.
  • New HuggingFaceImageSpec.img2img_uses_strength: True (default) for strength-based pipelines; False for unified pipelines like FLUX.2 Klein that condition on the reference image directly.
  • New aimu.models._images._reference_image_to_pil(): shared helper used by both HF and Gemini image clients to normalise any reference image input form to a PIL Image.
  • Changed scripts/hotdog_loop.py absorbs hotdog_climbing.py: the two scripts shared identical structure and differed only in their acceptance policy. Pass --strategy climbing for hill-climbing behaviour (keep best, revert on non-improvement); --strategy greedy (default) preserves the original loop behaviour. hotdog_climbing.py is removed.
  • New scripts/hotdog_img2img.py: iterative hotdog refinement via img2img + strength annealing. Hill-climbs in image space (always refines from the best image, not the most recent) while annealing strength from high (explore) to low (polish). Detects and warns when the active model does not support strength (e.g. FLUX.2 Klein).

Negative prompts

  • New ImageSpec.supports_negative_prompt capability flag. True by default; False for guidance-distilled / conversational models that have no negative-prompt parameter, such as HuggingFaceImageModel.FLUX_2_KLEIN_4B/_9B and the entire Gemini image family (GeminiImageSpec defaults it to False).
  • Behavior BaseImageClient.generate() now raises ValueError if negative_prompt= is passed to a model whose spec sets supports_negative_prompt=False, instead of crashing deep in the pipeline (HuggingFace) or silently ignoring it (Gemini). Callers branch on spec.supports_negative_prompt and fold avoidance into the prose prompt for unsupporting models. The hotdog scripts do this via a new negative_prompt_plan() helper (native kwarg → summarizer-folded positive constraints → prompt suffix, by model).

Curated model catalog (breaking for unknown ids)

  • Breaking Model id strings must name a model AIMU ships a spec for. Passing an arbitrary "hf:<unknown-repo>" / "gemini:<unknown-id>" / "openai:<unknown-id>" to an image, audio, or speech client now raises ValueError (listing available ids) instead of fabricating a spec with guessed capabilities. Text was always strict (resolve_model_string raises); this brings the other modalities in line. For a one-off custom model, construct the provider spec and pass the object (e.g. ImageClient(HuggingFaceImageSpec(...))), the explicit escape hatch.
  • Fixed A "provider:model_id" string for a known model now resolves to the same spec object as the equivalent enum member, so capabilities are identical regardless of construction path. Previously the string form fabricated a default spec; e.g. "hf:black-forest-labs/FLUX.2-klein-4B" lost supports_negative_prompt=False/img2img_uses_strength=False, and "hf:suno/bark" lost BARK's default_voice.
  • Removed The _REPO_PIPELINE_HINTS repo-prefix capability-guessing heuristics in the HuggingFace audio and speech clients (dead once unknown ids raise).

v0.5.0 (2026-05-31): Async, audio, speech, and default models

A feature release on top of the v0.4 redesign: a full async surface, two new output modalities (audio and speech), a cloud image provider, automatic default-model resolution, and streaming tools. No breaking changes to the v0.4 sync API.

Async surface (aimu.aio)

  • New aimu.aio mirrors the entire public sync API one-for-one, with the same class names in a different namespace. Switch paradigms with one import line plus await. Exports chat, client, Agent, SkillAgent, Chain, Router, Parallel, EvaluatorOptimizer, PlanExecuteEvaluator, OrchestratorAgent, MCPClient. Imported by default, so from aimu import aio needs no separate install.
  • New aio.Parallel and concurrent_tool_calls=True use asyncio.TaskGroup for structured concurrency: sibling cancellation on first failure, ExceptionGroup aggregation.
  • New Native async providers: Anthropic, OpenAI, Gemini, Ollama, and every OpenAI-compatible endpoint. In-process providers (HuggingFace, LlamaCpp) wrap an existing sync client so weights load only once (aio.client(sync_client)).
  • New async MCPClient built on FastMCP's native async Client (no anyio portal); construct via await MCPClient.connect(...). The sync MCPClient remains first-class.
  • New @tool async detection (__tool_is_async__): async def tools are awaited directly; sync CPU-bound tools are routed through asyncio.to_thread so the event loop stays free.
  • Note Streaming on the async surface returns AsyncIterator[StreamChunk] (consume with async for); the sync surface returns Iterator[StreamChunk]. The StreamChunk type itself is identical on both.
  • Requirement Python 3.11+ is now required (the async surface uses asyncio.TaskGroup, asyncio.timeout, and native ExceptionGroup).

Audio generation

  • New aimu.audio_client() / aimu.generate_audio() + AudioClient factory + BaseAudioClient ABC, parallel to the text and image surfaces.
  • New HuggingFace audio models: MusicGen small/medium/large (32 kHz, token-autoregressive), AudioLDM2 (16 kHz, diffusion), Stable Audio Open (44.1 kHz stereo, diffusion).
  • New AUDIO_GENERATING StreamChunk phase + StreamChunk.is_audio_progress(). Streaming progress for diffusers-backed models.
  • New encode_audio() output formats: numpy (default), bytes, data_url, path (WAV via soundfile).
  • New Built-in generate_audio streaming tool + make_audio_tool(client, duration_s=); builtin.audio subgroup.

Speech (text-to-speech)

  • New aimu.speech_client() / aimu.generate_speech() + SpeechClient factory + BaseSpeechClient ABC.
  • New Providers: HuggingFace local (SpeechT5, MMS-TTS, BARK) and OpenAI cloud (tts-1, tts-1-hd).
  • New SPEECH_GENERATING StreamChunk phase + StreamChunk.is_speech_progress(); OpenAI byte-chunk streaming.
  • New Built-in generate_speech streaming tool + make_speech_tool(client, voice=, speed=); builtin.speech subgroup.

Image generation

  • New Google Gemini "Nano Banana" cloud provider (GeminiImageClient, gemini-2.5-flash-image) under the [google] extra, dispatched via aimu.image_client("gemini:...").
  • New aimu.image_client() accepts ad-hoc "hf:<repo_id>" and "gemini:<id>" strings in addition to enum members.
  • New Streaming image generation: IMAGE_GENERATING chunks during denoising, with optional per-step latent previews via preview_every=N (HuggingFace diffusers).
  • New Built-in generate_image streaming tool, make_image_tool(client, preview_every=), and make_describe_image_tool(client) (binds vision Q&A to a vision-capable chat client); builtin.image subgroup.

Default-model resolution

  • New model= is now optional on aimu.chat() / aimu.client() / aimu.agent(). When omitted, AIMU resolves a text default: AIMU_LANGUAGE_MODEL ("provider:model_id") first, otherwise an already-available local model (running Ollama → cached HuggingFace model → running local OpenAI-compatible server), restricted to enum-known ids and preferring tool-capable ones. A cloud provider is never auto-selected and weights are never downloaded implicitly.
  • New AIMU_IMAGE_MODEL / AIMU_AUDIO_MODEL / AIMU_SPEECH_MODEL provide defaults for the image/audio/speech entry points (env-var only; an unset var raises a clear ValueError).

Tools and vision

  • New Streaming tools: a generator-function @tool may yield StreamChunk objects mid-execution (flag __tool_is_streaming__); the agent forwards them through agent.run(stream=True). The tool's recorded response resolves from its return value, the last chunk's result, or str(last_chunk.content).
  • New images= is now accepted on stateless generate() (one-shot vision Q&A that does not touch self.messages), in addition to stateful chat().
  • New builtin.make_tools(base_client, image_client=None, audio_client=None, speech_client=None) assembles the full built-in tool list with automatic image/vision/audio/speech wiring.

Examples

  • Changed The full-featured Streamlit chatbot (web/streamlit_chatbot.py) gains image, audio, and speech generation, plus optional TTS narration of completed responses.

v0.4 (2026-05-26): API redesign

Breaking changes across four areas, plus the new documentation site.

Top-level API

  • New aimu.chat(user_message, *, model, ...): one-shot chat with a model string or enum.
  • New aimu.client(model, *, system=None, **kwargs): one-line ModelClient factory.
  • New aimu.resolve_model_string("provider:model_id"): model-string parser.
  • New ModelClient now accepts a "provider:model_id" string in addition to enum members.

Model clients

  • New ModelSpec frozen dataclass replaces positional enum tuples. All Model enums migrated.
  • New client.reset(system_message="__keep__") clears history and unlocks the system-message setter.
  • Breaking system_message is immutable after the first chat() call. The setter raises RuntimeError; call reset() to unlock.
  • New include=[...] stream filter on chat() and generate() selects phases ("thinking", "tool_calling", "generating", "done").
  • Internal Abstract methods renamed chat → _chat, generate → _generate. Concrete chat/generate on the base class apply the include filter and delegate.
  • New Memory-aware GPU placement for HuggingFaceImageClient: on load it measures the pipeline size and each GPU's free VRAM (accounting for other processes), then pins to the freest GPU or falls back to model / sequential CPU offload so large models (SD3, FLUX) load without OOM. Override with model_kwargs={"device": "cuda:1"} or {"device_map": ...}. Audio/speech clients take the same {"device": ...} hint. Shared aimu/models/_hf_device.py helpers back all three.
  • New ImageSpec.max_prompt_tokens records the model's text-encoder prompt budget (77 for CLIP, 256/512 for T5 models like SD3/FLUX, None for uncapped cloud models), exposed on BaseImageClient. Use it to size prompts to the model.
  • Changed HuggingFaceImageClient now defaults torch_dtype per device (bf16 on CUDA, fp16 on MPS, fp32 on CPU) instead of "auto", which could silently load in fp32 and double VRAM. Pass model_kwargs={"torch_dtype": ...} to override.

Agents

  • Breaking Agent constructor signature changed: Agent(model_client, system_message=None, name=None, tools=None, ...). system_message is the second positional argument; name is optional (auto-derived).
  • Breaking AgenticModelClient removed from the public API. Use agent.as_model_client() instead.
  • Breaking OrchestratorAgent._setup_orchestrator renamed to _init_orchestrator.
  • New OrchestratorAgent.assemble(client, system_message, workers=[...]) factory builds an orchestrator without subclassing.
  • New Workflow factories: Chain.from_client(client, prompts), Router.from_client(client, classifier_prompt, handlers), Parallel.from_client(client, worker_prompts, aggregator_prompt=), PlanExecuteEvaluator.from_client(client, ...).
  • Breaking BaseAgent and Workflow ABCs removed. All concrete agents and workflows inherit directly from Runner. The agent-vs-workflow split survives as a conceptual category in the docs.
  • Breaking AgentChunk and ChainChunk collapsed into StreamChunk, with no back-compat aliases. chunk.agent_name → chunk.agent; chunk.step → chunk.iteration.

Tools

  • New @tool raises ToolSignatureError at decoration time on unsupported signatures (*args/**kwargs, params with no type hint and no default).
  • New Optional[T] and T | None unwrap to the inner type in tool specs.
  • New Built-in tool subgroups: builtin.web, builtin.fs, builtin.compute, builtin.misc.
  • New MCPClient raises MCPConnectionError (rather than silently failing) on construction or call failure. Added .ping() method.

Skills

  • Breaking SkillManager raises SkillLoadError on malformed SKILL.md (instead of silently skipping).
  • Breaking SkillManager.get_skill_body() raises SkillNotFoundError on unknown skill name (instead of returning a sentinel string).
  • New Skill catalogue prompt includes script-derived tool names inline.
  • Breaking Skill renamed to AgentSkill (no back-compat alias).
  • New Skills logged at INFO on discovery.

Documentation

  • New documentation site built with MkDocs Material and hosted on GitHub Pages.
  • Diátaxis structure: tutorials, how-to guides, reference, explanation.
  • README slimmed to landing-page size.

Earlier versions

This is the first formal changelog entry. Prior versions tracked changes via git history; consult git log on GitHub for v0.3.x and earlier.