aimu.tools¶
In-process @tool decorator and cross-process MCPClient.
Decorator¶
aimu.tools.tool ¶
Mark a Python function as an AIMU tool.
Inspects the signature and docstring at decoration time and attaches an OpenAI-format
tool spec to func.__tool_spec__. The function itself is unchanged and remains
directly callable.
Each parameter must either have a type hint or a default value. Variadic parameters
(*args / **kwargs) are not supported; declare each argument explicitly.
Supported parameter types: str, int, float, bool, list, dict,
plus Optional[T] and T | None (which unwrap to the inner type). A
Literal[...] parameter becomes a JSON Schema enum so the model sees the exact
allowed values.
The docstring is parsed Google-style: the prose before the first section header
(Args:, Returns:, ...) becomes the tool description, and an Args: /
Arguments: / Parameters: section supplies per-parameter descriptions (each
name: text entry, continuation lines allowed). Required vs. optional args are
derived from default values.
A tool may be plain (def fn() -> T), async (async def fn() -> T), a generator
(def fn(): yield ...; return T), or an async generator (async def fn(): yield ...).
Generator and async-generator tools stream :class:~aimu.models.StreamChunk objects
during execution; the agent forwards each yielded chunk through its own stream and
treats the final yielded TOOL_CALLING chunk's content["response"] as the
canonical tool result. The decorator sets discriminator attributes:
func.__tool_is_async__: True forasync deforasync def+yield.func.__tool_is_streaming__: True for generator functions (sync or async).
Usage::
import aimu
@aimu.tool
def letter_counter(word: str, letter: str) -> int:
"""Count occurrences of a letter in a word."""
return word.lower().count(letter.lower())
agent = Agent(client, tools=[letter_counter])
aimu.tools.ToolSignatureError ¶
Bases: TypeError
Raised by @tool when a function signature can't be converted to a tool spec.
Argument validation¶
aimu.tools.coerce_tool_arguments ¶
Validate and lax-coerce model-supplied tool arguments against fn's type hints.
Returns a new dict of model-facing arguments coerced to their declared types
("5" -> 5, "true" -> True). Raises :class:ToolArgumentError with a
single self-contained message when arguments are unknown, a required one is missing,
or a value can't be coerced.
Callables not built by @tool (e.g. MCP as_tools() wrappers) carry no
__tool_param_adapters__; their arguments pass through unchanged (the MCP server
validates them).
aimu.tools.ToolArgumentError ¶
Bases: ValueError
Raised when model-supplied tool-call arguments fail validation/coercion.
Caught at dispatch and surfaced to the model as a tool result so it can self-correct.
Tool approval¶
The gate an Agent runs before each tool call. See
gate tool calls for the how-to.
aimu.tools.Denied
dataclass
¶
A refusal that says why, returned by a policy in place of False.
The engine renders reason into the tool message the model sees, so a refused call can be
corrected rather than merely retried. A bare False says only that something was
disallowed, and a model's obvious next move is the same call again.
reason is written for the model: name the constraint and, where there is one, the thing
that would satisfy it ("only api.example.com is allowed"). Note that it lands in the
conversation, so it is readable by the model and by anything replaying the transcript --
a host that considers its own policy sensitive should keep the reason vague and return a
plain False instead. An empty reason renders as the unadorned refusal.
A type rather than a bare string because a policy's return value passes through bool():
a non-empty string already means approved, so repurposing one as a refusal would silently
invert any policy returning one. Mirrors Unsupported(remedy) in
aimu.models._internal.generate_kwargs, the same verdict-with-a-remedy shape.
aimu.tools.approve_all ¶
The default policy: approve every tool call (no behavior change).
MCP client¶
aimu.tools.MCPClient ¶
MCPClient(config: Optional[dict] = None, server: Optional[FastMCP] = None, file: Optional[str] = None, url: Optional[str] = None, auth=None, headers: Optional[dict] = None)
Synchronous wrapper around an async FastMCP Client.
Uses anyio's start_blocking_portal() to run the FastMCP Client in a background
thread with a properly initialized anyio event loop.
Pass exactly one of config, server, file, or url (a remote HTTP/SSE
server). auth (a bearer-token string, the literal "oauth", or a configured FastMCP
OAuth / httpx.Auth provider object) and headers apply only with url; a provider
object is passed straight to the FastMCP Client (and can't be combined with headers).
Connection errors are re-raised as :class:MCPConnectionError with the original exception chained.
close ¶
Close the MCP connection and release the blocking portal. Idempotent.
Call this when the client is held for the life of the process. Leaving teardown to __del__
does not work there: by the time the interpreter is finalizing, the portal's event loop is gone,
and the cross-thread call that closes the connection blocks forever, hanging the process on exit.
Closing explicitly happens while the portal can still answer.
ping ¶
Verify the connection is alive by listing tools. Returns the tool list.
Raises :class:MCPConnectionError if the connection has been closed or the
server is unreachable.
as_tools ¶
Return this server's tools as @tool-style callables.
Each callable closes over this client, invokes :meth:call_tool cross-process,
and returns the result's text content as a string. The callables carry
__tool_spec__ (OpenAI format), __tool_is_async__ = False, and
__tool_is_streaming__ = False, so they drop straight into client.tools or
Agent(tools=...) and dispatch through the same path as @tool functions,
no model_client.mcp_client reference needed::
mcp = MCPClient(server=my_server)
client.tools = builtin.web + mcp.as_tools()
The tool list is a snapshot taken now (one list_tools() round-trip); call
as_tools() again to pick up server-side changes. Keep a reference to this
MCPClient (or to the returned callables, which hold one) for the lifetime of
the connection.
aimu.tools.MCPConnectionError ¶
Bases: RuntimeError
Raised when an :class:MCPClient fails to establish or use a connection.
Built-in tools¶
The aimu.tools.builtin module ships ready-made @tool functions grouped by domain:
| Group | Tools |
|---|---|
builtin.web |
get_weather, get_webpage, get_webpage_html, web_search, wikipedia |
builtin.fs |
list_directory, read_file, write_file, edit_file |
builtin.compute |
calculate, execute_python, run_command |
builtin.time |
get_current_date_and_time, convert_time |
builtin.misc |
echo |
builtin.ALL_TOOLS |
All of the above except execute_python and run_command (isolation, not containment; opt in via builtin.compute) |
execute_python runs code in a fresh subprocess (its own hard timeout, crash isolation,
and no access to this process's environment variables -- but it does not confine the
filesystem or the network; see its docstring). execute_python_in_process is the
explicit opt-in for trusted code where subprocess startup cost matters; it is not
included in builtin.compute or builtin.ALL_TOOLS.
run_command runs a command line through /bin/sh -c (COMSPEC /c on Windows), sharing
execute_python's subprocess supervisor rather than a separate implementation:
timeout is seconds, clamped to 600. It returns the exit code plus stdout and stderr labelled
separately; a nonzero exit returns that output rather than an error string, since pytest exits 1
with the answer on stdout and git diff --exit-code exits 1 to mean "yes, there is a diff." Unlike
execute_python, there is no memory cap: a 512 MB address-space limit breaks compilers and test
suites, and imposing one on a shell child needs preexec_fn, which is neither portable nor safe
alongside threads.
Not a security boundary. This is isolation, not containment, one step sharper than
execute_python: the command reaches credentials sitting in files (a .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.
Unlike execute_python, run_command is not added by make_tools(allow_code_execution=True):
that flag names code execution, and widening it would hand a shell to every caller already
passing it, so builtin.compute is the only route in. make_command_tool(env_passthrough=...)
builds a variant whose child also sees the named environment variables, for callers that need gh
or ssh to work; run_command itself is that factory called with no arguments, so no extra
variable reaches the child beyond its default allowlist.
aimu.tools.builtin.get_current_date_and_time ¶
Returns the current date and time, with its UTC offset and timezone.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
timezone
|
Optional[str]
|
IANA timezone name (e.g. "Asia/Tokyo", "America/New_York") to report the time in. Omit for the local timezone. |
None
|
aimu.tools.builtin.convert_time ¶
Converts a date and time from one timezone to another.
Both timezones must be IANA names ("America/Los_Angeles"), not abbreviations ("PST") or spoken names ("Pacific Time"). Example call: convert_time("2026-08-11T05:00:00", "America/Los_Angeles", "Europe/Zurich").
Handles daylight saving time, and flags times that a DST transition makes nonexistent or ambiguous.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
datetime_str
|
str
|
Date and time, ISO 8601 preferred (e.g. "2026-11-02T15:00:00"). A 12-hour clock and an unpadded hour are also accepted ("2026-11-02 3:00 PM"). The date is required. If the time already carries a UTC offset, that offset is used and from_timezone is ignored. |
required |
from_timezone
|
str
|
IANA timezone name the time is given in (e.g. "Europe/Berlin"). |
required |
to_timezone
|
str
|
IANA timezone name to convert to (e.g. "America/Denver"). |
required |
aimu.tools.builtin.get_weather ¶
Returns the current weather for a given location.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
location
|
str
|
City name or coordinates (e.g. "London", "48.8566,2.3522"). |
required |
aimu.tools.builtin.calculate ¶
Evaluates an arithmetic expression and returns the result.
Python arithmetic syntax: numbers, + - * / // % ** and parentheses. ** is a power and ^ is
not; % is the remainder, not a percentage (write 15% of 200 as 0.15 * 200). Also accepts the
functions abs, round, min, max, sqrt, exp, log, log10, floor, ceil, and the constants pi and e.
Nothing else: no variables, units, or other functions. Use execute_python for anything more.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
expression
|
str
|
The expression to evaluate, e.g. "(1250 * 1.08) / 12" or "round(2**0.5, 4)". |
required |
aimu.tools.builtin.execute_python ¶
Execute Python code in a fresh subprocess and return its output.
NOT A SECURITY BOUNDARY: this is isolation, not containment. Running the code in a
subprocess buys a hard timeout (an in-process exec could hang this process forever)
and crash isolation (an ordinary crash or early exit in the code brings down only
the child -- though nothing stops the code from reaching back out and taking this
process down anyway, e.g. os.kill(os.getppid(), signal.SIGKILL), since process
signalling isn't confined any more than the filesystem is; see below). It also buys
no mutation of this process's imports or global state, a memory cap on Linux
(best-effort on other POSIX platforms, absent on Windows -- a one-time warning is logged
if the cap can't be applied, rather than failing silently), and no access to
this process's environment variables: ANTHROPIC_API_KEY and anything else set
there is invisible to the child.
That last point is about environment variables specifically, not credentials in general.
It does not confine filesystem or network access: the child runs as the
same user and can read, write, and make requests exactly as this process can, which
means a .env file, ~/.aws/credentials, ~/.config/gh/hosts.yml, or any other
credential sitting on disk is exactly as readable to the child as to this process's
own user account. Treat any code reaching this tool as code you have chosen to run;
gate it with tool_approval for untrusted callers, and reach for a container when
you need real containment.
The child also runs with restricted builtins and an import allowlist, the same
accident guard execute_python_in_process uses (stops accidents, not a determined
attempt) -- kept identical between both backends so switching between them changes
only where the code runs, not what it's allowed to do.
Captures stdout and the value of the last expression. Pre-imports math, statistics,
json, re, itertools, functools, datetime, zoneinfo, and numpy/pandas/scipy/matplotlib
when installed. For trusted code where the subprocess startup cost matters, see
execute_python_in_process (same disclosed limits, weaker isolation).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
code
|
str
|
Python code to execute. |
required |
aimu.tools.builtin.execute_python_in_process ¶
Execute Python code in this process and return its output. Explicit opt-in.
NOT A SECURITY BOUNDARY: this is isolation, not containment, and weaker isolation
than execute_python (the default execute-code tool) -- code here shares this
process's memory, imports, and environment variables (including any API keys sitting
in os.environ), a hang blocks this process indefinitely, and a crash takes it down
too. It runs with restricted builtins and an import allowlist, which stops accidents,
not a determined attempt: a one-line expression reaches subprocess.Popen through the
type hierarchy, and the filesystem through an allowlisted module's transitive
attributes. Use this only for trusted code where the subprocess startup cost of
execute_python matters; gate it with tool_approval otherwise, and reach for a
container when you need real containment.
Captures stdout and the value of the last expression. Pre-imports math, statistics, json, re, itertools, functools, datetime, zoneinfo, and numpy/pandas/scipy/matplotlib when installed.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
code
|
str
|
Python code to execute. |
required |
aimu.tools.builtin.get_web_content ¶
Fetches a URL and returns its content as Markdown, for a web page or a document.
Handles HTML pages, PDF documents, and any textual body: plain text, JSON, and XML (including feed formats like RSS and Atom). A page's publication timestamp is prepended as a "Published:" line when the page exposes one, and a PDF's text is marked with a "## Page N" heading per page so it can be cited.
A response that declares no Content-Type, or declares one that is not text (an image, an archive, a video), is reported rather than returned: guessing "text" for an undeclared body is exactly how binary reached a model as noise before this tool existed.
If the result says it was truncated, call again with the offset it names before drawing any conclusion from it: a partial document reads exactly like a complete one. Paging with offset is how a document larger than one window gets read whole. Each call re-fetches and re-converts the URL, so prefer a window large enough to answer the question over a walk through a long document in small steps.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
url
|
str
|
The URL to retrieve. |
required |
max_chars
|
int
|
Maximum characters to return (default 20000). |
20000
|
offset
|
int
|
1-indexed character to start reading from (default 1, the start). |
1
|
aimu.tools.builtin.get_webpage_html ¶
Fetches a web page and returns its raw HTML markup (tags and all).
Use this when you need to see the page structure, e.g. to locate links, attributes,
or form markup. For readable article text instead, use get_web_content. For inspecting
and submitting forms with cookie/session persistence, use the tools from
make_web_tools().
Note: this fetches server-rendered HTML only; it does not execute JavaScript, so pages built client-side (SPAs) will return their pre-render markup. Markup is token-heavy, so the window per call is fixed; if the result says it was truncated, call again with the offset it names to continue. The form or link you are looking for is often past the first window of a real page's head and navigation.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
url
|
str
|
The URL of the page to retrieve. |
required |
offset
|
int
|
1-indexed character to start reading from (default 1, the start). |
1
|
aimu.tools.builtin.web_search ¶
Search the web using a SearXNG instance and return the top results.
Each result includes its publication date when the search engine reports one (shown as a "Published:" line), useful for judging how recent an article is.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
query
|
str
|
The search query string. |
required |
num_results
|
int
|
Number of results to return (default 5). |
5
|
time_range
|
str
|
Optional recency filter, one of "day", "week", "month", or "year". Use "day" to restrict results to roughly the last 24 hours (best for fresh news). |
''
|
categories
|
str
|
Optional SearXNG category filter, e.g. "news" to restrict to news engines (which report publication dates far more reliably than general web engines). Comma-separated for multiple, e.g. "news,science". |
''
|
Set SEARXNG_BASE_URL env var to point to your SearXNG instance (or a .env file) (default: http://localhost:8080).
aimu.tools.builtin.wikipedia ¶
Fetches a Wikipedia article summary for the given query.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
query
|
str
|
Article title or search phrase (e.g. "Albert Einstein", "general relativity"). |
required |
aimu.tools.builtin.list_directory ¶
Lists files and subdirectories at the given path.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
str
|
Directory path to list. |
required |
aimu.tools.builtin.read_file ¶
Reads a local file and returns up to max_lines lines starting at line offset.
If the result says it was truncated, call again with the offset it names before drawing any conclusion from it: a partial document reads exactly like a complete one. Paging with offset is how a file larger than one window gets read whole; raising max_lines to swallow a large file in one call spends the context window the rest of the task needs.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
str
|
Path to the file to read. |
required |
max_lines
|
int
|
Maximum number of lines to return (default 2000). |
2000
|
offset
|
int
|
1-indexed line to start reading from (default 1, the start of the file). |
1
|
aimu.tools.builtin.write_file ¶
Writes content to a file, creating it or replacing what is already there.
This replaces the whole file. To change part of one, use edit_file, which leaves the rest untouched and fails loudly if what you meant to change is not where you thought.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
str
|
Path to the file to write. Parent directories are created as needed. |
required |
content
|
str
|
The full text to write. |
required |
aimu.tools.builtin.edit_file ¶
Replaces one exact occurrence of old_str with new_str in a file.
old_str must appear exactly once. If it appears zero times, or more than once, nothing is written and the count is returned: include enough surrounding context to make it unique rather than retrying with the same ambiguous string.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
str
|
Path to the file to edit. |
required |
old_str
|
str
|
Exact text to find, unique within the file. |
required |
new_str
|
str
|
Text to replace it with. Pass an empty string to delete it. |
required |
Scoping what an agent gets¶
builtin.fs carries writes as well as reads, so handing over a group is a decision, not a
default. select narrows one, and builtin.unscoped names the tools that can reach any target
the running account can — the group to deny when an agent should stay inside its own work.
There is no SAFE_TOOLS complement, deliberately: safety is a property of a deployment rather
than of a tool, and a list named for it would promise what it cannot deliver.
aimu.tools.builtin.select ¶
Narrow a tool list, by name or by tool, refusing a selector that matches nothing.
include keeps only the named tools; exclude drops them; passing both applies
include first. Each may hold tool names ("read_file"), the tool objects
themselves, or a group to pass whole (exclude=builtin.fs), so blocking a group and
re-allowing one member reads as what it is::
# everything except the filesystem, plus reading
tools = builtin.select(builtin.ALL_TOOLS, exclude=builtin.fs) + [builtin.read_file]
# the filesystem, minus the parts that write
tools = builtin.select(builtin.fs, exclude=["write_file", "edit_file"])
# let it write files, but never run a command
tools = builtin.select(builtin.ALL_TOOLS + builtin.fs, exclude=builtin.compute)
The result is deduplicated by name, so concatenating overlapping groups (as the third example does) is safe.
The reason this exists rather than a list comprehension is the 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 thought you had forbidden. Only string selectors are checked,
because only they can be misspelled: a tool passed by reference already failed at the
call site if its attribute name was wrong, and a group legitimately names tools the list
may not hold (exclude=builtin.fs against ALL_TOOLS, which carries only that
group's read members). Deliberately not a pattern language -- no globs, no precedence
rules to learn, and exclude=builtin.fs already says what fs.* would.
Tool factories¶
Bind a tool to a specific resource (a memory store, a knowledge base) or policy (a command's environment allowlist) instead of a process-wide singleton.
aimu.tools.builtin.make_command_tool ¶
Build a run_command whose child also sees the named environment variables.
env_passthrough is what keeps the capability real without the default being unsafe.
The child's environment is an allowlist with no API keys in it, which is what stops
run_command("env") lifting a credential into a model's context, and which also makes
gh, ssh, and git push over ssh fail. A host that wants one of those working names
the variable it needs, typically from its own configuration, so the allowance is the
user's to grant rather than this library's to assume.
builtin.compute's run_command is this factory called with no arguments, rather than
a separately defined twin. @tool freezes the docstring into __tool_spec__ at
decoration time, so a closure with no docstring reaches a model with an empty
description and a __doc__ assigned afterwards never lands; one definition is the only
way to keep one security disclosure.
aimu.tools.builtin.make_memory_tools ¶
Build store_memory, search_memories, and list_memories tools bound to store.
store may be any :class:aimu.memory.MemoryStore implementation:
:class:~aimu.memory.SemanticMemoryStore (ChromaDB vector search),
:class:~aimu.memory.DocumentStore (path-keyed), or a custom subclass.
Unlike the image/audio/speech tools, there is no env-var singleton for memory because the choice of store (ephemeral vs. persistent, which persist_path) is meaningful and should be explicit. Construct the store, pass it here, and add the returned list to the agent::
store = SemanticMemoryStore(persist_path="./.memory")
agent = Agent(client, tools=make_memory_tools(store) + builtin.web)
For cross-process or multi-agent memory, use the FastMCP servers in
aimu.memory.mcp / aimu.memory.document_mcp instead.
aimu.tools.builtin.make_retrieval_tool ¶
Build a retrieve_context tool bound to store for retrieval-augmented agents.
store may be any :class:aimu.memory.MemoryStore (typically a
:class:~aimu.memory.SemanticMemoryStore populated via :func:aimu.rag.ingest).
The tool runs :func:aimu.rag.retrieve and returns the joined context, letting an
agent fetch relevant background on demand::
from aimu.rag import ingest
store = SemanticMemoryStore()
ingest(store, my_documents)
agent = Agent(client, tools=[make_retrieval_tool(store)])
Like :func:make_memory_tools, there is no env-var singleton; the store (and what was
ingested into it) is a meaningful, explicit choice.
aimu.tools.builtin.make_subagent_tool ¶
make_subagent_tool(model, *, system_message: str = DEFAULT_SUBAGENT_SYSTEM_MESSAGE, tools: Optional[list[Callable]] = None, agent_types: Optional[dict[str, dict]] = None, max_depth: int = 1, max_iterations: int = 10, concurrent_tool_calls: bool = True, deps: Any = None, tool_approval: Optional[Callable] = None, tool_name: str = 'spawn_subagent', events: Optional[EventSink] = None, compaction: Optional[Callable[[list[dict]], list[dict]]] = None, inbox: Optional[Inbox] = None) -> Callable
Build a spawn_subagent tool that delegates subtasks to fresh, isolated sub-agents.
This is AIMU's answer to dynamic sub-agent spawning (as in Claude Code's Task tool): the
LLM decides at runtime to hand an independent subtask to a brand-new :class:~aimu.agents.Agent
with its own context, rather than choosing among a fixed roster. It is the dynamic complement
to :class:~aimu.agents.OrchestratorAgent (which wires a known set of workers up front); both
reduce to subagent.run(task) — the difference is who decides the roster.
Each invocation builds a fresh ModelClient(model) (its own message history — the
:func:aimu.agents.prebuilt._base.make_workers isolation idiom), so concurrent spawns share no
state. Parallelism is free: give the parent Agent concurrent_tool_calls=True and,
when the model emits several spawn_subagent calls in one turn, they run concurrently
(ThreadPoolExecutor). Genuine overlap is for cloud models — a single local model serializes on
the GIL/CUDA, and a shared deps object handed to concurrent spawns must be thread-safe.
Two shapes, chosen by agent_types:
- Generic (
agent_types=None): the tool isspawn_subagent(task)— a fresh general-purpose sub-agent usingsystem_message+tools. - Typed (
agent_typesgiven): the tool isspawn_subagent(agent_type, task)over a registry of named specialists (each value a dict with"system_message"and optional"tools"/"model"/"thinking"/"generate_kwargs"/"max_iterations"/"compaction"/"inbox"/"tool_groups"/"tool_approval", and nothing else -- an unrecognized spec key raises at factory-call time rather than being ignored, since an ignored key reads exactly like an applied one); the available names are listed in the tool description. An unknownagent_type, by contrast, is returned to the model as a tool result (self-correction), not raised: that one is the model's mistake to recover from, where a bad spec key is the programmer's."thinking"takes the same values as :class:~aimu.agents.Agent's field and is read with.get(), so a spec omitting it leaves the spawned agent atNonerather than inheriting anything from the caller: unlike"model", which falls back to the model this factory was built with, there is no factory-level thinking tier to fall back to."generate_kwargs"is a dict assigned to the spawned client'sdefault_generate_kwargs, so it applies to every request that sub-agent makes. Like"thinking"and unlike"model", an omitted key inherits nothing: there is no factory-level generation tier, so a caller with one default across a roster writes it into each spec. Only the keys a spec names are set, which matters because this tier sits above the model card in the precedence chain -- a filled-in default would shadow a card's own tuned profile."max_iterations"is the spawned agent's tool-loop cap, and it is the one key besides"model"with a factory-level tier beneath it: an omitted key falls back to this factory's ownmax_iterationsrather than inheriting nothing, which is what lets one default cover a roster without being written into each spec. It must be an int >= 1, checked at factory-call time, becauseboolis anintsubclass (soTruewould read as a cap of 1) and a cap below 1 is a loop that makes no model call at all."compaction"is the callable :class:~aimu.agents.Agentapplies before every model turn, and it is read by membership rather than.get(): an absent key falls back to this factory's owncompaction(a third key with a factory tier, alongside"model"and"max_iterations"), while a spec naming"compaction": Noneturns that policy off for one specialist. Those two cases are indistinguishable to.get(), which is why this key does not follow the others."inbox"is an :class:~aimu.agents.inbox.Inboxsource of mid-run messages for one spawned agent, read by the same membership rule as"compaction"and for the same reason: a spec naming"inbox": Noneis turning the factory's source off for this one specialist, which.get()cannot tell apart from a spec that never mentioned the key."tool_groups"is a list of :class:~aimu.tools.ToolGroupthe specialist can load on demand. There is no factory tier, so a spec omitting it spawns none; each spawn builds a fresh agent, so its loads are its own even when several spawns of one type run at once."tool_approval"is this specialist's own approval gate, read by the membership rule: omitted, it inherits the factory'stool_approval;"tool_approval": Noneturns gating off for this one specialist. A nested spawn tool keeps the factory's gate, for the reason it keeps the factory'sinbox.
A spawned sub-agent that runs out of context does not raise: the ContextOverflowError becomes a
tool result naming the sub-agent's window as the one that filled. Uncaught, it would reach the
parent model as Tool 'spawn_subagent' raised an error: <message>, and every provider's message
tells its reader to shorten the conversation, which at this seam is the wrong conversation. See
:func:_subagent_overflow_result. compaction is the other half of that story and the half that
prevents the failure rather than explaining it, though only for growth spread over many rounds: a
single tool result too large for the window is the tool's to cap, since compaction cannot drop the
most recent group without orphaning the call it answers.
max_depth (default 1) is the recursion guard: it counts the caller's agent as level 1, so the
default gives spawned sub-agents no spawn tool of their own. max_depth=2 lets one more level
spawn, and so on; the nested tool is rebuilt with a decremented depth, so recursion is finite.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
model
|
A |
required | |
system_message
|
str
|
Persona for generic sub-agents. |
DEFAULT_SUBAGENT_SYSTEM_MESSAGE
|
tools
|
Optional[list[Callable]]
|
Tools each generic sub-agent receives ( |
None
|
agent_types
|
Optional[dict[str, dict]]
|
Optional registry that switches the tool to typed mode. |
None
|
max_depth
|
int
|
Spawn levels permitted, counting the caller's agent as 1 (must be >= 1). |
1
|
max_iterations
|
int
|
Tool-loop cap forwarded to each spawned agent. |
10
|
concurrent_tool_calls
|
bool
|
Applied to spawned agents (so nested spawns overlap). The parent's own concurrency is set by its author. |
True
|
deps
|
Any
|
|
None
|
tool_approval
|
Optional[Callable]
|
Callback |
None
|
tool_name
|
str
|
Name of the produced tool (mint several differently-named spawn tools on one agent). |
'spawn_subagent'
|
events
|
Optional[EventSink]
|
The sink each spawned child reports to. It has to be passed explicitly: a spawn
builds a fresh client per call, which is outside the client family a caller's scoped
per-run override reaches, so a delegated run is otherwise invisible to a caller
measuring the whole turn. Each spawn passes it to the child's |
None
|
compaction
|
Optional[Callable[[list[dict]], list[dict]]]
|
A |
None
|
inbox
|
Optional[Inbox]
|
An :class: |
None
|
Example::
from aimu.tools.builtin import make_subagent_tool, web
spawn = make_subagent_tool("anthropic:claude-sonnet-4-6", tools=web)
agent = Agent(client, "Break the request into subtasks and spawn a sub-agent for each.",
tools=[spawn], concurrent_tool_calls=True)
print(agent.run("Compare the GDP growth of France, Japan, and Brazil since 2019."))
aimu.tools.builtin.make_web_tools ¶
make_web_tools(*, session=None, timeout: int = 15, max_content_chars: int = 20000, user_agent: Optional[str] = _DEFAULT_USER_AGENT)
Build find_forms, submit_form, and submit_json over one requests.Session.
The shared session preserves cookies across calls, so a GET-then-POST form flow
works: find_forms scrapes hidden fields (including CSRF tokens) from the page,
then submit_form echoes them back with the session's cookies intact.
submit_json is the same thing for a JSON API, sending a typed object as the
request body. Pass them to an agent alongside the stateless get_webpage_html::
agent = Agent(client, tools=[get_webpage_html, *make_web_tools()])
Pass your own session to control its lifecycle or to share one across several tool
sets; otherwise a fresh requests.Session is created. The session is also where
authentication belongs (a bearer token in session.headers, a cookie from a
prior login): neither submit tool lets the model name a request header, so a
credential the host configures here cannot be aimed at a host the model chooses.
Pass user_agent=None to leave the session's own User-Agent in charge.
Note: these fetch server-rendered HTML only and do not execute JavaScript, so JS-rendered (SPA) forms and anti-bot-protected pages are out of scope; a headless browser backend is a possible future addition.
submit_form and submit_json perform writes, to any URL the model names,
carrying whatever credentials the session holds. To require confirmation before one
runs, gate it via the tool_approval hook, e.g. Agent(..., tool_approval=policy)
where the policy inspects the tool name (see docs/how-to/gate-tool-calls.md).