Changelog¶
v0.38.0 (2026-10-21): calculate evaluates arithmetic and nothing else, and a delegator hears when its worker ran out of rounds¶
Tools¶
- Fix (security):
calculateno longer executes code. It waseval(expression, {"__builtins__": {}}), and an empty builtins dict is not containment:().__class__.__base__.__subclasses__()reachesosfrom any literal. Becausecalculateis inALL_TOOLS, that was code execution for every agent given the default tools and every client ofpython -m aimu.tools.mcp. It now walks the expression's syntax tree over an allowlist: numbers,+ - * / // % **, parentheses,abs,round,min,max,sqrt,exp,log,log10,floor,ceil,pi, ande. Anything else is anError:result naming what is accepted. An integer result past about 3,000 digits is refused, before computing it where it is a power, so9**9**9no longer holds the process; so isroundwithndigitsbeyond ±400, whose cost hides inside the call (round(1, -10**8)ran for minutes). A complex result, such as a fractional power of a negative number, is refused rather than returned. - Change:
calculaterefuses^. Python reads2^10as XOR and answers 8, a wrong number that looks right; the result now says to write**. The description also states the syntax, including that%is the remainder and not a percentage. - The
07-agentsand09-workflowsnotebooks defined their owneval-based calculator; both now callbuiltin.calculate.
Agents¶
- Change: a spawned sub-agent cut off at its round cap is reported as such.
make_subagent_toolandmake_async_subagent_toolnow put a note in front of the tool result when the child'sRunFinishedsaysended_by == "wrap_up", naming the cap and saying the answer may be incomplete. The wrap-up answer reads like any other and the parent model saw only that text, so a worker that ran out of rounds was indistinguishable from one that finished. The note goes to the model only: an observer'sfinishedcallback still receives the child's own text. A child that answered is returned unchanged. - Change: the default wrap-up prompt asks what was not finished.
DEFAULT_WRAP_UP_PROMPTasked only for a "best final answer", which invited the model to fill gaps from memory. It now asks the model to say which parts of the task it did not finish, keeping "checked and found nothing" apart from "never reached". A configuredfinal_answer_promptstill replaces it.
v0.37.0 (2026-10-17): an agent can hold tools it loads on demand, and each sub-agent type can carry its own approval gate¶
Tools¶
- New: tool groups.
aimu.tools.ToolGroupandAgent(tool_groups=[...])(sync and async, includingSkillAgent) let an agent hold tools without sending their schemas until it loads them. An agent with a deferred group carriesavailable_tools(filter="")andload_tools(name);Agent.load_tool_group(name)is the host's route to the same load (it does not pass throughtool_approval). Abuildthat succeeds runs once per agent instance, at load time (off the event loop onaimu.aio). Loading only appends and never shadows an existing tool name. A group withbuild=Noneis resident: already intools=, listed only. Seedocs/how-to/defer-tools.md. - New:
"tool_groups"and"tool_approval"spawn spec keys onmake_subagent_toolandmake_async_subagent_tool."tool_approval"gives one specialist its own gate: absent inherits the factory's,Noneturns it off for that specialist, and a nested spawn tool keeps the factory's. - Change: a call to a tool the agent does not have now names
available_toolsandload_toolsin its result when the agent can load tool groups. Agents without groups get the same message as before.
v0.36.0 (2026-10-15): every event names its run and parent, and a recorded request says where it went and what came back¶
Events¶
- New: run identity on every event.
RunEvent.run_idnames one run of an agent andRunEvent.parent_run_idthe run that started it, so two concurrent runs of one agent stay apart.Agent.run(run_id=, parent_run_id=)onaimu.agents.Agent,aimu.aio.Agent, andaimu.aio.SkillAgentset them (an id is generated when omitted), andToolContext.run_idtells a tool which run called it, so it can pass that on as a child'sparent_run_id. - Change: a spawn observer's
spawn_idis the child run'srun_id.make_subagent_toolandmake_async_subagent_toolname the child's run and pass the caller'sToolContext.run_idas itsparent_run_id, so every event of the child carries the link. The observed id was<agent_type>-<8 hex>(subagent-<8 hex>on the generic shape) and is nowsubagent-<agent_type>-<8 hex>(subagent-<8 hex>), the agent's name plus a suffix. A host that parsed the old prefix must read the new one; one that treated the id as opaque is unaffected. - New:
Runner.as_tool()parents the runs it starts. The toolas_tool()builds (sync and async) takes an injectedctx: Optional[ToolContext] = None, absent from the schema the model sees, and when the wrapped runner'srunacceptsrun_idandparent_run_idit passes a fresh id and the caller'sctx.run_id. A runner without those parameters (Chain,Router,Parallel,RemoteAgent) is called with the task alone, as before.OrchestratorAgent.run(sync and async) now acceptsrun_id=/parent_run_id=and forwards them, so an orchestrator's workers are parented to its run. - Fix: recording a request can no longer fail it. A payload
json_safecould not walk (a self-referential dict) or a malformed endpoint port used to raise out of the request. The event now carriesendpoint=Noneand a"<unserializable: ...>"payload marker and logs a warning; with no sink attached, neither the endpoint nor the payload is computed at all. - Fix: async HuggingFace and LlamaCpp clients forward
last_tool_calls,last_stop_reason,last_usage, andlast_output_truncated, soModelTurnFinishedreports them rather than[]andNone. - Fix:
generate()clearslast_tool_calls, so itsModelTurnFinishedno longer reports the previouschat()'s tool calls. - Fix: IPv6 endpoints keep their brackets (
http://[::1]:11434/v1), so a recordedendpointis a valid URL. - Change:
RequestPrepared.payloadis plain JSON data. Payloads held values with no JSON form (Anthropic'sNOT_GIVENsentinel, Pydantic classes and instances, in-process rendered inputs), so every host persisting a request hit the same error.NOT_GIVENkeys are dropped (the SDK omits them from the wire), a Pydantic instance becomes its JSON form, and anything else with no JSON form becomes a visible marker such as"<bytes 3>".client.last_requestkeeps the raw object, so a consumer comparing the event's payload by identity to it will now see a copy. - New:
RequestPrepared.endpointnames the server the request went to.provideris a class name, so two OpenAI-compatible servers were the same record. Each wire provider reports the base URL its SDK client resolved (environment overrides such asOLLAMA_HOSTincluded), with userinfo, query, and fragment stripped;Nonemeans an in-process provider (HuggingFace, LlamaCpp) with no wire request. Providers supply it through_request_endpoint(). - New:
RunFinished.ended_bysays which rule ended a run, andRunFinished.max_iterationsthe budget it ran under."answered"(the model's last turn was a real answer),"wrap_up"(the round budget ran out while the model still wanted tools, and the forced tools-disabled call produced the answer), or"error"(the run raised;errorsays what). Before this, telling a forced wrap-up from a real answer meant re-deriving the loop's control flow from the events before it. All four tool-loop drivers report it identically; aschema=run reports"answered"/"error"withmax_iterations=None, and a workflow reports neither. TheRunEndingliteral is exported fromaimu.events. - New:
ModelTurnFinished.stop_reasoncarries the provider's own word for how the turn ended, the value ofclient.last_stop_reason. A sink receiving a spawned sub-agent's events never holds that sub-agent's client, so before this it had no way to read it. - New:
ModelTurnFinished.tool_callsandModelTurnFinished.thinking, plusclient.last_tool_calls, forwarded throughModelClient,AsyncModelClient, the agentic view, andFallbackClient. A tools-only round'stextis empty, so these are what a host records as that round's response. - Fix:
last_stop_reason(and soModelTurnFinished.stop_reason) is now forwarded throughModelClient,AsyncModelClient, and the agentic view, and adopted byFallbackClient; throughaimu.client()it readNone.
Tools¶
- New:
generate_imagetakes an optionalreference_image, so an agent can refine an image it generated. Pass the path an earlier call returned and the call becomes image-to-image, asclient.generate(reference_image=...)already was. Applies tobuiltin.generate_image,make_image_tool, and the asyncgenerate_image/make_async_image_tool. The factories advertise the parameter only when the image model can use it, read from the newImageSpec.supports_reference_image(true for Gemini; on HuggingFace, true whenimg2img_pipeline_classis set, which every catalog member has). The singleton cannot know its model until first call, so it always advertises the parameter and refuses it, as a tool result the model can correct from, on a model without support. - Limitation: the tool accepts one reference image, and only from under
aimu.paths.output. The path is the model's choice and a cloud provider uploads the file, so the tool refuses other paths (including symlinks and..that resolve outside), URLs, anddata:URLs; the client keeps accepting all of them. Widening the directory is tracked in #3, multiple references in #4. - Change:
generate_image's tool spec has a second, optional parameter. A consumer asserting the exact schema ofbuiltin.generate_imagewill seereference_imageamong its properties (requiredis still["prompt"]).
v0.35.0 (2026-10-07): EmbeddingGemma 2, query/document prompts, and shorter embedding vectors¶
Models¶
- Fix:
model_kwargs=onaimu.image_client()/audio_client()/speech_client()/transcription_client()/embedding_client()(and their*Clientclasses) was dropped. These factories bundled every keyword they did not recognize into the client'smodel_kwargs, so a caller's ownmodel_kwargs={"device": "cuda:1"}arrived as{"model_kwargs": {"device": "cuda:1"}}and the loader never saw the device. That was the form the image how-to documented for GPU pinning anddevice_map, and it silently did nothing: the automatic placement ran instead. The same nesting let a float16 request slip past the new EmbeddingGemma 2 dtype guard. The factories now forward keywords to the client unchanged, asaimu.client()always has, somodel_kwargs=means the loader's dict at every level. - Change: a loader setting passed as a bare keyword to a modality factory now raises
TypeError.aimu.image_client(m, device="cuda:1")used to work by the same bundling; writeaimu.image_client(m, model_kwargs={"device": "cuda:1"}). The payoff is that a misspelled keyword (hots=forhost=) is an error naming it, where it used to vanish into a loader that might ignore it. Keywords the client declares itself (host=,timeout=,dimensions=on the embedding clients) are unchanged. - New: EmbeddingGemma 2 (
HuggingFaceEmbeddingModel.EMBEDDING_GEMMA_2,hf:google/embeddinggemma-2). 768-dim vectors, 8K context. The checkpoint also carries vision and audio encoders (740M parameters in all), butembed()takes text only, so the spec loads the 270M text backbone; passmodel_kwargs={"config_kwargs": {}}to load the rest. Two newHuggingFaceEmbeddingSpecfields make that expressible:load_kwargs(defaultSentenceTransformerkwargs, merged under the caller's) andrejected_dtypes. The second exists because this model in float16 returns NaN or degraded vectors rather than an error; asking for a rejected dtype now raises at construction. - New:
embed(texts, input_type="query" | "document")on every embedding client (sync and async). Retrieval-tuned models embed a search differently from the text it searches, and AIMU had no way to say which side a call was on, so the advice was to hand-prefix strings. The prefix each model's card specifies is now declared on the spec (EmbeddingSpec.query_prompt/document_prompt) and prepended for you: E5 (query:/passage:), BGE v1.5 and mxbai (query only), EmbeddingGemma 2, and Ollama's nomic-embed-text and mxbai-embed-large. Symmetric models declare none, soinput_typechanges nothing for them, andNone(the default) sends text as given. The spec, not a model's own sentence-transformerspromptsconfig, is the source: it is the one mechanism that also covers Ollama (whose API has no input type), and it keeps the exact text sent readable in the catalog. For EmbeddingGemma 2 the result is identical toencode_query/encode_document. An unknowninput_typeraisesValueError. - New:
dimensions=on every embedding client, for Matryoshka truncation.aimu.embedding_client("hf:google/embeddinggemma-2", dimensions=256)returns 256-wide vectors from every call, andclient.dimensionsreports 256. Each spec declares the widths its model was trained to be truncated to (EmbeddingSpec.matryoshka_dimensions); any other width raisesValueErrorat construction, naming the accepted ones. That is stricter than the warn-and-drop rule for generation kwargs on purpose: a vector of the wrong width corrupts a store without an error. Declared: EmbeddingGemma 2 (768/512/256/128) and OpenAI text-embedding-3 (any width up to native). Ollama's nomic-embed-text is Matryoshka-trained but left undeclared, since nomic's recipe layer-norms before slicing and nothing confirms Ollama'sdimensionsparameter does. - Fix:
OllamaEmbeddingModel.NOMIC_EMBED_TEXT.spec.max_input_tokensis 2048, not 8192. 8192 is what nomic v1.5 reaches elsewhere, and Ollama's Modelfile setsnum_ctx 8192, but the GGUF'scontext_lengthis 2048 and Ollama enforces that: measured, a 2048-token input fits and anything longer is a 400 undertruncate=False. The field is informational, so no behavior changes; it now tells the truth about how much text one vector covers. - Change:
embed(..., dimensions=N)per call now raises; set the width on the client. OpenAI previously forwarded a per-calldimensions=to its API. One width per client is what keeps a corpus and its queries comparable, so the per-call form points at the constructor instead. - Change:
SemanticMemoryStore(embedding_client=...)now embeds stored facts as documents and searches as queries. It overrides ChromaDB'sembed_query()hook, which ChromaDB calls for searches. Two consequences: a persistent collection built earlier with a model that now declares prefixes (E5, BGE v1.5, mxbai, nomic-embed-text) holds unprefixed vectors and should be rebuilt, and a duck-typedembedding_clientwhoseembed()does not acceptinput_typenow raisesTypeError. - Change: the
[hf]extra now requirestransformers>=5.19andsentence-transformers>=6.1. 5.19 is the first transformers release that knows theembedding_gemma2architecture, and 6.1 is what the model's own config declares. Existing embedding models run unchanged on 6.1. - Change:
[evals]now requiresdeepeval>=4, andaimu[all]no longer includes[evals]. transformers 5.19 pullshuggingface-hub>=1.31, which needsclick>=8.4.2; every deepeval 4.x pinsclick<8.4. Left unpinned, the resolver silently fell back to deepeval 2.6.6, which fails on import (it needslangchain) and, through its pytest plugin, takes the test suite down with it. The repo's uv lock overrides the click pin ([tool.uv] override-dependencies; deepeval 4 runs fine on click 8.5), but that override does not reach pip users, soaimu[hf,evals]is unresolvable under pip.[all]drops[evals]so thatpip install aimu[all]still works; installaimu[evals]into its own environment until deepeval relaxes its pin.uv sync --all-extrasin the repo still installs both.
Examples¶
- Fix: the personal-assistant example tagged every message of a proactive run
proactive, and overwrote the agent loop's own provenance doing it. Both halves were one line: a sweep overmessages[start:]with plain assignment. A scheduled task that runs tool rounds appends an assistant turn and a tool result per round, so a verified-summary task replayed as a dozen "proactive" messages, a claim true only of the reply actually pushed. That also contradicts the rule stated inmessage_meta.py: ordinary assistant turns carry no provenance, and absence means "ordinary turn". Now exactly two messages are tagged -- the reminder turn nobody typed, and the reply that lands in the conversation the user is reading -- and the sweep usessetdefault, so acontinuationnudge injected inside a proactive run keeps its own tag instead of replaying as a user bubble reading "Continue working on the task...". The old behavior was pinned by a test assertingall(... == PROVENANCE_PROACTIVE ...), which passed only because its mock was a single-turn reply with no tools; the replacement covers a tool-using run and a degenerate one. Note for hosts building on this: a host that mints a conversation per firing and announces it out of band (a notification) should tag nothing, since the user is not reading that conversation and no one message in it arrived unasked-for -- the whole conversation did. Tests:examples/personal-assistant/tests/test_assistant.py.
v0.34.0 (2026-10-07): a run already in progress has an inbox, and the inbox knows whose it is¶
Agents¶
- Change:
Steeringis nowInbox, with no legacy path. 0.33.0 named the seam after one thing a host does with it. Steering is a use of mid-run messages, and the mechanism is more general: the loop drains pending user messages at each round boundary and sends them as that round's user message, whether they redirect the run, answer a question it asked, add a constraint, or say thank you. The old name made the general case read as a special one, and made a host reaching for "how do I message a run already in progress" look for the wrong thing. Every public name moves:
| 0.33.0 | 0.34.0 |
|---|---|
aimu.agents.steering (module) |
aimu.agents.inbox |
Steering (protocol, exported from aimu and aimu.aio) |
Inbox |
Agent.steering (field, sync and async) |
Agent.inbox |
Agent.run(steering=...) |
Agent.run(inbox=...) |
make_subagent_tool(steering=...), make_async_subagent_tool(steering=...) |
inbox= |
an agent_types spec's "steering" key, in SUBAGENT_SPEC_KEYS |
"inbox" |
StreamingContentType.STEERING, value "steering" |
StreamingContentType.INBOX, value "inbox" |
pretty_print() / CLIChannel marker [steering] |
[message] |
WebChannel frame {"type": "steering"} |
{"type": "inbox"} |
Two kinds of consumer break, and what each has to change. A third-party implementation of the
protocol changes its import and its keyword: from aimu import Steering becomes
from aimu import Inbox (or from aimu.agents.inbox import Inbox), and run(steering=...)
becomes run(inbox=...). The class body needs exactly one change beyond that, and it belongs to
the reader(agent=None) entry below rather than to the rename: reader() has to accept the agent
name. A consumer that renders chunks changes the member it matches: STEERING is INBOX, and its
string value moved from "steering" to "inbox" too, so an exhaustive match chunk.phase over
StreamingContentType breaks on the member, and a renderer comparing chunk.phase == "steering"
stops matching. A WebChannel page keyed on the frame's type needs the same one-word change.
Which of those are loud and which are silent is the paragraph after the next one.
No alias, deliberately. A Steering = Inbox alias and a steering= keyword that
forwards would keep the old names working and would also keep the vocabulary that the
rename exists to remove: two names for one seam, one of them naming a use case, in a library whose
first principle is that it should be readable top to bottom. A deprecation shim here buys
compatibility for a protocol published one release ago, at the price of permanently teaching the
confusion.
The Python API names break at an import or a keyword. Three value-level breaks are silent.
from aimu import Steering, run(steering=...), make_subagent_tool(steering=...) and a spec's
"steering" key all fail loudly, the last of them because SUBAGENT_SPEC_KEYS is a closed set
that raises on a key outside it. What does not fail loudly is anything that never asks Python to
resolve a name:
- a renderer comparing
chunk.phase == "steering"(the string value, not the member) stops matching and renders nothing, since the value moved to"inbox"with the member; - a
WebChannelpage keyed on{"type": "steering"}renders nothing, because all three consumers dispatch withif/elifand noelse: see Channels below, which describes both of these from the consumer's side; - a host that set the standing field by assignment (
agent.steering = source) still assigns it.Agentis a plain@dataclasswith no__slots__, so the attribute is accepted, nothing reads it, and the run has no inbox. Only the assignment is quiet:Agent(steering=...)at construction raisesTypeErrorlike any unexpected keyword.
So grep your own tree for the string, not only the symbol: grep -rn "steering\|Steering\|STEERING" is
the migration, and it is the gate AIMU holds itself to for this rename.
- New: an inbox is told which agent is opening a reader.
Inbox.readeris nowreader(agent=None), and the loop passes the name of the run opening it. That is what lets a host route a message to one run rather than to every run's drain: a host that keeps a mailbox per conversation, or that wants to reach one spawned worker and not its siblings, previously had no way to tell whose reader it was being asked for, because nothing about the calling context identifies a run (under sequential tool dispatch a spawned sub-agent's loop executes in the same asyncio task as the agent that spawned it). All four tool-loop drivers pass it, sync and async, streamed and not, from the one shared_open_inbox, and a spawned worker opens its reader under the name that worker was built with (subagent-<agent_type>on a typed spawn; the untyped shape has no type to name, so its workers share the fixedsubagentand a host tells concurrent ones apart by counting opens, as it already had to for two runs of one agent).
An implementation must accept the argument, and the None default does not buy it a pass.
The label is passed positionally, so a host whose parameter has a different name still receives
the value, and a 0.33.0-era def reader(self) cannot be called at all. The entry below is what
happens then, and it is a breaking change rather than a footnote to this one. One fact worth
knowing before relying on None: a run built through Agent always has a name, because
Agent.__post_init__ generates one (agent-xxxxxx) when a caller supplies none, so an inbox
never sees None through the public Agent surface and only a directly-constructed loop can pass
it. What follows is the part to act on: a generated name is unguessable from outside the run that
got it, so a host that does not name its agents gets runs it has a label for and cannot actually
address. Name the agents you intend to message.
What AIMU deliberately still does not know. The drain returns list[str], and an INBOX
chunk's content stays {"text": str}: no sender, no address, no selector, no group, no roster, no
receipt. Who a message came from, who may send one, and whether delivery is acknowledged are the
host's own model, and a protocol carrying envelopes would make every host inherit one host's
addressing model. The agent name is the one thing that crosses, and nothing in AIMU attaches
meaning to the string beyond handing it back. Tests: tests/test_inbox.py,
tests/test_aio_inbox.py, and the four-driver cases in tests/test_loop_iteration_parity.py.
- Change: an
Inboxwhosereadercannot take the agent name is now refused when the run starts, instead of being logged and ignored. This is the migration the entry above forces, and a host that degrades silently today will raise after this upgrade, which is the sentence to act on.Inboxis aruntime_checkableProtocol, soisinstancetests whetherreaderexists and never what it accepts;_check_inboxis limited to the same question, and it runs only when a spawn factory is called, so it never sees a host's ownAgent.run. A 0.33.0-eradef reader(self)therefore passed every check AIMU had and then failed at the one positional call the loop makes, inside_open_inbox's broad guard: one WARNING line, and an inbox that was inert for the life of every run the host made. An inbox that never delivers is indistinguishable from a user who never typed, so that could survive a release unnoticed, which is why the trade is taken this way round. The tool loop's constructor now rehearses the exact call it is about to make and raisesTypeErrornaming the class, the signature it found, and the fix.
The fix is one line, and it is in the class body. def reader(self, agent=None). That is
generally not the file the run(steering=...) to run(inbox=...) keyword change takes you to:
in the one downstream application known to implement this protocol, those are two different modules. So grep for
def reader( rather than trusting the rename to have walked you past it. The label is passed
positionally, so the parameter may be called anything, and a host with no use for it can ignore
the value.
The check rehearses the call rather than asking the weaker question, "does reader accept an
argument". inspect.signature(inbox.reader).bind(agent_name) cannot refuse an implementation that
would have worked (a required def reader(self, label), with no default, binds fine) and cannot
accept one that will fail (def reader(self, **kwargs) takes a keyword, not the positional the
loop passes, so it is refused). It raises from the loop's constructor rather than from
_open_inbox, which is what makes raising safe at all: the constructor runs before a driver emits
RunStarted, where _open_inbox runs after it and would leave a sink holding a start with no
finish. Its one limit, stated plainly: a reader that inspect.signature cannot read (a
functools.partial over a C-implemented function, a builtin) is accepted unchecked and fails at
the call exactly as it did before, because an unreadable signature is no evidence of a wrong one,
and refusing a conforming inbox for being unusually constructed is the worse of the two errors.
Pinned on both surfaces, including that fail-open path, in tests/test_inbox.py and
tests/test_aio_inbox.py.
- New
Inboxis exported fromaimu.agents, sofrom aimu.agents import Agent, Inboxworks. It was reachable from top-levelaimuand fromaimu.aiobut not from the package that defines it, whereAgent,SkillAgentand the loop's own errors all are, for no reason anyone had written down. Additive; nothing moves.
Models¶
- New
StreamChunk.is_inbox(), besideis_continuing().INBOXwas the only phase with no predicate, so a consumer dispatching through them had to fall back to an equality check for that one phase alone. Additive, and named for its phase like the rest.
Console output¶
- Change
pretty_print()'s marker for a round that delivers an inbox message is now[message] <text>, not[steering] <text>. It is still ungated byshow_thinking/show_tools, still one line per delivered message, and still deliberately distinct from[continuing: <kind>], which names a prompt the loop composed for itself.
What that marker costs, written down rather than left to be tripped over. [message] is the
one marker here that does not mirror its phase's name: pretty_print()'s [tool] is
TOOL_CALLING and its [continuing: <kind>] is CONTINUING, where [message] is INBOX.
CLIChannel is the same way and adds [thinking] for THINKING. So a reader who meets the line
in a terminal cannot grep from it to StreamingContentType.INBOX, which is the one thing a
marker naming its phase buys. It is kept anyway, because [message] is what the line is to the
person reading it where [inbox] would name the machinery, and because a marker is read far more
often than it is traced back. The marker is not changing; this is the note that says what it
costs.
Channels¶
- Change
CLIChannelprints the same[message] <text>marker, andWebChannel's frame is now{"type": "inbox", "text": str}rather than{"type": "steering", "text": str}. A page keyed on the oldtyperenders nothing for the new one, since all three consumers dispatch withif/elifand noelse. It remains its own frame type rather than a thirdreasononloop, for the reason it was given one:loopmeans the agent loop injected the round, so conflating the two would attribute the user's own words to the assistant.
One word now means two things across the two channels, and that is worth knowing before you
read either. CLIChannel's [message] marks an inbox delivery, something the user sent into
a run already in progress, while WebChannel's {"type": "message"} frame is a finished,
non-streamed reply and has been since that frame existed; the web frame for a delivery is
{"type": "inbox"}. Each name was chosen for its own consumer, so neither is wrong on its own
terms, and neither is changing. See Console output above for the related cost on the CLI side.
Documentation¶
- New: the inbox has a how-to and a notebook, which the feature has not had since it shipped
under its old name in 0.33.0: docs/how-to/use-an-inbox.md and
notebook 29. Before this, the only prose was incidental: the
INBOXphase in the stream-phase table, the"inbox"key in the spawn-roster page's list of spec keys, and theInboxprotocol's own docstring in the API reference. Each is accurate and none of them answers "how do I message a run that is already going", so the capability was documented in the places you look after you already know it exists. Both pages carry the parts a host gets wrong rather than only the happy path: the three round boundaries a message can land on (and that the third one rescues a message arriving after the model's final answer), the round budget a delivered message resets, the non-advancing drain that uncaps that reset and the warning it earns, the name each run opens its reader under, and the four failures that are survived against the one that is refused at run start.
v0.33.0 (2026-09-29): a run already in progress can be steered, and a refused tool call says why¶
Tools¶
- New: a
tool_approvalpolicy may returnDenied(reason), so a refused call tells the model why. The refusal message was a fixed string,"Tool '<name>' was not approved.", which says that something was disallowed and never what, so a model's most likely next move was the identical call: the gate cost an iteration and taught nothing. A policy may now returnDenied("only api.example.com is allowed")in place ofFalseand the reason is rendered into the tool message the model sees, which is what lets it correct the argument instead of repeating it.Deniedis a frozen dataclass exported fromaimuandaimu.tools, mirroringUnsupported(remedy)in the generate-kwarg tables: the same verdict-with-a-remedy shape, for the same reason. It is falsy, so it substitutes forFalsewherever verdicts are composed, and an empty reason renders as the unadorned refusal.TrueandFalseare unchanged, so every existing policy behaves exactly as before.
A type rather than a bare string because a policy's return passes through bool(): a
non-empty string already means approved, so repurposing one as a refusal would have silently
inverted any policy that returned one. tests/test_tool_approval.py::test_a_truthy_string_still_approves
pins that, since it is the one behavior a future shortcut here would break.
This is the shape to reach for when the rule is argument-scoped rather than per-tool (a host
allowlist for URLs, a path prefix for writes, a command allowlist). Because the gate runs at
dispatch, one policy covers every tool uniformly -- built-ins, @tool functions, and MCP tools --
including tools that expose no factory to configure, which a per-factory parameter cannot reach.
Two limits stated rather than implied: the reason lands in the conversation, so a host whose
policy is itself sensitive should keep it vague or return a plain False; and it constrains the
arguments the model chose, so a tool that follows a redirect somewhere else is still the tool's
own business.
ToolDenied gains a reason field ("" for a bare False), so an events sink records why a
call was refused without reproducing the policy's logic. Additive, per the event-union rule that a
new field must never break an existing sink. Covers both surfaces and every dispatch path:
non-streaming, streaming, and concurrent, sync and async, including a coroutine policy returning
Denied (the isinstance check runs after the await). How-to:
Gate tool calls. Tests:
tests/test_tool_approval.py, tests/test_aio_tool_approval.py.
-
New:
submit_json(url, payload, method="POST"), a third tool frommake_web_tools(), so an agent that can read a JSON API can also write to one.submit_formis form-encoded, andget_web_contentalready returns a JSON body as-is, so the library could read an API and not call it. Nothing scoped closed that gap either:requestsandurllibare not in theexecute_pythonsandbox allowlist, which leftrun_commandpluscurlas the only route, and that is inbuiltin.unscopedfor good reason.payloadis a typeddict, advertised to the model as a JSON Schemaobjectand validated bycoerce_tool_argumentsat dispatch, so the model emits a structured argument instead of hand-serializing; the equivalent tool elsewhere takes a string of JSON and is known to fail when a model wraps it in a fence.methodaccepts the verbs that carry a body (POST,PUT,PATCH);GETstayssubmit_form's,DELETEis absent, and anything else is refused before a request goes out. There is deliberately noheadersparameter: credentials belong on the session the factory was built with, so a token a host configures for one API cannot be aimed at a host the model names. Like its siblings it is returned only from the factory, never inbuiltin.web,ALL_TOOLS, or the MCP server. Re-exported fromaimu.aio.tools.builtinvia the factory (dispatched throughasyncio.to_thread). How-to: Fetch HTML and submit web forms. Tests:tests/test_web_tools.py. -
Change:
submit_formandsubmit_jsonfollow a redirect only while it stays on the same host. A cross-host redirect is reported, naming the target, instead of followed. The reason is narrow and measured: a 307 or 308 replays the request method and body, so an allowlisted host answering a submit with one would re-send the entire payload to a host the model never named and notool_approvalpolicy ever inspected -- a body-exfiltration channel invisible to a gate that only sees the URL the model chose. Verified against realrequests: a cross-host 302 downgrades to a bodylessGETand is harmless, while a cross-host 307 arrived at the second host withPOSTand the full JSON body intact. (requestsdoes stripAuthorizationacross hosts, so the credential was never the leak; the payload was.) Refusing the hop puts the new host back in front of the approval gate, since reaching it now takes a fresh tool call the policy sees, which is why this is the fix rather than anallowed_hosts=list a caller has to remember to configure.
Same-host redirects are still followed, so the documented login flow (POST credentials, server
sets a cookie and 302s to a landing page) still completes in one call, with a 301/302/303
downgrading to a bodyless GET as requests and browsers do and a 307/308 replaying the method
and body. A chain is capped at five hops and hitting the cap is reported. The read tools are
deliberately untouched: a redirected GET carries no body and no credential, so requests' own
handling stays in charge, pinned by
tests/test_web_tools.py::test_read_tools_still_follow_redirects_normally. Callers asserting on
the exact kwargs of a mocked submit will now see allow_redirects=False on the call.
-
Change: both submit tools now return an HTTP error status's response body instead of discarding it.
submit_formrouted through_fetch_html, which callsraise_for_status(), so a 422 came back as"Error submitting form: 422"and the body went in the bin. For an HTML form that is merely unhelpful; for an API it removes the only actionable thing in the exchange, since the rejected field is named in that body._requestis now the non-raising helper both submit tools call,_fetch_htmlis the raising wrapper the three read tools keep (a 404 page's body is noise), and_format_responserenders status, final URL, and truncated body for both so they cannot drift. Transport failures are unchanged and still report as an error string: with no response there is nothing to show. Callers parsing the old"Error submitting form:"prefix out of a 4xx should readStatus:instead; the prefix survives for transport errors only. -
Fix:
make_web_tools(user_agent=...)had no effect on any request it made. The value was applied withsession.headers.setdefault("User-Agent", ...), which never fires becauserequests.Session()already carries aUser-Agent, and_fetch_htmlthen sent its own per-request header, which requests gives precedence over a session's anyway. So the parameter was ignored twice over, silently, and a caller identifying their crawler to a site was not identifying it at all. The factory now threads the value into each request,user_agent=Nonesends no header and leaves a caller-supplied session's ownUser-Agentin charge, and the annotation admits theNone. Pinned bytests/test_web_tools.py::test_make_web_tools_sends_the_user_agent_it_was_given. -
Change:
make_web_tools()returns three tools, not two. Documented usage has always beentools=[get_webpage_html, *make_web_tools()], which is unaffected, but code unpacking the result asfind_forms, submit_form = make_web_tools()raisesValueErrorand needs a third name. -
New: a spawned sub-agent can be steered through its spec.
make_subagent_toolandmake_async_subagent_toolgain asteeringparameter, and a typedagent_typesspec gains an eighth key of the same name (SUBAGENT_SPEC_KEYS), so one specialist can read from the sameSteeringsource its caller does, or from a source of its own. It is read by membership rather than.get(), joining"compaction"as the second key read that way and for the identical reason: an absent key inherits the factory's own source, while a spec naming"steering": Noneturns it off for that one specialist, and.get()cannot tell those two cases apart. A nested spawn (max_depth > 1) carries the same source to a grandchild it spawns in turn, so steering reaches however deep the roster goes. How-to: Spawn sub-agents. Tests:tests/test_subagent_tools.py,tests/test_aio_subagent_tools.py.
A steering that does not implement the protocol is rejected at factory-call time, at both
tiers, the way a non-callable compaction is and for its reason: deferred, it surfaces from
inside the child's loop as AttributeError: 'str' object has no attribute 'reader', which the
parent loop turns into a tool failure the parent model is asked to recover from. A programmer
error reported to a model is the wrong reader.
Models¶
-
New:
StreamingContentType.STEERING, the chunk that opens a round the host steered. A streamed agent-loop driver yields it, carryingdict {"text": str}, immediately before the round in which the model reads a message a host handed the run while it was already in progress. Distinct fromCONTINUING, whosekindnames a prompt the loop composed for itself: a steering message is the user's, not the loop's, which is also why it is stored as a plain user message with no provenance tag. Reference: Stream phases. -
Change A new
StreamingContentTypemember is a breaking change for a consumer whosematch chunk.phaseis exhaustive. Every consumer AIMU ships dispatches withif/elifand ignores what it does not recognize, so none of them break; a host that wrote an exhaustive match over the phases has one more arm to add.
Agents¶
- New:
Agent.run(steering=...)and anAgent.steeringfield, on both the sync and async surfaces, so a host application can hand a running agent a user message instead of queuing it behind the whole run.steeringis aSteeringsource: an object with one method,reader() -> Callable[[], list[str]]. The loop callsreader()once, at the run's start, and drains the cursor it returns at each round boundary, sending whatever comes back as that round's user message on a model call the loop was going to make anyway. One reader per run rather than one shared drain is load-bearing: under sequential tool dispatch a spawned sub-agent's loop runs in the same asyncio task as the agent that spawned it, so a reader keyed by task, or by agent name, would let two runs consume each other's messages. A spec's own"steering"key andmake_subagent_tool's /make_async_subagent_tool's factory-levelsteeringargument carry the same source into a spawned sub-agent, which opens a reader of its own (see Tools, above). A message that arrives after the model has already produced what would be a final answer still buys one more round, so a message landing as the model composes its last reply is not dropped; a healthy turn with nothing pending ends the run exactly as before.
A misbehaving Steering never takes the run down, at any of the four points a host object
touches the loop: reader() raising, the drain raising, the drain returning the wrong shape, and
the drain never advancing. Each is logged and the run continues unsteered. Two are worth naming.
A drain returning a bare str is refused rather than iterated, because str is iterable and an
unchecked drain would accept "stop" and deliver it to the model as four one-character
paragraphs, which is worse than a raise for looking like it worked. And reader()'s guard matters
more than the drain's: it runs ahead of the try/finally that emits RunFinished, so a raise there
used to end the run before it began and leave an events sink holding a RunStarted with nothing
after it. Tests: tests/test_steering.py, tests/test_aio_steering.py.
- New: a delivered steering message resets the round budget.
max_iterationsbounds autonomous iteration -- how far the loop may run without a human in it -- and a steering message is a human back in the loop, so the cap counts again from the round it landed in rather than toward whatever was left of the original budget. Without this, a long-running delegate task would spend its whole steerable window on the turns before the host ever said anything, and a message arriving late could still be cut off by the forced wrap-up moments later.
Bounded at one extension per permitted round, unlike the human action it stands for. The
reset is what makes steering usable and it is also the only thing standing between a run and an
unbounded bill: a drain that returns its whole list instead of the unread slice (a one-character
mistake) makes every round look like a fresh human message, and max_iterations stops bounding
anything. Past the cap the loop logs once and stops extending, while still delivering the
message on the call it was making anyway, so a host that genuinely steers that often degrades to
a turn that ends sooner rather than one that drops input.
Console output¶
- New
pretty_print()shows a steered round as a[steering] <text>line. Ungated byshow_thinking/show_toolsfor the reason the[continuing: <kind>]line is: it is one line per steered round, and it is what explains a run changing direction. A renderer that showed nothing for it left a redirected run reading as the model changing its own mind.
Channels¶
- New
CLIChannelprints the same[steering] <text>marker, andWebChannelemits a new{"type": "steering", "text": str}frame. Its own frame type rather than a thirdreasononloop:loopmeans the agent loop injected the round, so a page that conflated the two would attribute the user's own words to the assistant. Both are emitted unconditionally, asloopis.
v0.32.0 (2026-09-21): a skill's prose is editable, and a script write leaves it alone¶
Skills¶
- Fix: adding or updating a skill script no longer rewrites the skill's
SKILL.md, which was silently dropping frontmatter keys.add_skill_scriptwrote its file by callingwrite_skill(..., overwrite=True)with the description and body it had just read back, so the rewrite could not change the prose and existed only as a side effect. What it could do was lose everythingwrite_skilldoes not re-emit: the spec's optionallicense,compatibility, andallowed-tools, plus any key outside the spec. A hand-written or installed skill therefore shed those fields the first time an agent attached a script to it, with nothing raised anywhere, and shed them again on every later fix to that script. Observed on an installed skill carryinglicense: Apache-2.0and acompatibility:line naming its own requirements, both gone after one script write.
The script write is now its own unit: write_skill_script(name, filename, content, *, skills_dir)
validates the filename, creates scripts/, writes the file, and marks a .sh executable, without
opening SKILL.md at all. write_skill's own scripts= parameter routes through it, so skill
creation is unchanged, and add_skill_script calls it directly: same filename validation, same
unknown-skill guard, same Added/Updated wording, same {skill}__{stem} tool callable in the
same turn. A skill's prose is now changed only by something that means to change it (see
update_skill). Tests:
tests/test_aio_skill_authoring.py::test_add_skill_script_preserves_every_frontmatter_key.
-
New:
update_skill(name, *, description=None, body=None, skills_dir)andmake_skill_update_tool(manager, skills_dir), so an agent can revise a skill it already wrote.author_skillrefuses to clobber andadd_skill_scriptonly writes scripts, which left a skill's prose unreachable once it existed: an agent could fix a skill's scripts forever while the instructions a first attempt most often gets wrong stayed frozen, and the only advice the library had for a wrong skill was to author a second one under a different name. The async@tool update_skill(skill_name, description=None, body=None)calls it and thenmanager.refresh(); unlikeadd_skill_scriptit needs no agent, since editing a skill's text changes none of its tools. Only what is passed is written, and every other key in the file is read and put back as it was, which is why this is notwrite_skill(overwrite=True). There is no rename (the name is the skill's directory, its catalogue entry, and the prefix of every{skill}__{stem}tool, and the spec requires the frontmatter name and the directory to agree) and nometadataparameter (it is a host's provenance record, and an update path able to rewrite it would be more capable than the create path, which cannot set it either). A missing skill or a call with nothing to change comes back as a sentence the model can act on; a malformed file on disk still raises. The refresh limit is the oneauthor_skillalready had, now stated per field in the tool's own reply: a new body applies at the nextactivate_skill, which reads it from disk, while a new description reaches the catalogue in a fresh conversation. Tests: eleven intests/test_aio_skill_authoring.py, from::test_update_skill_replaces_the_description_and_keeps_every_other_key. -
New module
aimu/skills/frontmatter.py:split_frontmatter,load_frontmatter,render_frontmatter. TheSKILL.mdformat had three partial copies of itself (discovery's fence-finding inSkillManager._parse, a second copy inAgentSkill.load_body, and a hand-built emitter inwrite_skill), and editing a skill needs all three at once: read the file, change one field, write the rest back unchanged. One place is what makes "unchanged" true.SkillManager._load_yamlmoved there asload_frontmatter(same lenient fallback, now raising plainValueErrorfor the caller to wrap, so the module depends on nothing else in the package); both of_parse's frontmatter error messages are unchanged. -
Change:
write_skillrenders its frontmatter throughyaml.safe_dump, so a value needing quotes gets them. A description containing": "is the ordinary case and used to be written bare, producing a file that loaded only through the lenient fallback meant for hand-written skills. Simple values render exactly as before. Tests:::test_write_skill_quotes_a_description_containing_a_colon.
v0.31.0 (2026-09-11): a truncated read names the call that continues it, the fs group writes, and a full sub-agent is its own context¶
Sub-agents¶
- Fix: a sub-agent that runs out of context no longer reads as the parent's conversation being
too long. Every provider composes
ContextOverflowErrorfor whoever built the client, so its message ends "shorten the conversation, advertise fewer tools, or compact history first". The tool loop hands an uncaught exception to the model verbatim asTool 'spawn_subagent' raised an error: <message>, and at that one seam the reader is a different agent than the one whose window filled, so "the conversation" resolves to the parent's own. The failure is not hypothetical: a delegating assistant read it exactly that way, told its user "this conversation has grown too long to spawn another worker", and stopped delegating for the rest of the run, while the real cause was a single 2 MB tool result inside one child that had been alive for four rounds. An evaluation of that transcript then concluded the parent's history was being passed to its workers, which it never is: a spawn is built fresh per call andAgenthas nomessagesparameter at all.
make_subagent_tool and make_async_subagent_tool now catch ContextOverflowError from the child
and return a tool result naming the sub-agent's window as the one that filled, saying plainly that
the caller's conversation is neither the cause nor something the child ever saw, and giving the
parent something it can act on (delegate again narrower, split the work, tell the worker to page
long documents). Returned rather than raised because a full child is the model's to recover from,
like an unknown agent_type and unlike a bad spec key. The provider's own sentence is kept as
evidence and explicitly disclaimed rather than dropped, so which backend refused is still on the
record, and a WARNING is logged so the operator's copy does not depend on the model mentioning it.
Only ContextOverflowError is converted; every other child failure propagates as before. On the
async surface the catch sits outside _run_observed, so an observer still receives
finished(..., error=ContextOverflowError) and a front end's spawn card is still marked failed:
display keeps the error, and only the model gets the explanation.
- New:
compactionon both spawn factories, and"compaction"inSUBAGENT_SPEC_KEYS.Agenthas taken acompactioncallable since it was introduced, but nothing could route one into a spawned worker: not the factory, and not a spec, since the spec key set is closed. A worker that filled its window therefore died in it, with no way to express otherwise. It is now a factory argument applied to every spawn, and a per-specialist spec key. Like"model"and"max_iterations"it has a factory-level tier beneath it, and unlike either it is read by membership rather than.get(), because"compaction": Nonewritten into a spec is a decision (turn the factory's policy off for this one specialist) that a lookup could not tell from an absent key. A non-callable value raisesValueErrorat factory-call time, where a programmer can act on it, rather than as aTypeErrorfrom inside a child's tool loop, where the parent model would see it and try to recover from it.
Worth being exact about what this buys: it bounds growth spread over many rounds, which is the
common shape, and it cannot save a worker from one oversized tool result. trim_messages will not
drop the most recent group without orphaning the call it answers, so a tool able to return megabytes
still has to cap itself, which is why get_web_content and read_file cap what one call returns.
Tools¶
- New:
edit_documentinmake_document_tools, andsave_documentnow warns against saving a partial read back. The group had no edit tool, so the only way for an agent to change one line of a stored document wasread_document+save_document. That became destructive whenread_documentstarted windowing earlier in this release:read_documentreturns one window,save_documentreplaces the whole document, so the round-trip deleted everything past the window -- silently, with the truncation marker written into the file as content, and the tool answeringSaved /paper.md.A 3,000-line document came back 51 lines long.edit_document(path, old_str, new_str)wrapsDocumentStore.edit, so it changes part of a document without reading or re-emitting the rest, and it inherits the unique-match rule: zero or many matches writes nothing and returns the count. Likeread_document, it reports a miss as a message rather than raising, which is this group's convention and the opposite ofdocument_mcp'smemory_edit.
Change -- save_document now refuses to replace a document it has not shown you in full.
A docstring warning was the first attempt, and a documented foot-gun is the fallback, not the
fix. make_document_tools now keeps a per-factory set of digests of the document texts it has
shown the model entire, and save_document will not replace an existing document whose current
text is absent from it: read-before-replace, the rule Claude Code's own Write tool enforces, for
the same reason. A full read grants the permission, a truncated read revokes it (a model
reading a window is working from a partial view, whatever it knew before -- including having
authored the document, since it would not be reading a window if it still held the text), a
successful save_document grants it, and edit_document carries it across an edit the model
itself chose. Creating a new document needs no read. The refusal names the document's real line
count, so a full read can be sized, and names edit_document as the alternative.
Keyed by digest rather than by path on purpose: it needs no path canonicalization, so it cannot
drift from DocumentStore._normalize's ..-collapsing rules, and it expires on its own -- a
document a user or another process changed after the read no longer matches, and the permission
lapses exactly when it should. The tracking is per make_document_tools(...) call and in-process,
so two agents sharing one store each answer for what they have read. store.write() is
deliberately unguarded: a programmatic caller holds the document, and this exists to stop a
model discarding text it has never seen. save_document's description still carries the
warning, since the guard refuses after the fact and the description is what stops the attempt.
Tests: tests/test_memory_tools.py::test_saving_back_a_windowed_read_is_refused,
::test_reading_a_document_in_full_permits_replacing_it,
::test_replacing_an_unread_document_is_refused,
::test_an_out_of_band_change_revokes_the_permission_to_replace,
::test_an_edit_after_a_full_read_still_permits_replacing,
::test_creating_a_new_document_needs_no_read.
-
Corrected the last remnant of the retracted Anthropic-compatibility claim, in
docs/how-to/use-document-memory.md's MCP section ("matching Anthropic's API naming"), missed when the other six were fixed. -
New:
write_fileandedit_file, in thefsgroup.builtin.fswas read-only, so an agent that had to write reached forrun_commandorexecute_python-- the opt-in, isolation-disclaimed tools -- and a shell heredoc write is strictly worse than an edit: invisible as an edit in the event stream, ungateable per path, and it fails in ways the model cannot read back.write_file(path, content)creates or replaces a file whole and reports which of the two happened, with the line count it destroyed, because a bare "wrote N lines" reads identically whether the file was new or held someone else's work a moment ago, and the transcript is the only record.edit_file(path, old_str, new_str)replaces exactly one occurrence: zero matches or more than one writes nothing and returns the count, since replace-first applies an ambiguous edit to whichever match came first, which is a silently wrong edit. It also reads strictly rather than withread_file'serrors="replace": that tolerance exists so a model can read a mostly-text file, and writing the decoded result back would replace every undecodable byte in the user's file with U+FFFD. Neither tool confines writes to a project root, deliberately -- a root check a symlink or a relative path can walk out of reads as containment while providing none, so the docstring states the exposure the wayexecute_python's does instead.
Change -- builtin.fs now grants write access. The writes are in the group, not quarantined
beside it, so any existing tools=builtin.fs call site has gained the ability to modify files with
no change to its own code. That is the intended design: scoping is the host's job, and it is now a
job the host has to actually do. Two things make it doable, and one keeps the blast radius from
widening on its own:
-
New:
builtin.select(tools, include=None, exclude=None)narrows a tool list by name or by tool, and accepts a whole group, so a deny-then-allow scope reads as what it is:select(ALL_TOOLS, exclude=builtin.fs) + [read_file], orselect(ALL_TOOLS + fs, exclude=builtin.unscoped). It earns its place over a list comprehension through oneValueError: a comprehension filtering on a misspelled name silently keeps the tool you meant to remove, and that failure is invisible at the call site -- it surfaces only as an agent doing something you believed you had forbidden. Only string selectors are validated, because only they can be misspelled; a tool passed by reference already raised at the call site if its attribute name was wrong, and a group legitimately names tools the target list does not hold. Deliberately not a pattern language: no globs and no precedence rules, sinceexclude=builtin.fsalready says whatfs.*would. The result is deduplicated by name (keeping the entry dispatch would have resolved to anyway), because groups overlap now thatunscopedcuts acrossfsandcompute, so concatenating two of them is normal and would otherwise advertise the same tool twice -- wasted tokens on every request, and a duplicate name some providers reject. -
New:
builtin.unscoped--execute_python,run_command,write_file,edit_file-- the one group organized by reach rather than by subject, and exactly whatALL_TOOLSleaves out. What those four share is that the model names the target: arbitrary code, an arbitrary command, an arbitrary path, bounded by the account running the process and nothing else. Every other built-in reads, computes, or writes somewhere AIMU chose (generate_imagesaves underpaths.output), which is why the line is drawn there rather than at "has a side effect".
There is deliberately no SAFE_TOOLS / ALL_SAFE_TOOLS complement. Safety is a property of a
deployment, not of a tool: get_web_content will fetch an internal endpoint if the model names
one, web_search hands the query to whatever SearXNG instance is configured, and generate_image
spends real money. Naming the remainder "safe" would promise what no list can deliver, and this
project has already had to retract one confident claim of that shape. So the risky set is the one
with a name, and the absence of its complement is the design.
ALL_TOOLS keeps its meaning (every built-in except those four) rather than being redefined to
include them: it is what make_tools() starts from and what python -m aimu.tools.mcp serves
cross-process, so redefining it would have handed a shell and a file writer to every existing MCP
deployment without a line of their code changing. make_tools(allow_file_writes=True) is the
opt-in, mirroring allow_code_execution.
Tests: tests/test_tools.py::test_unscoped_group_is_exactly_what_all_tools_leaves_out (fails if a
new built-in lands in neither ALL_TOOLS nor unscoped), ::test_there_is_no_safe_tools_alias,
plus the write_file / edit_file / select cases.
- Every capped read a caller can safely repeat now takes an
offset, and their truncation notices name the call that continues from where they stopped. A cap with no offset is not a smaller read, it is an unreachable tail: the only remedy such a tool can offer is a bigger cap, and on exactly the documents where the cap bites, a bigger cap overflows the context window the cap was protecting. Four tools had that shape.read_fileandread_documenttakemax_lines=2000, offset=1(1-indexed lines),get_web_contenttakesmax_chars=20000, offset=1(1-indexed characters), andget_webpage_htmltakesoffset=1against its fixed 20,000-character window -- fixed rather than caller-raisable because raw markup costs several times the tokens of the text it carries, sooffsetis deliberately the only way to the rest, and the form or link a caller is after is routinely past a real page's head and navigation. Each marker now readstruncated: showing lines 1-2000 of 24709; call read_file with offset=2001 to continue: the window returned, the size of the gap, and the exact next call. A non-positiveoffsetor cap, and anoffsetpast the end, come back as teaching strings naming the real size rather than raising, matching how the rest ofbuiltin.pyreports a model-fixable argument mistake; the web tools check the window before fetching, so a bad offset costs no network round trip. Reading from the defaultoffset=1is unchanged everywhere, including on an empty file or document.
On get_web_content the advice being replaced was worse than merely unhelpful. v0.30.0's cap is
what stands between a worker and a document like the 2,047,329-character PDF body one production
run decoded as text and fed to a 200k-context worker in a single call -- a failure compaction
cannot rescue, since trim_messages will not drop the most recent group. But the marker that cap
emitted read call again with a larger max_chars to read more, and the docstring said the same,
so a model that followed the tool's own instruction on such a document handed itself the whole
thing and reproduced exactly the failure the cap had just prevented. The offset is the remedy that
does not undo the cap. (Credit for the production trace: a parallel session working on sub-agent
context overflow.)
One deliberate exception: submit_form's response body keeps the old un-paged _truncate
marker, because continuing that read would mean submitting the form again, and the tool exists to
send POSTs. aimu.memory.document_mcp's memory_read is windowed too -- see the next entry for
why the compatibility argument against it turned out to be false.
The window, the marker, and the argument complaints live in one place (_window /
_window_complaint / _past_end in aimu/tools/builtin.py), which is what keeps four markers
from drifting into four dialects; _window slices a str for character windows and a list of
lines for line windows, since slicing and join.join behave identically on both. _truncate's
parameter= argument is gone, having had exactly one caller left that could name a remedy.
Tests: tests/test_web_tools.py (window helpers, both web tools, the PDF path, the no-fetch
guard), tests/test_memory_tools.py (read_document paging), tests/test_tools.py (read_file).
- New:
read_file(path, max_lines=2000, offset=1)reads a window, and its truncation notice names the offset that continues the read. The tool had a cap and no way past it:max_linescould only be raised, so the only route to the end of a long file was to re-read it from the top with a bigger cap, and the truncation marker said exactly that. On a 24,709-line source file that advice is unusable in both directions -- the cap hides the rest, and lifting it floods the context window the rest of the task needs -- which left an agent able to see that a file was too long and unable to do anything about it.offsetis the 1-indexed line to start from, so a file larger than any one window is now readable whole by paging, and the marker readstruncated: showing lines 1-2000 of 24709; call read_file with offset=2001 to continue: the window it returned, the size of the gap, and the exact next call. A non-positiveoffsetormax_lines, and anoffsetpast the end of the file, come back as teaching strings naming the file's real line count rather than raising, matching how the rest of this module reports a model-fixable argument mistake. Reading from the defaultoffset=1is unchanged, including on an empty file. Tests:tests/test_tools.py::test_read_file_paginates_a_file_larger_than_any_one_window,::test_read_file_offset_reads_a_later_window,::test_read_file_reports_how_much_it_truncated,::test_read_file_offset_past_the_end_says_how_long_the_file_is,::test_read_file_rejects_a_non_positive_offset,::test_read_file_advertises_offset_to_the_model.
Memory¶
-
Breaking:
DocumentStore.edit(andmemory_edit) now requireold_strto match exactly once. It replaced the first of several matches, which answers a question the caller did not ask, and answers it silently, in the file, where the mistake is found later by whoever reads the document. Zero matches and two-or-more matches now both raise and write nothing, and the two-or-more message names the count, because "not unique" without it gives the caller nothing to aim at. This was deliberate, documented behaviour pinned by a test (test_edit_replaces_first_occurrence,"foo foo bar"->"baz foo bar"), so it is a real contract change and not a bug fix: anyone relying on first-of-several will now see aValueError. Two things soften it. The exception type is unchanged -- the absent-match case already raisedValueError, so code already writingexcept ValueErroraroundedit()stays correct across the change. And the escape hatch is a legible one-liner that says what it does at the call site rather than hiding it in a default:store.write(path, store.read(path).replace(old_str, new_str, 1))
The reason to take the break is that v0.31 added edit_file with exactly-one semantics, and a
library holding two edit primitives with opposite ambiguity rules is a trap regardless of which
one a reader happens to learn first. The model-facing path is where it bites hardest: through
memory_edit, a model cannot see which occurrence changed without reading the memory back.
Tests: tests/test_document_store.py::test_edit_refuses_an_ambiguous_match_and_writes_nothing,
::test_edit_ambiguity_raises_the_same_type_as_a_missing_match,
::test_mcp_memory_edit_refuses_an_ambiguous_match.
-
Corrected two now-false claims that
builtin.fshas no write surface (CLAUDE.md,docs/how-to/use-document-memory.md), left behind by addingwrite_file/edit_fileto that group. Both now point atbuiltin.select(builtin.fs, exclude=builtin.unscoped)for the read-only scope they were describing. -
memory_readwindows like the rest, and the "drop-in compatible with Anthropic's memory API" claim is gone from five places, because it was not true.memory_readhad the same unreachable tail as the other capped reads and was initially left alone to preserve wire compatibility with Anthropic's Managed Agents Memory API, whoseviewcommand was believed to spell a windowview_range. Checking rather than recalling:view_rangebelongs to the text editor tool (text_editor_20250728), and neither memory surface is what this module resembles. Anthropic's client-side memory tool (memory_20250818) has commandsview/create/str_replace/insert/delete/rename; its Managed Agents memory stores exposelist/retrieve/create/update/delete, whereretrievetakes amem_...id rather than a path, and a session reaches a store as a filesystem mounted at/mnt/memory/<name>/read with ordinary file tools -- there are no dedicated memory tools inside a session at all. AIMU'smemory_list/memory_search/memory_read/memory_write/memory_edit/memory_deletematch neither: different names from the first, different addressing from the second, plus amemory_searchthat exists in neither. So there was no compatibility property to protect, and the claim was misinforming anyone who read it.memory_read(path, max_lines=2000, offset=1)now windows with AIMU's own spelling, consistent withread_fileandread_document-- which is also the closest thing to how Anthropic's own agents read memory, since they read the mount with file tools. A missing path still raisesKeyError: that is this server's documented contract, distinct from theread_documenttool's "not found" string. The claim was corrected indocument_mcp.py,document_store.py,CLAUDE.md(two lines),docs/how-to/use-document-memory.md,docs/reference/cli.md, andREADME.md. Tests:tests/test_document_store.py. -
The window helpers moved to a new private
aimu/_window.pysoaimu.memorycan share them without importingaimu.tools, whose package__init__eagerly importsbuiltinand would dragrequestsand the whole built-in tool set into an MCP server that wants neither.aimu.tools.builtinre-exports them under their former private names, so its call sites and tests are unchanged.
v0.30.0 (2026-09-09): a fetched PDF is a document, not mojibake¶
Tools¶
- Breaking:
get_webpageis nowget_web_content(url, max_chars=20000), and it returns Markdown. The old tool never asked what it had fetched: it handedresponse.textto an HTML stripper, andrequestsdecodes.textwitherrors="replace", so a PDF behind a URL became megabytes of replacement-character noise that a tag stripper passed through almost whole. One user's stored transcripts held four such results, 6.28 MB of compressed PDF body recorded as tool output, each of which had also entered a model's context whole, where it could not fit. Nothing converted the document and nothing reported that it had not been, which is principle 6's silent fallback twice over. The response is now classified byContent-Typewith the%PDF-magic bytes as a tiebreaker (a PDF served asapplication/octet-streamis common, and the header is the half that lies), converted to Markdown, and capped. HTML becomes Markdown rather than bare stripped text, keeping thePublished:line it already recovered. A PDF becomes its text under one## Page Nheading per page, so a caller citing a report has a page number and a capped read can say how far it got. An encrypted PDF is opened with an empty password first, since published reports routinely carry an owner password alone, which restricts printing while leaving text readable; one that needs a real password says so, and a scan with no text layer says that rather than returning an empty document, and an HTML page with no readable text (a JavaScript-rendered shell) says so instead of coming back empty. Any other textual body, plain text, JSON, or XML (including feed formats like RSS and Atom), comes back as-is; a response with no declaredContent-Type, or one that is not text at all, is refused, reported by media type (or its absence) and size instead of being read as text. No alias is kept for the old name: an alias would double the model-facing surface, andsearchwas renamed toweb_searchthe same way. Three caps arrive with it:max_chars(default 20,000, matchingget_webpage_html) on what is returned, whose truncation marker now names the parameter to raise; a 10 MB limit on what is downloaded at all, sincerequests.getallocates an entire body before any caller inspects it; and, for a PDF specifically, a 200,000-character bound on extraction itself, so a many-page or heavily compressed document cannot burn arbitrary CPU extracting text thatmax_charswould only discard, noting the page it stopped at when it does. An oversized body is refused rather than truncated, because a half-read PDF does not parse and a half-read page silently loses content. New core dependencies:markdownify(withbeautifulsoup4) andpypdf[crypto]; thecryptoextra is required rather than optional, because pypdf decrypts RC4 natively but raisesDependencyErrorfor the AES that modern PDFs use. Tests:tests/test_documents.py,tests/test_web_tools.py.
v0.29.0 (2026-09-08): a session store can be asked for metadata alone¶
Sessions¶
- New
SessionSummaryandSessionStore.list_summaries(). A store previously had no way to answer "every session's title and timestamp, without its messages": a caller listing conversations paid one whole-session read per conversation just to discard the transcript.SessionSummaryis a frozen dataclass carrying everything aSessiondoes exceptmessages, plusmessage_count(the one field that is not a projection, since it is computed over the very thing being excluded).list_summaries()is concrete on theSessionStoreABC rather than abstract, the same reasoning asclose()'s default no-op: the inherited implementation (walklist_keys()thenget()each one) is correct for any existing store and slow only where a full read is expensive, so nothing already subclassingSessionStorebreaks.TinyDBSessionStoreoverrides it with a single table read instead of one file parse per session.InMemorySessionStore's override returns a deeply-copiedmetadata, deeper thanget()'s one-level copy: aSessionfromget()is expected to be saved back, so a shared nested value is visible there, but a summary is a read-only snapshot nobody saves, and a caller mutating one must not be able to reach stored state at all. No ordering parameter and no paging: both need a sort key, and every candidate lives inmetadata, which this library treats as opaque, so a caller sorts the result by whichever of its own keys it means. Tests:tests/test_sessions.py.
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.