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_KEYSgains a sixth entry, so anagent_typesroster 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_iterationswas 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 whatevermake_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, alongsidemax_depth < 1and the closed key set: an int below 1 is a loop that makes no model call, andboolis anintsubclass, so an uncheckedTruewould 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_toolandmake_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
CONTINUINGstream 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 onlychunk.iterationrise, which is also what an ordinary tool round does, so the two injected rounds were indistinguishable from each other and from a tool round.contentis{"kind", "prompt"}, wherekindis the samePROVENANCE_CONTINUATION/PROVENANCE_FINAL_ANSWERthe injected message is tagged with, andpromptis the string actually sent (a configuredcontinuation_prompt/final_answer_promptreports itself).StreamChunk.is_continuing()dispatches on it. - Change
StreamingContentTypegains a member, so an exhaustive dispatch over it needs a new arm. Amatch chunk.phase(or anif / elifchain) written to cover every phase will now fall through on aCONTINUINGchunk, 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 plainclient.chat(stream=True)never sees one, and there is no way to filter it out of a streamedAgent.run:include=[...]is achat()/generate()argument, and this chunk comes from the loop above them. A consumer that does not want it drops it onchunk.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.pypins that they announce the same boundaries.
Console output¶
- New
pretty_print()shows the injected round as a[continuing: <kind>] <prompt>line. Not gated byshow_thinking/show_tools: those control volume, and this is one line per injected round.
Channels¶
- New
CLIChannelwrites the same[continuing: <kind>] <prompt>line, andWebChannelsends a{"type": "loop", "reason", "text"}frame, wherereasonis the chunk'skindandtextis the prompt. Both are unconditional rather than behindstream_thinking/stream_tools, for the reason above.
Examples¶
- Fixed
examples/personal-assistant's page renders theloopframe 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.mdargues why this needs a phase of its own rather than a reading ofchunk.iteration;docs/reference/stream-phases.mdcarries 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.mdsays 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
TruncatedTurnErroractually fires.client.last_output_truncatedhas 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 ofFalsemeans "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_reasonis now the single seam, mandatory on every request path for the same reason_record_requestis, and enforced the same way -- bytest_every_client_records_how_the_turn_ended, parametrized over every installed client x chat/generate x stream/non-stream, rather than by convention. Anthropic readsresponse.stop_reason, the OpenAI-compatible family readschoices[0].finish_reason(streaming splits that fromusageacross 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 againstmax_new_tokenssince Transformers reports no reason at all. New:client.last_stop_reasoncarries the provider's own word for it, so the raw signal is inspectable rather than only its derived bool.Nonemeans 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 withstop_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 thestop_detailscategory 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 withFallbackClient(retry_on=(ModelRefusalError,))-- routing to another model is the vendor's own recommended recovery. Exported fromaimu,aimu.modelsandaimu.aioalongsideModelConnectionErrorandContextOverflowError. 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'stool_callsand leaves execution to the loop. So exhaustingmax_iterationson 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 withmessages.N: `tool_use` ids were found without `tool_result` blocks immediately after, and OpenAI requires an assistanttool_callsmessage be followed by tool messages. All four wrap-up sites were affected (runandrun_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_toolsnow closes the stranded calls with results stating they were not executed and why, logged atWARNINGrather than emitted as an event (ToolDeniedis 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, viaoutput_config.effort. Five of the eightAnthropicModelmembers declaredthinking_levels=Trueand 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/mediummap straight through;highmaps to the vendor'sxhigh, becausehighis what Anthropic already uses when the parameter is unset and sending it would be a silent no-op (the same reasoning asQWEN_REASONING_EFFORT).maxstays out of reach of the three-value portable vocabulary; passgenerate_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 asModelSpec.effort_levels, a tuple rather than a bool because effort support does not followThinkingStyle:xhighexists 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 keepbudget_tokensunchanged, so this release is additive. One guard comes with it: Opus 5 rejectsthinking: {"type": "disabled"}combined withxhighormaxeffort, validated independently on every request. AIMU cannot build that pair itself, but a caller passingoutput_configthroughgenerate_kwargscan, so the disable path lowers such an effort tohighand 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_tokensfallback 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 andLOCAL_MAX_TOKENS= 4096 inaimu/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 tomax_tokens=9024against abudget_tokensof 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
anthropicextra 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, nowith_raw_response, nooutput_formatdicts, no Bedrock client, and already required Python 3.11 -- but one removal bites:temperature,top_pandtop_kare gone from themessages.create()/.stream()signatures, so passing one is aTypeErrorraised before any request is made. Two AIMU paths still carried one: a thinking-capable model called withthinking=False, and every structured-output call, which hadtemperature=1forced into it by_rewrite_generate_kwargsand never stripped (the structured path routes around_thinking_kwargs). The sampling decision now lives in one hook,_route_sampling_kwargs, reached from_rewrite_generate_kwargsso it runs on every request path: all three keys are dropped when thinking is in effect (the API fixestemperatureat 1 there) or the model isADAPTIVE(Opus 4.7+/Sonnet 5/Fable 5 reject them outright), and otherwise moved intoextra_body, which is merged into the request JSON as-is. Sotemperature=0.2still reaches Opus 4.6 / Sonnet 4.6 / Haiku 4.5, andANTHROPIC_GENERATE_KWARGSstill declares all three supported; only the transport changed. One behavior change beyond the fix:top_kused to survive alongside extended thinking (onlytemperatureandtop_pwere stripped) and is now dropped with the other two, matching Anthropic's documented restriction.httpx2(the SDK's new HTTP layer, the maintained fork ofhttpxby its original author, published by Pydantic atgithub.com/pydantic/httpx2) arrives transitively; the only direct use is intests/test_context_overflow_providers.py, whose Anthropic section buildshttpx2request and response objects while its OpenAI section stays onhttpx, since theopenaiSDK has not moved. New guard:tests/test_anthropic_sdk_contract.pybinds the payloads AIMU builds -- every model x everythinking=value x both request paths -- against the installed SDK's real signature. Every other Anthropic test monkeypatchesmessages.create, which is exactly why this removal could have shipped green. -
New
AnthropicModelmembersCLAUDE_OPUS_5(claude-opus-5) andCLAUDE_SONNET_5(claude-sonnet-5), bothtools=True, thinking=True, vision=True, structured_output=Trueand bothThinkingStyle.ADAPTIVE. Addressable asaimu.client("anthropic:claude-opus-5").claude-mythos-5is deliberately absent: it is invitation-only, and the catalog is curated to models a caller can actually reach. -
Fixed
thinking=Falsenow really turns thinking off on Anthropic's adaptive models. AIMU disabled reasoning by omitting thethinkingparameter, which is correct for theENABLED-style models but not for the 5-series: Opus 5 and Sonnet 5 run adaptive thinking when the parameter is absent, sothinking=Falsewould 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_kare stripped on the adaptive models whether or not the request asks them to think (they are rejected outright there, and this client's ownDEFAULT_GENERATE_KWARGSsupplies atemperature, so athinking=Falsecall to Opus 4.7/4.8 was already 400ing before this release); andCLAUDE_FABLE_5now declaresthinking_optional=False, since it always reasons and 400s on an explicit disable -- sothinking=Falsethere 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_anthropictook its"tool_calls" in msgbranch and built onlytool_useblocks, never readingmsg["content"]-- which_append_assistant_tool_callsstores 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_callsand::test_anthropic_omits_an_empty_text_block_when_a_tool_call_carries_no_prosefor the adapter's two branches directly.
Memory¶
- Fixed A persistent
DocumentStorenow reads through to its directory, so a document copied intopersist_pathby 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 toread,list_paths, andsearch_full_textfor the lifetime of the process, with nothing raised or logged to say so:list_documentsreported "No documents stored." while the file sat in the directory. Theaimu.memory.document_mcpserver 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 arefresh()a host would have to remember to call (and an agent could never trigger): withpersist_pathset,readopens the file, andlist_paths/search_full_textscan 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_mcpneeded no change. Tests:tests/test_document_store.py::test_list_paths_sees_file_copied_in_after_constructionand the six read-through cases beside it. - Changed An unreadable file in a persistent store's directory is logged at
WARNINGnaming 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_documentsreports 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 newDocumentStore.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_documentsreturns an excerpt per match, not the whole document. It joined the full text of up ton_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 toread_documentfor the full text, and the docstring directs the model toread_documentwhen 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 readstruncated: 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_toolandmake_async_subagent_tooltake anevents=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 scopedevents=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'sAgent.eventsfield, which is whatrun()already falls back to when its own per-callevents=is left atNone(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 callsrun()internally. It also threads through the recursivemake_async_subagent_tool/make_subagent_toolcall each factory makes for its own nested spawn tool (themax_depthmechanism), 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 intests/test_subagent_tools.py).
v0.24.0 (2026-08-25): a command tool that shares the supervisor execute_python earned¶
Tools¶
- New
run_command, inbuiltin.compute. Runs a command line through/bin/sh -c(COMSPEC /con 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 sharesexecute_python's supervision rather than reimplementing it. The new_run_supervisedowns 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=Truewith 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", becausepytestexits 1 with the answer on stdout andgit diff --exit-codeexits 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 needspreexec_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 thanexecute_pythonhas: the command reaches credentials sitting in files (.env,~/.aws/credentials) as the calling user, and process signalling is unconfined, sokill -9against the host process is one command away. Gate it withtool_approvalfor 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, plusSHELL,TERM,TZ,USER,LOGNAME), so no API key in this process reaches a command andrun_command("env")cannot lift a credential into a model's context. That default also makesgh,ssh, andgit pushover 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, sinceSSH_AUTH_SOCK=""misleads ssh in a way a missing variable does not. Tests:tests/test_code_execution.py.
Internal¶
- Change
_kill_execute_python_process_groupis 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_toolsare nowstream_thinking/stream_tools, and both default toTrue(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 whetherTHINKINGandTOOL_CALLINGcontent reaches the far side, not whether the far side draws it. A page that received athinkingframe 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 passingshow_thinking=/show_tools=gets aTypeErrorat construction naming the argument. A subclass that readsself.show_thinkingwhile streaming (an overriddensend, a replay path) raisesAttributeErroron the first stream rather than quietly treating the frames as suppressed. Rename the arguments at the construction site; a caller that was passingTruecan drop them.aimu.pretty_printdeliberately keepsshow_thinking/show_tools. It writes to aTextIOand 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-assistantdropsAssistantConfig.show_thinking/show_toolsand constructs both channels bare, since those fields only ever carried the value that is now the default. Tests:tests/test_aio_channels.pyandtests/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), plusexamples/personal-assistant/tests/test_web_assistant.py.
Models¶
-
Fix Reasoning is no longer dropped when the server names the field
reasoning(_reasoning_textinaimu.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 usereasoning_content(the DeepSeek spelling), while mlx-lm and OpenRouter usereasoning. AIMU read only the first, so against an mlx-lm server every reasoning block vanished:client.last_thinkingstayed empty, noTHINKINGchunks were yielded, no"thinking"key reached the assistant message, and nothing was raised anywhere. Measured againstmlx-community/Qwen3.8-27B-8biton 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 incontent, 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,_chatand_chat_streamedin each ofproviders/openai_compat.py,aio/providers/openai_compat.py, andproviders/llamacpp.py(async llama.cpp wraps the sync client, so there is no fourth set).reasoning_contentwins 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.pyandtests/test_aio_models_api.py(fifteen new, one per call site plus the helper's precedence, non-text and mapping rules). The four existingreasoning_contenttests are unchanged and still pass, so the DeepSeek spelling keeps its behavior exactly. -
Fix A tool call's
argumentsnow reach an OpenAI-compatible server as a JSON string rather than a dict (encode_tool_call_argumentsinaimu.models._internal.message_meta, called at all four openai-compat request sites:_chatand_chat_streamedin each ofproviders/openai_compat.pyandaio/providers/openai_compat.py).self.messagesstores 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 typestool_calls[].function.argumentsas 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 callsjson.loadson that field, so mlx-lm answered404 {'error': 'the JSON object must be str, bytes or bytearray, not dict'}. Measured againstmlx-community/Qwen3.8-27B-8biton mlx-lm, anaio.Agentholding a single tool failed every run; it now completes, as does a parent agent delegating throughspawn_subagentto a sub-agent that callsweb_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 PythonTypeErrormessage 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 (AsyncOpenAIClientandAsyncGeminiClientsubclass 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 intostrip_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 noargumentskey does not acquire one.encode_tool_call_argumentsis exported alongside its sibling for the reason its sibling is: a caller writing a custom OpenAI-format request path needs it, andtests/test_public_surface.pyrecords 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
argumentsadaptation where each already lists what changes betweenchat()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, soaio.Agent(max_iterations=3)made four model calls whereAgent(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; raisemax_iterationsby one to restore the old behavior exactly. The definition, now written down where the parameter is set (Agent.max_iterationson both surfaces, bothrun()docstrings, CLAUDE.md):max_iterationsis the maximum number of model calls the bounded loop itself makes. Thefinal_answer_promptwrap-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.eventsfor the duration of the run, and shipped with a known gap pinned bytest_KNOWN_GAP_parallel_from_client_shared_events_sink_drops_events:Parallel.from_client-- the documented quick-start -- builds every workerAgentover one shared client and runs them concurrently, so their swap/restore sequences interleaved. Reproduced: 11 of an expected 12 events, and a worker'sRunFinishedarriving before its ownModelTurnFinished. The scoped override now lives in a module-levelcontextvars.ContextVarread 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." AContextVarread unconditionally would trade the race for a worse ambient leak -- most visibly onmake_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'sclients,_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 ofconcurrent_tool_calls; it falls back to its ownself.events, so give it an explicitevents=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 (reusingctx.deps, say) sees the override under sequential dispatch on both surfaces, and underconcurrent_tool_calls=Trueonly on async --asyncio.TaskGroup.create_taskcopies the current context,ThreadPoolExecutor.submitdoes 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 withoutaclose()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(), orasync with contextlib.aclosing(...). Teardown deliberately does not reset acontextvars.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 oneEventSink(Callable[[RunEvent], None]) viaemit(). A union rather than aProtocolof named methods, deliberately: aProtocolgrows a method and every implementation is suddenly incomplete; a union grows a member and an existing sink just ignores it. The same reasoning that keepsself.messagesalist[dict]rather than aMessageclass hierarchy keeps telemetry plain data too.Turn events (import logging from aimu.events import log_events reply = aimu.chat("hi", model="ollama:qwen3:8b", events=log_events(logging.getLogger("aimu")))ModelTurnStarted/RequestPrepared/ModelTurnFinished) fire fromBaseModelClient.chat()/generate()themselves, so a bareaimu.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 stampsagentanditerationonto everything passing through so one sink attributes events correctly inside a nested workflow. Wired in:Agent(events=...)plus a per-runrun(events=...)override;Chain.from_client(..., events=...),Router.from_client,Parallel.from_client, andEvaluatorOptimizer'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 contractSubagentObserveralready 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. Withconcurrent_tool_calls=True,ToolCalled/ToolDeniedare 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_requestandRequestPreparedshow the payload as it actually left the process. Between a caller'schat()and the wire sit the four-tiergenerate_kwargsmerge, theGENERATE_KWARG_SUPPORTrenames 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_requestseam every provider calls (guarded bytests/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.droppedno longer shares object identity withclient.messages. The event now carries copies of the removed messages. A prior test assertedevent.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_clientand run concurrently drops and misorders events --Parallel.from_clientbuilds every worker over one shared client, so it hits this. Give each worker its own client to avoid it. Seetest_KNOWN_GAP_parallel_from_client_shared_events_sink_drops_events. - Known gap: a
client.chat(schema=...)call emits onlyRequestPrepared; turn events are not emitted on the structured-output path, since that path makes exactly one call and returns beforeModelTurnStartedwould fire. Inside anAgent.run(schema=...), the run is still bracketed byRunStarted/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 overEventSink-- OTel is not a new AIMU dependency. notebooks/27-observing-runs.qmd is the runnable companion, sinceaimu.eventswas otherwise the one subsystem in thenotebooks/collection without a demo:log_eventsfirst (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 overlist[dict], plus anAgentfield that runs one of them automatically.count_tokens(messages, counter=None),trim_messages(messages, max_tokens, ...), andsummarize_messages(client, messages, ...)mirroraimu.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 aContextPolicyclass 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-runrun(compaction=...)override) calls the given callable withclient.messagesbefore each model turn and adopts its result if changed. The invariant that justifies all of this existing: compaction must never orphan atoolmessage from theassistantmessage carrying itstool_calls-- every provider rejects that shape, and it is exactly what a naivemessages[-n:]slice produces.trim_messagesandsummarize_messagesboth treat a tool-call turn and its results as one indivisible group, extending akeep_lastboundary outward to the group edge rather than splitting it. An applied compaction announces itself twice: aContextCompactedevent carrying the removed messages (see above), and aWARNINGlog 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) // 4over 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, viaclient.last_usagefollowing a real call. Passcounter=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.contextneeded one:count_tokensand its honesty caveat,trim_messagesand the tool-pairing invariant,keep_lastcounting messages rather than exchanges,summarize_messages,Agent(compaction=...)announcing a drop twice, andContextOverflowErrorwith a recovery recipe.
Models¶
- New
ContextOverflowErroris now portable across every text backend, not Ollama-only. Each backend's own overflow signal is mapped to the same exception, chained viaraise ... from excso the original cause survives: OpenAI-compat's machine-readablecontext_length_exceedederror 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 separateRequestTooLargeError413 (a sibling exception class, not a subclass ofBadRequestError, 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 withaimu.context.trim_messages/summarize_messages. - New
resolve_default_text_modelis public (aimu.models,aimu.models.model_client, top-levelaimu; credit to a sibling session's contribution, merged frome76f3c9). 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 inaimu.models._internal.model_defaultsand 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.modelis an enum, and an enum is exactly the form that can't hold an@base_url. A real instance of the gap: a host withAIMU_LANGUAGE_MODELpointed at a remote Ollama server had its sub-agents rebuilt againstlocalhost, silently, because the only public surface for "what did the default resolve to" dropped the endpoint on the way out.
Tools¶
- Change (breaking)
execute_pythonruns 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 viaRLIMIT_ASon Linux (best-effort on other POSIX platforms, absent on Windows, with a one-timeWARNINGlogged 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_KEYand anything else inos.environis 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.envfile,~/.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 withtool_approval, and reach for a real container when actual containment is required. What you must do: code that relied onexecute_pythonsharing 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 tobuild_skills_server(manager, env=...), and one of them (docs/how-to/build-personal-assistant.md) did so in a section whose own example constructs anaio.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, withbuild_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 readingREPORT_DIR, called through a bare skills server (REPORT_DIR unset, the quiet failure the field prevents), then throughenv=, then through an agent carryingscript_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 offimport 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 anif TYPE_CHECKING:block importing the names it lazily exports. This is the mirror image of theaimu.Agentbug v0.19.0 fixed, and safe for the opposite reason: there the name was importable only underTYPE_CHECKING, so it raisedAttributeErrorat 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 insys.modulesafter an import, andimport aimu.models/from aimu import aiostay at 0.26s / 0.82s.mkdocsaborts 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.modelsuses theX as Xredundant-alias re-export form, since it builds__all__dynamically inside itsHAS_*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). PushingvX.Y.Znow builds, tests, and uploads; previously a release meant building locally and runningtwine uploadagainst a token in~/.pypirc, so shipping depended on one machine's credential. Two jobs:testinstalls[all,dev]and runs ruff plus the full suite, andpublishneeds it, so a red suite blocks the upload. Authentication is PyPI trusted publishing over OIDC -- no token is stored in the repository, and only thepublishjob holdsid-token: write(the workflow's top-level default is no permissions at all). The tag is also asserted againstpyproject's version, which catches a forgottendevsuffix 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 todeepeval2.6.6, which importslangchainwithout declaring it, and since deepeval registers apytest11plugin that killed the suite at startup; CI now installs the committeduv.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. Atests.ymlon 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-wideOLLAMA_HOSTenv var or nothing: the native clients builtollama.Client()with no host, andollama:<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.The kwarg is the ollama SDK's own spelling, forwarded verbatim (the ruleclient = 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 formtimeout/max_retriesalready follow), so a bare host,host:port, andscheme://host:portall work. An unsethostis omitted rather than passed asNone, leaving the SDK's ownOLLAMA_HOST-else-localhost resolution in charge. A/v1suffix 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 namesollama-openaias the provider that does want/v1. - Fix
OllamaEmbeddingClientstops silently embedding against localhost, and gainstimeout=on the way. It called module-levelollama.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 anollama.Clientlike the text clients do. Give it the samehost=. - Change Accepting a remote endpoint and accepting an uncatalogued model id are now separate policies (
_ENDPOINT_PROVIDERSand_ADHOC_PROVIDERSinaimu.models.model_client, split out of the single_BASE_URL_PROVIDERS). One set had been doing both jobs, so admittingollamato 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-tagstill raises, with or without an endpoint or capability flags. The endpoint-to-kwarg mapping (base_url=for the OpenAI-compatible providers,host=forollama) lives in one sharedendpoint_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_kwargsinaimu.models._internal.factory). All five bundled every kwarg intomodel_kwargs, which is right for a weight-loading client (device=belongs tofrom_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_MODELaccepts the full model string, endpoint and flags included (resolve_default_text_modelinaimu.models._internal.model_defaults). It validated the env var withresolve_model_string(), which reads onlyprovider:model_id, soAIMU_LANGUAGE_MODEL=ollama:qwen3.5:9b@http://gpu-box:11434died onProvider 'ollama' has no model id 'qwen3.5:9b@http://gpu-box:11434'-- the endpoint was reachable by an explicitaimu.client("...")argument but never by the env var. Validation now uses the extendedresolve_model(), so anything the client factory accepts, the env var accepts. This affects;<flags>ad-hoc ids and every@base_urlprovider, not just theollamaendpoint support new in this release. The two enum-returning resolvers,resolve_default_text_model_enum()and the publicresolve_model_enum(), still refuse both extended forms, since aModelmember 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 forresolve_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_stringinaimu.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, soImageClient("hf:<any repo>")works whereresolve_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:This is the modality-side match for the textProvider '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.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-modeldefault, and ambiguous-bare-name resolution all run before any client exists, so a per-clienthost=orbase_url=is invisible to them: Ollama discovery readsOLLAMA_HOST(else127.0.0.1:11434) and the OpenAI-compat probes try each provider's defaultbase_url. Stated in theavailable_text_models/resolve_default_text_modeldocstrings, the_ollama_installed_namesprobe itself, and docs/how-to/switch-providers.md. ExportOLLAMA_HOSTwhen discovery should consider a remote server too. - Change Intrinsic model capabilities move out of nine per-provider catalogs into one shared
MODEL_FACTStable (aimu/models/_catalog.py, private).tools/thinking/vision,thinking_levels/thinking_optional, and a card'sgeneration_kwargs/nonthinking_generation_kwargsare properties of the weights, not of who serves them, so restating them per catalog was how they drifted (see thePHI_4_MINIrename below for a real instance this migration would have caught). Every local-runtime catalog (OllamaModel,HuggingFaceModel,LlamaCppModel, and the seven*OpenAIModellocal-server catalogs) now declares aWire(id)per member instead of a fullModelSpec(...);Wireresolves againstMODEL_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 awhy=(e.g.Wire(id, why="no mmproj projector loaded by default", vision=False)) -- an override withoutwhy=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 bareModelSpec(...)directly. See thePHI_4_MINIrename below for the characterization test this migration let retire, and the "Adding New Models" section inCLAUDE.mdfor 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_FACTSholds onegeneration_kwargs/nonthinking_generation_kwargspair per model name, and every local-runtime catalog now resolves through it -- so a catalog that never wrote a card profile (every*OpenAIModellocal-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 at2be71b6and 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 tothe card's own thinking-mode row, verified against{"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}merge_generate_kwargs()directly. Three existingOllamaModelmembers are affected the same way:QWEN_3_8Bgains{temperature: 0.6, top_p: 0.95, top_k: 20, min_p: 0},GPT_OSS_20Bgains{temperature: 1.0, top_p: 1.0, top_k: 0}, andDEEPSEEK_R1_8Bgains{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 aclient.default_generate_kwargsvalue 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 viagenerate_kwargs=orclient.default_generate_kwargsrather 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, andLMStudioOpenAIModel's MLX-engine ids) grew the most because each MLX quantization is a separatemlx-communityrepo -- 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'smodel_path=does.docs/reference/model-matrix.mddocuments 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. Runpython scripts/generate_model_matrix.py --writeafter any catalog change;tests/test_docs_model_matrix.py::test_matrix_tables_match_the_generatorfails the suite if the committed file drifts from the generator's output. The surrounding prose (the legend, theThinkingStylediscussion, the footnote paragraphs) stays hand-written; only the marker-delimited tables regenerate. - Change (breaking)
PHI_4_MINIis renamedPHI_4_MINI_3_8Bon every catalog that carried it (LMStudioOpenAIModel,VLLMOpenAIModel,HFOpenAIModel,LlamaServerOpenAIModel,OllamaOpenAIModel,SGLangOpenAIModel,LlamaCppModel). It is the same weights asOllamaModel.PHI_4_MINI_3_8BandHuggingFaceModel.PHI_4_MINI_3_8B--OllamaOpenAIModel.PHI_4_MINIeven carried the identical idphi4-mini:3.8b-- so two names meantresolve_model_enum("PHI_4_MINI")andresolve_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 forphi4-minicarries the "tools" capability badge, so the shared intrinsic fact istools=True.OllamaModel.PHI_4_MINI_3_8Bnow carries it (its priorFalsewas a stale entry, not a serving-path limitation).HuggingFaceModel.PHI_4_MINI_3_8Bkeepstools=False, but now as an explicit,why=-documented override rather than an undocumented disagreement: the in-process HF client has noToolCallFormatand 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: replacePHI_4_MINIwithPHI_4_MINI_3_8Bin 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 retirestests/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 oneMODEL_FACTStable) 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_urlmodel string (_fresh_async_subagent_clientinaimu.aio.tools.builtin). It pre-resolved a string model throughresolve_model_string, which reads onlyprovider:model_id, so every spawn from a parent configured with an endpoint died onProvider 'ollama' has no model id 'qwen3.8:27b@http://gpu-box:11434'while the parent itself ran fine (AsyncModelClientparses 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 toAsyncModelClientunresolved, 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 toModelClientdirectly. This affects;<flags>ad-hoc ids and every@base_urlprovider, not just theollamaendpoint 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 toclient.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 syncaimu.agents.skill_agent.SkillAgentand the asyncaimu.aio.SkillAgent).build_skills_server(manager, env=...)has carried a host environment since 0.14.1, but aSkillAgentbuilds that server itself, on first run and again inreload_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. Merged over the inherited environment byrun_script_file, like thebuild_skills_serverargument it mirrors, soPATHsurvives. DefaultNoneleaves the previous behavior exactly. Both build sites take it, sincereload_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 aimudrops 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 itsHAS_*flags --sentence_transformersalone was 3.9s of the 8.5s, and a caller who only wantedaimu.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, andaimu/tools/__init__.pyall made this conversion.HAS_*flags now answer fromimportlib.util.find_specinstead of the success of a real import, and bothaimu.aioandaimu.Agent-- previously imported eagerly at the top ofaimu/__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 firstclient()call reloaded everything the import itself had just avoided -- asking foranthropic:...pulled in torch, transformers,ollama, andllama_cppregardless. 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.aioitself still cost 6.42s / 4375 modules after all of the above, becauseaimu/aio/__init__.pyimported its five modality modules (.audio,.embedding,.image,.speech,.transcription) eagerly, and each of those still had its own module-leveltry/except ImportErroraround the real provider SDK -- so torch, diffusers, and soundfile all loaded beforeimport aimu.aioreturned, regardless of the lazy-symbol table sitting unused below them. Converted to the sameinstalled()+ 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 infastmcp(and its own dependency tree --mcp,jsonschema,rfc3987_syntax) regardless of whether MCP was ever touched were fixed alongside it:aio.MCPClientnow resolves lazily offaio/__init__.py(mirroring the plain-lazy patternaimu/tools/__init__.pyalready used for the syncMCPClient), andaimu.skills.build_skills_servernow resolves lazily offaimu/skills/__init__.py(aio.SkillAgentpulls inSkillManager, which shares a package__init__.pywithbuild_skills_server).import aimu.aiois now ~0.5s / ~1,100 modules. - Change (breaking)
HAS_*now means "installed," not "imported cleanly." Checkingfind_specinstead of actually importing is what makes the flags cheap, but it changes what aTruepromises. A dependency that is present but broken (an ABI clash amongonnxruntime/grpcio/protobuf, say) used to fail its import silently and leave the flagFalse, so AIMU treated that provider as simply not installed and any caller checking the flag skipped it without incident. The same broken install now reportsTrueand raises at first real use instead. An absent dependency is unaffected -- it still yieldsNonefor 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 aHAS_*flag goingFalseto 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 owntry/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)
chromadbandsqlalchemymove to new[memory]and[prompts]extras, both folded into[all].pip install aimuno longer pullsonnxruntime,grpcio,opentelemetry, andposthogfor a vector store most callers never touch. Core is nowfastmcp,tinydb,requests,python-dotenv,pydantic>=2, plus Windows-onlytzdata.nest_asyncioandwatchdogwere declared dependencies that nothing in the package imported; both are dropped outright. What you must do: if your code touchesaimu.memory.SemanticMemoryStore, installaimu[memory]; if it touchesaimu.prompts.catalog.PromptCatalog, installaimu[prompts]. Without the extra, importing either now raisesImportErrornaming exactly what to install. The guard checks the failing import's own module name (exc.name), not a bareexcept ImportError, so achromadborsqlalchemythat 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];SemanticMemoryStoreis resolved lazily insideaimu/memory/__init__.pyso it no longer forceschromadbon everyone who imports the package. This bullet's claim -- that only code touchingPromptCatalogneeds[prompts]-- did not hold at first:aimu.prompts.__init__still importedPrompt/PromptCatalogeagerly, andaimu.agents.workflows.plan_execute_evaluatorimportsaimu.prompts.tuners.scorersunconditionally, so a bareimport aimu.agents(and thereforeimport aimu.aio, which importsaimu.agents) raisedImportErrorwithout sqlalchemy installed, regardless of whetherPromptCatalogwas ever touched. Fixed by movingPrompt/PromptCatalogintoaimu/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 ofaimu,aimu.models,aimu.tools,aimu.agents, andaimu.aiowithchromadb/sqlalchemysimulated absent. Tests:tests/test_optional_extras.py.
Tools¶
- Change (breaking)
execute_pythonstops 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__()reachessubprocess.Popen, andjson.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, andnotebooks/24-personal-assistants.qmd, alongside the tool itself. Three of those -- the personal-assistant how-to, its example README, and notebook 24 -- had been recommendingexecute_pythonas 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 nowmake_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, andaimu.aioeach get a captured__all__baseline, asserted asREQUIRED ⊆ actual ⊆ REQUIRED ∪ CONDITIONALrather than set equality --aimu.modelsbuilds its__all__from fifteenHAS_*-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 inaimu.__all__since theaimu.agent()shortcut was added, but the only import ofAgentinaimu/__init__.pywas underif TYPE_CHECKING(for a return annotation), soaimu.Agenthad been raisingAttributeErrorat runtime since May. Fixed with a real binding through the same lazy__getattr__that now resolvesaimu.aio. - Fix Three stale claims, each contradicted by the code, are corrected.
CLAUDE.mdcalledcontinuation_prompta deprecated no-op; it is live -- it recovers a degenerate empty turn (one that returns no content and no tool calls).CLAUDE.mdalso calledPROVENANCE_CONTINUATIONno longer produced; it still tags that same degenerate-turn recovery nudge, at four call sites acrossaimu/agents/_tool_loop.pyandaimu/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.mddocumentedChain.of()/Router.of()/Parallel.of(), which have never existed; the API isfrom_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; newPORTABLE_GENERATE_KWARGS,Unsupported,apply_kwarg_support, and aGENERATE_KWARG_SUPPORTclass attribute inaimu.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-providermax_tokensrename inside each rewrite hook, aPROVIDER_CONTEXT_LENGTH_KWARG/CONTEXT_LENGTH_REMEDYpair 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 typesoptionsas a pydanticOptionsmodel with nomin_pfield and the repetition knob spelledrepeat_penalty; unknown keys are discarded on validation, soOptions(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'sgenerate()raising onpresence_penaltyand Anthropic's Messages API rejectingmin_pwere 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 declaresGENERATE_KWARG_SUPPORT: the backend's own spelling for each portable key it accepts, anUnsupported(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_kwargshook, 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_kwargsand the per-callgenerate_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 carriestop_k/min_p/repetition_penaltyunderextra_bodyrather than at the top level, since the OpenAI schema has no top-level place for them. Gemini'stop_kis declared unsupported on an unresolved question, not a settled one. Google's OpenAI-compatibility reference documents no top-leveltop_kand no place for it underextra_body, but it does documentextra_body={"generation_config": ...}, and the native Gemini API hastopKinsidegenerationConfig. 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_penaltyis renamed intorepeat_penaltyon Ollama and llama.cpp (aimu.models.providers.ollama,aimu.models.providers.llamacpp, and theaiotwins), 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 carryrepetition_penalty: 1.0. That key used to be discarded silently by the SDK'sOptionsmodel, so Ollama's own server default of 1.1 applied instead; now that the portable spelling is renamed intorepeat_penalty, every Qwen request on Ollama shipsrepeat_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 theaiotwins). "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/completionsshim maps a fixed OpenAI field set onto its native call and reads none oftop_k,min_p, orrepetition_penalty, so inheriting the family's "supported" verdict routed all three intoextra_bodyfor the server to discard without a word -- exactly the silent loss the declared table exists to eliminate.OllamaOpenAIClientandAsyncOllamaOpenAIClientnow declare all threeUnsupported, each remedy naming the nativeollamaprovider (which acceptstop_kand, asrepeat_penalty, the repetition knob) or the Modelfile (formin_p, which the native SDK'sOptionsmodel cannot carry either).LlamaServerOpenAIClientand its async twin get a narrower, one-key fix: llama-server does read all three but spells the repetition knobrepeat_penalty, as llama.cpp's own/completionendpoint does, where vLLM and SGLang userepetition_penalty. The rename and theextra_bodyrouting 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'screate()accepts no arbitrary keywords, so a renamed key left at the top level would raiseTypeErrorrather than reach the server. Tests:tests/test_generate_kwargs_merge.py. - Change A
Nonevalue means unset for every portable key, not justcontext_length. Assigningclient.default_generate_kwargs = {"temperature": None}or passinggenerate_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 keyUnsupportedwould 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, andapply_context_lengthare gone, absorbed into the single declaration above along with each provider'smax_tokensrename. ABaseModelClientsubclass outside this repository that set either attribute declares oneGENERATE_KWARG_SUPPORTentry forcontext_lengthinstead: the backend's own spelling as a string if it takes the window per request, or anUnsupported(remedy)naming where to set it instead. No in-tree caller is affected, andCONTEXT_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 eachagent_typesspec, applied to the sub-agent's client (aimu.tools.builtin.make_subagent_tool,aimu.aio.tools.builtin.make_async_subagent_tool;SUBAGENT_SPEC_KEYSgrows 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 carriesgenerate_kwargs, assigned to the fresh client'sdefault_generate_kwargs, so a roster can pair one specialist with a cold temperature and another with a long context window: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. Likespawn = 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 }, )"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 owndefault_generate_kwargscannot 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_lengthsets the model's context window fromgenerate_kwargs, as a client default or per request (aimu.models,aimu.aio; newCONTEXT_LENGTH_KWARGandapply_context_length()inaimu.models._internal.generate_kwargs). Sizing the context window was the one generation parameter with no portable name: on Ollama it worked only by accident, becausegenerate_kwargsbecomes that provider'soptionsdict 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:Translation runs on the base, inclient = 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_GenerateKwargsMixin._resolve_generate_kwargsbetween the merge and the provider's_rewrite_generate_kwargshook, 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) orCONTEXT_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'sn_ctx=constructor argument,OLLAMA_CONTEXT_LENGTHforollama-openai,--ctx-size/--max-model-lenfor the other OpenAI-compatible servers, the weights' ownmax_position_embeddingsfor HuggingFace, and "fixed by the provider" for Anthropic / OpenAI / Gemini. Dropping rather than raising is the same rulethinking=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
ContextOverflowErrorandTruncatedTurnErrornamecontext_lengthrather thannum_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_LENGTHis still named where it applies. No behavior change beyond the wording.
Tools¶
- New A
"thinking"key on eachagent_typesspec, applied to the sub-agent it spawns (aimu.tools.builtin.make_subagent_tool,aimu.aio.tools.builtin.make_async_subagent_tool). v0.16.0 madethinking=a standing field onAgent, 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":The key is read withspawn = 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 }, ).get(), soFalseis carried rather than swallowed by a truthiness test, and it takes the same four value forms theAgentfield does. Nested spawns (max_depth > 1) rebuild the tool with the sameagent_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 atNone, 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_typesspec's keys are a closed set, and an unrecognized one raises (aimu.tools.builtin,aimu.aio.tools.builtin; newSUBAGENT_SPEC_KEYS). A spec may carrysystem_message,tools,model,thinking, and nothing else; anything else is aValueErrorat factory-call time, naming the bad key, theagent_typeit 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 onethinking="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 themake_subagent_tool()call, not at spawn time), so the fix is to delete the key. An unknownagent_typeis 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 -- pinaimu>=0.17.0if 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, whichqwen3.8belongs to) refuses with500 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 whoseweb_fetchresults 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 raiseContextOverflowErrornaming 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 ofTruncatedTurnError, which reports an output that ran out of room, and is exported fromaimu.modelsandaimu.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 originalollama.ResponseErrorpropagates 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_kwargssets 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 repeatinggenerate_kwargs=on everychat(). 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:Assigning a whole dict and mutating in place both work, and both now propagate through theclient = 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 onlyModelClient/AsyncModelClientwrapper thataimu.client()returns, throughagent.as_model_client(), and down aFallbackClient's chain, all of which previously copied the dict on construction (so mutation happened to work while reassignment silently detached). Behavior change on Ollama: readingdefault_generate_kwargsused to return the model card's profile and now returns{}until you write to it. UseModel.generation_kwargsto 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_kwargsclobbering the card's profile on Ollama and HuggingFace, the two providers whose catalogs carry one. The other three never readModelSpec.generation_kwargsat all: their_resolve_generate_kwargsmerged only their class-levelDEFAULT_GENERATE_KWARGSwith the caller's dict, so ageneration_kwargs=profile on one of their members was discarded silently, as was thenonthinking_generation_kwargsinstruct-mode switch thatthinking=Falseselects. 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'sDEFAULT_GENERATE_KWARGSfallbacks, then the model card's profile, thenclient.default_generate_kwargs, then the per-callgenerate_kwargs. The library's own fallbacks sit at the bottom, below the card, so a generictemperature=0.1cannot 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 forcedtemperature=1under extended thinking, the o-seriesmax_completion_tokensrename, HuggingFace droppingpresence_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 newaimu.models._internal.generate_kwargsowns the tier merge (merge_generate_kwargs(),select_profile(), moved out of_internal.thinking) and a_GenerateKwargsMixinthat 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 itsself._merge_generate_kwargs(...)first line and now only declares what its API needs reshaped (Ollama'snum_predictrename, HuggingFace'smax_new_tokensrename andpresence_penaltydrop, Anthropic's forcedtemperature=1under extended thinking, the o-seriesmax_completion_tokensrename, 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_ChatStateMixinis back to message/system/tool state only (kwarg resolution servesgenerate()as much aschat(), so it never belonged there). All three names are private, so this reaches only out-of-treeBaseModelClientsubclasses: move such a subclass's_update_generate_kwargsbody to_rewrite_generate_kwargs, minus the merge call. Atests/test_generate_kwargs_merge.pyguard now fails if any shipped client overrides the resolve entrypoint. Contributor guide: Add a provider.
Agents¶
- New
thinking=onAgent, applied to every model turn of a run (aimu.agents.Agent,aimu.aio.Agent, and bothSkillAgents).thinking=reachedchat()andgenerate()in v0.15.0, but anAgenthad 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-runrun(thinking=...)override, mirroringtools=/deps=/tool_approval=: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 (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 onlyfinal_answer_prompt), and theschema=structured-output turn. Effort is therefore uniform across a run rather than applying to the opening turn and decaying afterwards. The override testsis Nonerather than truthiness, sothinking=Falsegenuinely 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 theRunnerABC 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=onchat()andgenerate()(sync and async, plus the top-levelaimu.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 raisesValueErrorbefore 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_efforttemplate 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) getextra_body={"chat_template_kwargs": {"enable_thinking": ...}}plusreasoning_effort, with"high"sent as Qwen's own"xhigh". Anthropic maps a level tobudget_tokens(low 2048, medium 8000, high 16000), and all sixAnthropicModelmembers now declarethinking_levels=True(previously none did, since a level had nowhere to go without the flag); the threeThinkingStyle.ADAPTIVEmodels (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 acceptreasoning_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). NewModelSpecfields carry the capability:thinking_levels(accepts an effort level),thinking_optional(Falsemeans the model always reasons and cannot be disabled), andnonthinking_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 declaresthinking_levels=Truetoday; Anthropic's six models declare it too, through thebudget_tokensmechanism above.GEMINI_2_5_PROis the only model withthinking_optional=False:thinking=Falseagainst it warns and the call still proceeds at full reasoning cost, billed as such and visible onclient.last_usage. On the HuggingFace tokenizer path,enable_thinkingnow follows the model's declaredsupports_thinkingcapability instead of a hardcodedTruewhenthinking=None; this is inert for every current non-thinking member (each either takes the processor branch, which never readsenable_thinking, or is otherwise not driven by this flag), and it corrects a latent inconsistency (sendingenable_thinking=Trueto 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_kwargsno longer discards the model's tuned sampling profile (aimu.models.providers.ollama,aimu.models.providers.hf.text; sync and async)._update_generate_kwargson 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. Sochat("hi", generate_kwargs={"max_tokens": 2000})silently discardedtemperature/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:This is a genuine behavior change for anyone currently passing partialchat("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}generate_kwargson 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_penaltycorrected against the model cards (aimu.models.providers.ollama,aimu.models.providers.hf.text). Ollama's_QWEN_3_6_KWARGSused0.9and HuggingFace's_QWEN_KWARGSused1.5(HuggingFace's own instruct-mode value, misapplied to thinking mode); the 27B card specifies0.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 old0.9was wrong for both 3.6 members, in opposite directions, not just under- or over-shooting one shared correct value. On HuggingFace,_update_generate_kwargsdropspresence_penaltybefore 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 anonthinking_generation_kwargsinstruct-mode profile now, selected automatically whenthinking=Falseresolves off. -
Fix HuggingFace Qwen 3.8 loads through the multimodal path (
aimu.models.providers.hf.text).QWEN_3_8_27BandQWEN_3_8_27B_FP8shipped in v0.13.2 withvision=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 noAutoProcessor, so image input could not work at all despite the declared capability, and it builds a text-only module tree while the FP8 checkpoint'squantization_configskip-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_profilealso 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 declaringvision=Truemust route to a profile whose loader builds a processor, which catches the next catalog addition that forgets a prefix. - Change
HuggingFaceModel.generate_kwargsremoved (aimu.models.providers.hf.text). This per-enum-member dict mergedDEFAULT_GENERATE_KWARGSwith the member'sgeneration_kwargsat class-definition time. Nothing read it:_update_generate_kwargsrecomputes 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_thinkingmeans "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 twoValueErrorpaths 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: HuggingFaceGEMMA_4_12BandNEMOTRON_H_8B, llama-cppGEMMA_4_12B, and the whole Qwen 3.8 family. Wrong flags: HuggingFaceGEMMA_4_E4Bwasthinking=✗where the catalog says✅, and llama-cppLLAMA_3_1_8B/LLAMA_3_2_3Bweretools=✗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 readerOMLXOpenAIModel.GEMMA_4_12Bexists; 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.pynow 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_kwargsmerge 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-controlModelSpecfields, including whythinking_levelsshould 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
TruncatedTurnErrorinstead of being nudged (aimu.agents/aimu.aio; a subclass ofDegenerateTurnError, 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 ownmax_tokensdoing its job. - New
client.last_output_truncated(bool, alongsidelast_usageon 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 fromdone_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=...)andbuild_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.envis merged over the inherited environment rather than replacing it, because a replacement would stripPATH, 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 atclient.py's__del__.__del__now does nothing whilesys.is_finalizing(), andclose()is the explicit, idempotent teardown. A client held for the life of the process must callclose(). Skipping teardown in__del__is necessary but not sufficient on its own: if nobody releases the portal, Python's finalization ofstart_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.mdfrontmatter is validated against the Agent Skills specification (aimu.skills.validate, exportingvalidate_frontmatterandSkillSpecError; both re-exported fromaimu.skills). Discovery now enforces every rule the spec states:nameis required (1-64 characters, lowercase alphanumerics and single hyphens, no leading or trailing hyphen, and must match its parent directory),descriptionis required and at most 1024 characters,compatibilityat most 500, andmetadatamust be a mapping of string to string. A violation raisesSkillLoadErrornaming 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 wayskills-ref validateis. - Change (breaking)
nameno longer defaults to the skill's directory name._parsepreviously fell back toskill_md.parent.namewhen the frontmatter omittedname; the spec makes the field required, so an omission is an error rather than a silent substitution. ASKILL.mdwith noname:, aname:that is not a spec-valid slug, or aname: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), andwrite_skillalready enforced the slug on the authoring path, so only hand-written skills can reach this. - New
allowed-toolsis parsed and carried (AgentSkill.allowed_tools, atuple[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 existinglicense→license_infoprecedent. - New A skill's
.pyscripts may declare their dependencies inline (PEP 723), which the Agent Skills script guidance recommends as the way to make a skill self-contained. A.pyscript containing a# /// scriptblock now runs throughuv run --script, which resolves the declared dependencies into an isolated environment; previously every.pyran onsys.executable, so a spec-recommended script failed onModuleNotFoundErrorat its first import (verified: a script declaringhumanizeraised, whileuv runon the same file installed and ran it).--scriptrather than a bareuv 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 onsys.executable, so a skill relying on packages installed in the host environment is unaffected. Inline dependencies with nouvon 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 immediateEOFError(measured: 30.0s to 0.05s) that names the real problem. Nothing regresses:run_script_filehas 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 onlyargs. -
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()andbuild_skills_server()both readskills. A name inincludethat no search path provides raisesSkillLoadErrorlisting 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'sscripts/*.py/*.shwere registered asf"{skill_name}__{script.stem}"with both halves verbatim, so thepdf-processingskill producedpdf-processing__extract_pages. Both halves are now lowercased with every run of characters outside[a-z0-9]collapsed to_, givingpdf_processing__extract_pagesand 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._parsetookname:from the frontmatter unvalidated (onlywrite_skillenforced the kebab-case slug, and only on the authoring path), so a hand-writtenSKILL.mdreadingname: My Skillproduced the toolMy 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), andadd_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 callscript_tool_name, and a new test asserts every advertised name is present both incatalog_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.shalready collided on one name and were deduped by stem,.pywinning. Slugifying adds a second collision the stem check could not see:_SCRIPT_STEMdeliberately allows both separators, sobackup-db.pyandbackup_db.pyare both valid in one skill and now map toops__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; notebook08-agent-skillscalledunit-converter__nowand 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-27BplusQWEN_3_8_27B_FP8),OMLXOpenAIModel(bareQwen3.8-27Bplus_4BIT/_8BIT/_BF16), andLMStudioOpenAIModel(_4BIT/_8BIT) -- the same spread Qwen 3.6 uses. All entries aretools=True, thinking=True, vision=True, and none of those flags is a guess: the config carries avision_configwithimage_token_id/video_token_id(a dense unified vision-language model,Qwen3_5ForConditionalGeneration, so no separate-VLvariant), the chat template defines the XML<tool_call><function=…>framing the existingToolCallFormat.XMLparser 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 setsstructured_output=True(Ollama grammar-enforces JSON for any model). - New
_QWEN_3_8_KWARGSsampling defaults (aimu.models.providers.ollamaandaimu.models.providers.hf.text). Qwen 3.8's card recommendspresence_penalty=0.0in thinking mode, so 3.8 cannot reuse either existing constant --_QWEN_3_6_KWARGSuses0.9and 3.5 / the HF-side_QWEN_KWARGSuse1.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=Trueon both HuggingFace 3.8 entries. Qwen 3.8's chat template appends<think>\nto 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 matchesQWEN_3_5_9B. (The same latent bug in the existingQWEN_3_6_27B/_FP8andDEEPSEEK_R1_8Bentries is fixed separately below.) - Note on naming:
QWEN_3_8_27Bis Qwen 3.8 at 27B, one underscore away from the pre-existingQWEN_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:27bvsqwen3:8b) are unambiguous, but the pair is easy to misread, so both catalogs now carry a comment pointing it out. The2.4T-A95Bsibling (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.pyis data-driven, so it already holds the new members' intrinsic flags in agreement across the four catalogs and guards the shared-ModelSpec.idalias trap for the per-quantization members (166 → 175 assertions). -
Fix
think_opener_in_promptwasFalseon three HuggingFace thinking models whose chat templates prefill the opener (aimu.models.providers.hf.text):QWEN_3_6_27B,QWEN_3_6_27B_FP8, andDEEPSEEK_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/_generatesplits correctly whenever</think>is present (response.startswith("<think>")is False either way, and thestartoffset is computed independently), so the flag only bites when a thinking block is truncated before its close -- token budget exhausted mid-reasoning. There, theelif openedbranch 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 andlast_thinkingwas left empty. On the streaming path the flag is the only signal (opened = self.model.think_opener_in_promptwith nostartswithfallback), so the same truncation leaked reasoning tokens asGENERATINGchunks. NowTrueon all three, matchingQWEN_3_5_9B. - New Guard against the same flag drifting again (
tests/test_model_catalog_consistency.py)._EXPECTED_THINK_OPENERpins the expected value for every HuggingFace thinking model against its published chat template, with the verification recipe and the reason eachFalseentry is correct (QWEN_3_8B/SMOLLM3_3Bemit only the closed<think>\n\n</think>;GEMMA_4_*use<|channel>thoughtframing rather than<think>at all;GPT_OSS_20Bemits no opener). A companion test fails when a newly added thinking model has no pinned entry, so theFalsedefault 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
omlxprovider (OMLXOpenAIClient+OMLXOpenAIModelinaimu.models.providers.openai_compat, async twinAsyncOMLXOpenAIClient) targeting oMLX athttp://localhost:8000/v1;lmstudio, which gains MLX catalog entries (its MLX engine is auto-selected for MLX weights); andollama, 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 onOpenAICompatClient-- OpenAI-shapedtool_calls, streaming withstream_options.include_usage, vision content blocks, andreasoning_content-- so the client is the same five-line subclass asLlamaServerOpenAIClientand no behavioural code was added. No new extra either: oMLX and LM Studio are external server processes reached through the already-declaredopenaiSDK, so both rideaimu[openai_compat].hfandllamacppare deliberately excluded rather than given guessed entries:HuggingFaceClientis torch/transformersandLlamaCppClientis GGML/GGUF, and neither can load MLX's quantized safetensors layout --mlx-communityrepos 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 animage_token_idand animage-text-to-textpipeline tag), so all entries aretools=True, thinking=True, vision=True, matching the existingOllamaModel.QWEN_3_6_35B. Because each MLX quantization is a separatemlx-communityrepo, the catalog carriesQWEN_3_6_35B_4BIT/_8BIT/_BF16alongside the bareQWEN_3_6_35B(the quant-agnostic layout, and the name shared with the Ollama catalogs soresolve_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.OllamaOpenAIModelalso gains the previously-missingQWEN_3_6_35B(qwen3.6:35b) for parity with the nativeOllamaModel, which already had both 35B and 27B. oMLX ids are--model-dirsubdirectory names (oMLX discovers models from subdirectories), so likeLlamaServerOpenAIModel's GGUF filenames they are conventions, not contracts; the entries follow "directory name == themlx-communityrepo's model segment", which is what a copy-pasted download produces. - New
omlxjoins_BASE_URL_PROVIDERS(aimu.models.model_client), and_ASYNC_COMPAT_CLIENTSgains 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, andaimu.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. NoteAdHocModelcapability flags default toFalse, so they have to be spelled out. The async ad-hoc path routes by provider prefix through the hand-maintained_ASYNC_COMPAT_CLIENTSdict (the sync side reads_provider_registry()and cannot have this gap), so a missing entry there would have raised a bareKeyError; 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 byavailable_text_models()and can be auto-selected as the default. oMLX's default port is shared withvllmandhf-openai, which is safe because each probe keeps only enum members whose.valueappears in that server's/v1/modelsresponse, 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 withqwen3.6:35bpulled 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.idbefore enum's duplicate-value scan runs, andModelSpec.__eq__/__hash__are id-only, so two members of one catalog sharing aModelSpec.idsilently become an alias: the second vanishes from iteration (and therefore fromTOOL_MODELS/VISION_MODELS, the local-availability probes, and every check in that file) and its ownModelSpec-- 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 assertsset(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:OMLXOpenAIModelis auto-discovered, and becauseQWEN_3_6_35Bis now shared by three catalogs andQWEN_3_6_35B_4BIT/_8BITby two, their intrinsic flags are held in agreement for free -- no_INTENTIONAL_DIVERGENCESentry was needed. -
Tests:
tests/test_model_catalog_consistency.py(alias guard),tests/test_model_client_base_url.pyandtests/test_aio_model_client_base_url.py(the sync/async wiring canaries: default endpoint,@base_urloverride, ad-hoc directory ids),tests/test_default_model.py(port-8000 probe coexistence in both directions).tests/helpers.py,tests/helpers_aio.py, andtests/conftest.pyare wired forpytest tests/test_models.py --client=omlx_openai. -
New Muse Glimmer 30B on oMLX (
OMLXOpenAIModel:MUSE_GLIMMER_30Bplus_4BIT/_8BIT/_BF16),tools=True, thinking=True, vision=True, matching the existingOllamaModel/OllamaOpenAIModel/VLLMOpenAIModelentries. 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 asreasoning_contentand 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 anmlx-communitycheckpoint, because oMLX's ownJundot/Muse-Glimmer-30B-oQ4ewas 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 upstreamnvfp4/mxfp4conversions 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 guessingtools/thinking. -
Fix
HuggingFaceModel.QWEN_3_6_27Bsilently pointed at an FP8 checkpoint (aimu.models.providers.hf.text). The member's id wasQwen/Qwen3.6-27B-FP8while 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. SinceHuggingFaceClientloads 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 withOllamaModel.QWEN_3_6_27B(Ollama's default ~Q4 tag) described materially different numerics, which the cross-provider consistency guard cannot detect because it only comparestools/thinking/vision.QWEN_3_6_27Bnow resolves to the unquantizedQwen/Qwen3.6-27B, and the FP8 checkpoint is reachable as a new explicitQWEN_3_6_27B_FP8member -- 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 theqwen-multimodalload profile (_load_profileprefix-matchesQwen/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/_BF16members,GLM_4_7_FLASH_31B_Q4), and is left out when the provider resolves it (Ollama default tags, LM Studio keys, llama-cppmodel_path=). Note the bare member now downloads an unquantized 27B (~54 GB in bf16); useQWEN_3_6_27B_FP8on supported hardware, orbitsandbytesload-time quantization viamodel_kwargs(already in the[hf]extra).
Testing¶
- Fix
--client=llamaserver_openaiand--client=sglang_openaisilently tested Ollama (tests/helpers.py::_resolve_client,tests/helpers_aio.py::_resolve_async_client_for_type). Both resolvers ended inreturn 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 increate_real_model_clientwithValueError: Unknown model. Both options now resolve toLlamaServerOpenAIClient/SGLangOpenAIClient, both catalogs construct in the live-client fixtures (sync and async), and both are included in the--client=allmatrix so it matches its "full cross-provider" docstring. The silent fallback itself is gone:ollamais 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=sglangrather thansglang_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
toolframe carries the call's result (aimu.aio.channels.web.WebChannel.send). The frame is now{"type": "tool", "name", "arguments", "response"}. ATOOL_CALLINGchunk is yielded after the call has been dispatched and already carries the tool result oncontent["response"](seeaimu.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.responseisNoneonly for a chunk that omits it (a provider or a test that buildsTOOL_CALLINGcontent by hand). Additive and backwards-compatible on the wire: a page that ignores the key renders as before.CLIChannelis 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_30BinOllamaModel,OllamaOpenAIModel, andVLLMOpenAIModel). 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=Trueon all three, with Meta's recommended sampling defaults (temperature=1.0, top_p=0.95, top_k=64) on the native Ollama entry; ids aremuse-glimmer:30b(Ollama) andmeta-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_glimmerand--reasoning-parser muse_glimmerare enabled together (they key off the same markers, and the reasoning parser forcesskip_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-processHuggingFaceClientwould need both a new load profile (the weights load viaAutoModelForMultimodalLM, which the client doesn't import) and a parser for the channel/ATEM markup that noToolCallFormatcovers. BecauseMUSE_GLIMMER_30Bis now a shared name,tests/test_model_catalog_consistency.pyholds 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_12Bwere missingthinking=Trueon the HuggingFace catalog andGEMMA_4_12Bon 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-onlyQWEN_3_6_35B) were markedvision=Trueonly 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), sovision=Trueis 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_3Bhadtools=Falseon 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 enabledtools=Trueacross all four, matching the already-Trueserver catalogs. Two divergences are kept and now documented as intentional (they reflect a real serving-path limitation, not a bug):GEMMA_3_12Btools=Falseon the in-process HuggingFace/native-Ollama clients (no tool-call parse format assigned; OpenAI-compat servers parse server-side), andGEMMA_4_12Bvision=Falseon LlamaCpp (the default GGUF path loads nommprojprojector).structured_outputandaudioremain 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-specificModelSpec.id(the wire identifier:qwen3:8bvsQwen/Qwen3-8Bvsqwen3-8b.gguf), but shares one enum-member name (QWEN_3_8B) thatresolve_model_enumsearches 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=Truefails the suite.structured_output/audioare 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 nowprovider: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 genericopenai-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 fromtools,thinking,vision,audio,structured); such ids resolve to a newAdHocModel(exported fromaimu.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_keystays 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
ModelConnectionErrorwhen an inference server is unreachable (aimu.models.base, exported fromaimu.modelsandaimu.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'sAPIConnectionErrorat thechat.completions.createcall (and during stream consumption, where a mid-stream drop can surface it) and re-raise it asModelConnectionErrorfrom 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 existingMCPConnectionError/A2AConnectionErrorwrappers and lets a front end distinguish "server is down" from a generic failure instead of receiving a raw, provider-specific exception. OnlyAPIConnectionErroris 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 toself.messagesnow carries an inerttimestamp(ISO-8601, append time) via a single_append_messageseam 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 (syncaimu.models.providers.{anthropic,ollama,openai_compat,llamacpp,hf.text}, asyncaimu.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.timestampwas already inINERT_MESSAGE_KEYS, so it is still stripped from every provider request; stamping never changes the payload sent to a model. Previously only the syncConversationManagerset the key; the client now fills it on every path (sync and async), andConversationManager.update_conversationsetdefaults it so a client-stamped value wins._append_messageusessetdefault, 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 loneGEMMA_4_12Bentry each previously carried. Capabilities are set from Google's Gemma 4 model card:tools=True, thinking=True, vision=Trueon all four (thinking surfaces over OpenAI-compat via<think>-tag parsing). Provider-appropriate ids include the MoEgoogle/gemma-4-26B-A4B-itand the densegoogle/gemma-4-31B-itfor the HuggingFace-repo servers.vision=Truewas 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, soaimu.aiopicks up the new members automatically.
Agents and workflows¶
- New
SubagentObserverreports sub-agent activity to a display hook (aimu.aio.tools.builtin.make_async_subagent_tool(observer=...)). Passing anobserver(aSubagentObserver:spawned/chunk/finished) switches that spawn to a streamed child run and reports it as it happens, while thespawn_subagenttool itself stays non-streaming, soconcurrent_tool_callsstill 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.finishedfires from afinally, 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 missingspawned/chunk/finishedis logged once per call and skipped rather than raisingAttributeErrorinto 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 hittingmax_iterationswith 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 withcontinuation_prompt(tools still enabled, so the model can resume its plan), bounded bymax_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-infinal_answer_prompt, which now only customizes the wrap-up prompt (a built-inDEFAULT_WRAP_UP_PROMPTis used when unset). If even the wrap-up yields no answer, the loop raises the newDegenerateTurnError(exported fromaimu.agentsandaimu.aio) instead of returning empty output. Injected continuation nudges are taggedPROVENANCE_CONTINUATION(revived) so a UI can hide them. Behavior change: an agent left at the defaultfinal_answer_prompt=Nonethat 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 ofSemanticMemoryStoreandDocumentStore). A store's methods run in worker threads when an async agent dispatches sync memory tools viaasyncio.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 — interleavingDocumentStore'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.edit→read+write,store→write). Reads are serialized too, so a read never observes a half-applied write. Single-threaded use is unaffected. Concrete stores setself._lock = threading.RLock()in__init__;synchronizedis exported fromaimu.memory.basefor 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; nativeOllamaClient, 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_streamused bygenerate(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_contentis 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 separatereasoning_contentfield and strip<think>tags fromcontent. The clients only parsed inline<think>tags, so on these servers thinking was silently lost (e.g.gemma-4-31b-iton llama-server emitted noTHINKINGchunks and leftlast_thinkingempty). The clients now readreasoning_contentoff the delta/message and surface it asTHINKING(streaming) or store it inlast_thinking(non-streaming); when present it takes precedence over the<think>parser (which stays for servers that inline tags) and is not gated onsupports_thinking(if the server sent it, it is reasoning). Tests:tests/test_models_api.py,tests/test_aio_models_api.py. - Fix
HAS_LLAMACPPno longer reports installed when llama-cpp-python is absent (aimu.models.providers.llamacpp). The module deferredfrom llama_cpp import LlamaintoLlamaCppClient.__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) setHAS_LLAMACPP = Trueas a false positive.llamacppthen appeared inresolve_model's "available providers" list and in_provider_registry(), only to fail later at client construction. The module now does a hard top-levelimport llama_cpp(matching the diffusers/soundfile convention that keepsHAS_HF_IMAGE/HAS_HF_AUDIOtruthful); theLlamaweights are still loaded lazily in__init__. With the dep uninstalled,llamacppcorrectly drops out of the registry. - Fix
resolve_modelstops advertisingopenai-compatwhen its extra is missing (aimu.models.model_client; the async path reuses the same resolver). The "unknown provider" error unconditionally appendedopenai-compatto the "available providers" list even whenHAS_OPENAI_COMPATwasFalse, so the message contradicted itself: it namedopenai-compatas available, and using it then failed with a differentImportError("requires the openai-compatible extra"). The list now includesopenai-compatonly when theopenai_compatextra is installed. - Fix Ollama thinking + multi-tool-call turn crash (
aimu.models.providers.ollamaandaimu.aio.providers.ollama). Non-streaming_chatrecorded the turn'sthinkingontoself.messages[-1 - len(tool_calls)], but_record_tool_callsappends exactly one assistant message, so the offset pointedlen(tool_calls)messages too far back: it wrotethinkingonto 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 raisedIndexError: list index out of range, surfacing asTool 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 tobuiltin.web+ALL_TOOLS+ the MCP server) that returns a page's raw HTML markup (truncated), complementing the existing text-strippingget_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 sharedrequests.Session, so cookies persist across calls and a GET-then-POST form flow works:find_forms(url)parses every<form>(stdlibhtml.parser; no new dependency) into a listing of resolved-absolute action / method / fields includingtype=hidden(CSRF tokens surface), andsubmit_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_formis the mutating tool — gate it via thetool_approvalhook when confirmation is wanted. Both are re-exported fromaimu.aio.tools.builtin(dispatched viaasyncio.to_thread). How-to: Fetch HTML and submit web forms. Tests:tests/test_web_tools.py. - Change
@tooldocstring 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. AnArgs:/Arguments:/Parameters:section is parsed into per-parameterdescriptions (name: textorname (type): textentries, with more-indented continuation lines joined), and aLiteral[...]parameter now emits a JSON Schemaenum(with the element type when the literals are homogeneous) advertising the exact allowed values instead of a bare"string". Previously an opaquedict/Literalparameter 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 newLiteral-aware_schema_for. Tests:tests/test_tool_decorator.py. - Change
get_current_date_and_timeis timezone-aware (aimu.tools.builtin). The tool returnedstr(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 optionaltimezone=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 theTZenvironment variable and then the/etc/localtimesymlink 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, matchingget_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 tobuiltin.time+ALL_TOOLS+ the MCP server, re-exported fromaimu.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 caseszoneinfootherwise resolves silently (defaulting tofold=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 anote: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 thatfrom_timezonewas 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_timeaccepts the wall-clock formats a model actually emits (aimu.tools.builtin._parse_datetime).fromisoformatrequires a zero-padded 24-hour time, so a model asked for ISO 8601 that produced2026-08-11T5:00:00(unpadded hour) or2026-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 (with12 AM→00and12 PM→12, the two a naive+12gets wrong), and an optionalZ/±HH:MMoffset is preserved. Normalization rebuilds a strict ISO string and defers tofromisoformat, 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_timeis 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 —PSTandPacific Timeremain 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@toolparser puts it in the model-facing description (anExample:section would be parsed as a section header and dropped). Tests:tests/test_time_tools.py. - Change New
builtin.timesubgroup; the time tools leavebuiltin.misc(aimu.tools.builtin, re-exported fromaimu.aio.tools.builtin).time = [get_current_date_and_time, convert_time]andmiscnarrows to[echo]. The two tools were only inmiscbecause that is where ungrouped built-ins landed, which made "grant this agent a clock" inseparable from "grant itecho" — 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_TOOLSgains*timewhere it previously picked both up via*misc, so the default set and the MCP server (which registers everyALL_TOOLSentry) are unchanged. Behavior change: a caller passingtools=builtin.miscand expectingget_current_date_and_timenow gets onlyechoand must passbuiltin.time(orbuiltin.misc + builtin.time) — the one break, and it is import-time visible only as a missing tool at runtime, not anAttributeError, so check any call site that namesmisc. In-tree callers updated:examples/personal-assistant(web + time + misc) and tutorial 02, which uses the group for a date question and now asks forcompute + time. The name deliberately shadows the stdlibtimemodule as abuiltinattribute; nothing in the module imports it (date work usesdatetime), and a comment at the definition records that adding such an import would be silently rebound. Tests:tests/test_time_tools.py. - Change
zoneinfoadded to theexecute_pythonsandbox allowlist (aimu.tools.builtin._SANDBOX_ALLOWLIST). The sandbox permitteddatetimebut notzoneinfo, 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, sinceexecute_pythonis opt-in and not inALL_TOOLSby default. - Change
tzdatais now a Windows-only dependency (pyproject.toml,tzdata; sys_platform == 'win32'). Windows ships no system tz database, sozoneinfo— which the time tools above depend on — has no data to read and raisesZoneInfoNotFoundErrorfor 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.aiotwin 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 fromBaseModelClient(aimu/models/_base/text.py) andAsyncBaseModelClient(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_BaseToolLoopin aimu/agents/_tool_loop.py, subclassed by both_ToolLoopandaimu.aio._tool_loop._AsyncToolLoop; only the loop drivers and dispatch (threads vsasyncio.TaskGroup,await) stay per-surface. - The near-identical in-process async wrappers
AsyncHuggingFaceClient/AsyncLlamaCppClientreduced 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 onlyMODELS+_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.ipynbto plain-text Quarto.qmd(markdown with executablepythoncells), so notebooks diff cleanly and are easy to edit or hand to an AI assistant. Files are renamed to kebab-case (01 - Model Client.ipynb→01-model-client.qmd), and anotebooks/_quarto.ymlmakes 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 intoeval: true+freeze: autoper file. The docs site (MkDocs + Material) is unchanged; how-to/tutorial deep-links now point at the.qmdfiles. The[notebooks]extra is nowjupyter+jupytext(the latter lets JupyterLab open the.qmdfiles 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 twinaimu.aio.tools.builtin.make_async_subagent_tool) returns aspawn_subagent@toolthat 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-styleTaskpattern. It is the dynamic complement toOrchestratorAgent: an orchestrator dispatches to a fixed roster wired up front, whilespawn_subagentlets the LLM decide the fan-out. Two shapes: genericspawn_subagent(task)(a general-purpose sub-agent) or, withagent_types=, typedspawn_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 freshModelClient(isolated history, themake_workersidiom); parallelism is free — give the parentconcurrent_tool_calls=Trueand multiple spawns in one turn run concurrently (ThreadPoolExecutorsync /asyncio.TaskGroupasync).max_depth(default 1) bounds recursion. Non-streaming by design (keeps the concurrent path and avoids interleaving). Composes as a plain tool (no newRunnersubclass). How-to: Spawn sub-agents; demo:examples/news-summarizer --method spawn. - New approval gate for spawned sub-agents:
make_subagent_tool/make_async_subagent_toolgained atool_approval=parameter — the same(name, arguments) -> boolhookAgentaccepts — forwarded into every spawned sub-agent (and, withmax_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 thetools=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.toolsis 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 therole:"tool"results, and calls the client again until a turn makes no tool calls (bounded bymax_rounds), then the optionalfinal_answer_promptwrap-up. Not public API; the ladder stayschat()→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 theschema=short-circuit. Tool config lives on theAgent(fields + per-runrun(tools=/deps=/tool_approval=)overrides); theAgentnever 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 withstream=Trueonchat()/generate()(sync +aimu.aio), lifting the previousValueError. The call returns aStreamChunkiterator so thinking / generation stream live, then a terminalDONEchunk carries{"result": <validated object>}; the object is also stored onclient.last_structuredonce the stream is consumed (mirrorslast_usage; proxied throughModelClient/_AgenticView/FallbackClientand their async twins, cleared byreset()). Aninclude=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 threadsformat=into its streamed call); Anthropic streams the answer JSON as it is built (GENERATING viainput_json_delta) with no thinking, because its structured mode is a forcedtool_choicethe API forbids alongside extended thinking (no regression: Anthropic structured output never produced thinking). Also threaded throughAgent.run(schema=..., stream=True)(sync + asyncAgent/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="", notool_calls) before the part carryingtool_calls, the tool call was missed and one or more emptyGENERATINGchunks were yielded (a stray/empty response bubble in the web UI)._chat_streamednow consumes each turn fully, collectingtool_callsfrom any part and yielding only non-emptyGENERATINGchunks (which also drops the cosmetic empty trailingdonechunk). As defense-in-depth, the personal-assistantWebChannelandaimu.aio.CLIChannelskip emptyGENERATINGchunks. - Fix local thinking models now record their reasoning in
self.messagesconsistently. 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) andConversationManagerpersistence. This also covers the tool-call turn in an agentic loop: the reasoning that precedes a tool call is attached to the assistant message carryingtool_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 onlyrole/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 bychannel:sender, so one process can serve many users/chats.Sessionholds a conversation'slist[dict]history (OpenAI format) + an optionalmemory_namespace+metadata;SessionStore(ABC) hasInMemorySessionStore(non-durable) andTinyDBSessionStore(durable, reusingConversationManager's TinyDB mechanics, no new dep).session_key(channel, sender)collapses single-user to"default:default", andSessionLocksgives a lazy per-keyasyncio.Lock(serialize a session's turns; run different sessions concurrently). Generalizes the single-conversationConversationManagerusing the existingreset()+restore()per-turn seam (agents never share a livemessageslist). First piece of the personal-assistant substrate roadmap (network channel adapters and run-safety hooks are separate follow-ups).
Memory¶
- Fix
DocumentStorenow canonicalizes every path through a single_normalizehelper (single leading slash, forward slashes,posixpath.normpathto collapse redundant separators and contain..). Previouslywrite("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/deleteand thelist_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: aChannelABC (receive()async-generator,async send(),aclose()) andChannelMessageplain-data type, plus aCLIChannelstdin/stdout adapter. A new uniform interface alongsideAsyncRunner/MemoryStorefor 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 fromaimu.aio. - New
WebChannel(aimu.aio.channels.web, exported fromaimu.aio): the WebSocket twin ofCLIChannel. Bridges one browser WebSocket onto theChannelABC (a server pumpfeed()s inbound text into a queuereceive()drains;send()relays a finished string or a streamed reply as JSON frames). The frame protocol is{"type": "message"|"token"|"thinking"|"tool"|"done", ...}(a finishedmessagecarriesproactivewhen there is noreply_to); a publicsend_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 nostarletteimport and is unit-testable with a fake. The Starlette server, route, and HTML page stay app-side (seeexamples/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 oneasyncio.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 astop()signalled before it started (no lost-stop race). Persistence is intentionally out of scope. Exported fromaimu.aio. - New
aimu.aio.RunHandle: cooperative cancellation for an in-flightaio.Agent.run(...).RunHandle.start(coro)schedules the run as a task;cancel()stops it at the nextawait,await result()returns the result or raisesasyncio.CancelledError. The asyncAgentloop now snapshots its messages in afinally, so a cancelled run still records its partial turn for resume viarestore(). Async-only (asyncio cancellation; no threaded token). The personal-assistant example gains a/stopthat 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 discoverableSKILL.md(slug validation + traversal guard + no-clobber + parser round-trip), andaimu.skills.make_skill_authoring_tool(manager, skills_dir)returns an asyncauthor_skill@toolfor the Hermes-style self-improvement loop. NewSkillManager.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/*.pyandscripts/*.share each registered as a{skill}__{stem}tool that runs the script as a subprocess (.pyvia the current Python,.shviabash), now with an optionalargsstring forwarded to the script's argv (shlex-split; backward-compatible).write_skill(..., scripts={"name.py"|"name.sh": source})writes them (.shmarked executable);aimu.skills.make_skill_script_tool(agent, manager, skills_dir)returns an asyncadd_skill_script@tool. NewSkillAgent.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_pythonremains the sandboxed alternative.
Tools¶
- New
MCPClient(syncaimu.tools.MCPClient+ asyncaimu.aio.MCPClient) accepts a remote server byurl=, plusauth=(a bearer-token string or"oauth") andheaders=. Aurl=is folded into a single-servermcpServersconfig so FastMCP infers SSE vs streamable-HTTP and applies auth/headers in one path (shared_build_transporthelper);auth/headerswithouturlraises. This makes hosted MCP services usable through the existingas_tools()path with no config-dict boilerplate.auth=also accepts a configured provider object (a FastMCPOAuth/httpx.Authinstance) for persistent OAuth token storage or a custom redirect handler; it is forwarded straight to thefastmcp.Client(and cannot be combined withheaders=). - New
make_document_tools(store)inaimu.tools.builtin(parallel tomake_memory_tools): wraps aDocumentStore's path API assave_document/read_document/list_documents/search_documents@tools. The names are distinct frommake_memory_tools' triad, so one agent can carry both aSemanticMemoryStore(facts) and aDocumentStore(documents).make_memory_tools,make_document_tools, andmake_retrieval_toolare now re-exported fromaimu.aio.tools.builtinfor async discoverability. - New tool-call approval hook (
aimu.ToolApproval+aimu.approve_all): an optional gate(tool_name, arguments) -> boolrun 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 barechat(), or on anAgent(Agent(tool_approval=...)/ per-runrun(tool_approval=...)), on both the sync andaimu.aiosurfaces (async policies may be coroutines). It gates every dispatch path (non-streaming, streaming, concurrent). The personal-assistant example uses it to confirm the full-accessadd_skill_scripttool 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+Schedulerfor a proactive reminder + aSkillAgentthat authors skills viaauthor_skilland runnable Python/shell scripts viaadd_skill_script, persisted viaConversationManager, with a small fixed set of built-in toolsbuiltin.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 +uvicornWebSocket server) with an example-localWebChannel(aChannelover a browser WebSocket) and a dependency-free static page. Streams replies and pushes proactive scheduler messages to the browser, with no change to theAssistantloop, a worked example of extending theChannelABC. - New both personal-assistant channels can surface per-turn reasoning and tool calls, not just the final answer.
CLIChannelgains opt-inshow_thinking/show_toolsflags (off by default, preserving the minimal library default); the example-localWebChannelemitsthinking/toolframes the page renders as distinct blocks. The example enables both viaAssistantConfig.show_thinking/show_tools. - New how-to guide Build a personal assistant (incl. a "Web front end" section);
aimu.aioandaimu.skillsAPI references extended with the new symbols.
Packaging (breaking)¶
- Moved the Streamlit/Gradio chat apps from
web/toexamples/web/, consolidating all runnable programs underexamples/. - Breaking
streamlitandgradioare no longer core dependencies; they (withstarlette/uvicornfor the personal-assistant web UI) moved to a new optional[web]extra. Install the web UIs withpip install aimu[web].aimu[all]now includesweb. - New
[tuning]extra (pandas,tqdm) for the prompt-tuning subsystem and the evalsBenchmarkharness, which previously imported these without declaring them.aimu.promptsnow imports thePromptTunersubclasses lazily, soimport aimuandfrom aimu.prompts import PromptCatalog/Scorerwork without the extra; touching a tuner class raisesModuleNotFoundErroronly if[tuning]isn't installed. Included inaimu[all]. - Breaking the
[deepeval]extra is renamed to[evals](pip install aimu[evals]); the DeepEval adapters, module paths, andHAS_DEEPEVALflag are unchanged. Extras are now documented in two groups, provider backends (ollama,anthropic,openai_compat,google,llamacpp,hf) and capabilities (web,tuning,evals,a2a), withdev/notebooks/docsas 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, matchingModelClient(model, base_url=...)and the top-levelaimu.image_client(model, variant="fp16")helpers:ImageClient(HuggingFaceImageModel.SDXL_BASE, variant="fp16"). The oldmodel_kwargs={...}argument is removed (pass the kwargs directly instead). The concrete provider clients (HuggingFaceImageClient, etc.) are unchanged and still takemodel_kwargs=. - Fix optional-provider import guards (
aimu.models,ModelClient, and theiraimu.aiomirrors) now catchImportErrorinstead of bareException. A real error inside a provider module (aSyntaxError, anAttributeError, 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/workerout of range now raiseIndexErrorwith a descriptive message (Router already raisedKeyErroron an unknown route). Existing keyword calls are unaffected; only positional selector calls (e.g.chain.restore(msgs, 1)) need updating tostep=1. The semantic names are kept rather than collapsed to a generictarget=. - Fix async
SkillAgent.run()(aimu.aio) ignoreddeps=andschema=, which its sync twin andaio.Agent.run()both accept; async skill users silently lostToolContextdependency injection and structured output. The async override now mirrorsaio.Agent.run()in full:deps=,schema=(mutually exclusive withstream=True), and thefinal_answer_promptforced-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_usagenow populates after a fully-consumedchat(stream=True)/generate(stream=True), where before it was reset toNone. OpenAI-compat clients request it viastream_options={"include_usage": True}and read the terminal usage chunk; Ollama reads the final streamed part's eval counts; Anthropic readsstream.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 yieldsNone), and matches the non-streaming semantics (final turn's counts). Hardened the OpenAI-compat stream loop against empty-choiceschunks. In-process providers (HuggingFace, LlamaCpp) expose no streaming counts and still leave itNone. - New opt-in Anthropic prompt caching:
AnthropicClient/AsyncAnthropicClientacceptcache_prompt=True(threads throughaimu.client("anthropic:...", cache_prompt=True)), which marks the system prompt and the tool definitions withcache_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_anthropicnow also surfacescache_creation_input_tokens/cache_read_input_tokensinclient.last_usagewhen the response reports them, so cache creation/hits are observable (the baseinput/output/total_tokenskeys are unchanged). Pure passthrough; no AIMU-side caching layer. - New
FallbackClient(sync) /aio.AsyncFallbackClient(async): wrap an ordered list ofBaseModelClients and fail over to the next on error. The first client that answers wins; a raising client (by default anyException, narrowable viaretry_on=) hands off to the next with the same conversation state, so multi-turn history is preserved across a failover; when all fail,FallbackExhaustedErroris raised with the last error chained as__cause__(and all errors on.errors). Because it is aBaseModelClient, it drops intoAgent, workflows,Benchmark, andagent.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-clienttimeout/max_retriesfor in-SDK retry plus cross-provider failover. Exported fromaimu,aimu.models, andaimu.aio. - New
timeoutandmax_retrieson 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 theanthropic/openaiSDKs' native support. Ollama's native client supportstimeout(the syncOllamaClientnow holds anollama.Clientinstance rather than calling module-level functions) but has no request-retry, so passingmax_retriesto it raisesValueErrorpointing at theollama-openaiprovider. 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
@toolfunction'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"→5for anintparam); an uncoercible value, a missing required argument, or an unknown argument raises the newToolArgumentError, 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 PydanticTypeAdapterper parameter is built once at decoration time, so dispatch stays cheap. The validator is exposed asaimu.tools.coerce_tool_arguments(fn, arguments). MCPas_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 fullaimu.aioparity. The save/restore pattern (persist a failed run'slist[dict], reload, resume) now coversRouter.restore(messages, route=None)(route key selects a handler;Nonerestores the routing classifier),Parallel.restore(messages, worker=0)(index selects a worker), andOrchestratorAgent.restore(messages)(delegates to the inner orchestrator agent), in addition to the existingAgent/Chain/EvaluatorOptimizer. The async surface previously had norestore(); all six aio runners now mirror their sync twins.restore()stays per-class (signatures vary by selector), not on theRunnerABC. - New
Runner.as_tool(*, name=None, description=None)(sync andaimu.aio): wraps any agent or workflow as a@tool-style callable (tool(task: str) -> str) that delegates torun(). This is the seam that lets an autonomousAgentcall anyRunner(including aChain/Router/Parallelworkflow or a remote A2A agent), not just other agents. The name defaults to the runner'sname(sanitised), the description to the first line of itssystem_message(or a generic fallback for workflows). - Change
OrchestratorAgent.assemble(workers=...)now acceptslist[Runner](waslist[Agent]) on both surfaces, wrapping each worker viaRunner.as_tool(). Worker dispatch can now target a workflow or a remote agent, not only anAgent. ExistingAgent-only call sites are unaffected; the internal_wrap_worker_as_toolhelper is removed in favour ofas_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 withpip install 'aimu[a2a]';aimu.agents.HAS_A2Areports availability. A2A types never leak intoRunner/Agentcore; they adapt at the boundary. - Consume:
RemoteAgent.connect(url)resolves a remote agent card and returns a localRunner. Because it is aRunner, a remote A2A agent composes like any local one (intoChain/Router/Parallel/OrchestratorAgent.assemble(workers=[...]), or into anAgent's tool list viaremote.as_tool()), with no A2A-specific wiring. The sync client drives the asynca2a-sdkthrough an anyio portal (mirroringMCPClient);aimu.aio.a2a.RemoteAgentuses it natively and supports incrementalmessage/streamstreaming. - Expose:
serve_a2a(runner)(blocking) /build_a2a_app(runner)(returns a Starlette ASGI app) wrap anyRunneras 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-sdk0.3.xline (pydantic-native API matching the A2A ecosystem); the protobuf1.xline is a tracked future migration. Connection / call failures raiseA2AConnectionError.
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 andaimu.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 anAgentwith a system prompt (which resets its conversation on everyrun()) 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 annotatedToolContext(orToolContext[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.toolrecords the injected parameter names onfunc.__tool_injected__; both sync and async dispatch fill them via_tool_call_kwargs()from the client'stool_context_deps. Exported fromaimuandaimu.tools.
Agents and workflows¶
- New
Agent.depsfield + per-runAgent.run(..., deps=...)override (sync andaimu.aio): supplies the value injected asctx.depsinto tools that declare aToolContextparameter. The per-rundeps=takes precedence over the agent'sdeps=field;_prepare_run()publishes the effective value to the model client before each run.None(bareclient.chat()) meansctx.depsisNone. Forwarded bySkillAgent. - New
Agent.run(..., schema=...)(sync andaimu.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 withstream=True. - New
EvaluatorOptimizertyped-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 whenverdict_schemais set),verdict_schema(a dataclass / Pydantic model the evaluator must return via structured output; acceptance reads itspassedbool and revision uses itsfeedbackstr,passed_attr/feedback_attrare configurable, and a malformed verdict raises rather than silently continuing), orpass_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 theStreamChunkiterator fromclient.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 thechunk.is_tool_call()/chunk.is_text()dispatch loop. Exported fromaimu.
Documentation¶
- New README "Agents and workflows", "Tools", "Output and utilities", and quick-start sections cover
ToolContextinjection, the configurableEvaluatorOptimizeracceptance (pass_keyword/stop_when/verdict_schema), andpretty_print, with a runnable example combining all three.
Examples¶
- Change Consolidated the loose
scripts/directory and thedata/skills/demo skills into a single top-levelexamples/tree, organized by theme:examples/text-refinement/(theepic_*family),examples/image-refinement/(thehotdog_*family),examples/news-summarizer/, andexamples/skills/(haiku-poet,unit-converter). Each example directory has its ownREADME.md, andexamples/README.mdindexes them. Files were moved withgit mv(history preserved);scripts/anddata/are removed. - New
aimu.paths.examplesconstant pointing at theexamples/directory.aimu.paths.skillsnow resolves toexamples/skills(wasdata/skills); the unusedaimu.paths.dataconstant is removed. - Change The example test suites (
test_epic_scripts.py,test_hotdog_scripts.py) are now scoped out of the defaultpytestrun viatestpaths = ["tests"]. Run them explicitly withpytest examples/. The two refinement directories are onpythonpathso their shared-helper imports resolve. - New Examples are surfaced from the README (
## Examplessection), the docs site (docs/examples.md+ nav entry), and cross-linked from notebooks 07, 08, and 09. The two iterative-refinement how-to guides andgenerate-images.mdnow reference theexamples/paths.
Models¶
- Fix
HuggingFaceModel.QWEN_3_6_27B(and the Qwen 3.5/3.6 family) crashed at generation withRuntimeError: expected mat1 and mat2 to have the same dtype, but got: c10::BFloat16 != c10::Float8_e4m3fn. These are unified multimodal FP8 checkpoints whosequantization_config.modules_to_not_convertskip-list is written against the multimodal module tree (model.language_model.*/model.visual.*). The text-only entries loaded viaAutoModelForCausalLM, which builds a text-only tree (model.layers.*) the skip-list can't match, so layers meant to stay bf16 (routermlp.gate,lm_head,linear_attnprojections) mis-quantized. Qwen 3.5/3.6 now always load viaAutoModelForImageTextToText. - Change Merged the Qwen 3.5/3.6 text-only and
_VLenum members into singlevision=Trueentries (QWEN_3_6_27B,QWEN_3_5_9B); removedQWEN_3_6_27B_VLandQWEN_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 andmodel_kwargsbut loading via different classes (AutoModelForCausalLMvsAutoModelForImageTextToText) produced the same cache key, so the second silently received the first's model object._make_cache_keynow folds in a load-profile tag (mirroring how the image/audio/speech clients key onpipeline_class/pipeline_type).
v0.8.0 (2026-06-12): Embeddings, transcription, structured output, RAG & audio input¶
Models¶
- New
audio: bool = Falsefield onModelSpec. Audio-capable text models exposesupports_audioon their enum members,is_audio_modelon their client instances, and anAUDIO_MODELSclassproperty (parallel toTOOL_MODELS,THINKING_MODELS,VISION_MODELS). - New Audio-capable models added to the catalog:
OpenAIModelGPT-4o, GPT-4o-mini, GPT-4.1, GPT-4.1-mini, GPT-4.1-nano;GeminiModel2.0 Flash, 2.0 Flash Lite, 2.5 Pro, 2.5 Flash;HuggingFaceModel.GEMMA_4_E4B,GEMMA_4_12B,NEMOTRON_H_8B. Ollama models remainaudio=Falsewith 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 bothchat()(stateful; turn persists inself.messages) andgenerate()(stateless one-shot; no history touched). Accepts any mix of: file path strings,pathlib.Path, raw bytes (WAV assumed),https://URLs (fetched eagerly), anddata: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 withsupports_audio=FalseraisesValueErrorbefore any API call. - New
images=andaudio=are mutually exclusive per turn; passing both raisesValueError. - Internally normalised to OpenAI
input_audiocontent 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 viasoundfileand passes them to theAutoProcessor; Ollama raises with a clear message (API does not yet support audio). - Mirrored on the async surface (
aimu.aio): same signature onaio.chat()andaio.generate(). - Fix
ModelClient._generate(and the asyncAsyncModelClient._generate/_chat) now accept and forwardaudio=. They were missing the parameter while the basegenerate()/chat()always pass it, so everyaimu.client().generate()/aimu.chat(...)call through the factory raisedTypeError: _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()+TranscriptionClientfactory +BaseTranscriptionClientABC: a dedicated speech-to-text surface, parallel to TTS (BaseSpeechClient). Disjoint from theaudio=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 byopenai.audio.transcriptions.create(). Models:WHISPER_1,GPT_4O_TRANSCRIBE,GPT_4O_MINI_TRANSCRIBE. Auth viaOPENAI_API_KEY. Uses the sameopenaiSDK already required by the[openai_compat]extra. - New
HuggingFaceTranscriptionClient+HuggingFaceTranscriptionModel: local ASR backed bytransformers.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 asaudio=onchat().response_format="verbose_json"returns a dict withtext,segments(start/end/text),language,duration.response_formatdefaults to"text"(plain string). - New
AIMU_TRANSCRIPTION_MODELenv var: sets the default model foraimu.transcription_client()andaimu.transcribe()whenmodel=is omitted. - New Async mirror under
aimu.aio:AsyncTranscriptionClient,aio.transcription_client(sync_client),await aio.transcribe(audio, *, model, ...). Wraps sync viaasyncio.to_thread(Decision 7, same as every other aio modality). - New Built-in
transcribe_audio(audio_path: str) -> str@toolinaimu.tools.builtin;builtin.transcriptionsubgroup; included inALL_TOOLS. Backed by a lazy_transcription_clientsingleton viaAIMU_TRANSCRIPTION_MODEL.make_transcription_tool(client)binds a fresh tool to a caller-supplied client. - New
docs/how-to/transcribe-audio.mdandnotebooks/21 - Transcription.ipynb.
Embeddings (text-to-vector)¶
- New
aimu.embedding_client()/aimu.embed()+EmbeddingClientfactory +BaseEmbeddingClientABC: a dedicated text-embedding surface, parallel to the other modality clients.embed()takes one string (returnslist[float]) or a list (returnslist[list[float]], order preserved); an empty list returns[]without a provider call.client.dimensionsreports the spec's vector width. - New
OpenAIEmbeddingClient+OpenAIEmbeddingModel(text-embedding-3-small/large,text-embedding-ada-002) viaopenai.embeddings.create(); auth viaOPENAI_API_KEY. - New
OllamaEmbeddingClient+OllamaEmbeddingModel(nomic-embed-text,mxbai-embed-large,bge-m3,all-minilm) viaollama.embed(). - New
HuggingFaceEmbeddingClient+HuggingFaceEmbeddingModel(MiniLM-L6-v2, BGE small/base/large-en-v1.5, GTE-large, E5-large-v2, mxbai-embed-large-v1) backed bysentence-transformersso each model's own pooling/normalization config is honoured; lazy load + module-level weight cache (freed byaimu.clear_hf_cache()). Addssentence-transformers>=3to the[hf]extra. - New
SemanticMemoryStore(embedding_client=...): pluggable embedding model; defaultNonekeeps ChromaDB's built-in embedder (unchanged behaviour). - New
AIMU_EMBEDDING_MODELenv var sets the default model foraimu.embedding_client()/aimu.embed()whenmodel=is omitted (raises if unset; no implicit download). - New Async mirror:
aio.embedding_client(sync_client)/aio.embed()wrap a sync client viaasyncio.to_thread. - Docs
docs/how-to/use-embeddings.md,notebooks/11 - Embeddings.ipynb, API reference, and env-var reference.
Structured output¶
- New
schema=onchat()andgenerate()(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 withstream=True. - New
ModelSpec.structured_outputflag →client.supports_structured_outputproperty and aSTRUCTURED_MODELSclassproperty (parallel totools/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(OpenAIresponse_formatjson_schema; Ollamaformat=; 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 raisesValueError. self.messagesstays plain strings; the typed object is a return value only, so conversation history remains provider-portable.- Composition:
schema=works alongsidetools=on OpenAI-compatible and parse-path providers. On Anthropic (native structured output is a forced tool) combiningschema=with active tools raisesValueError. - New
schema_to_json_schema()(internal) converts a dataclass/Pydantic model to a JSON Schema, reusing the@tooldecorator's Python-type → JSON-Schema mapping. - Docs
docs/how-to/use-structured-output.md. - Deferred:
Agent.run(schema=...), astrict=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 theMemoryStoreinterface (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_functiondefaults to character count; pass a tokenizer's counter for token-aware chunking. Oversized unsplittable text hard-cuts atchunk_size. - New
ingest(store, documents, *, chunk_size, chunk_overlap, separators, length_function) -> int: splits one or many documents and stores each chunk viastore.store(); returns the chunk count.retrieve(store, query, *, n_results=5, **search_kwargs) -> list[str]is a RAG-named pass-through tostore.search()(forwards e.g.max_distance=).format_context(chunks, *, separator="\n\n", numbered=False) -> strjoins chunks for prompt augmentation. - New
rerank(query, documents, *, model="cross-encoder/ms-marco-MiniLM-L-6-v2", top_n=None): cross-encoder reranking viasentence-transformers(the[hf]extra); lazy-loaded and cached. Empty input returns[]without loading the model. - New
make_retrieval_tool(store, *, n_results=5)inaimu.tools.builtin: wrapsretrieve+format_contextas aretrieve_context(query)agent tool (returns numbered context). - Docs
docs/how-to/use-rag.mdand theaimu.ragAPI 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 theMemoryStorecontract.
Token usage surfacing¶
- New
client.last_usage: token counts for the most recent non-streamingchat()/generate(), as{"input_tokens", "output_tokens", "total_tokens"}(orNonewhen 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 theModelClient/AsyncModelClientwrappers. Reset toNoneon streaming calls (streaming usage capture is a separate follow-up) and byreset(). Token counts only; dollar cost is derivable but intentionally not computed (no maintained price table).
Anthropic models & adaptive thinking¶
- New
AnthropicModelmembers:CLAUDE_FABLE_5(claude-fable-5),CLAUDE_OPUS_4_8(claude-opus-4-8),CLAUDE_OPUS_4_7(claude-opus-4-7), alltools=True, thinking=True, vision=True. - New
ThinkingStyleenum (ENABLED/ADAPTIVE) carried as a per-member extra onAnthropicModel(analogous to HuggingFace'sToolCallFormat).AnthropicClient._thinking_kwargs()builds the request accordingly:ENABLED→{"type": "enabled", "budget_tokens": N};ADAPTIVE→{"type": "adaptive", "display": "summarized"}withtemperature/top_p/top_kdropped. Opus 4.7+ and Fable 5 are adaptive-only (theenabledform 400s on them); Opus 4.6, Sonnet 4.6, and Haiku 4.5 use the budget form. - Fix
CLAUDE_HAIKU_4_5now correctly hasthinking=True. Haiku 4.5 supports extended thinking via theenabled/budget_tokensform (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) demonstratingThinkingStyleand adaptive models skipping thinking on trivial prompts.
Dependencies¶
- Fix Pinned the
[hf]extra'skernelsto>=0.12,<0.13. It was unconstrained and resolved tokernels 0.15.2, which is outside the rangetransformerssupports (<0.13);transformersconstructskernels.LayerRepository(...)at import time and 0.13+ maderevision/versionmandatory, sofrom transformers import AutoProcessorraisedValueError, silently flippingHAS_HFtoFalse(HuggingFace clients unavailable) and erroring every HF test on import. - Pinned the
[hf]extra'stransformersto>=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/AsyncLlamaCppClientrun their sync client's_chattool-dispatch loop in a worker thread (viaasyncio.to_thread), and that sync dispatcher refusesasync deftools, 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 theto_threadfuture). 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_tokensraised from1024to4096. 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 withgenerate_kwargs={"max_tokens": N}. - Fix Streamed
chat()on a HuggingFace thinking model no longer raisesRuntimeError: generator raised StopIterationwhen 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 unguardednext()on an empty token stream. - Fix (tests) Mock-only audio/speech/image API tests previously replaced
transformers/soundfile/diffusersinsys.moduleswith 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_clientattribute. MCP tools now integrate through the singlemodel_client.toolsregistry: callMCPClient(...).as_tools()(sync) orawait aio.MCPClient.connect(...).as_tools()(async) to turn a server's tools into@tool-style callables, then add them totools(constructorAgent(tools=...),client.tools = ..., or the per-callchat(tools=...)/run(tools=...)override). Migration: replaceclient.mcp_client = mcpwithclient.tools = mcp.as_tools()(concatenate with@toolfunctions as needed, e.g.builtin.web + mcp.as_tools()). Two consequences: dispatch is now one by-name lookup overtools, so on a name collision the last entry wins (previously Python@toolalways beat a same-named MCP tool; to preserve that, append the Python tool aftermcp.as_tools()); andMCPClient.get_tools()is no longer called on everychat()(the tool list is snapshotted byas_tools()), so callas_tools()again to pick up server-side tool changes.SkillAgentand the internal dispatch (_handle_tool_calls(tool_calls),_call_plain_tool(tc, tc_id), both of which lost theirtoolsparameter) were updated accordingly. TheMCPClientclass, itsget_tools()/call_tool()/ping(), and theaio.MCPClientparallel are unchanged. - Breaking
system_messageis no longer immutable after the firstchat(). The setter is now always live: assigning it mid-conversation rewrites the{"role": "system"}entry inmessagesin place (re-conditioning the model on the new prompt while preserving history), inserts one if absent, or removes it onNone. Before the first chat it still just seeds the value. The previous behaviour raisedRuntimeError; code that caught that error to gate areset()can now assign directly. To change the prompt and drop history, usereset(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 aModelClientshared by another agent's in-flight conversation, so don't share a live-conversation client across agents that each setsystem_message. The_system_message_lockedflag has been removed. See System message lifecycle.
Models¶
- New
aimu.resolve_model_enum(model)andaimu.resolve_image_model_enum(model): resolve a model to itsModel/ImageModelenum member from any of three input forms: an enum member (returned unchanged), a"provider:model_id"string (delegates toresolve_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-modeldefault 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,ValueErrorlists the"provider:model_id"options. This availability probe runs only on the ambiguous path.resolve_image_model_enumhas no local-availability notion (image catalogs don't collide) and raises on the rare ambiguity. Exported fromaimu.modelsand top-levelaimu. - New
aimu.available_text_models(*, include_hf_cache=True)for discovery: return locally available text models asModelenum 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 backsclient()/chat()/agent()whenmodel=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-tunedgoogle/gemma-4-12b-it, tools/vision, processorparse_responsepath), and aGEMMA_4_12Bmember on every OpenAI-compat server enum (OllamaOpenAIModel,LMStudioOpenAIModel,VLLMOpenAIModel,HFOpenAIModel,LlamaServerOpenAIModel,SGLangOpenAIModel) plusLlamaCppModel. The server/llama.cpp entries aretools=True, matching the establishedGEMMA_3_12Bconvention 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
tooldecorator is re-exported at the top level asaimu.tool.@aimu.toolis 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-namedtooldecorator (LangChain, smolagents, etc.).from aimu.tools import toolremains valid and unchanged (same object); it's the natural form for code already insideaimu.tools. TheToolSignatureErrormessage prefix is now@aimu.tool:to match. No behaviour change to decoration or dispatch. - New
MCPClient.as_tools()(sync) andaio.MCPClient.as_tools()(async) return a server's tools as@tool-style callables, each closing over the client, invokingcall_tool()cross-process, and carrying__tool_spec__/__tool_is_async__/__tool_is_streaming__. Drop them straight intotools(client.tools = mcp.as_tools(),Agent(tools=builtin.web + mcp.as_tools())). This unifies MCP and in-process tools onto the singleself.toolsregistry and one dispatch path; see the breaking-change note above for the migration frommodel_client.mcp_client. New shared helperaimu.tools.mcp_format.mcp_content_to_text(tool_response)flattens acall_toolresult to a string. - New Per-call tool override:
chat(..., tools=None)andAgent.run(..., tools=None)(both sync andaimu.aio) accept atools=list that replaces the client's configuredself.toolsfor a single call/run, restored afterward.tools=None(default) keeps the existing behaviour;tools=[]disables tools for the call (MCP tools, being callables inself.toolsviaas_tools(), are included in the swap). On anAgent, the override applies to every turn of the agentic loop. Implemented as a scopedself.toolsswap (_ChatStateMixin._tools_override) covering both request-spec building and dispatch; the agent threads it through each loopchat()call so no new agent state is introduced. Not safe across concurrentchat()calls on a shared client; same contract asself.messages. Not added to theRunnerABC / workflow classes.
Agents and workflows¶
- New
Agent.final_answer_prompt(opt-in, defaultNone; sync andaimu.aio): guarantees a final answer when the agentic loop exhaustsmax_iterationswhile 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 againstmax_iterations.OrchestratorAgent._init_orchestrator()andOrchestratorAgent.assemble(..., final_answer_prompt=...)(sync +aio) forward it to the inner orchestrator agent, and it is accepted as afrom_configkey. Leaving itNonepreserves prior behaviour exactly.
Fixes¶
- Fix
SkillAgentskill injection no longer wipes conversation history when applied to an already-used client. It previously calledreset()to unlock the setter (clearingmessages); it now assignssystem_messagedirectly, 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_DEV→FLUX_1_DEVandFLUX_SCHNELL→FLUX_1_SCHNELLfor naming consistency with theFLUX_2_KLEIN_4B/FLUX_2_KLEIN_9Bmembers. 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.computenow includesexecute_pythonalongsidecalculate. If you were passingtools=builtin.computeand want to exclude the sandboxed REPL, switch totools=[builtin.calculate]explicitly.ALL_TOOLSandmake_tools()are unchanged (opt-in only viapython_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 v2BaseModelasschemato coerce the parsed dict into a typed object. RaisesValueErroron all-strategy failure with the first 200 characters of the response included. Exported fromaimu.models._json,aimu.models, and top-levelaimu. - New
aimu.generate_json(client, prompt, schema=None, *, retries=2, generate_kwargs=None): callclient.generate()and parse the result as JSON, retrying up toretriestimes on parse failure. Convenience wrapper aroundparse_json_response. - New
aimu.extract_tool_calls(messages): convert an OpenAI-format message list (e.g.agent.model_client.messages) into a flatlist[dict]of{iteration, tool, arguments, result}records. Handles bothargumentsandparameterskey 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 callingfrom_pretrained()again. The text client checks on construction; the lazy-loading modality clients check on first load.LlamaCppClienthas 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 callgc.collect()+cuda.empty_cache(). Pass a model enum member to clear just that model; passNoneto clear all. - New
aimu.clear_llamacpp_cache(model=None): same forLlamaCppClient.
Tools¶
- New
execute_python(code)built-in tool inbuiltin.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, andnumpy/pandas/scipy/matplotlibwhen installed. Filesystem (open,os,pathlib) and subprocess access are blocked. Not included inALL_TOOLS; opt in viatools=builtin.computeormake_tools(python_sandbox=True). - New
make_tools(..., python_sandbox=False): newpython_sandbox=kwarg appendsexecute_pythonwhenTrue. - New
make_memory_tools(store)inaimu.tools.builtin: wraps anyMemoryStoreinstance 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 withSemanticMemoryStore,DocumentStore, or anyMemoryStoresubclass. 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): newmemory_store=kwarg appendsmake_memory_tools(store)to the assembled tool list when provided.
Agents and workflows¶
- New
Agent.restore(messages): restore an agent from a savedlist[dict](OpenAI message format) for resuming after failure. Callsmodel_client.reset(), strips the leading system message to prevent duplication on the nextchat(), and setsmodel_client.messages. The live partial state after a failed run is onagent.model_client.messages(not the post-run snapshot fromagent.messages). - New
EvaluatorOptimizer.restore(messages): delegates togenerator.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 (includingclear_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=toBaseImageClient.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 viafrom_pipe()(shared weights, no extra VRAM).strength=(default0.75) controls deviation from the reference for FLUX.1-style pipelines.width/heightare 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_4BandFLUX_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. UsesFlux2KleinPipeline(diffusers 0.37+), a unified pipeline that handles both txt2img and img2img natively (image=parameter, nostrength).img2img_uses_strength=Falseon the spec distinguishes it from FLUX.1-style img2img. - New
HuggingFaceImageSpec.img2img_pipeline_class: diffusers class name for the img2img variant (e.g."StableDiffusionImg2ImgPipeline");Nonefor ad-hoc"hf:<repo>"strings. - New
HuggingFaceImageSpec.img2img_uses_strength:True(default) for strength-based pipelines;Falsefor 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.pyabsorbshotdog_climbing.py: the two scripts shared identical structure and differed only in their acceptance policy. Pass--strategy climbingfor hill-climbing behaviour (keep best, revert on non-improvement);--strategy greedy(default) preserves the original loop behaviour.hotdog_climbing.pyis 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 annealingstrengthfrom high (explore) to low (polish). Detects and warns when the active model does not supportstrength(e.g. FLUX.2 Klein).
Negative prompts¶
- New
ImageSpec.supports_negative_promptcapability flag.Trueby default;Falsefor guidance-distilled / conversational models that have no negative-prompt parameter, such asHuggingFaceImageModel.FLUX_2_KLEIN_4B/_9Band the entire Gemini image family (GeminiImageSpecdefaults it toFalse). - Behavior
BaseImageClient.generate()now raisesValueErrorifnegative_prompt=is passed to a model whose spec setssupports_negative_prompt=False, instead of crashing deep in the pipeline (HuggingFace) or silently ignoring it (Gemini). Callers branch onspec.supports_negative_promptand fold avoidance into the prose prompt for unsupporting models. The hotdog scripts do this via a newnegative_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 raisesValueError(listing available ids) instead of fabricating a spec with guessed capabilities. Text was always strict (resolve_model_stringraises); 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"lostsupports_negative_prompt=False/img2img_uses_strength=False, and"hf:suno/bark"lost BARK'sdefault_voice. - Removed The
_REPO_PIPELINE_HINTSrepo-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.aiomirrors the entire public sync API one-for-one, with the same class names in a different namespace. Switch paradigms with one import line plusawait. Exportschat,client,Agent,SkillAgent,Chain,Router,Parallel,EvaluatorOptimizer,PlanExecuteEvaluator,OrchestratorAgent,MCPClient. Imported by default, sofrom aimu import aioneeds no separate install. - New
aio.Parallelandconcurrent_tool_calls=Trueuseasyncio.TaskGroupfor structured concurrency: sibling cancellation on first failure,ExceptionGroupaggregation. - 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
MCPClientbuilt on FastMCP's native asyncClient(no anyio portal); construct viaawait MCPClient.connect(...). The syncMCPClientremains first-class. - New
@toolasync detection (__tool_is_async__):async deftools are awaited directly; sync CPU-bound tools are routed throughasyncio.to_threadso the event loop stays free. - Note Streaming on the async surface returns
AsyncIterator[StreamChunk](consume withasync for); the sync surface returnsIterator[StreamChunk]. TheStreamChunktype itself is identical on both. - Requirement Python 3.11+ is now required (the async surface uses
asyncio.TaskGroup,asyncio.timeout, and nativeExceptionGroup).
Audio generation¶
- New
aimu.audio_client()/aimu.generate_audio()+AudioClientfactory +BaseAudioClientABC, 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_GENERATINGStreamChunkphase +StreamChunk.is_audio_progress(). Streaming progress for diffusers-backed models. - New
encode_audio()output formats:numpy(default),bytes,data_url,path(WAV viasoundfile). - New Built-in
generate_audiostreaming tool +make_audio_tool(client, duration_s=);builtin.audiosubgroup.
Speech (text-to-speech)¶
- New
aimu.speech_client()/aimu.generate_speech()+SpeechClientfactory +BaseSpeechClientABC. - New Providers: HuggingFace local (SpeechT5, MMS-TTS, BARK) and OpenAI cloud (
tts-1,tts-1-hd). - New
SPEECH_GENERATINGStreamChunkphase +StreamChunk.is_speech_progress(); OpenAI byte-chunk streaming. - New Built-in
generate_speechstreaming tool +make_speech_tool(client, voice=, speed=);builtin.speechsubgroup.
Image generation¶
- New Google Gemini "Nano Banana" cloud provider (
GeminiImageClient,gemini-2.5-flash-image) under the[google]extra, dispatched viaaimu.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_GENERATINGchunks during denoising, with optional per-step latent previews viapreview_every=N(HuggingFace diffusers). - New Built-in
generate_imagestreaming tool,make_image_tool(client, preview_every=), andmake_describe_image_tool(client)(binds vision Q&A to a vision-capable chat client);builtin.imagesubgroup.
Default-model resolution¶
- New
model=is now optional onaimu.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_MODELprovide defaults for the image/audio/speech entry points (env-var only; an unset var raises a clearValueError).
Tools and vision¶
- New Streaming tools: a generator-function
@toolmayyieldStreamChunkobjects mid-execution (flag__tool_is_streaming__); the agent forwards them throughagent.run(stream=True). The tool's recorded response resolves from itsreturnvalue, the last chunk'sresult, orstr(last_chunk.content). - New
images=is now accepted on statelessgenerate()(one-shot vision Q&A that does not touchself.messages), in addition to statefulchat(). - 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-lineModelClientfactory. - New
aimu.resolve_model_string("provider:model_id"): model-string parser. - New
ModelClientnow accepts a"provider:model_id"string in addition to enum members.
Model clients¶
- New
ModelSpecfrozen dataclass replaces positional enum tuples. AllModelenums migrated. - New
client.reset(system_message="__keep__")clears history and unlocks the system-message setter. - Breaking
system_messageis immutable after the firstchat()call. The setter raisesRuntimeError; callreset()to unlock. - New
include=[...]stream filter onchat()andgenerate()selects phases ("thinking","tool_calling","generating","done"). - Internal Abstract methods renamed
chat → _chat,generate → _generate. Concretechat/generateon the base class apply theincludefilter 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 withmodel_kwargs={"device": "cuda:1"}or{"device_map": ...}. Audio/speech clients take the same{"device": ...}hint. Sharedaimu/models/_hf_device.pyhelpers back all three. - New
ImageSpec.max_prompt_tokensrecords the model's text-encoder prompt budget (77 for CLIP, 256/512 for T5 models like SD3/FLUX,Nonefor uncapped cloud models), exposed onBaseImageClient. Use it to size prompts to the model. - Changed
HuggingFaceImageClientnow defaultstorch_dtypeper device (bf16 on CUDA, fp16 on MPS, fp32 on CPU) instead of"auto", which could silently load in fp32 and double VRAM. Passmodel_kwargs={"torch_dtype": ...}to override.
Agents¶
- Breaking
Agentconstructor signature changed:Agent(model_client, system_message=None, name=None, tools=None, ...).system_messageis the second positional argument;nameis optional (auto-derived). - Breaking
AgenticModelClientremoved from the public API. Useagent.as_model_client()instead. - Breaking
OrchestratorAgent._setup_orchestratorrenamed 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
BaseAgentandWorkflowABCs removed. All concrete agents and workflows inherit directly fromRunner. The agent-vs-workflow split survives as a conceptual category in the docs. - Breaking
AgentChunkandChainChunkcollapsed intoStreamChunk, with no back-compat aliases.chunk.agent_name → chunk.agent;chunk.step → chunk.iteration.
Tools¶
- New
@toolraisesToolSignatureErrorat decoration time on unsupported signatures (*args/**kwargs, params with no type hint and no default). - New
Optional[T]andT | Noneunwrap to the inner type in tool specs. - New Built-in tool subgroups:
builtin.web,builtin.fs,builtin.compute,builtin.misc. - New
MCPClientraisesMCPConnectionError(rather than silently failing) on construction or call failure. Added.ping()method.
Skills¶
- Breaking
SkillManagerraisesSkillLoadErroron malformedSKILL.md(instead of silently skipping). - Breaking
SkillManager.get_skill_body()raisesSkillNotFoundErroron unknown skill name (instead of returning a sentinel string). - New Skill catalogue prompt includes script-derived tool names inline.
- Breaking
Skillrenamed toAgentSkill(no back-compat alias). - New Skills logged at
INFOon 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.