Skip to content

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): calculate no longer executes code. It was eval(expression, {"__builtins__": {}}), and an empty builtins dict is not containment: ().__class__.__base__.__subclasses__() reaches os from any literal. Because calculate is in ALL_TOOLS, that was code execution for every agent given the default tools and every client of python -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, and e. Anything else is an Error: result naming what is accepted. An integer result past about 3,000 digits is refused, before computing it where it is a power, so 9**9**9 no longer holds the process; so is round with ndigits beyond ±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: calculate refuses ^. Python reads 2^10 as 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-agents and 09-workflows notebooks defined their own eval-based calculator; both now call builtin.calculate.

Agents

  • Change: a spawned sub-agent cut off at its round cap is reported as such. make_subagent_tool and make_async_subagent_tool now put a note in front of the tool result when the child's RunFinished says ended_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's finished callback 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_PROMPT asked 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 configured final_answer_prompt still 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.ToolGroup and Agent(tool_groups=[...]) (sync and async, including SkillAgent) let an agent hold tools without sending their schemas until it loads them. An agent with a deferred group carries available_tools(filter="") and load_tools(name); Agent.load_tool_group(name) is the host's route to the same load (it does not pass through tool_approval). A build that succeeds runs once per agent instance, at load time (off the event loop on aimu.aio). Loading only appends and never shadows an existing tool name. A group with build=None is resident: already in tools=, listed only. See docs/how-to/defer-tools.md.
  • New: "tool_groups" and "tool_approval" spawn spec keys on make_subagent_tool and make_async_subagent_tool. "tool_approval" gives one specialist its own gate: absent inherits the factory's, None turns 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_tools and load_tools in 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_id names one run of an agent and RunEvent.parent_run_id the run that started it, so two concurrent runs of one agent stay apart. Agent.run(run_id=, parent_run_id=) on aimu.agents.Agent, aimu.aio.Agent, and aimu.aio.SkillAgent set them (an id is generated when omitted), and ToolContext.run_id tells a tool which run called it, so it can pass that on as a child's parent_run_id.
  • Change: a spawn observer's spawn_id is the child run's run_id. make_subagent_tool and make_async_subagent_tool name the child's run and pass the caller's ToolContext.run_id as its parent_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 now subagent-<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 tool as_tool() builds (sync and async) takes an injected ctx: Optional[ToolContext] = None, absent from the schema the model sees, and when the wrapped runner's run accepts run_id and parent_run_id it passes a fresh id and the caller's ctx.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 accepts run_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_safe could not walk (a self-referential dict) or a malformed endpoint port used to raise out of the request. The event now carries endpoint=None and 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, and last_output_truncated, so ModelTurnFinished reports them rather than [] and None.
  • Fix: generate() clears last_tool_calls, so its ModelTurnFinished no longer reports the previous chat()'s tool calls.
  • Fix: IPv6 endpoints keep their brackets (http://[::1]:11434/v1), so a recorded endpoint is a valid URL.
  • Change: RequestPrepared.payload is plain JSON data. Payloads held values with no JSON form (Anthropic's NOT_GIVEN sentinel, Pydantic classes and instances, in-process rendered inputs), so every host persisting a request hit the same error. NOT_GIVEN keys 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_request keeps the raw object, so a consumer comparing the event's payload by identity to it will now see a copy.
  • New: RequestPrepared.endpoint names the server the request went to. provider is 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 as OLLAMA_HOST included), with userinfo, query, and fragment stripped; None means an in-process provider (HuggingFace, LlamaCpp) with no wire request. Providers supply it through _request_endpoint().
  • New: RunFinished.ended_by says which rule ended a run, and RunFinished.max_iterations the 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; error says 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; a schema= run reports "answered" / "error" with max_iterations=None, and a workflow reports neither. The RunEnding literal is exported from aimu.events.
  • New: ModelTurnFinished.stop_reason carries the provider's own word for how the turn ended, the value of client.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_calls and ModelTurnFinished.thinking, plus client.last_tool_calls, forwarded through ModelClient, AsyncModelClient, the agentic view, and FallbackClient. A tools-only round's text is empty, so these are what a host records as that round's response.
  • Fix: last_stop_reason (and so ModelTurnFinished.stop_reason) is now forwarded through ModelClient, AsyncModelClient, and the agentic view, and adopted by FallbackClient; through aimu.client() it read None.

Tools

  • New: generate_image takes an optional reference_image, so an agent can refine an image it generated. Pass the path an earlier call returned and the call becomes image-to-image, as client.generate(reference_image=...) already was. Applies to builtin.generate_image, make_image_tool, and the async generate_image / make_async_image_tool. The factories advertise the parameter only when the image model can use it, read from the new ImageSpec.supports_reference_image (true for Gemini; on HuggingFace, true when img2img_pipeline_class is 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, and data: 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 of builtin.generate_image will see reference_image among its properties (required is still ["prompt"]).

v0.35.0 (2026-10-07): EmbeddingGemma 2, query/document prompts, and shorter embedding vectors

Models

  • Fix: model_kwargs= on aimu.image_client() / audio_client() / speech_client() / transcription_client() / embedding_client() (and their *Client classes) was dropped. These factories bundled every keyword they did not recognize into the client's model_kwargs, so a caller's own model_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 and device_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, as aimu.client() always has, so model_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; write aimu.image_client(m, model_kwargs={"device": "cuda:1"}). The payoff is that a misspelled keyword (hots= for host=) 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), but embed() takes text only, so the spec loads the 270M text backbone; pass model_kwargs={"config_kwargs": {}} to load the rest. Two new HuggingFaceEmbeddingSpec fields make that expressible: load_kwargs (default SentenceTransformer kwargs, merged under the caller's) and rejected_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, so input_type changes nothing for them, and None (the default) sends text as given. The spec, not a model's own sentence-transformers prompts config, 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 to encode_query / encode_document. An unknown input_type raises ValueError.
  • 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, and client.dimensions reports 256. Each spec declares the widths its model was trained to be truncated to (EmbeddingSpec.matryoshka_dimensions); any other width raises ValueError at 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's dimensions parameter does.
  • Fix: OllamaEmbeddingModel.NOMIC_EMBED_TEXT.spec.max_input_tokens is 2048, not 8192. 8192 is what nomic v1.5 reaches elsewhere, and Ollama's Modelfile sets num_ctx 8192, but the GGUF's context_length is 2048 and Ollama enforces that: measured, a 2048-token input fits and anything longer is a 400 under truncate=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-call dimensions= 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's embed_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-typed embedding_client whose embed() does not accept input_type now raises TypeError.
  • Change: the [hf] extra now requires transformers>=5.19 and sentence-transformers>=6.1. 5.19 is the first transformers release that knows the embedding_gemma2 architecture, and 6.1 is what the model's own config declares. Existing embedding models run unchanged on 6.1.
  • Change: [evals] now requires deepeval>=4, and aimu[all] no longer includes [evals]. transformers 5.19 pulls huggingface-hub>=1.31, which needs click>=8.4.2; every deepeval 4.x pins click<8.4. Left unpinned, the resolver silently fell back to deepeval 2.6.6, which fails on import (it needs langchain) 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, so aimu[hf,evals] is unresolvable under pip. [all] drops [evals] so that pip install aimu[all] still works; install aimu[evals] into its own environment until deepeval relaxes its pin. uv sync --all-extras in 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 over messages[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 in message_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 uses setdefault, so a continuation nudge 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 asserting all(... == 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: Steering is now Inbox, 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 WebChannel page keyed on {"type": "steering"} renders nothing, because all three consumers dispatch with if / elif and no else: 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. Agent is a plain @dataclass with 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 raises TypeError like 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.reader is now reader(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 fixed subagent and 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 Inbox whose reader cannot 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. Inbox is a runtime_checkable Protocol, so isinstance tests whether reader exists and never what it accepts; _check_inbox is limited to the same question, and it runs only when a spawn factory is called, so it never sees a host's own Agent.run. A 0.33.0-era def 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 raises TypeError naming 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 Inbox is exported from aimu.agents, so from aimu.agents import Agent, Inbox works. It was reachable from top-level aimu and from aimu.aio but not from the package that defines it, where Agent, SkillAgent and the loop's own errors all are, for no reason anyone had written down. Additive; nothing moves.

Models

  • New StreamChunk.is_inbox(), beside is_continuing(). INBOX was 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 by show_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 CLIChannel prints the same [message] <text> marker, and WebChannel's frame is now {"type": "inbox", "text": str} rather than {"type": "steering", "text": str}. A page keyed on the old type renders nothing for the new one, since all three consumers dispatch with if / elif and no else. It remains its own frame type rather than a third reason on loop, for the reason it was given one: loop means 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 INBOX phase in the stream-phase table, the "inbox" key in the spawn-roster page's list of spec keys, and the Inbox protocol'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_approval policy may return Denied(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 return Denied("only api.example.com is allowed") in place of False and the reason is rendered into the tool message the model sees, which is what lets it correct the argument instead of repeating it. Denied is a frozen dataclass exported from aimu and aimu.tools, mirroring Unsupported(remedy) in the generate-kwarg tables: the same verdict-with-a-remedy shape, for the same reason. It is falsy, so it substitutes for False wherever verdicts are composed, and an empty reason renders as the unadorned refusal. True and False are 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 from make_web_tools(), so an agent that can read a JSON API can also write to one. submit_form is form-encoded, and get_web_content already returns a JSON body as-is, so the library could read an API and not call it. Nothing scoped closed that gap either: requests and urllib are not in the execute_python sandbox allowlist, which left run_command plus curl as the only route, and that is in builtin.unscoped for good reason. payload is a typed dict, advertised to the model as a JSON Schema object and validated by coerce_tool_arguments at 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. method accepts the verbs that carry a body (POST, PUT, PATCH); GET stays submit_form's, DELETE is absent, and anything else is refused before a request goes out. There is deliberately no headers parameter: 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 in builtin.web, ALL_TOOLS, or the MCP server. Re-exported from aimu.aio.tools.builtin via the factory (dispatched through asyncio.to_thread). How-to: Fetch HTML and submit web forms. Tests: tests/test_web_tools.py.

  • Change: submit_form and submit_json follow 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 no tool_approval policy ever inspected -- a body-exfiltration channel invisible to a gate that only sees the URL the model chose. Verified against real requests: a cross-host 302 downgrades to a bodyless GET and is harmless, while a cross-host 307 arrived at the second host with POST and the full JSON body intact. (requests does strip Authorization across 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 an allowed_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_form routed through _fetch_html, which calls raise_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. _request is now the non-raising helper both submit tools call, _fetch_html is the raising wrapper the three read tools keep (a 404 page's body is noise), and _format_response renders 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 read Status: 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 with session.headers.setdefault("User-Agent", ...), which never fires because requests.Session() already carries a User-Agent, and _fetch_html then 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=None sends no header and leaves a caller-supplied session's own User-Agent in charge, and the annotation admits the None. Pinned by tests/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 been tools=[get_webpage_html, *make_web_tools()], which is unaffected, but code unpacking the result as find_forms, submit_form = make_web_tools() raises ValueError and needs a third name.

  • New: a spawned sub-agent can be steered through its spec. make_subagent_tool and make_async_subagent_tool gain a steering parameter, and a typed agent_types spec gains an eighth key of the same name (SUBAGENT_SPEC_KEYS), so one specialist can read from the same Steering source 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": None turns 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, carrying dict {"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 from CONTINUING, whose kind names 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 StreamingContentType member is a breaking change for a consumer whose match chunk.phase is exhaustive. Every consumer AIMU ships dispatches with if/elif and 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 an Agent.steering field, 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. steering is a Steering source: an object with one method, reader() -> Callable[[], list[str]]. The loop calls reader() 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 and make_subagent_tool's / make_async_subagent_tool's factory-level steering argument 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_iterations bounds 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 by show_thinking / show_tools for 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 CLIChannel prints the same [steering] <text> marker, and WebChannel emits a new {"type": "steering", "text": str} frame. Its own frame type rather than a third reason on loop: loop means 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, as loop is.

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_script wrote its file by calling write_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 everything write_skill does not re-emit: the spec's optional license, compatibility, and allowed-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 carrying license: Apache-2.0 and a compatibility: 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) and make_skill_update_tool(manager, skills_dir), so an agent can revise a skill it already wrote. author_skill refuses to clobber and add_skill_script only 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 then manager.refresh(); unlike add_skill_script it 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 not write_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 no metadata parameter (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 one author_skill already had, now stated per field in the tool's own reply: a new body applies at the next activate_skill, which reads it from disk, while a new description reaches the catalogue in a fresh conversation. Tests: eleven in tests/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. The SKILL.md format had three partial copies of itself (discovery's fence-finding in SkillManager._parse, a second copy in AgentSkill.load_body, and a hand-built emitter in write_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_yaml moved there as load_frontmatter (same lenient fallback, now raising plain ValueError for 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_skill renders its frontmatter through yaml.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 ContextOverflowError for 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 as Tool '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 and Agent has no messages parameter 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: compaction on both spawn factories, and "compaction" in SUBAGENT_SPEC_KEYS. Agent has taken a compaction callable 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": None written 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 raises ValueError at factory-call time, where a programmer can act on it, rather than as a TypeError from 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_document in make_document_tools, and save_document now 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 was read_document + save_document. That became destructive when read_document started windowing earlier in this release: read_document returns one window, save_document replaces 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 answering Saved /paper.md. A 3,000-line document came back 51 lines long. edit_document(path, old_str, new_str) wraps DocumentStore.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. Like read_document, it reports a miss as a message rather than raising, which is this group's convention and the opposite of document_mcp's memory_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_file and edit_file, in the fs group. builtin.fs was read-only, so an agent that had to write reached for run_command or execute_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 with read_file's errors="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 way execute_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], or select(ALL_TOOLS + fs, exclude=builtin.unscoped). It earns its place over a list comprehension through one ValueError: 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, since exclude=builtin.fs already says what fs.* would. The result is deduplicated by name (keeping the entry dispatch would have resolved to anyway), because groups overlap now that unscoped cuts across fs and compute, 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 what ALL_TOOLS leaves 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_image saves under paths.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_file and read_document take max_lines=2000, offset=1 (1-indexed lines), get_web_content takes max_chars=20000, offset=1 (1-indexed characters), and get_webpage_html takes offset=1 against its fixed 20,000-character window -- fixed rather than caller-raisable because raw markup costs several times the tokens of the text it carries, so offset is 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 reads truncated: 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-positive offset or cap, and an offset past the end, come back as teaching strings naming the real size rather than raising, matching how the rest of builtin.py reports 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 default offset=1 is 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_lines could 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. offset is the 1-indexed line to start from, so a file larger than any one window is now readable whole by paging, and the marker reads truncated: 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-positive offset or max_lines, and an offset past 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 default offset=1 is 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 (and memory_edit) now require old_str to 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 a ValueError. Two things soften it. The exception type is unchanged -- the absent-match case already raised ValueError, so code already writing except ValueError around edit() 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.fs has no write surface (CLAUDE.md, docs/how-to/use-document-memory.md), left behind by adding write_file / edit_file to that group. Both now point at builtin.select(builtin.fs, exclude=builtin.unscoped) for the read-only scope they were describing.

  • memory_read windows 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_read had 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, whose view command was believed to spell a window view_range. Checking rather than recalling: view_range belongs 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 commands view / create / str_replace / insert / delete / rename; its Managed Agents memory stores expose list / retrieve / create / update / delete, where retrieve takes a mem_... 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's memory_list / memory_search / memory_read / memory_write / memory_edit / memory_delete match neither: different names from the first, different addressing from the second, plus a memory_search that 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 with read_file and read_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 raises KeyError: that is this server's documented contract, distinct from the read_document tool's "not found" string. The claim was corrected in document_mcp.py, document_store.py, CLAUDE.md (two lines), docs/how-to/use-document-memory.md, docs/reference/cli.md, and README.md. Tests: tests/test_document_store.py.

  • The window helpers moved to a new private aimu/_window.py so aimu.memory can share them without importing aimu.tools, whose package __init__ eagerly imports builtin and would drag requests and the whole built-in tool set into an MCP server that wants neither. aimu.tools.builtin re-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_webpage is now get_web_content(url, max_chars=20000), and it returns Markdown. The old tool never asked what it had fetched: it handed response.text to an HTML stripper, and requests decodes .text with errors="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 by Content-Type with the %PDF- magic bytes as a tiebreaker (a PDF served as application/octet-stream is common, and the header is the half that lies), converted to Markdown, and capped. HTML becomes Markdown rather than bare stripped text, keeping the Published: line it already recovered. A PDF becomes its text under one ## Page N heading 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 declared Content-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, and search was renamed to web_search the same way. Three caps arrive with it: max_chars (default 20,000, matching get_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, since requests.get allocates 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 that max_chars would 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 (with beautifulsoup4) and pypdf[crypto]; the crypto extra is required rather than optional, because pypdf decrypts RC4 natively but raises DependencyError for 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 SessionSummary and SessionStore.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. SessionSummary is a frozen dataclass carrying everything a Session does except messages, plus message_count (the one field that is not a projection, since it is computed over the very thing being excluded). list_summaries() is concrete on the SessionStore ABC rather than abstract, the same reasoning as close()'s default no-op: the inherited implementation (walk list_keys() then get() each one) is correct for any existing store and slow only where a full read is expensive, so nothing already subclassing SessionStore breaks. TinyDBSessionStore overrides it with a single table read instead of one file parse per session. InMemorySessionStore's override returns a deeply-copied metadata, deeper than get()'s one-level copy: a Session from get() 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 in metadata, 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_KEYS gains a sixth entry, so an agent_types roster can hand one specialist a different tool-loop cap from its peers: a search-heavy researcher that spends every round calling tools gets the rounds it needs while the rest of the roster stays on the factory default. Before this, max_iterations was a single value per spawn tool, so the only way to raise one worker's cap was to raise everyone's. The key is the second, after "model", whose missing-key behavior is to fall back rather than inherit nothing: a spec omitting it gets whatever make_subagent_tool(max_iterations=...) was called with, which is what lets a roster share one default without writing the key into every spec. "thinking" and "generate_kwargs" cannot work that way, having no factory-level tier beneath them. Validated at factory-call time, alongside max_depth < 1 and the closed key set: an int below 1 is a loop that makes no model call, and bool is an int subclass, so an unchecked True would have been accepted as a cap of 1. A nested spawn tool (max_depth > 1) still carries the factory's tier rather than the spec that built it, since it serves the whole roster again. Both surfaces: make_subagent_tool and make_async_subagent_tool. Tests: tests/test_subagent_tools.py, tests/test_aio_subagent_tools.py. How-to: docs/how-to/spawn-subagents.md.

Models

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

Agents

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

Console output

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

Channels

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

Examples

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

Documentation

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

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

Models

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

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

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

Agents

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

Models

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

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

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

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

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

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

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

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

Memory

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

Tools

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

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

Tools

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

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

Tools

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

Internal

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

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

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

Channels

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

Models

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

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

Documentation

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

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

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

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

Agents

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

Observability

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

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

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

Observability

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

Context Management

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

Models

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

Tools

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

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

Agents

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

Documentation

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

Packaging

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

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

Models

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

Tools

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

Agents

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

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

Models

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

Packaging

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

Tools

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

Documentation

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

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

Models

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

Tools

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

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

Models

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

Tools

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

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

Models

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

Agents

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

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

Models

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

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

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

Documentation

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

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

Agents

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

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

Skills

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

Tools

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

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

Skills

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

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

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

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

Models

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

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

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

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

Models

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

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

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

Testing

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

Channels

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

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

Models

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

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

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

Agents and workflows

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

Memory

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

Fixes

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

Tools

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

Internal

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

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

Documentation

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

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

Agents and workflows

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

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

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

Models

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

Sessions and persistence

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

Memory

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

Agents and workflows

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

Skills

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

Tools

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

Examples & docs

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

Packaging (breaking)

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

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

Models

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

Agents and workflows

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

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

Models

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

Tools

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

Agents and workflows

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

A2A interop (new optional a2a extra)

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

Documentation

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

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

Agents and workflows

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

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

Tools

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

Agents and workflows

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

Console output

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

Documentation

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

Examples

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

Models

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

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

Models

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

ModelClient.chat() and ModelClient.generate()

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

Documentation

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

Transcription (speech-to-text)

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

Embeddings (text-to-vector)

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

Structured output

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

RAG primitives (retrieval-augmented generation)

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

Token usage surfacing

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

Anthropic models & adaptive thinking

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

Dependencies

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

Async surface

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

Fixes

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

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

Breaking changes

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

Models

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

Tools

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

Agents and workflows

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

Fixes

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

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

Breaking changes

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

Output utilities

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

Model weight caching

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

Tools

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

Agents and workflows

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

Documentation

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

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

Image generation

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

Negative prompts

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

Curated model catalog (breaking for unknown ids)

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

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

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

Async surface (aimu.aio)

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

Audio generation

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

Speech (text-to-speech)

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

Image generation

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

Default-model resolution

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

Tools and vision

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

Examples

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

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

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

Top-level API

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

Model clients

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

Agents

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

Tools

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

Skills

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

Documentation

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

Earlier versions

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