Capabilities
indusagi.capabilitiesis the batteries-included tool layer the agent runtime exposes to a model: file read/write/edit/ls, content grep and filename find, one-shot bash and background process control, a per-conversation todo checklist, and web search/fetch. Import the whole surface from one site:from indusagi.capabilities import tool_box, define_tool, ToolSpec.
Every tool is authored as a declarative ToolSpec and wrapped by a single
define_tool adapter, so authors write only an async run(). The defining
architectural rule: tools never touch pathlib/subprocess/httpx-for-files
directly — they receive abstract Fs and Shell capabilities through their
ToolContext, and the one sanctioned binding to the real OS lives entirely in
backends/local.py. The registry groups tools into read-only / coding / all
collections and mints runtime ToolBoxes from them.
Table of Contents
- Layout
- The authoring kernel
- The Fs and Shell seams
- Output budgeting
- The built-in tools
- Registry and collections
- Local backends
- Diff helpers
- Using the suite
- Authoring a custom tool
- Relationship to neighbors
- Notable behaviors
Layout
Three layers stack under capabilities/__init__.py, which re-exports the whole
surface from one import site.
| Sub-directory | Holds |
|---|---|
kernel/ |
The frozen authoring shape: ToolSpec/define_tool/DefinedTool, ToolContext/ToolResult/content blocks, the Fs/Shell Protocols, OutputBudget+clamp, ToolRegistry, and the resolve_path/make_context context shape. |
backends/ |
The single sanctioned OS binding: LocalFs (os/shutil on threads), LocalShell (asyncio + Popen, process-group kills), and make_local_context with the standard 64 KB budget. |
files/ |
read/write/edit/ls tools plus a from-scratch Myers line differ (diff_lines/render_unified_diff) the edit tool uses for its unified-diff output. |
search/ |
grep_tool (regex content search) and find_tool (filename/glob search with a file/dir kind filter). |
shell/ |
bash_tool (one-shot Shell.exec with timeout/cwd) and process_tool (start/list/poll/stop background jobs via a global process table + scratch capture files). |
planning/ |
The todo checklist: todo_set/todo_read tools over a module-level TodoStore keyed by ToolContext identity; TodoItem/TodoStatus + reset_todos. |
web/ |
web_search_tool (DuckDuckGo HTML scraping via httpx) and web_fetch_tool (httpx GET + HTML-to-text simplification, byte-capped). |
The two outward contracts capabilities depends on — the runtime's
ToolCall/ToolOutcome/ToolBox and the gateway's ToolDescriptor/JsonSchema
— are deliberately not re-exported here; consumers import those from their
own contracts so there is a single source of truth.
The authoring kernel
A tool is a ToolSpec: a name, a model-facing description, a JSON-Schema
parameters shape, and one async run(input, ctx) -> ToolResult. define_tool
wraps it into a DefinedTool so the author never writes descriptor, invocation,
cancellation, or error-folding boilerplate.
| Name | Kind | Source | Purpose |
|---|---|---|---|
define_tool |
function | kernel/spec.py |
Wrap a ToolSpec into a DefinedTool the model sees and the runtime invokes. |
ToolSpec |
dataclass | kernel/spec.py |
Declarative description: name, description, parameters (JsonSchema), async run(input, ctx) -> ToolResult. |
DefinedTool |
class | kernel/spec.py |
Wrapped tool; exposes descriptor()/invoke(call, ctx) for the runtime plus facade-shaped label/description/parameters/execute(). |
ToolResult |
dataclass | kernel/spec.py |
A tool's return: a sequence of content blocks + an is_error flag. |
TextContentBlock |
dataclass | kernel/spec.py |
A plain-text result fragment (kind='text'). |
JsonContentBlock |
dataclass | kernel/spec.py |
A structured result fragment (kind='json'), serialized when shown to the model. |
ToolContentBlock |
type | kernel/spec.py |
Union alias TextContentBlock | JsonContentBlock. |
ToolContext |
dataclass | kernel/spec.py |
What every run receives besides its input: cwd, fs, shell, cancel, budget. Declared eq=False so per-session stores key by identity. |
resolve_path |
function | kernel/context.py |
Pure-string anchoring of a tool-supplied path against ctx.cwd (absolute passes through). |
make_context |
function | kernel/context.py |
Declared test-seam ContextFactory — intentionally raises NotImplementedError; use make_local_context instead. |
ContextOverrides |
dataclass | kernel/context.py |
Partial ToolContext shape for make_context (test seam). |
ContextFactory |
class | kernel/context.py |
Protocol for building a ToolContext defaulting omitted fields. |
DefinedTool.invoke(call, ctx) coerces the model's raw input (unwrapping a
JSON-string or None arg bag), honors an already-cancelled token up front, runs
the spec, and folds both success and any thrown exception into the runtime's
ToolOutcome. Outcome projection collapses a ToolResult's content blocks into
the flat ToolOutcome.output: a lone text block becomes a bare string, a lone
json block becomes its raw value, and any mix stays a block sequence. A hard
asyncio.CancelledError is re-raised, never folded into an outcome.
The Fs and Shell seams
Tools receive abstract Fs and Shell Protocols via ToolContext and may
never import pathlib/subprocess themselves — so a host can swap in a
sandboxed, remote, or in-memory backend invisibly. These are the only I/O seams
a tool may use.
| Name | Kind | Source | Purpose |
|---|---|---|---|
Fs |
class | kernel/backends.py |
Protocol: async read_text/read_bytes/write_file/stat/readdir/mkdir/rm/exists — the only filesystem seam. |
Shell |
class | kernel/backends.py |
Protocol: async exec() (blocking, captured) + spawn() (background ShellHandle) — the only command seam. |
FsEntryInfo |
dataclass | kernel/backends.py |
stat() result: is_file/is_directory/is_symlink/size/modified_ms. |
DirChild |
dataclass | kernel/backends.py |
readdir() entry: name + is_directory. |
RemoveOptions |
dataclass | kernel/backends.py |
Fs.rm options: recursive, ignore_missing. |
ShellLaunchOptions |
dataclass | kernel/backends.py |
Shared spawn/exec settings: cwd, env, cancel. |
ShellExecOptions |
dataclass | kernel/backends.py |
exec() extras (subclass): timeout_ms, input. |
ShellExecResult |
dataclass | kernel/backends.py |
Settled exec() result: stdout, stderr, code (None when signalled). |
ShellHandle |
class | kernel/backends.py |
Protocol over a spawned process: pid, kill(signal), on_exit(listener) -> unsubscribe. |
Output budgeting
OutputBudget is a windowing policy that bounds tool output to a number of
UTF-8 bytes so no single tool can flood the model's context window.
| Name | Kind | Source | Purpose |
|---|---|---|---|
OutputBudget |
dataclass | kernel/output.py |
UTF-8 byte windowing policy: kind (head/tail/middle), max_bytes, optional notice callback. |
clamp |
function | kernel/output.py |
Bound text to an OutputBudget on character boundaries, splicing in a notice where bytes were dropped. |
ClipEnd |
type | kernel/output.py |
Literal['head', 'tail', 'middle']. |
clamp measures in UTF-8 bytes and always cuts on a character boundary (backing
off over 0b10xxxxxx continuation bytes), so no multibyte sequence is split.
The default supplied by make_local_context is a 64 KB middle-clamp.
The built-in tools
Eleven capabilities — twelve registered tool objects, because todo is two
tools. Each is a DefinedTool constant ready to register.
| Name | Kind | Source | Purpose |
|---|---|---|---|
read_tool |
const | files/read.py |
Tool read: UTF-8 file contents with a line-number gutter, offset/limit windowing, budget-clamped. |
write_tool |
const | files/write.py |
Tool write: create/overwrite a file (creating parent directories). |
edit_tool |
const | files/edit.py |
Tool edit: replace an exact-or-whitespace-fuzzy snippet, return a unified diff; replaceAll supported. |
ls_tool |
const | files/ls.py |
Tool ls: list directory entries with kind/size columns. |
grep_tool |
const | search/grep.py |
Tool grep: regex content search over a tree, hit-capped and budget-clamped. |
find_tool |
const | search/find.py |
Tool find: filename/glob search with an optional file/dir kind filter. |
bash_tool |
const | shell/bash.py |
Tool bash: run one command to completion via Shell.exec, with timeoutMs (10-min ceiling, 2-min default) and cwd. |
process_tool |
const | shell/process.py |
Tool process: start/list/poll/stop long-lived background jobs whose output is captured to scratch files. |
todo_set_tool |
const | planning/todo.py |
Tool todo_set: replace the whole per-context checklist. |
todo_read_tool |
const | planning/todo.py |
Tool todo_read: play back the stored checklist (read-only). |
web_search_tool |
const | web/websearch.py |
Tool websearch: scrape the DuckDuckGo HTML endpoint via httpx, return ranked title/url/snippet hits. |
web_fetch_tool |
const | web/webfetch.py |
Tool webfetch: httpx GET an http(s) URL, simplify HTML to text, byte-cap the result. |
Supporting state and test helpers:
| Name | Kind | Source | Purpose |
|---|---|---|---|
reset_process_table |
function | shell/process.py |
Clear the global background-job registry (test setup/teardown). |
todo_store |
const | planning/todo.py |
Module-level TodoStore keyed by ToolContext identity. |
TodoStore |
class | planning/todo.py |
In-process per-context checklist store: read/replace/reset. |
TodoItem |
dataclass | planning/todo.py |
One checklist line: id, text, status. |
TodoStatus |
type | planning/todo.py |
Literal['pending', 'active', 'done']. |
reset_todos |
function | planning/todo.py |
Wipe all stored checklists (re-export of todo.reset). |
Stateful tools key off ToolContext identity. todo_store is a dict keyed by
the context object (which is why ToolContext is eq=False), so each
conversation threads a distinct context and gets isolated state. process_tool
uses a module-global process table of job rows, capturing each background job's
stdout/stderr to scratch files under a .indus-process directory and tracking
read offsets so poll returns only new bytes.
Registry and collections
A ToolRegistry is a mutable catalog of tools plus the named collections that
group them. Its payoff is to_tool_box, which turns a collection into a runtime
ToolBox: the descriptors the model is shown together with a runner that
dispatches calls.
| Name | Kind | Source | Purpose |
|---|---|---|---|
ToolRegistry |
class | kernel/registry.py |
Mutable catalog of tools + named collections; to_tool_box(ctx_factory, collection) builds a runtime ToolBox. |
UnknownToolError |
class | kernel/registry.py |
Raised when a tool or collection name is not registered. |
RegistryContextFactory |
type | kernel/registry.py |
Callable[[], ToolContext] supplied per dispatch. |
builtin_registry |
function | registry.py |
Mint a fresh ToolRegistry with all 12 tools registered and the read-only/coding/all collections defined. |
tool_box |
function | registry.py |
One-call helper: produce a runnable ToolBox for a named collection, backed by local fs/shell rooted at cwd. |
ToolCollection |
type | registry.py |
Literal['read-only', 'coding', 'all']. |
The three standard collections:
| Collection | Members |
|---|---|
read-only |
read, ls, grep, find, websearch, webfetch — observe the workspace and the web; safe when no mutation should be possible. |
coding |
read-only plus write, edit, bash, todo_set, todo_read, process. |
all |
The union of every registered tool. |
At dispatch time the registry's runner looks up the tool by call.name, mints a
fresh context via the factory, threads the runtime's per-call cancel token in via
dataclasses.replace, and calls tool.invoke. resolve_path anchors every
tool-supplied path at ctx.cwd before any fs call, so relative paths cannot
escape the sandbox.
Local backends
backends/local.py is the single sanctioned OS binding. Nothing above this
layer touches platform modules directly.
| Name | Kind | Source | Purpose |
|---|---|---|---|
local_fs |
const | backends/local.py |
Shared LocalFs instance backed by os/shutil on worker threads. |
local_shell |
const | backends/local.py |
Shared LocalShell instance backed by asyncio subprocesses + subprocess.Popen for background jobs. |
make_local_context |
function | backends/local.py |
Assemble a ToolContext from a cwd plus local_fs/local_shell, a never-cancelling token, and the 64 KB middle standard budget. |
LocalFs runs os/shutil work on asyncio.to_thread worker threads and
re-shapes OSErrors into stable, libuv-style messages
(ENOENT: no such file or directory, open '/path'). LocalShell.exec uses
asyncio.create_subprocess_shell with start_new_session=True so a kill reaches
the whole process group via os.killpg; LocalShell.spawn uses
subprocess.Popen with a daemon watcher thread.
Diff helpers
edit_tool builds its unified-diff output from a from-scratch Myers line diff,
exported for direct use.
| Name | Kind | Source | Purpose |
|---|---|---|---|
diff_lines |
function | files/diff.py |
Myers O(ND) line diff -> LineDiff (ops + added/removed tallies). |
render_unified_diff |
function | files/diff.py |
Render before/after as a @@-hunk unified diff (used by edit_tool). |
DiffOp |
dataclass | files/diff.py |
One line op: kind, text, before_line, after_line. |
DiffOpKind |
type | files/diff.py |
Literal['keep', 'add', 'remove']. |
LineDiff |
dataclass | files/diff.py |
diff_lines outcome: ops tuple + added/removed counts. |
Using the suite
A host wires up the whole suite with a single tool_box('coding', cwd) call. The
box advertises descriptors (what the model is shown) and dispatches each call
through box.runner.run.
import asyncio
from indusagi.capabilities import tool_box
from indusagi.runtime.contract import ToolCall
from indusagi._internal.cancel import CancelToken
async def main():
box = tool_box("coding", cwd=".")
# Descriptors are what the model is shown:
print([d.name for d in box.descriptors()])
call = ToolCall(id="c1", name="read", input={"path": "README.md", "limit": 20})
outcome = await box.runner.run(call, CancelToken())
print(outcome.is_error, outcome.output[:200])
asyncio.run(main())
To invoke a single tool directly, build a local context and call invoke:
import asyncio
from indusagi.capabilities import edit_tool, make_local_context
from indusagi.runtime.contract import ToolCall
async def main():
ctx = make_local_context(cwd="/tmp/project")
call = ToolCall(id="e1", name="edit", input={
"path": "main.py",
"oldText": "print('hi')",
"newText": "print('hello')",
})
outcome = await edit_tool.invoke(call, ctx)
print(outcome.output) # text summary, or a block list incl. the unified diff
asyncio.run(main())
The diff helpers and clamp are usable without any filesystem:
from indusagi.capabilities import diff_lines, render_unified_diff, clamp, OutputBudget
before = "a\nb\nc\n"
after = "a\nB\nc\nd\n"
result = diff_lines(before, after)
print(result.added, result.removed) # tallies
print(render_unified_diff(before, after)) # @@ ... @@ / + / - text
big = "x" * 100_000
out = clamp(big, OutputBudget(kind="middle", max_bytes=64 * 1024))
print(len(out.encode("utf-8"))) # bounded; a notice is spliced at the cut
Authoring a custom tool
Write only an async run; define_tool supplies descriptor, invocation,
cancellation, and error-folding. Register the result and box a collection over
it.
import asyncio
from indusagi.capabilities import (
define_tool, ToolSpec, ToolResult, TextContentBlock,
ToolRegistry, make_local_context,
)
async def run(inp, ctx):
name = inp.get("name", "world")
return ToolResult(content=(TextContentBlock(text=f"hello {name}"),))
greet = define_tool(ToolSpec(
name="greet",
description="Say hello to someone.",
parameters={"type": "object", "properties": {"name": {"type": "string"}}},
run=run,
))
reg = ToolRegistry().register(greet)
reg.collection("mine", ["greet"])
box = reg.to_tool_box(lambda: make_local_context("."), "mine")
print([d.name for d in box.descriptors()]) # ['greet']
Because DefinedTool also carries a facade-compatible surface
(label/description/parameters + an execute() entry point), the same
greet object drops straight into the high-level Agent
via set_tools([greet]).
Relationship to neighbors
Capabilities sits between two outward contracts it imports rather than redefines:
- The Runtime's
ToolCall/ToolOutcome/ToolBox/ToolRunner.DefinedTool.invoke()produces the runtime's outcome;ToolRegistry.to_tool_boxreturns a runtimeToolBox. - The LLM Gateway's
ToolDescriptor/JsonSchema.DefinedTool.descriptor()produces the gateway's model-facing advertisement.
It also depends on indusagi._internal.cancel (CancelToken) for cooperative
cancellation. Upward, the facade surface lets a defined tool drop into
Agent. For the bigger picture of how these layers
compose, see the Architecture overview.
Notable behaviors
make_contextis a stub.kernel/context.py'smake_contextintentionally raisesNotImplementedError— it is importable for typing but never callable; callers must usemake_local_context.- Eleven capabilities, twelve tool objects. The
todocapability is two tools (todo_set+todo_read); the suite's "eleven" count treats todo as one capability. resolve_pathenforces the sandbox. It anchors every tool-supplied path atctx.cwd(absolute paths pass through) before any fs call, so relative paths cannot escape the working directory. Output and error text still echo the model's spelling of the path.- Stable filesystem error text.
LocalFsreshapesOSErrors into libuv-style strings (ENOENT: no such file or directory, open '/path'); anEISDIR-on-read produces a path-less message on purpose. edit_toolmatching. Strict literal matching first (must be unique unlessreplaceAll), then a single whitespace-fuzzy fallback via canonicalized whitespace comparison; identicaloldText/newTextor zero-change results are refused.bash_tooltimeout.timeoutMsis pinned to a 10-minute ceiling and defaults to 2 minutes.websearchnever raises. It scrapes the DuckDuckGo HTML endpoint with regexes via httpx (no parser dependency, no API key); every network failure is returned as anis_errorToolResult, never raised.processtable is process-global on purpose. A job started in one call must be pollable later; captures are written to a.indus-processscratch dir undercwdwith a tail window per poll.grep_toolflags. It accepts JS-style flag lettersg/i/m/s/u/y;gandyare accepted-but-noops.
