Agent Facade
indusagi.agentis the high-level, batteries-included entry point for driving a coding agent: pick a model, attach tools, callawait agent.prompt(...). Imported asfrom indusagi.agent import Agent, create_coding_tools. It is a thin shim over the runtime core — it owns no conversation loop of its own.
The package wraps the green runtime (Runtime) in a
friendly, mutable surface: the Agent class, the create_*_tool factories over
the Capabilities built-ins, append-only JSONL
session trees, and a runtime registry for app-defined message kinds. The message
and content models themselves (UserMessage, AssistantMessage, TextContent,
…) live in AI Facade and are consumed here but not
re-exported.
Table of Contents
- Public exports
- The Agent class
- AgentState and AgentOptions
- Prompt, steer, and follow up
- Events
- Tool wiring
- Session persistence
- The message-kind registry
- Relationship to neighbors
Public exports
From indusagi/agent/__init__.py:
| Name | Kind | Source | Purpose |
|---|---|---|---|
Agent |
class | agent.py |
High-level agent: set_model/set_tools/set_system_prompt, then async prompt/continue_/steer/follow_up/wait_for_idle/abort; subscribe() to events; mutable .state |
AgentState |
dataclass | agent.py |
Mutable runtime state the UI watches: systemPrompt, model, thinkingLevel, tools, messages, isStreaming, streamMessage, pendingToolCalls, error |
AgentOptions |
TypedDict | agent.py |
Optional constructor kwargs (initial_state, convert_to_llm, transform_context, steering_mode, follow_up_mode, stream_fn, session_id, get_api_key, thinking_budgets) |
AgentTool |
Protocol | agent.py |
runtime_checkable structural protocol: name/label/description/parameters + execute(...) |
AgentToolResult |
dataclass | agent.py |
Frozen result of a tool execute(): content tuple, details, isError |
AgentEvent |
type | agent.py |
Union of the 10 streamed event dataclasses |
AGENT_EVENT_GROUPS |
const | agent.py |
Dict grouping event-type strings into agent/turn/message/toolExecution buckets |
ThinkingLevel / ThinkingBudgets / QueueMode / StreamFn |
type | agent.py |
Vocabulary aliases: reasoning tier, per-level token budgets, queue release mode, swappable stream entry point |
validate_message / is_user_message / is_thinking / is_tool_call |
function | agent.py |
Runtime shape guards over duck-typed messages and blocks |
SessionManager |
class | sessions.py |
Append-only JSONL session tree |
SessionContext / SessionInfo / SessionTreeNode |
dataclass | sessions.py |
Recovered model-facing context, per-file summary, and a node in the tree |
build_session_context / find_most_recent_session / parse_session_entries / migrate_session_entries / load_entries_from_file / get_latest_compaction_entry |
function | sessions.py |
Free functions for parsing/migrating JSONL logs and rebuilding the context |
CURRENT_SESSION_VERSION |
const | sessions.py |
int 3 — the on-disk session schema version (v1/v2 migrated in place) |
register_message_type / unregister_message_type / get_message_type / registered_message_types |
function | registry.py |
Runtime registry mapping a role tag to its class |
convert_to_llm |
function | registry.py |
Reduce an AgentMessage list to the plain user/assistant/toolResult messages the model accepts |
BashExecutionMessage / CustomMessage / BranchSummaryMessage / CompactionSummaryMessage |
dataclass | registry.py |
The four built-in session-only message kinds, pre-registered at import |
create_coding_tools / create_read_only_tools / create_all_tools |
function | tools.py |
Build cwd-bound tool collections |
create_read_tool / create_bash_tool / create_edit_tool / create_write_tool / create_grep_tool / create_find_tool / create_ls_tool / create_process_tool / create_todo_read_tool / create_todo_write_tool / create_web_search_tool / create_web_fetch_tool |
function | tools.py |
1:1 factories binding a built-in to a working dir, returning a FacadeAgentTool |
FacadeAgentTool |
class | tools.py |
One capabilities tool wearing the facade AgentTool surface |
ToolRegistry / ToolMetadata / TOOL_METADATA / create_tool_registry |
class/const | tools.py |
Name→factory registry, its metadata record, the static map, and the cwd-bound builder |
coding_tools / read_only_tools / all_tools + read_tool/bash_tool/… |
const | tools.py |
Module-level FacadeAgentTool singletons bound to the process cwd, plus curated lists |
The Agent class
Construct an Agent with no arguments, a keyword bag, or an AgentOptions
mapping (the two merge — kwargs win). Configuration is set with snake_case
setters, then a run is started with await agent.prompt(...):
import asyncio
from indusagi.agent import Agent, create_coding_tools
async def main():
agent = Agent()
agent.set_system_prompt("You are a helpful coding assistant.")
agent.set_tools(create_coding_tools("/path/to/project"))
unsubscribe = agent.subscribe(lambda ev: print(ev.type))
await agent.prompt("explain this repository")
unsubscribe()
for msg in agent.state.messages:
print(getattr(msg, "role", None))
asyncio.run(main())
On construction the model defaults to {provider: "google", id: "gemini-2.5-flash"}
(resolved against the indusagi.ai catalog; None if unavailable). Setters:
| Setter | Effect |
|---|---|
set_model(m) |
The model to invoke (a model card or a bare id string). prompt() fails if None. |
set_system_prompt(v) |
The system instruction passed to the core config. |
set_thinking_level(level) |
A ThinkingLevel tier (see below). |
set_tools(tools) |
The list of AgentTools exposed to the model. |
set_steering_mode(mode) / set_follow_up_mode(mode) |
Queue release: "all" or "one-at-a-time". |
replace_messages(...) / append_message(...) / clear_messages() |
Edit conversation history; appends replace the list reference so watchers see fresh objects. |
ThinkingLevel carries the tiers "off", "minimal", "low", "medium",
"high", "xhigh". The shim folds them onto the core's reasoning levels:
"off" → None, "minimal" → "low", "xhigh" → "max", the rest pass
through unchanged.
AgentState and AgentOptions
agent.state is a live, mutable AgentState dataclass with TS-style camelCase
fields, intended to be watched by a UI:
| Field | Meaning |
|---|---|
systemPrompt / model / thinkingLevel / tools |
Current configuration. |
messages |
The conversation so far (AgentMessage objects). |
isStreaming |
True while a run is in flight. |
streamMessage |
The live partial assistant message during streaming, else None. |
pendingToolCalls |
Set of tool-call ids currently executing. |
error |
The last run failure string, or None. |
AgentOptions keys (all optional; equally valid as keyword arguments):
| Key | Purpose |
|---|---|
initial_state |
A mapping or AgentState to seed state from. |
convert_to_llm |
Override the default history → model-message reduction. |
transform_context |
Hook to rewrite history before a batch is submitted. |
steering_mode / follow_up_mode |
Default QueueMode for each queue (defaults to "one-at-a-time"). |
stream_fn |
Stored for API parity; the real model seam is the core gateway (see notes). |
session_id |
Forwarded for provider-side caching. |
get_api_key |
Callable[[provider], key] spliced into the model invocation per run. |
thinking_budgets |
A ThinkingBudgets TypedDict of per-level token allowances. |
Prompt, steer, and follow up
prompt() accepts raw text (optionally with images), a single AgentMessage, or
a list of messages. It validates that no run is already streaming and that a
model is selected, then drives the run to completion before returning:
import asyncio
from indusagi.agent import Agent, create_read_only_tools
async def main():
agent = Agent(steering_mode="one-at-a-time", follow_up_mode="all")
agent.set_tools(create_read_only_tools("."))
agent.set_thinking_level("medium")
task = asyncio.create_task(agent.prompt("survey the codebase"))
agent.steer("focus on the agent package") # folds into the live run
agent.follow_up("now summarize what you found") # held until idle
await task
await agent.wait_for_idle()
asyncio.run(main())
| Method | Semantics |
|---|---|
await prompt(input, images=None) |
Start a new run; resolves when the run settles. |
await continue_() |
Resume the existing conversation (e.g. retry after a context overflow). Named continue_ because continue is a Python keyword. |
steer(message) |
Enqueue a redirection that folds into the live core run at the next safe step. |
follow_up(message) |
Enqueue a message held back until the agent goes idle, then drained in the post-settle loop. |
await wait_for_idle() |
Resolve once no run is in flight. |
abort() |
Tear down the current run. |
reset() |
Clear conversation data and queues (configuration is kept). |
clear_steering_queue() / clear_follow_up_queue() / clear_all_queues() |
Drop queued messages. |
Internally prompt() resolves the input into messages, builds a core
AgentConfig (model id, system prompt, mapped thinking level, and a tool box
adapting the facade tools onto the core ToolRunner), then calls
indusagi.runtime.create_agent. An invoke_model closure reaches
indusagi.llmgateway.stream, splicing in any per-provider key from
get_api_key. The core's run event stream is translated back into the facade
event vocabulary. If a run faults, a zeroed-usage error assistant message is
synthesized (stopReason "aborted" or "error") and a non-empty trailing
partial may be salvaged.
A missing tool yields the verbatim text No registered tool named "<name>".;
exceptions raised by a tool are wrapped with the prefix
[agent:tool_execution:<name>]. A faulty event listener cannot sink the run —
exceptions in listeners are swallowed (but CancelledError propagates).
Events
subscribe(fn) registers an event listener and returns an unsubscribe disposer.
Listeners receive AgentEvents, each a frozen dataclass with a type discriminator:
| Group | Events | Carries |
|---|---|---|
agent |
agent_start, agent_end |
agent_end.messages — the run's produced messages |
turn |
turn_start, turn_end |
turn_end.message + turn_end.toolResults |
message |
message_start, message_update, message_end |
the live/finished message; message_update.assistantMessageEvent carries the originating delta |
toolExecution |
tool_execution_start, tool_execution_update, tool_execution_end |
toolCallId, toolName, args, result/partialResult, isError |
AGENT_EVENT_GROUPS is the dict mapping each group name to its event-type
strings, handy for filtering or logging. The translator maintains the live
partial assistant message, per-turn tool results, and turn framing, appending
finished messages onto state.messages as it goes.
Tool wiring
A tool is anything satisfying the AgentTool protocol — name, label,
description, parameters, and an async execute(tool_call_id, params, signal, on_update) returning an AgentToolResult. The create_*_tool factories bind a
Capabilities built-in to a working directory
and return a FacadeAgentTool, which mints a fresh local tool context per call
(or pins a shared one for the todo pair).
from indusagi.agent import (
create_coding_tools,
create_read_only_tools,
create_read_tool,
create_bash_tool,
)
# Curated collections bound to a cwd:
coding = create_coding_tools("/path/to/project") # read/bash/edit/write/process/todo*/web*
readonly = create_read_only_tools("/path/to/project") # read/grep/find/ls/todoread/web*
# Or wire individual tools:
agent_tools = [create_read_tool("/repo"), create_bash_tool("/repo")]
The twelve factories map 1:1 onto built-ins: read, bash, edit, write,
grep, find, ls, process, todo_read / todo_set (the create_todo_*
pair), websearch, webfetch. Pass the same todo_store token to both
create_todo_read_tool and create_todo_write_tool so the pair shares one
checklist — the store is keyed by tool-context identity. Per-factory options
bags are accepted for signature parity but mostly ignored, since the core tools
carry their own defaults.
create_tool_registry(cwd) returns a ToolRegistry with all twelve built-ins
registered; TOOL_METADATA is the static name → ToolMetadata map (each with a
ToolCategory). Module-level singletons (read_tool, bash_tool, …) and the
coding_tools / read_only_tools lists plus the all_tools dict are bound to
the process working directory. The Composio tool family is excluded here; it
lives behind Connectors.
Session persistence
SessionManager stores each conversation as one append-only JSONL file. The
first line is a {"type": "session", "version": 3, ...} header; every subsequent
record carries an id and parentId, so the records form a tree. A leaf pointer
marks "now": appends hang a child off the leaf, and branching moves the leaf back
— history is never rewritten on branch.
from indusagi.agent import SessionManager
sm = SessionManager.create("/path/to/project")
uid = sm.append_message({"role": "user", "content": "hello"})
sm.append_model_change("google", "gemini-2.5-flash")
sm.append_message({"role": "assistant", "content": [{"type": "text", "text": "hi"}]})
ctx = sm.build_session_context() # SessionContext(messages, thinkingLevel, model)
print(len(ctx.messages), ctx.model)
# Resume the most recent session for this cwd later:
resumed = SessionManager.continue_recent("/path/to/project")
print(resumed.get_session_id())
Static factories: create(cwd), open(path), continue_recent(cwd),
in_memory(cwd), fork_from(...), plus the async list(...) / list_all(...)
scanners. Append methods include append_message, append_compaction,
append_model_change, append_thinking_level_change, branch_with_summary,
append_custom_entry, and append_label_change. Navigation: build_session_context(),
get_tree(), get_branch(), get_leaf_id(), get_entry(), get_children().
build_session_context() walks parent links root-to-leaf, applies the latest
compaction (a CompactionSummaryMessage recap, then the retained tail from
firstKeptEntryId, then everything after), and projects records into
model-facing messages — reconstructing session-only kinds through the registry
constructors. It returns a SessionContext(messages, thinkingLevel, model).
Key behaviors:
- Records are kept as plain dicts in the exact TS JSON shape (camelCase, never re-modelled), so unknown or extension fields survive load → migrate → rewrite losslessly.
CURRENT_SESSION_VERSIONis3. v1→v2 adds theid/parentIdtree shape (and convertsfirstKeptEntryIndex→firstKeptEntryId); v2→v3 relabels"hookMessage"→"custom". Damaged JSONL lines are silently skipped.- The default directory is
~/.pindusagi/agent/sessions/<encoded-cwd>/, overridable viaINDUSAGI_DIR,INDUSAGI_AGENT_DIR, orINDUSAGI_CODING_AGENT_DIR. Persistence is deferred until at least one assistant message exists, so empty or aborted sessions leave no file.
The message-kind registry
register_message_type is the runtime replacement for static declaration-merging:
it maps a role tag to the class that carries it so renderers and session loaders
recognize app-defined and built-in kinds. It works directly or as a class
decorator:
from dataclasses import dataclass
from typing import ClassVar, Literal
from indusagi.agent import register_message_type, convert_to_llm
@register_message_type("reminder")
@dataclass(frozen=True)
class ReminderMessage:
role: ClassVar[Literal["reminder"]] = "reminder"
content: str
timestamp: int
# Unregistered/unknown roles are dropped by convert_to_llm; the built-in
# session kinds (bashExecution/custom/branchSummary/compactionSummary) are
# translated into user messages for the model.
model_msgs = convert_to_llm([ReminderMessage(content="check tests", timestamp=0)])
print(model_msgs) # [] — 'reminder' has no convert_to_llm handler
The four built-in session-only kinds — BashExecutionMessage, CustomMessage,
BranchSummaryMessage, CompactionSummaryMessage — are frozen dataclasses with
camelCase fields (exitCode, customType, fromId, tokensBefore) and are
pre-registered at import. convert_to_llm reduces a full AgentMessage list to
the plain user/assistant/toolResult messages the model accepts:
user/assistant/toolResult pass through; bashExecution is restated as user
text (respecting the excludeFromContext opt-out); custom becomes a user
message; the two summary kinds are wrapped in their delimiters and presented as
user messages; everything else is dropped. The constructors
(create_branch_summary_message, create_compaction_summary_message,
create_custom_message, bash_execution_to_text) rebuild typed messages from
saved ISO-string entry fields, and get_field reads a field off either a
dataclass or a raw JSON mapping.
Relationship to neighbors
The facade sits one layer above the core. It depends on the
Runtime (create_agent, AgentDeps,
AgentConfig, RunSnapshot, RunEvent, ToolCall, ToolOutcome) for the
conversation loop, the LLM Gateway (stream
plus the contract Turn/Block/ToolDescriptor/StreamOptions types) for the
model seam, and Capabilities (DefinedTool,
ToolContext, make_local_context, and the twelve built-in tools) which
tools.py wraps 1:1. The message/content vocabulary is imported from the
AI Facade and consumed but not re-exported. The
MCP and Memory facades are
sibling public surfaces, and TUI/CLI layers (TUI,
CLI) build on the Agent.
A few documented shim deviations preserve the observable surface while delegating real work to the core:
convert_to_llm/transform_contextrun once per submitted batch, not before every internal model call — the core owns intra-run history.stream_fnis accepted and stored only for API parity; swapping transports happens at the core gateway's model invoker.- Steering messages are framed (
message_start/message_end) when folded into the live run, not exactly at the nextturn_start.
Back to the Architecture overview.
