Avo API Reference¶
Package: avo Version: 0.1.7 (src/avo/__init__.py: __version__ = "0.1.7")
Python: >=3.11 Core dependency: pydantic>=2.8,<3 (only one)
Stable ABI target: 0.2.0 (_STABLE_ABI in src/avo/__init__.py)
This document is the complete, verbatim API reference for the avo
agent runtime. Every signature, field, enum member, env var, and error
string below was read from source under src/avo/. When this document
disagrees with another doc, this document wins; when this document
disagrees with the code, the code wins — fix the document.
CLAUDE.md cites this file by section number (e.g. "reference §3.1", "§5", "§6", "§9", "§19", "§22"). Do not renumber existing sections.
Contents¶
- Overview and architecture
- Public API surface
- Core contracts
- Core data models
- State machine
- Event store and event invariants
- Providers
- AgentRuntime
- Tools: registry, schemas, fingerprints
- Application tools and the sandbox
- Storage
- Configuration and environment variables
- MCP server and client
- CLI
- Native extension (
avo_native) - Reliability primitives
- Usage, cost, ledger, compaction, context
- Extensibility: hooks, permissions, skills, plugins, subagents
- Design principles
- Observability
- Integrations, chat REPL, web UI
- Quality gates
- Conventions and anti-patterns
1. Overview and architecture¶
Avo is an event-sourced agent runtime: every state change of a run
is appended to an immutable event log, durable checkpoints allow
crash-safe resume, and policies (step caps, token budgets, loop
detectors, circuit breakers) bound runaway loops with deterministic
StopReasons.
flowchart TD
task(["task"]) --> RT["AgentRuntime<br/>run() / resume()<br/><i>one asyncio.Lock per runtime</i>"]
RT --> MP["ModelProvider<br/>fake, anthropic, openai,<br/>ollama, groq, ..."]
RT --> LP["LoopPolicy<br/>steps, tokens, timeouts,<br/>circuit breaker"]
RT --> ES["EventStore<br/>SQLite / InMemory"]
RT --> TR["ToolRegistry<br/>FunctionTools, approval"]
RT --> PD["ProgressDetector<br/>repeated action, no progress"]
MP --> HTTP["HTTP<br/>(httpx, [providers] extra)"]
TR --> SB["sandbox<br/>(docker-py, [sandbox] extra)"]
Message flow for one step:
MODEL_PENDING— runtime builds aModelRequest, callsprovider.generate()(orstream()when the provider supports it and astream_callbackis set).DECISION_RECEIVED— response iscontent(final answer) or atool_call.TOOL_PENDING→APPROVAL_PENDING→TOOL_EXECUTING— approval goes throughapproval_callback; execution goes throughToolRegistry.invoke().OBSERVATION_RECORDED— theToolResultis appended as arole: "tool"message; loop detectors record the observation.- Back to
MODEL_PENDINGuntil a terminal state or a policy stop.
Each of those transitions is a STATE_CHANGED event, and a Checkpoint
is written per step (or at least after every model decision and every
tool result, depending on LoopPolicy.checkpoint_every_step).
Key modules and their one-line responsibility (first docstring line):
| Module | Responsibility |
|---|---|
avo/models.py |
Pydantic domain models shared across the runtime |
avo/state.py |
RunState / StopReason enums and the transition table |
avo/events.py |
AgentEvent, EventType, append-time invariants |
avo/policies.py |
LoopPolicy bounds (steps, tokens, runtime, loops) |
avo/tools.py |
FunctionTool, ToolRegistry, schema helpers |
avo/runtime.py |
AgentRuntime.run() / resume() / inspect() |
avo/runtime_handlers.py |
Per-state handler functions (the loop body) |
avo/runtime_persistence.py |
Event append, transition, checkpoint, terminate helpers |
avo/providers/ |
Model adapters (ModelProvider implementations) |
avo/savers/ |
Opt-in deterministic request compression and terse-output presets |
avo/storage/ |
EventStore implementations (SQLite, in-memory) |
avo/config.py |
Env-driven provider construction (build_provider_from_env) |
avo/app_tools/ |
Workspace-bounded file/shell/git tools + Docker sandbox |
avo/mcp.py, avo/mcp_server/, avo/mcp_servers/ |
MCP client, stdio server, built-in servers |
avo/cli.py (+ cli_*.py) |
The avo console script |
avo/_native.py, native/ |
Optional Rust accelerator (fallback mandatory) |
2. Public API surface¶
src/avo/__init__.py re-exports exactly __all__ (stable public API;
everything else is internal and may change without notice):
__all__ = [
"AgentEvent", "AgentRuntime", "Checkpoint", "DeprecatedSymbol",
"EventType", "FakeProvider", "FunctionTool", "LetheMemoryAdapter",
"LoopPolicy", "MemoryProvider", "ModelRequest", "ModelResponse",
"OtelDisabledError", "ProgressDetector", "RunRecord", "RunResult",
"RunState", "RunTrace", "ScriptItem", "StopReason", "TokenUsage",
"Tool", "ToolCall", "ToolMetadata", "ToolRegistry", "ToolResult",
"TraceEntry", "TraceInspector",
"configure_tracer", "deprecated", "deprecation_index", "is_enabled",
"span_for_turn", "to_anthropic_tool", "to_json_schema",
"to_openai_function",
]
Canonical minimal usage (what all tests do):
from avo import (
AgentRuntime, FakeProvider, FunctionTool, LoopPolicy, ModelResponse,
ToolRegistry,
)
provider = FakeProvider([ModelResponse(content="done")])
runtime = AgentRuntime(provider=provider, tools=[], policy=LoopPolicy(max_steps=5))
result = await runtime.run("say done")
assert result.status.value == "completed"
avo.runtime additionally exposes AgentRuntime, ApprovalCallback,
Clock, and the internal _RunContext (underscore-prefixed; not for
external use). Provider protocol types (ModelProvider,
StatefulModelProvider, StreamingModelProvider) are importable from
avo.providers / avo.providers.base but are not in the top-level
__all__.
3. Core contracts¶
These three contracts are the extension points. CLAUDE.md's rules reference §3.1 (FunctionTool) and §3.3 (LoopPolicy timeout).
3.1 FunctionTool (src/avo/tools.py)¶
ToolCallable = Callable[[ArgumentsT], JsonValue | Awaitable[JsonValue]]
class FunctionTool(Generic[ArgumentsT]):
def __init__(
self,
*,
name: str,
description: str,
arguments_model: type[ArgumentsT],
function: ToolCallable[ArgumentsT],
) -> None: ...
- All four parameters are keyword-only.
arguments_modelmust be a PydanticBaseModelsubclass;metadatadeep-copies the JSON schema viato_json_schema(model).async invoke(arguments)behavior:model_validatefailure raisesToolValidationError.- Any exception from
functionis wrapped:ToolExecutionError(f"Tool {name!r} raised {type(exc).__name__}: {exc}"). - The returned object is JSON-adapted before becoming output.
- The
Toolprotocol (what the registry accepts):
@runtime_checkable
class Tool(Protocol):
@property
def metadata(self) -> ToolMetadata: ...
async def invoke(self, arguments: Mapping[str, JsonValue]) -> JsonValue: ...
Contract: your function returns a plain object (usually dict).
Never return both success and error; never wrap into ToolResult
yourself — the registry does that (§3.2).
3.2 ToolRegistry (src/avo/tools.py)¶
class ToolRegistry:
def __init__(self, tools: Iterable[Tool] = ()) -> None: ...
def register(self, item: Tool) -> Tool: ... # DuplicateToolError on name clash
@property
def metadata(self) -> list[ToolMetadata]: ... # registration order
def get(self, name: str) -> Tool: ... # ToolNotFoundError if absent
async def invoke(
self,
call: ToolCall,
*,
completed_tool_call_ids: set[str], # REQUIRED keyword — no default
) -> ToolResult: ...
invoke() semantics, verbatim from source:
- If
call.tool_call_id in completed_tool_call_ids→ raiseToolAlreadyCompletedError(idempotency guard; do not pass a mutable global — the runtime owns the set per run). ToolNotFoundError,ToolValidationError,ToolExecutionErrorare caught and returned asToolResult(success=False, error=str(exc)); anything else propagates.- Success path returns
ToolResult(success=True, output=...)withduration_msmeasured viatime.perf_counter.
Note:
docs/api-stability.mdshows a default value forcompleted_tool_call_ids. That is stale — source has no default and callers must pass the set explicitly.
3.3 LoopPolicy (src/avo/policies.py)¶
class LoopPolicy(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
max_steps: int = 20 # gt=0
max_runtime_seconds: float | None = 300 # gt=0
max_input_tokens: int | None = None
max_output_tokens: int | None = None
max_total_tokens: int | None = None
repeated_action_limit: int = 3
consecutive_error_limit: int = 3
no_progress_window: int = 5
checkpoint_every_step: bool = True
provider_timeout_seconds: float = 60
tool_timeout_seconds: float = 60 # CLAUDE.md rule 3: the ONLY tool timeout
circuit_breaker: CircuitBreakerPolicy | None = None
Methods:
def token_budget_reason(self, usage: TokenUsage, *, accounting_available: bool) -> StopReason | None
def runtime_reason(self, elapsed_seconds: float) -> StopReason | None # fires on >= max_runtime_seconds
extra="forbid" + frozen=True: no unknown keys, no mutation.
PermissionPolicy is not here — it lives in avo/permissions.py
(§18).
4. Core data models (src/avo/models.py)¶
All models derive from:
Helpers: new_id() -> str (UUID4 string) and utc_now() -> datetime
(timezone-aware UTC). Every datetime field is validated by
_require_aware — naive datetimes are rejected.
TokenUsage¶
TokenUsage(input_tokens: int = 0, output_tokens: int = 0) # both ge=0
.total_tokens # property, input + output
.plus(other) # returns a new TokenUsage
ToolMetadata¶
ToolMetadata(
name: str, # min_length=1
description: str, # min_length=1
input_schema: dict[str, JsonValue],
)
ToolCall¶
ToolCall(
tool_call_id: str = Field(default_factory=new_id),
name: str,
arguments: dict[str, JsonValue] = Field(default_factory=dict),
)
ToolResult¶
ToolResult(
tool_call_id: str,
tool_name: str,
success: bool,
output: JsonValue | None = None,
error: str | None = None,
started_at: datetime,
finished_at: datetime,
duration_ms: float = 0, # ge=0
)
Validation (validate_success_payload), exact messages:
success=Trueanderrorset →ValueError("a successful tool result cannot contain an error")success=Falsewithouterror→ValueError("a failed tool result must contain an error")finished_at < started_at→ error.
ModelRequest / ModelResponse¶
ModelRequest(
request_id: str,
run_id: str, # min_length=1
step: int, # ge=1
messages: list[dict[str, JsonValue]],
tools: list[ToolMetadata] = [],
cache: bool = False,
cache_prefix_messages: int | None = None, # ge=0
)
ModelResponse(
response_id: str,
content: str | None = None,
tool_call: ToolCall | None = None,
usage: TokenUsage | None = None,
)
ModelResponse enforces exactly one of content / tool_call:
"model response must contain exactly one of content or tool_call".
is_final is True for plain content responses.
RunRecord, Checkpoint, RunResult¶
RunRecord(
run_id: str,
task: str, # min_length=1
state: RunState = RunState.CREATED,
stop_reason: StopReason | None = None,
output: str | None = None,
error: str | None = None,
steps: int = 0,
token_usage: TokenUsage = TokenUsage(),
token_accounting_available: bool = True,
user_state: dict[str, JsonValue] = {},
created_at: datetime,
updated_at: datetime,
duration_seconds: float | None = None,
) # validator: terminal state / stop_reason must agree (§5)
Checkpoint(
checkpoint_id: str,
run_id: str,
created_at: datetime,
state: RunState,
messages: list[dict[str, JsonValue]],
next_step: int, # ge=1
token_usage: TokenUsage,
token_accounting_available: bool,
consecutive_errors: int,
repeated_action_history: list[str],
observation_fingerprints: list[str],
model_response_fingerprints: list[str],
progress_markers: list[str],
completed_tool_call_ids: set[str],
user_state: dict[str, JsonValue],
policy: dict[str, Any],
provider_metadata: dict[str, JsonValue],
pending_response: ModelResponse | None,
last_event_sequence: int = 0,
)
RunResult(
run_id: str,
status: RunState,
stop_reason: StopReason | None,
output: str | None,
error: str | None,
steps: int,
token_usage: TokenUsage,
token_accounting_available: bool,
) # what run()/resume() return
Exceptions (src/avo/exceptions.py)¶
AvoError(Exception)
├── InvalidStateTransitionError
├── RunNotFoundError
├── RunAlreadyTerminalError
├── CheckpointNotFoundError
├── UnsafeResumeError
├── DuplicateToolError
├── ToolNotFoundError
├── ToolValidationError
├── ToolExecutionError # aliases: NotifierError, SandboxError, MCPError, SkillError
├── ToolAlreadyCompletedError
├── StorageError
│ └── EventInvariantError
└── ProviderError(message, *, retryable: bool = True) # attribute .retryable
└── FakeProviderExhaustedError # retryable=False
ConfigError(ValueError), HistoryError, McpCliError, AuthError,
AuditError, MetricsError, PluginError(=AvoError),
ConcurrencyError, RateLimitError(=AvoError), CheckpointError(=AvoError),
SchemaError(=AvoError), ValidationFailure(=AvoError),
WorkspacePathError(AvoError), SubAgentError(=AvoError) are module
local. EditFileError and WorkspaceNotBoundError /
SandboxNotBoundError are RuntimeError subclasses.
5. State machine (src/avo/state.py)¶
RunState (StrEnum — 12 members)¶
| Member | Value | Kind |
|---|---|---|
CREATED |
"created" |
initial |
MODEL_PENDING |
"model_pending" |
working |
DECISION_RECEIVED |
"decision_received" |
working |
TOOL_PENDING |
"tool_pending" |
working |
APPROVAL_PENDING |
"approval_pending" |
waiting |
TOOL_EXECUTING |
"tool_executing" |
working |
OBSERVATION_RECORDED |
"observation_recorded" |
working |
PAUSED |
"paused" |
resumable |
COMPLETED |
"completed" |
terminal |
FAILED |
"failed" |
terminal |
STOPPED |
"stopped" |
terminal |
CANCELLED |
"cancelled" |
terminal |
TERMINAL_STATES = {COMPLETED, FAILED, STOPPED, CANCELLED}.
docs/api-stability.mdlistsINIT / RUNNING / WAITING_TOOL / WAITING_APPROVAL / COMPACTING— those values do not exist in source. The table above is authoritative.
Transition table (_ALLOWED_TRANSITIONS)¶
| From | Allowed targets |
|---|---|
CREATED |
MODEL_PENDING, PAUSED, FAILED, CANCELLED |
MODEL_PENDING |
DECISION_RECEIVED, PAUSED, FAILED, STOPPED, CANCELLED |
DECISION_RECEIVED |
TOOL_PENDING, COMPLETED, PAUSED, FAILED, STOPPED, CANCELLED |
TOOL_PENDING |
APPROVAL_PENDING, PAUSED, FAILED, STOPPED, CANCELLED |
APPROVAL_PENDING |
TOOL_EXECUTING, PAUSED, FAILED, STOPPED, CANCELLED |
TOOL_EXECUTING |
OBSERVATION_RECORDED, PAUSED, FAILED, STOPPED, CANCELLED |
OBSERVATION_RECORDED |
MODEL_PENDING, PAUSED, FAILED, STOPPED, CANCELLED |
PAUSED |
every non-terminal state except PAUSED, plus FAILED, STOPPED, CANCELLED |
| terminals | (none) |
Violations raise InvalidStateTransitionError.
StopReason (StrEnum — 13 members) and state agreement¶
Members: COMPLETED, MAX_STEPS, MAX_RUNTIME, TOKEN_BUDGET_EXCEEDED,
REPEATED_ACTION, NO_PROGRESS, CONSECUTIVE_ERRORS, POLICY_DENIED,
USER_CANCELLED, PROVIDER_ERROR, TOOL_ERROR, INVALID_MODEL_RESPONSE,
INTERNAL_ERROR.
_STOP_REASONS_BY_STATE (validated by validate_terminal_outcome):
| Terminal state | Permitted stop reasons |
|---|---|
COMPLETED |
COMPLETED |
STOPPED |
MAX_STEPS, MAX_RUNTIME, TOKEN_BUDGET_EXCEEDED, REPEATED_ACTION, NO_PROGRESS, CONSECUTIVE_ERRORS, POLICY_DENIED |
FAILED |
PROVIDER_ERROR, TOOL_ERROR, INVALID_MODEL_RESPONSE, INTERNAL_ERROR |
CANCELLED |
USER_CANCELLED |
Functions: is_terminal(state) -> bool,
validate_transition(current, target), and
validate_terminal_outcome(state, reason).
6. Event store and event invariants (src/avo/events.py)¶
AgentEvent¶
AgentEvent(
event_id: str,
run_id: str,
sequence: int = 0, # ge=0; assigned by the store
event_type: EventType,
created_at: datetime,
payload: dict[str, JsonValue] = {},
parent_event_id: str | None = None,
)
EventType (StrEnum — 20 members)¶
RUN_CREATED, STATE_CHANGED, MODEL_REQUESTED, MODEL_RESPONDED,
MODEL_FAILED, ROUTE_FAILOVER, SAVER_APPLIED, TOOL_REQUESTED, TOOL_APPROVAL_REQUESTED,
TOOL_APPROVED, TOOL_DENIED, TOOL_STARTED, TOOL_COMPLETED,
TOOL_FAILED, POLICY_TRIGGERED, CHECKPOINT_CREATED, RUN_RESUMED,
RUN_COMPLETED, RUN_FAILED, RUN_STOPPED, RUN_CANCELLED.
TERMINAL_EVENT_TYPES = {RUN_COMPLETED, RUN_FAILED, RUN_STOPPED,
RUN_CANCELLED}.
Standard payloads (as emitted by runtime_persistence /
runtime_handlers):
| Event | Payload keys |
|---|---|
RUN_CREATED |
task, policy |
STATE_CHANGED |
from_state, to_state |
MODEL_RESPONDED |
step, response, duration_ms, token_accounting_available |
MODEL_FAILED |
includes error_type, consecutive_errors |
SAVER_APPLIED |
preset, stages_applied, tokens_before, tokens_after, saved_percent, message_count, addendum |
TOOL_APPROVAL_REQUESTED |
tool_call_id, idempotency_key, name, mode: "v0.1_callback" |
TOOL_DENIED |
reason, blocked_by ("hook" or approval denial) |
CHECKPOINT_CREATED |
checkpoint_id, state, next_step |
POLICY_TRIGGERED |
policy, stop_reason, state, steps, elapsed_seconds |
RUN_RESUMED |
checkpoint_id, checkpoint_sequence, state |
terminal (RUN_COMPLETED/RUN_FAILED/RUN_STOPPED/RUN_CANCELLED) |
state, stop_reason, output, error, steps, token_usage, token_accounting_available |
Append-time invariants (validate_event_append)¶
Violations raise EventInvariantError. The rules, verbatim behavior:
- The first event of a run must be
RUN_CREATED; exactly one. - Nothing may be appended after a terminal event.
STATE_CHANGEDpayloads must carry a transition legal per §5.TOOL_COMPLETED/TOOL_FAILEDrequire a priorTOOL_STARTEDwith the sametool_call_id, and no second result for the same call.- Terminal events must carry a state/stop-reason pair compatible with
§5's
_STOP_REASONS_BY_STATE. RUN_COMPLETEDrequires a priorMODEL_RESPONDED.
7. Providers (src/avo/providers/)¶
7.1 Protocols¶
avo/providers/base.py:
@runtime_checkable
class ModelProvider(Protocol):
async def generate(self, request: ModelRequest) -> ModelResponse: ...
class StatefulModelProvider(Protocol):
def snapshot_state(self) -> dict[str, JsonValue]: ...
def restore_state(self, state: dict[str, JsonValue]) -> None: ...
avo/providers/streaming.py:
class StreamingModelProvider(ModelProvider, Protocol):
def stream(self, request: ModelRequest) -> AsyncIterator[ModelChunk]: ...
StatefulModelProvider is required for CLI/REPL resume: the runtime
calls provider_snapshot() / restore_provider() around checkpoints
and raises UnsafeResumeError on mismatch. StreamingModelProvider is
optional — the runtime only streams when it also has a
stream_callback (§8).
7.2 Streaming primitives (streaming.py)¶
@dataclass(frozen=True)
class ModelChunk:
text: str = ""
thought: str = ""
finish_reason: str | None = None
tool_call_delta: dict[str, JsonValue] | None = None
usage: TokenUsage | None = None
response_id: str | None = None
tool_call_delta uses normalized keys: index (int, default 0), id,
name, and arguments string fragments concatenated in arrival order.
split_thinking(content) -> tuple[str, str]— splits<think>…</think>into(thought, answer).ThinkingStreamParser(.feed() -> list[(channel, text)],.flush()) does the same incrementally.StreamAssembler—.feed(chunk)/.build() -> ModelResponse. Lossless: a streamed tool call wins over text (lowestindexwins among calls); missing name raisesProviderError("Streamed tool call is missing a name.", retryable=False); invalid accumulated JSON raisesProviderError. Usage merge keeps a known nonzero value when the incoming one is zero; firstresponse_idwins.response_to_chunks(response)— decomposes a response to chunks (finish_reason"stop"/"tool_use").collect_stream(provider, request)— async helper; assemble is lossless versusgenerate.
7.3 HTTP transport helpers (http_common.py)¶
Shared by all HTTP adapters: build_openai_payload,
parse_openai_response, parse_openai_stream_payload(payload: str) ->
ModelChunk | None, _normalize_openai_tool_delta, iter_sse_lines,
iter_anthropic_sse_events, parse_anthropic_stream_event,
stream_openai_chunks(..., transport_name: str = "OpenAI"),
stream_anthropic_chunks(..., transport_name: str = "Anthropic"),
plus redact_text / json_safe_content. All adapters take
httpx lazily — httpx arrives via the [providers] extra.
7.4 Adapters¶
All HTTP providers share the constructor shape
(config, max_completion_tokens: int = 1024, request_timeout_seconds:
float = 30.0, *, client=None) and implement generate + stream.
Class (name) |
Module | Base URL default | Model default | Auth | Required env |
|---|---|---|---|---|---|
AnthropicProvider ("anthropic") |
anthropic.py |
https://api.anthropic.com |
claude-sonnet-4-6 |
header x-api-key + anthropic-version: 2023-06-01 |
AVO_ANTHROPIC_API_KEY |
OpenAICompatibleProvider ("openai-compatible") |
openai_compatible.py |
https://api.openai.com/v1 |
from env | Authorization: Bearer |
AVO_OPENAI_API_KEY |
OllamaProvider (no name attr) |
ollama.py |
http://localhost:11434 |
llama3.1 (catalog) |
optional Authorization: Bearer |
none (key optional) |
OllamaProvider via ollama-cloud |
ollama.py |
https://ollama.com |
qwen3-coder:480b-cloud |
official API/device key | AVO_OLLAMA_CLOUD_API_KEY or stored Ollama credential |
MiniMaxProvider |
minimax.py |
https://api.minimax.io |
catalog | style-dependent | AVO_MINIMAX_API_KEY |
GroqProvider ("groq") |
groq.py |
https://api.groq.com/openai/v1 |
catalog | Bearer | AVO_GROQ_API_KEY |
CerebrasProvider ("cerebras") |
cerebras.py |
https://api.cerebras.ai/v1 |
catalog | Bearer | AVO_CEREBRAS_API_KEY |
OpenRouterProvider ("openrouter") |
openrouter.py |
https://openrouter.ai/api/v1 |
catalog | Bearer | AVO_OPENROUTER_API_KEY or OPENROUTER_API_KEY or stored token |
GeminiProvider ("gemini") |
gemini.py |
https://generativelanguage.googleapis.com |
gemini-2.5-pro |
header x-goog-api-key |
AVO_GEMINI_API_KEY |
Endpoints: Anthropic POST {base_url}/v1/messages; OpenAI-compatible /
Groq / Cerebras / OpenRouter POST {base_url}/chat/completions; Ollama
POST {base_url}/api/chat; Gemini
POST {base_url}/v1beta/models/{model}:generateContent and stream
{base_url}/v1beta/models/{model}:streamGenerateContent?alt=sse.
Missing-key errors are exact strings, e.g.
"AVO_ANTHROPIC_API_KEY is required when AVO_PROVIDER=anthropic",
"AVO_OPENAI_API_KEY is required when AVO_PROVIDER=openai",
"AVO_GROQ_API_KEY is required when AVO_PROVIDER=groq" (same pattern
for cerebras/gemini/minimax via from_avo_env).
Provider-specific details:
- Minimax —
ApiStyle = Literal["openai", "anthropic"];MiniMaxConfig.api_stylefield default is"openai"butfrom_avo_envdefaults the env lookup to"anthropic";AVO_MINIMAX_API_STYLEis validated against the two literals. Anthropic-style requests sendx-api-key+anthropic-version: 2023-06-01. A legacyfrom_envuses non-AVO_variable names. - OpenRouter — also sends
HTTP-RefererfromAVO_OPENROUTER_SITE_URL(defaulthttps://github.com/Fqih/avo) andX-TitlefromAVO_OPENROUTER_APP_NAME(defaultAvo Agent Runtime); token fallback viaavo.auth.get_stored_token("openrouter"). avo.providers/__init__.pyexports:FakeProvider,MiniMaxConfig,MiniMaxProvider,ModelProvider,OpenAIConfig,OpenAIProvider,ScriptItem,StatefulModelProvider. The legacyopenai.py(OpenAIConfig.from_env,OPENAI_API_KEY/OPENAI_BASE_URL) is not part of theAVO_env path.
7.5 FakeProvider (fake.py) — the test workhorse¶
ScriptItem = ModelResponse | Mapping[str, Any] | Exception
class FakeProvider:
def __init__(self, responses: Sequence[ScriptItem], *, repeat_last: bool = False): ...
cursor: int # next script index
requests: list[ModelRequest] # every request received
async def generate(self, request) -> ModelResponse
def stream(self, request) -> AsyncIterator[ModelChunk]
def reset(self) -> None
def snapshot_state(self) -> dict[str, JsonValue]
def restore_state(self, state: dict[str, JsonValue]) -> None
@classmethod
def from_snapshot(cls, state) -> FakeProvider
Scripted Exception items are raised. Responses without usage get a
default TokenUsage() injected. Exhaustion raises
FakeProviderExhaustedError (retryable=False). Snapshot shape:
{"provider_type": "fake", "cursor": …, "repeat_last": …, "script": […]}.
7.6 Router (router.py)¶
FallbackRouterProvider and RaceRouterProvider both expose
name = "router" and model = f"router({','.join(names)})".
class BaseRouterProvider:
def __init__(self, routes: Sequence[tuple[str, ModelProvider]], *, cooldown_seconds: float = 30.0): ...
routes: list[tuple[str, ModelProvider]]
cooldown_seconds: float
def get_health_status(self) -> dict[str, dict[str, Any]]: ...
def reset_health(self, name: str | None = None) -> None: ...
async def probe_route(self, name: str, *, timeout_seconds: float = 3.0) -> ...
async def probe_all(self, *, timeout_seconds: float = 3.0) -> ...
async def aclose(self) -> None: ...
@dataclass
class RouteHealth:
name: str; is_healthy: bool = True; consecutive_failures: int = 0
cooldown_until: float = 0.0; last_error: str | None = None
last_latency_ms: float | None = None
# .is_cooling_down property; .remaining_cooldown()
class FallbackRouterProvider(BaseRouterProvider): ... # sequential failover
class RaceRouterProvider(BaseRouterProvider): # parallel/speculative
def __init__(self, routes, *, cooldown_seconds, speculative_delay_seconds): ...
Empty routes raises ValueError(f"{cls.__name__} requires at least one route").
Failure after all routes marks providers cooling down for
cooldown_seconds. Built by config (§12) from AVO_ROUTER_* env vars.
7.7 Prompt caching (prompt_cache.py)¶
@dataclass
class CacheBreakpoints:
prefix: int
tail: int = 1
# .prefix_index property
def stable_prefix_size(messages) -> int # leading system/assistant turns
def compute_breakpoints(messages, *, prefix_override=None, tail=1) -> CacheBreakpoints
def annotate_anthropic_messages(messages, breakpoints) -> list # adds cache_control {"type": "ephemeral"}
def cache_key_for_request(*, run_id: str, step: int, messages) -> str
cache_key_for_request returns f"avo:{run_id}:{step}:{digest.hexdigest()[:16]}"
(SHA-256 over canonicalized messages). It prefers the native digest via
avo._native (§15): if is_available() it calls
cache_key_hash_native; TypeError/ValueError fall back to pure
Python. Both paths produce identical keys — that is the contract
stated in the module docstring and asserted in tests.
8. AgentRuntime (src/avo/runtime.py)¶
Constructor¶
Clock = Callable[[], datetime]
ApprovalCallback = Callable[[ToolCall], bool | Awaitable[bool]]
class AgentRuntime:
def __init__(
self,
*,
provider: ModelProvider,
tools: Iterable[Tool] = (),
policy: LoopPolicy | None = None, # None -> LoopPolicy()
event_store: EventStore | None = None, # None -> InMemoryEventStore
clock: Clock = utc_now,
approval_callback: ApprovalCallback | None = None, # None -> always approve
memory: LetheMemoryAdapter | None = None,
hooks: HookRegistry | None = None,
stream_callback: Callable[[str], None] | None = None,
stream_interrupt_callback: Callable[[], None] | None = None,
) -> None: ...
approval_callback is the approval seam (CLAUDE.md rule: build
approval on it, never beside it). The default internal callback is
_always_approve returning True. A single asyncio.Lock serializes
runs on one runtime instance.
async run(task, *, system_prompt=None, user_state=None, run_id=None) -> RunResult¶
- Emits
RUN_CREATED(payload{task, policy}) then drives §5. - If
system_promptis non-empty, it is sent as a nativesystemmessage before the user task. This is the preferred way for callers to provide agent role or workspace instructions without mixing them into user text. - If
memoryis set, a recall step injects a"Relevant memories:"system message before the first model call. asyncio.CancelledError→ terminatesCANCELLED/USER_CANCELLEDwith error"Run cancelled by the caller.".StorageErrorpropagates. Any other unexpected exception →FAILED/INTERNAL_ERROR.
async resume(run_id) -> RunResult¶
RunAlreadyTerminalErrorif the run is terminal;CheckpointNotFoundErrorif no checkpoint exists.- Restores policy, messages,
ProgressDetector, completed tool-call ids, pending response; callsrestore_provider(StatefulModelProvider; mismatch →UnsafeResumeError); runsreconcile_after_checkpoint— a tool that started without a durable result raisesUnsafeResumeError("Cannot safely resume because these tool calls started without a durable result: {ids}"). - Emits
RUN_RESUMED(payload{checkpoint_id, checkpoint_sequence, state}).handle_pausedinruntime_handlers.pyexists to reject resuming intoPAUSEDimproperly withUnsafeResumeError.
async inspect(run_id) -> RunTrace¶
Delegates to TraceInspector (§20.4).
Per-state handlers (runtime_handlers.py)¶
Module-level handlers: handle_created, handle_model_pending,
handle_provider_error, handle_decision_received,
handle_tool_pending, handle_approval_pending, handle_tool_executing,
handle_observation_recorded, handle_paused.
Order of checks inside the loop (all verbatim from source):
handle_model_pending— runtime boundary check (policy.runtime_reason), thennext_step > max_steps→MAX_STEPS. BuildsModelRequestwith registrymetadata. If aCircuitBreakerPolicyis configured,breaker.allow()failing (BreakerOpen) routes tohandle_provider_error(ProviderError(..., retryable=False)). Streams via_stream_with_callbackonly when astream_callbackis set and the provider implementsStreamingModelProvider.asyncio.wait_for(..., policy.provider_timeout_seconds). AValidationErrorfrom the provider →MODEL_FAILED+FAILED/INVALID_MODEL_RESPONSE.usage is Nonesetstoken_accounting_available = False. EmitsMODEL_RESPONDED, transitionsDECISION_RECEIVED, checkpoints whencheckpoint_every_step or response.tool_call is not None, then the token-budget check (policy.token_budget_reason).handle_provider_error—consecutive_errors += 1, emitsMODEL_FAILED(payload includeserror_type,consecutive_errors), checkpoints. Non-retryable errors orFakeProviderExhaustedError→FAILED/PROVIDER_ERROR. Atconsecutive_error_limitit fires theNOTIFICATIONhook and callstrigger_policy(CONSECUTIVE_ERRORS).handle_tool_pending— always appendsTOOL_APPROVAL_REQUESTED(payload{tool_call_id, idempotency_key, name, mode: "v0.1_callback"}) then transitionsAPPROVAL_PENDING. There is no bypass path.handle_approval_pending— firesPRE_TOOL_USEhook first:BLOCK→TOOL_DENIED(payload{reason, blocked_by: "hook"}) andtrigger_policy(POLICY_DENIED). Thenapproved = runtime._approval_callback(call), awaited if awaitable.False→TOOL_DENIED+POLICY_DENIED.True→TOOL_APPROVED→TOOL_EXECUTING.handle_tool_executing—TOOL_STARTED; invokesruntime.tools.invoke(call, completed_tool_call_ids=...)wrapped inasyncio.wait_for(..., policy.tool_timeout_seconds). Timeout → a synthesizedsuccess=Falseresult with errorf"Tool {name!r} exceeded the configured timeout of {n} seconds.". EmitsTOOL_COMPLETEDorTOOL_FAILED;record_tool_resultpersists therole: "tool"message;POST_TOOL_USEhook fires after persistence; always checkpoints; then boundary → consecutive-error → no-progress policy checks.
Persistence primitives (runtime_persistence.py): append,
append_with_run, transition, checkpoint, record_tool_result,
provider_snapshot / restore_provider, trigger_policy (emits
POLICY_TRIGGERED then terminates STOPPED), and terminate (forces a
checkpoint first, then STATE_CHANGED + the terminal event via
event_store.finalize_run).
9. Tools: registry, schemas, fingerprints¶
The registry itself is §3.2. This section covers the schema helpers and
fingerprint utilities in src/avo/tools.py that tool authors and the
loop detectors rely on.
Schema conversion¶
def to_json_schema(model: type[BaseModel]) -> dict[str, JsonValue]
# model_json_schema(ref_template="#/$defs/{model}"), then recursively
# forces additionalProperties: false on every object node
def to_openai_function(tool_name: str, description: str, model: type[BaseModel]) -> dict
# {"type": "function", "function": {"name", "description", "parameters", "strict": True}}
def to_anthropic_tool(tool_name: str, description: str, model: type[BaseModel]) -> dict
# {"name", "description", "input_schema"}
Providers pick the shape they need; ModelRequest.tools carries raw
ToolMetadata and each adapter converts.
Fingerprints (loop detection inputs)¶
def canonical_fingerprint(value: JsonValue) -> str
# sha256 of json.dumps(value, sort_keys=True)
def tool_call_fingerprint(call: ToolCall) -> str
# name + arguments ONLY — tool_call_id is deliberately excluded so a
# repeated identical call with a fresh id still fingerprints equal
def tool_result_fingerprint(result: ToolResult) -> str
# tool_name, success, output, error
ProgressDetector (src/avo/progress.py) consumes them:
class ProgressDetector:
def __init__(self, *, action_history=None, observation_history=None,
model_history=None, progress_markers=None): ...
def record_action(self, call: ToolCall) -> None
def record_observation(self, result: ToolResult) -> None
def record_model_response(self, response: ModelResponse) -> None
def record_progress_marker(self, marker: str) -> None
def repeated_action(self, limit: int) -> bool
def no_progress(self, window: int) -> bool
# True when the trailing `window` of observations OR model
# responses OR markers are each all identical
def model_response_fingerprint(response: ModelResponse) -> str # module-level helper
All four histories are persisted in Checkpoint fields
(repeated_action_history, observation_fingerprints,
model_response_fingerprints, progress_markers) so resume restores
detection state exactly.
10. Application tools and the sandbox (src/avo/app_tools/)¶
Application tools plug into the existing FunctionTool /
ToolRegistry / approval_callback contracts; they modify neither the
runtime nor the state machine (module docstring, verbatim intent).
10.1 Workspace containment (workspace.py)¶
PathLike = str | Path
class Workspace:
def __init__(self, root: PathLike, *, create: bool = False) -> None
# resolve(strict=True); errors: "workspace root ... does not
# exist; pass create=True to make it" / "... is not a directory"
def validate_path(self, requested: PathLike, *, must_exist: bool = True) -> Path
def validate_for_write(self, requested: PathLike) -> Path
def validate_path(root: PathLike, requested: PathLike, *, must_exist: bool = True) -> Path
validate_path order of checks (this order is the security property):
null byte → empty string → resolve relative against root → containment
check via relative_to BEFORE any existence check — escape raises
WorkspacePathError(f"path escapes workspace root {root}: {candidate}")
— only then existence. validate_for_write additionally rejects a
symlink at the leaf ("refusing to follow symlink at write target") and
any symlink ancestor ("refusing to follow symlink in path").
TOCTOU note from the docstring: containment alone is not enough for
writes — file writes must also open with O_NOFOLLOW (done in
file_tools._write_file and batch_replace._write_atomic:
os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | O_NOFOLLOW, 0o644),
O_NOFOLLOW where POSIX).
10.2 Sandbox (sandbox.py) — docker-py, [sandbox] extra¶
SandboxError = ToolExecutionError
@dataclass(frozen=True)
class SandboxResult:
exit_code: int; stdout: str; stderr: str
duration_ms: float; image: str; network_mode: str; mem_limit: str
class SandboxExecutor:
def __init__(self, *, client=None, image: str | None = None,
language: str | None = None,
mem_limit: str = "256m", cpu_quota: int = 50000,
network_mode: str = "none",
timeout_seconds: float = 30.0) -> None
@classmethod
def for_language(cls, language: str, *, client=None, **kwargs) -> SandboxExecutor
async def run(self, command: str, *, workspace_dir: Path,
env: Mapping[str, str] | None = None,
timeout_seconds: float | None = None) -> SandboxResult
The exact container create call (assert this in tests with a mocked client — CLAUDE.md test rule):
client.containers.create(
image=self.image,
command=["sh", "-c", command],
environment=dict(env),
network_mode=self.network_mode,
mem_limit=self.mem_limit,
cpu_quota=self.cpu_quota,
working_dir=_IN_CONTAINER_WORKDIR, # "/workspace"
remove=True, # ephemeral — never a leaked container
detach=True, stdout=True, stderr=True,
)
container.remove(force=True) runs in a finally. Importing docker is
guarded; without the extra the error is exactly:
"SandboxExecutor requires thedockerpackage; install avo with the
[sandbox] extra."
Language defaults (_LANGUAGE_IMAGES, _DEFAULT_IMAGE = "python:3.12-slim"):
| language | image |
|---|---|
python |
python:3.12-slim |
node |
node:20-alpine |
typescript |
node:20-alpine |
go |
golang:1.22-alpine |
rust |
rust:1.80-slim |
ruby |
ruby:3.3-slim |
java |
eclipse-temurin:21-jre-alpine |
bash / sh / generic |
alpine:3.20 |
language_from_path(path) maps extensions via _PATH_LANGUAGE_HINTS
(.py/.pyi, .js/.mjs/.cjs, .ts/.tsx, .go, .rs, .rb,
.java, .sh/.bash); resolve_image(language=None, *,
explicit_image=None) picks explicit > language > default.
Hard rule (CLAUDE.md 1): run_shell never calls subprocess on
the host. Always SandboxExecutor, ephemeral container, default
network_mode="none".
10.3 Approval wiring (approval.py)¶
class ApprovalDecision(Protocol): ...
def build_approval_callback(
environ: Mapping[str, str] | None = None,
*,
on_require: Callable[[ToolCall], None] | None = None,
) -> ApprovalDecision
def required_tool_names(environ=None) -> set[str]
v0.1 behavior, verbatim: tools listed in AVO_TOOLS_REQUIRE_APPROVAL
(comma / whitespace / newline separated) cause the callback to return
False — the runtime then stops the run with POLICY_DENIED while the
operator gates the real decision outside the loop (on_require is
called first). Every other tool returns True immediately without
any callback machinery running (auto-approve, CLAUDE.md rule 4). The
richer interactive callback is permissions.build_approval_callback
(§18.2) — same name, different module; do not confuse them.
10.4 The tool catalog¶
Builders return FunctionTool[ArgumentsT] (imported as
PublicFunctionTool). avo/app_tools/__init__.py.__all__ exports
bind_workspace plus thirteen builders: batch_replace_tool,
edit_file_tool, git_commit_tool, git_diff_tool, git_status_tool,
glob_tool, grep_tool, lint_tool, read_file_tool,
symbols_tool, test_runner_tool, workspace_map_tool,
write_file_tool. run_shell_tool, submit_plan_tool,
plan_tasks_tool, task_tool, web_fetch_tool, web_search_tool are
imported from their own modules (not re-exported).
| Tool name | Builder | Arguments model | Key fields (name: type = default, constraints) |
|---|---|---|---|
run_shell |
run_shell_tool() |
RunShellArguments |
command: str (min_length=1); timeout_seconds: float \| None (ge=0) |
read_file |
read_file_tool() |
ReadFileArguments |
path: str (min_length=1); encoding: str = "utf-8" |
write_file |
write_file_tool() |
WriteFileArguments |
path: str; content: str; encoding: str = "utf-8" |
edit_file |
edit_file_tool() |
EditFileArguments |
path (min_length=1); old_string (min_length=1); new_string: str; replace_all: bool = False; validator rejects old_string == new_string ("old_string and new_string must differ") |
batch_replace |
batch_replace_tool() |
BatchReplaceArguments |
patches: list[FilePatch] (min_length=1); dry_run: bool = False |
git_status |
git_status_tool() |
GitStatusArguments |
include_untracked: bool = True; max_files: int = 20 (gt=0, le=500) |
git_diff |
git_diff_tool() |
GitDiffArguments |
path: str \| None = None; staged: bool = False; max_lines: int = 500 (gt=0, le=5_000) |
git_commit |
git_commit_tool() |
GitCommitArguments |
message: str (required, Conventional Commits); paths: list[str] \| None = None |
glob |
glob_tool() |
GlobArguments |
pattern: str (min_length=1); path: str = "."; recursive: bool = True; max_results: int = 200 (gt=0, le=10_000) |
grep |
grep_tool() |
GrepArguments |
pattern: str (min_length=1, Python regex); path: str = "."; include_glob: str \| None = None; case_insensitive: bool = False; max_results: int = 100 (gt=0, le=10_000); context_lines: int = 0 (ge=0, le=20) |
lint |
lint_tool() |
LintArguments |
path: str \| None = None |
symbols |
symbols_tool() |
SymbolsArguments |
path: str = "."; symbol_name: str \| None = None; max_symbols: int = 200 (gt=0, le=2000) |
test_runner |
test_runner_tool() |
TestRunnerArguments |
target: str \| None = None; max_failures: int = 5 (ge=1, le=50) |
workspace_map |
workspace_map_tool() |
WorkspaceMapArguments |
include_map: bool = True; include_recent: bool = True; max_entries: int (gt=0, le=5_000); recent_limit: int (gt=0, le=200) |
submit_plan |
submit_plan_tool() |
SubmitPlanArguments |
plan_text: str (min_length=1) |
plan_tasks |
plan_tasks_tool() |
PlanTasksArguments |
goal: str \| None (min_length=1, max_length=2_000); tasks: list[Task] \| None (max_length=200); subtasks: list[SubTask] \| None (max_length=500); complete: str \| None; reset: bool = False |
task |
task_tool(*, parent_runtime, tools=None, policy_overrides=None) |
TaskArguments |
agent_type: AgentType = AgentType.EXPLORE; prompt: str (min_length=1); max_steps: int \| None = None (gt=0) |
web_fetch |
web_fetch_tool() |
WebFetchArguments |
url: str (http/https only); max_bytes: int = 16*1024 (gt=0, le=1_048_576); timeout_seconds: float = 15.0 (gt=0, le=120) |
web_search |
web_search_tool() |
WebSearchArguments |
query: str (min_length=1, max_length=512); max_results: int = 10 (gt=0, le=50) |
Notes:
- Workspace-bound tools (
read_file,write_file,edit_file,batch_replace, git tools,glob,grep,lint,symbols,test_runner,workspace_map) resolve their root from thebind_workspace(...)contextmanager stack (file_tools._current_workspace()); unbound invocation raisesEditFileError("... invoked without an active workspace; wrap the run in avo.app_tools.file_tools.bind_workspace(...)"). Same pattern forrun_shellviashell_tool.bind_sandbox(executor)/SandboxNotBoundError. _read_filereturns{"path", "size", "content"}. File tools carry no own timeout or approval — the runtimetool_timeout_secondsandapproval_callbackgovern (CLAUDE.md rule 3).task_toolspawns a childAgentRuntime:agent_type="explore"gets a read-only tool subset,"general"gets the full set (ToolSelector = Callable[[AgentType], list[Tool]]).web_searchdefault client hits the DuckDuckGo HTML endpoint (https://html.duckduckgo.com/html/) — no API key;SearchClient(Protocol)withasync search(query, *, max_results)is injectable.
11. Storage (src/avo/storage/)¶
11.1 EventStore protocol (storage/base.py)¶
class EventStore(Protocol):
async def create_run(self, run: RunRecord, event: AgentEvent) -> RunRecord: ...
async def append_event(self, event: AgentEvent) -> AgentEvent: ...
async def append_event_and_update_run(self, event: AgentEvent, run: RunRecord) -> ...: ...
async def save_checkpoint(self, checkpoint: Checkpoint, event: AgentEvent) -> tuple[Checkpoint, AgentEvent]: ...
async def finalize_run(self, run: RunRecord, state_event: AgentEvent, terminal_event: AgentEvent) -> tuple[AgentEvent, AgentEvent]: ...
async def get_run(self, run_id: str) -> RunRecord | None: ...
async def update_run(self, run: RunRecord) -> RunRecord: ...
async def list_runs(self) -> list[RunRecord]: ... # newest first
async def get_events(self, run_id: str) -> list[AgentEvent]: ... # ascending sequence
async def get_latest_checkpoint(self, run_id: str) -> Checkpoint | None: ...
async def is_terminal_run(self, run_id: str) -> bool: ...
async def close(self) -> None: ...
11.2 Implementations¶
SQLiteEventStore(path: str | Path)(storage/sqlite.py) — tablesruns,events,checkpoints; supportsasync with. Checkpoint read/write free functions live instorage/sqlite_checkpoints.py(save_checkpoint,get_latest_checkpoint) and the class methods delegate to them. Every write path enforces §6 invariants viavalidate_event_append; sequence numbers are assigned per run in ascending order.InMemoryEventStore()(storage/memory.py) — same contract, dict backed; the runtime default whenevent_store=None.
11.3 Run-level checkpoint store (src/avo/checkpoint.py)¶
Separate from the runtime Checkpoint model — this is the REPL/job
store. CheckpointError = AvoError. Table
checkpoints(run_id, sequence, step, state_json, created_at,
PRIMARY KEY(run_id, sequence)).
@dataclass
class Checkpoint: # note: distinct from avo.models.Checkpoint
run_id: str; sequence: int; step: int
state: dict[str, JsonValue]; created_at: datetime
class CheckpointStore:
def __init__(self, path: str | Path): ...
def save(...) / latest(run_id) / history(run_id) / truncate(...)
11.4 Token ledger (src/avo/ledger.py)¶
Table ledger_entries.
@dataclass
class LedgerEntry: # one persisted usage record
id: int
run_id: str
step: int
model: str | None
usage: TokenUsage
cost_usd: Decimal | None
created_at: datetime
class TokenLedger:
def __init__(self, path: str | Path): ...
def record(self, run_id: str, *, step: int, usage: TokenUsage,
model: str | None = None,
cost_usd: Decimal | None = None) -> LedgerEntry
def entries(...) / total(...) / cost_total(...) / by_model(...) / truncate(...)
11.5 Conversation store (src/avo/conversations.py)¶
Table conversation_turns; HistoryError for failures.
@dataclass
class ConversationTurn:
session_id: str; sequence: int; role: str; content: str
metadata: dict; created_at: datetime
class ConversationStore:
def __init__(self, path: str | Path): ...
def append(self, session_id: str, *, role: str, content: str,
metadata: dict | None = None) -> ConversationTurn
def turns(...) / sessions(...) / last_turn(...) / truncate(...) / search_turns(...)
12. Configuration and environment variables (src/avo/config.py)¶
12.1 Provider factory¶
ProviderName = Literal["ollama", "minimax", "anthropic", "openai", "groq",
"cerebras", "openrouter", "gemini", "router"]
class ConfigError(ValueError): ...
def build_provider_from_env(
environ: Mapping[str, str] | None = None,
*,
max_completion_tokens: int = 1024,
request_timeout_seconds: float = 30.0,
event_callback: Callable[[dict[str, Any]], Awaitable[None] | None] | None = None,
) -> Any: ...
def apply_runtime_overrides(
policy_kwargs: dict[str, Any],
environ: Mapping[str, str] | None = None,
) -> dict[str, Any]
# AVO_MAX_TOTAL_TOKENS (int), AVO_MAX_RUNTIME_SECONDS (float),
# AVO_REPEATED_ACTION_LIMIT (int); bad numbers -> ConfigError
def database_path_from_env(environ=None) -> Path | None # AVO_DATABASE_PATH
AVO_PROVIDER is validated against ProviderName; AVO_MODEL is
required for every provider except router. Per-provider model
override is AVO_{NAME.upper()}_MODEL.
PROVIDER_MODELS: dict[ProviderName, tuple[str, ...]] is the curated
catalog (first entry = default when the model is unset):
| Provider | Models (verbatim) |
|---|---|
ollama |
llama3.1, llama3.2, qwen2.5-coder, qwen2.5, mistral, mixtral, codellama, deepseek-coder-v2, phi3, gemma2, command-r |
openai |
gpt-4o, gpt-4o-mini, gpt-4.1, gpt-4.1-mini, gpt-4.1-nano, o1, o1-mini, o1-preview, o3-mini, gpt-3.5-turbo |
anthropic |
claude-sonnet-4-5, claude-opus-4-5, claude-haiku-4-5, claude-3-5-sonnet, claude-3-5-haiku, claude-3-opus |
minimax |
MiniMax-M2, MiniMax-M3 |
groq |
llama-3.3-70b-versatile, llama-3.1-8b-instant, mixtral-8x7b-32768, gemma2-9b-it |
cerebras |
llama-3.3-70b, llama-3.1-8b, qwen-2.5-32b |
openrouter |
10 entries incl. meta-llama/llama-3.3-70b-instruct:free, deepseek/deepseek-r1:free, … |
gemini |
gemini-2.5-pro, gemini-2.5-flash, gemini-2.5-flash-lite, gemini-2.0-flash |
router |
auto, "ollama,openrouter", "ollama,groq", "openrouter,anthropic" |
(Anthropic's adapter default _DEFAULT_MODEL is claude-sonnet-4-6 —
the catalog list is for config validation/fallbacks; see §7.4.)
12.2 Router construction (_build_router_from_env)¶
- Chain comes from
AVO_ROUTER_CHAIN(or legacyAVO_ROUTER_PROVIDERS), comma-separated provider names. AVO_ROUTER_CHAIN=auto(or unset with multiple keys present) builds: alwaysollama, then any ofopenrouter(needsAVO_OPENROUTER_API_KEYorOPENROUTER_API_KEY),groq,cerebras,anthropic,openai,geminiwhose API key env var is set. A single-provider result getsopenrouterappended.- Each route model =
AVO_{NAME}_MODELelse catalog[0]. AVO_ROUTER_COOLDOWN_SECONDS— float, default30.0.AVO_ROUTER_STRATEGY(orAVO_ROUTER_MODE):"race"/"fastest"/"parallel"→RaceRouterProvider, anything else →FallbackRouterProvider.AVO_ROUTER_SPECULATIVE_DELAY_MS— milliseconds, converted to seconds for the race strategy.
12.3 Complete AVO_* environment variable inventory¶
Every AVO_ variable read anywhere in src/avo/ (grep of the whole
package), grouped by subsystem.
Provider selection / config (config.py, providers/*)
| Variable | Meaning |
|---|---|
AVO_PROVIDER |
One of ProviderName (§12.1) |
AVO_MODEL |
Model for the selected provider (required except router) |
AVO_SAVER |
Optional terse, yagni, compact, or full request-time saver; overrides saver.json |
AVO_OLLAMA_API_KEY / AVO_OLLAMA_BASE_URL / AVO_OLLAMA_MODEL |
Ollama Local; key optional (default base http://localhost:11434) |
AVO_OLLAMA_CLOUD_API_KEY |
Ollama Cloud credential; remote models are not pulled locally |
AVO_OPENAI_API_KEY / AVO_OPENAI_BASE_URL / AVO_OPENAI_MODEL |
OpenAI-compatible adapter; key required for AVO_PROVIDER=openai |
AVO_ANTHROPIC_API_KEY / AVO_ANTHROPIC_BASE_URL / AVO_ANTHROPIC_MODEL |
Anthropic; key required for AVO_PROVIDER=anthropic |
AVO_MINIMAX_API_KEY / AVO_MINIMAX_BASE_URL / AVO_MINIMAX_MODEL / AVO_MINIMAX_API_STYLE |
Minimax; style must be openai or anthropic |
AVO_GROQ_API_KEY / AVO_GROQ_BASE_URL / AVO_GROQ_MODEL |
Groq |
AVO_CEREBRAS_API_KEY / AVO_CEREBRAS_BASE_URL / AVO_CEREBRAS_MODEL |
Cerebras |
AVO_OPENROUTER_API_KEY / AVO_OPENROUTER_BASE_URL / AVO_OPENROUTER_MODEL / AVO_OPENROUTER_SITE_URL / AVO_OPENROUTER_APP_NAME |
OpenRouter (key also accepted as OPENROUTER_API_KEY or a stored token) |
AVO_GEMINI_API_KEY / AVO_GEMINI_BASE_URL / AVO_GEMINI_MODEL |
Gemini |
AVO_ROUTER_CHAIN / AVO_ROUTER_PROVIDERS / AVO_ROUTER_MODELS / AVO_ROUTER_MODE / AVO_ROUTER_STRATEGY / AVO_ROUTER_COOLDOWN_SECONDS / AVO_ROUTER_SPECULATIVE_DELAY_MS |
Router (§12.2); AVO_ROUTER_MODELS consumed by chat/web setup |
Runtime policy overrides
| Variable | Meaning |
|---|---|
AVO_MAX_TOTAL_TOKENS |
int → LoopPolicy.max_total_tokens |
AVO_MAX_RUNTIME_SECONDS |
float → LoopPolicy.max_runtime_seconds |
AVO_REPEATED_ACTION_LIMIT |
int → LoopPolicy.repeated_action_limit |
Security / approval / workspace
| Variable | Meaning |
|---|---|
AVO_TOOLS_REQUIRE_APPROVAL |
Comma-separated tool names needing manual approval (§10.3, §18.2) |
AVO_PERMISSION_MODE |
One of default, accept_edits, plan, bypass_permissions (§18.2) |
AVO_ALLOW_SUBSCRIPTION |
Explicit opt-in flag (1/true) allowing subscription OAuth backends |
Storage / configuration paths
| Variable | Meaning |
|---|---|
AVO_DATABASE_PATH |
Event-store / DB path override (database_path_from_env) |
AVO_CONFIG_DIR |
Auth-token storage dir override (auth.default_auth_dir) |
Chat REPL / web UI
| Variable | Meaning |
|---|---|
AVO_CHAT_STREAM |
Streaming gate (chat_stream.STREAM_GATE_ENV); "0" / "false" disables — streaming is default-on |
AVO_SYSTEM_PROMPT |
Add workspace-wide instructions after Avo's built-in role and tool-boundary prompt (persona.py) |
AVO_CONTEXT_WINDOW_LIMIT |
Force a context-window token limit (min 1000; context_advisor) |
AVO_CONTEXT_WARNING_THRESHOLD |
Float 0–1, default "0.80" (context_advisor) |
AVO_USAGE_RATES_INPUT_PER_1K / AVO_USAGE_RATES_OUTPUT_PER_1K |
USD per 1k tokens for cost math (usage._rates_from_env) |
AVO_USAGE_RATES |
Documented rate-pair form referenced in usage.py docstring |
Observability / notifications
| Variable | Meaning |
|---|---|
AVO_LOG_FORMAT |
"json" selects JsonFormatter (logging_config) |
AVO_OTEL_ENABLED |
Gate for configure_tracer (observability) |
AVO_OTEL_SERVICE_NAME |
OTel service.name, default "avo" |
AVO_NOTIFY_WEBHOOK |
Webhook URL for NotificationDispatcher.from_env |
AVO_NOTIFY_DESKTOP |
"1" enables DesktopNotifier when a notifier command is detected |
Auth
| Variable | Meaning |
|---|---|
AVO_GITHUB_CLIENT_ID |
GitHub device-flow client id; falls back to "Iv1.8a2e1d713c77d018" (auth.login_github_device) |
Not env vars: AVO_VERSION occurrences in chat_render.py /
web_*.py are the imported __version__ alias.
13. MCP server and client¶
13.1 Client (src/avo/mcp.py)¶
Minimal JSON-RPC 2.0 over stdio — no dependency on the mcp SDK (the
[mcp] extra is only for richer upstream helpers). Framing is
Content-Length: <n>\r\n\r\n<body> (_send / _read_header).
MCPError = ToolExecutionError
class MCPServer:
def __init__(self, *, command: list[str],
env: dict[str, str] | None = None,
cwd: Path | str | None = None) -> None
# empty command -> MCPError("MCPServer requires a non-empty command")
server_info: JsonDict # property
tools: tuple[JsonDict, ...] # property
async def start(self) -> None # spawn + initialize + tools/list
async def stop(self) -> None # terminate, wait 2.0s, kill
async def call_tool(self, name: str, arguments: JsonDict) -> JsonDict
def mcp_tool(server: MCPServer, descriptor: JsonDict) -> FunctionTool[Any]
def mcp_tools(server: MCPServer) -> list[FunctionTool[Any]]
@dataclass
class MCPClient:
server: MCPServer
started: bool = False
# async CM: __aenter__ starts, __aexit__ stops
def tools(self) -> list[FunctionTool[Any]]
# outside the CM: MCPError("MCPClient used outside `async with` block")
Handshake details (verbatim): initialize params are
{"protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo":
{"name": "avo", "version": "0.1.0"}}, followed by the
notifications/initialized notification. Every request waits on
asyncio.wait_for(future, timeout=90.0) — the source comment: "90s
covers spawning the server on a cold CI runner". Tool output is wrapped
as {"name", "is_error", "result"} plus "text" when the first content
item carries it. stderr is drained and discarded (protocol never uses
it).
13.2 Server (src/avo/mcp_server/)¶
def serve_stdio(registry: ToolRegistry, *, server_name: str = "avo",
server_version: str = "0.1.7", workspace_root=None,
read_fn=None, write_fn=None) -> None
async def serve_stdio_async(...) # same parameters
def build_default_registry() -> ToolRegistry
# read_file_tool(), write_file_tool(), edit_file_tool(),
# glob_tool(), grep_tool(), git_status_tool()
class AvoMcpServer:
def __init__(self, registry: ToolRegistry, *, server_name: str = "avo",
server_version: str = "0.1.7", workspace_root=None) -> None
def serve_stdio(self, *, read_fn=None, write_fn=None) -> None
def run_forever(self, ...) -> None # asyncio.run wrapper
Wire contract (mcp_server/server.py):
- Methods:
initialize,ping,shutdown,tools/list,tools/call,resources/list,resources/read,resources/templates/list. - Lifecycle gate: requests before
initializeor aftershutdownfail with JSON-RPC code-32002. protocolVersionechoes the client value, default"2025-06-18".capabilities:{"tools": {"listChanged": False}, "resources": {"subscribe": False, "listChanged": False}, "prompts": {"listChanged": False}, "logging": {}, "completions": {}}.serverInfois{"name": server_name, "version": server_version}.- Resources: URI prefix
"avo://workspace/", listing capped at_RESOURCE_LIMIT = 200. _tool_annotations(name): read-only names (read_file,glob,grep,git_statusor anyread_*prefix) getreadOnlyHint=True,destructiveHint=False,idempotentHint=True,openWorldHint=False; names inapproval.required_tool_names()getdestructiveHint=True,openWorldHint=True.tools/callinvokesregistry.invoke(call, completed_tool_call_ids=set())— per-call idempotency is delegated to the MCP client layer.- Framing (
mcp_server/framing.py):encode_message,iter_messages,FramingError.
13.3 Built-in servers (src/avo/mcp_servers/)¶
filesystem (workspace-bounded), git (bounded subprocess wrappers),
http_fetch (stdlib urllib), sqlite (read-only, parameterized),
plus the _stdio shared transport. Registered with the REPL through
~/.avo/mcp.json (§14).
14. CLI (src/avo/cli.py, avo = "avo.cli:main")¶
prog="avo"; --version prints f"avo {__version__}";
--database/-d is a Path, default Path("avo.db").
main() uses parse_known_args; trailing rest args are accepted only
for {login, ui, plugin, mcp, skill, init, bench, sandbox, cost}.
Errors print avo: {exc} and exit code 2. Delegated modules
re-parse their tail via _tail_argv(command) (strips the leading
avo <command>).
| Command | Purpose / notable flags |
|---|---|
avo runs list |
newest-first run table |
avo saver list|show|use|off |
inspect, select, or disable deterministic token-saver presets |
avo runs inspect RUN_ID |
render RunTrace.to_text() |
avo runs resume RUN_ID |
replay via FakeProvider; restrictions below |
avo runs diff RUN_A RUN_B [--json] |
structural diff (see diff.py) |
avo chat |
REPL; --database, --workspace-root, --session SESSION_ID, --new-session |
avo resume [SESSION_ID] |
Explicitly resume the latest eligible chat session or a specific session |
avo doctor |
provider/model/base-URL/API-key diagnostics (doctor.py) |
avo plugin … |
§18.4 plugin management |
avo mcp … |
§14.1 |
avo skill … |
install/list/show/remove (cli_skills.py) |
avo init |
scaffold .avo/skills/repo-overview/SKILL.md + AGENTS.md |
avo bench … |
benchmark runner (bench.py) |
avo sandbox … |
§14.2 |
avo cost … |
cost.main cost report (§17) |
avo serve-mcp |
expose tool registry as MCP stdio server; --workspace-root |
avo login … |
auth.main_login (§18.7) |
avo ui … |
web server (§21.3) |
avo runs resume hard limits (each raises AvoError): no checkpoint →
refuse; provider_type != "fake" → "CLI resume can reconstruct only
the built-in FakeProvider…"; a pending model tool_call → "CLI resume
cannot reconstruct application tool callables…".
14.1 avo mcp (cli_mcp.py)¶
Config file: MCP_CONFIG_PATH = Path.home() / ".avo" / "mcp.json",
a JSON object {name: {"command": [...], "env": {...}}} (same shape as
Claude Code .mcp.json). Name regex [A-Za-z0-9_.-]+.
avo mcp add NAME [--env KEY=VALUE]… command…— variadic trailing argv. The source comment explains theparse_known_args+ merge dance: "on Python <=3.11 argparse cannot interleave the variadiccommandpositionals after the--envoptionals ... Merging the extras back in makes the trailing-argv semantics identical across all supported interpreters."avo mcp list—NAME COMMANDtable; secret-looking env keys (KEY,TOKEN,SECRET,PASSWORD,PASS,AUTH,CREDENTIAL) are masked to***(_looks_secret).avo mcp remove NAME [--yes/-y]— interactive[y/N]confirm.load_servers(path=None)+resolve_env(entry)(os.path.expandvarsfor${VAR}) are what the chat REPL consumes at boot.
14.2 avo sandbox (cli_sandbox.py)¶
avo sandbox run [--image python:3.12-slim] [--workspace CWD] [--network
none] [--memory 256m] [--timeout 30.0] [--env K=V] [--json] <command
REMAINDER> — direct one-shot use of SandboxExecutor (§10.2).
14.3 Chat REPL surface¶
chat.run_repl(*, database_path: Path, workspace_root: Path, stdin=None,
stdout=None, stderr=None, environ=None, prompt="You > ",
secret_reader=None, session_id=None, force_new_session=...). Slash
commands are defined in chat_render.SLASH_COMMANDS — 39 entries:
/help, /provider, /router, /model [NAME], /context,
/persona [NAME], /instructions [TEXT], /cost,
/permissions [MODE], /shell [CMD] (or !CMD), /diff [PATH],
/undo, /grep PATTERN [GLOB], /find [GLOB] (/search),
/map [N] (/tree), /symbols [PATH], /lint [PATH], /test [TARGET],
/commit [MSG], /branch [NAME], /log [N], /stash [CMD],
/bench [PROMPT], /clear, /export [PATH], /compact [N],
/history [QUERY], /draft [show|save|clear], /sessions,
/resume [ID] (arrow-key picker when no ID is supplied), /session, /new, /inspect RUN_ID, /skills,
/skill NAME, /jobs, /job ID, /cancel ID, /quit (/exit,
Ctrl-D).
15. Native extension (avo_native)¶
Optional Rust accelerator (directory native/), used only for the
prompt-cache-key digest (§7.7).
src/avo/_native.py is a lazy loader — importing it is safe anywhere:
def is_available() -> bool # True when avo_native imported
def version() -> str | None # bundled extension version
def import_error() -> BaseException | None
def cache_key_hash_native(run_id: str, step: int,
messages_json: list[str]) -> str
# RuntimeError("avo_native extension is not loaded") when absent
# module __getattr__ re-exports cache_key_hash / version on demand
_try_load() runs at import: import avo_native → binds
_cache_key_hash = _ext.cache_key_hash, _ext_version = _ext.version;
ImportError is captured, never raised.
Cargo side (native/Cargo.toml): package avo-native, version
0.1.4, lib name avo_native, pyo3 = { version = "0.23", features =
["abi3-py311"] }. Exported function
(native/src/lib.rs): fn cache_key_hash(run_id: &str, step: u64,
messages_json: Vec<String>) -> String. native/pyproject.toml pins
version 0.1.4, requires-python = ">=3.11".
Install: avo[native] extra = avo-native>=0.1.4 (prebuilt abi3
wheels cp311+ for Linux/macOS/Windows published by
native-release.yml). The pure-Python fallback is mandatory and
always produces identical cache keys — never branch on native
availability for semantics, only for speed.
16. Reliability primitives¶
16.1 Retry (src/avo/retry.py)¶
@dataclass(frozen=True)
class RetryPolicy:
max_attempts: int = 3
initial_delay: float = 0.5
max_delay: float = 8.0
jitter: float = 0.1
multiplier: float = 2.0
def is_transient_error(exc: BaseException) -> bool
# ConnectionError / TimeoutError / OSError
async def call_with_retry(factory, *, policy: RetryPolicy | None = None,
retry_on=is_transient_error,
sleep=asyncio.sleep)
16.2 Rate limiting (src/avo/rate_limit.py)¶
RateLimitError = AvoError
@dataclass
class RateLimitConfig:
requests_per_minute: int | None = None
tokens_per_minute: int | None = None
class RateLimiter:
def __init__(self, config: RateLimitConfig | None = None): ...
async def acquire(self, *, input_tokens: int = 0) -> None
Token-bucket; acquire awaits refill or raises RateLimitError when
indefinite starvation would occur.
16.3 Circuit breaker (src/avo/circuit_breaker.py)¶
class CircuitState(StrEnum): CLOSED; OPEN; HALF_OPEN
class BreakerOpen(Exception):
def __init__(self, state: CircuitState, retry_after_seconds: float): ...
@dataclass(frozen=True)
class CircuitBreakerPolicy:
failure_threshold: int = 5 # ge=1
cooldown_seconds: float = 30.0 # gt=0
half_open_max_calls: int = 1 # ge=1
class CircuitBreaker:
def __init__(self, policy: CircuitBreakerPolicy, *, clock=time.monotonic): ...
def allow(self) -> None # raises BreakerOpen when OPEN
def record_success(self) -> None
def record_failure(self) -> None
def reset(self) -> None
state: CircuitState # property
consecutive_failures: int # property
Wired into the loop via LoopPolicy.circuit_breaker; a BreakerOpen
at MODEL_PENDING routes to handle_provider_error with a
non-retryable ProviderError (§8).
16.4 Budget and concurrency¶
# budget.py
@dataclass(frozen=True)
class BudgetConfig:
warning_usd: Decimal | None = None
hard_limit_usd: Decimal | None = None
@dataclass(frozen=True)
class BudgetDecision:
allowed: bool; spent_usd: Decimal
crossed_warning: bool; exceeded_hard_limit: bool
class BudgetChecker:
def check(self, total: TokenUsage,
*, rates: tuple[Decimal, Decimal] | None = None) -> BudgetDecision
# cost math delegates to usage.estimate_cost (§17.1)
# concurrency.py
class ConcurrencyError(...) # raised when the limit is hit
class ConcurrencyLimiter: ... # async semaphore-style cap
17. Usage, cost, ledger, compaction, context¶
17.1 Usage tracking (src/avo/usage.py)¶
@dataclass(frozen=True)
class UsageRecord:
step: int; run_id: str; usage: TokenUsage; model: str | None = None
def estimate_cost(usage: TokenUsage,
*, rates: tuple[Decimal, Decimal] | None = None) -> Decimal | None
# quantized to "0.000001"; None when both rates are 0
# rates default from AVO_USAGE_RATES_INPUT_PER_1K / _OUTPUT_PER_1K
class UsageTracker:
def record(...) / records / total / by_model / cost_total / to_list / reset
def merge(records) -> TokenUsage
17.2 Cost reporting (src/avo/cost.py)¶
@dataclass
class ModelCost: model: str; runs: int; usage: TokenUsage; cost_usd: Decimal | None
@dataclass
class RunCost: run_id: str; entries: int; usage: TokenUsage; cost_usd; by_model
@dataclass
class CostReport:
database: str; run_count: int; total: TokenUsage
cost_usd: Decimal | None; runs: list[RunCost]; models: list[ModelCost]
def to_text(self) -> str
def to_json(self) -> str
def report_to_dict(report) -> dict
def aggregate_costs(database: Path | str) -> CostReport
def main(argv) -> int # backs `avo cost`
Per-run persistence sidecar: TokenLedger (§11.4).
17.3 Compaction (src/avo/compact.py)¶
SummarizeCallable = ... # async callable over messages -> str
def compact_messages(messages, *, keep_last: int = 8, keep_first_user: bool = True,
summarize_callable=None) -> list[dict]
def estimate_message_count(messages) -> int
With no summarizer a fixed placeholder note replaces the middle.
Compaction is never automatic in the runtime loop — only the REPL's
/compact invokes it manually (§14.3).
17.4 Context advisor (src/avo/context_advisor.py)¶
def get_model_context_limit(model_name: str, environ=None) -> int
# AVO_CONTEXT_WINDOW_LIMIT override (floor 1_000); else pattern table;
# _DEFAULT_FALLBACK_LIMIT = 8_192
def estimate_text_tokens(text: str) -> int # max(1, len(text) // 4)
@dataclass(frozen=True)
class ContextAdvisorReport:
estimated_tokens: int; context_limit: int; usage_percent: float
is_warning: bool; is_critical: bool
warning_threshold: float; advice_message: str
def evaluate_session_context(turns, model_name, *,
last_turn_tokens=None, environ=None) -> ContextAdvisorReport
# AVO_CONTEXT_WARNING_THRESHOLD, default "0.80"
Known-limit table (regex → tokens): gemini → 1_000_000;
claude-3 → 200_000; gpt-4o|o1|o3 → 128_000; llama3.x → 128_000;
llama3 → 8_192; deepseek → 64_000; qwen2.5 → 32_768;
mistral → 32_768.
18. Extensibility: hooks, permissions, skills, plugins, subagents¶
18.1 Hooks (src/avo/hooks.py)¶
class HookEvent(StrEnum):
PRE_TOOL_USE = "pre_tool_use"; POST_TOOL_USE = "post_tool_use"
STOP = "stop"; NOTIFICATION = "notification"
class HookAction(Enum): ALLOW; BLOCK
@dataclass(frozen=True)
class HookDecision:
action: HookAction = HookAction.ALLOW
reason: str = ""
modified_args: dict | None = None
# HookDecision.allow(); HookDecision.block(reason)
@dataclass
class HookContext:
event: HookEvent; run_id: str
tool_call: ToolCall | None = None; tool_result: ToolResult | None = None
run: RunRecord | None = None; notification: str = ""; extra: dict = ...
HookCallable = Callable[[HookContext], HookDecision | Awaitable[HookDecision]]
class HookRegistry:
def register(self, event: HookEvent, hook: HookCallable) -> None
def hooks_for(self, event: HookEvent) -> tuple[HookCallable, ...]
async def fire(self, event: HookEvent, context: HookContext) -> HookDecision
def make_blocking_hook(reason: str) -> HookCallable
def make_logging_hook(sink: list) -> HookCallable
fire short-circuits on the first BLOCK. A hook raising is contained:
result becomes ALLOW with reason f"hook raised {type(exc).__name__}: {exc}".
Fired by the runtime at handle_approval_pending (PRE_TOOL_USE),
after tool persistence (POST_TOOL_USE), at consecutive-error
threshold (NOTIFICATION), and at run end (STOP).
18.2 Permissions (src/avo/permissions.py)¶
class PermissionMode(StrEnum):
DEFAULT = "default"; ACCEPT_EDITS = "accept_edits"
PLAN = "plan"; BYPASS_PERMISSIONS = "bypass_permissions"
class PermissionPolicy(BaseModel): # frozen, extra="forbid"
mode: PermissionMode = ...
require_approval: tuple[str, ...] = ()
def should_require_approval(tool_name, mode, *, plan_submitted=False,
require_approval=()) -> bool
def build_approval_callback(policy: PermissionPolicy, *, prompter=None,
stdin=None, stdout=None) -> ApprovalCallback
def permission_policy_from_env(environ=None) -> PermissionPolicy
# AVO_PERMISSION_MODE + AVO_TOOLS_REQUIRE_APPROVAL
# active-run tracking: set_active_run / clear_active_run /
# mark_plan_submitted / is_plan_submitted / active_run_id
Tool classes (module constants, verbatim membership):
_READ_ONLY_TOOLS = {read_file, git_diff, git_status, lint, test_runner, grep, glob, symbols, workspace_map}_MUTATING_TOOLS = {write_file, edit_file, batch_replace, git_commit}_SHELL_TOOLS = {run_shell}_PLAN_TOOL_NAME = "submit_plan"
Decision table for should_require_approval: bypass_permissions →
never; explicitly listed → always; shell tools → always (except bypass);
accept_edits → auto-approve known tools except unknown ones; plan →
read-only never, mutating only after the plan is submitted, everything
else always. The interactive callback approves submit_plan
automatically (marking the plan); in PLAN mode mutating tools before
a submitted plan are a hard deny (False); default prompter text:
f"Approve {name}({args})? [y/N]: ".
18.3 Skills (src/avo/skills.py)¶
SkillError = ToolExecutionError. Names must match
_NAME_PATTERN = ^[a-z0-9][a-z0-9_-]{0,63}$; files use the .md
suffix.
class SkillRegistry:
def __init__(self, root: Path): ...
root: Path # property
def exists(name) / load(name) / names() / __iter__()
def load_skills(root) -> dict[str, str] # creates the dir; {name: body}
avo init scaffolds .avo/skills/repo-overview/SKILL.md +
AGENTS.md; /skills and /skill NAME in the REPL list/inject them.
18.4 Plugins (src/avo/plugins.py)¶
Entry-point groups: TOOL_GROUP = "avo.tools",
NOTIFIER_GROUP = "avo.notifiers", PROVIDER_GROUP = "avo.providers",
ALL_GROUPS (all three). PluginError = AvoError.
@dataclass(frozen=True)
class PluginEntry:
group: str; name: str; factory: Callable[[], Any]; package: str | None = None
def discover(group: str, *, package=None) -> list[PluginEntry]
def discover_all(*, groups=ALL_GROUPS) -> dict[str, list[PluginEntry]]
def names(group: str, *, package=None) -> list[str]
Managed from the shell with avo plugin install|list|show|remove|init.
18.5 Subagents (src/avo/subagent.py)¶
SubAgentError = AvoError
@dataclass(frozen=True)
class SubAgentResult:
parent_run_id: str; child_run_id: str; steps: int
outputs: list[str]; metadata: dict
def child_run_id(parent: str, child: str) -> str # f"{parent}.{child}"
@dataclass(frozen=True)
class SubAgentRunner:
run_step: Callable[[str, int], Any]
def run(self, parent_run_id: str, child_name: str, *,
max_steps: int = 1, resume_from=None) -> SubAgentResult
def run_many(...)
The higher-level path is the task app tool (§10.4), which spawns a
child AgentRuntime under parent_run_id + "." + child run ids.
18.6 Schemas and content blocks¶
schemas.py: SchemaError = AvoError, ValidationFailure = AvoError;
SchemaRegistry with register / unregister / get / has / names /
validate.
content_blocks.py:
TextBlock(type: Literal["text"] = "text", text: str) # min_length=1
ImageBlock(type: Literal["image"], media_type: str, data: str)
# ImageBlock.from_path(path, *, media_type=None); png/jpeg/gif/webp
# .as_anthropic_source()
Block = Annotated[TextBlock | ImageBlock, Field(discriminator="type")]
Message(role: Literal["system","user","assistant","tool"], content: list[Block])
# .to_dict()
def dump_block_anthropic(...) / dump_block_openai(...) / dump_block_ollama(...)
18.7 Auth, deprecation, audit, background jobs¶
auth.py — token store under AVO_CONFIG_DIR (default ~/.avo):
AuthError; default_auth_dir(), auth_file_path(),
load_all_tokens(), get_stored_token(provider),
store_token(provider, token), remove_stored_token(provider);
GitHub OAuth helpers generate_pkce_pair(),
run_localhost_callback_server(...), login_openrouter(...),
login_github_device(...) (client id: param → AVO_GITHUB_CLIENT_ID
→ "Iv1.8a2e1d713c77d018"), CLI entry main_login(argv) (avo login).
deprecation.py — deprecated(*, since: str, removal: str,
replacement: str | None = None, strict: bool = False) decorator;
deprecation_index() lists tagged symbols; DeprecatedSymbol(AvoError)
is raised when strict=True past the removal version. The top-level
_STABLE_ABI = "0.2.0" marks the version where breaking changes are
allowed to land.
audit.py — AuditError, AuditEvent, AuditLog (JSONL append),
redact(payload) masking secret-looking keys.
background.py — Job, BackgroundJobManager,
render_job_row / render_job_detail (REPL /jobs, /job ID,
/cancel ID).
19. Design principles¶
These are load-bearing invariants; violating one is a review blocker.
- Offline core.
pytestmust pass with no network, no Docker, no provider APIs. Docker is mocked (SandboxExecutor(client=...)is injectable for exactly this); real-Docker and live-API paths live in opt-in suites (tests/integrationis ignored by default;--run-live+ thelivemarker gate real HTTP;benchmark/live/tests/requires consent/API keys). - Pydantic-only core dependency. Anything heavier (
httpx,docker,mcp,opentelemetry-*,langchain-core,matplotlib) is an optional extra and imported lazily. Never add a runtime dependency toproject.dependencies. - Append-only events; validation at append time. No updates, no deletes; §6 invariants are enforced in the store, not the caller.
- Crash-safe resume. A checkpoint is durable at every policy-
significant boundary; unsafe resume raises
UnsafeResumeErrorrather than guessing (§8). - Every stop has a
StopReason. Terminal outcomes always agree with §5's table; there is no silent failure path. - Native extension is an optimization only. Identical semantics
with or without
avo_native(§15). - Approval is one seam.
AgentRuntime(approval_callback=...)— permission modes and env gating both just build callbacks (§10.3, §18.2). - Determinism where it matters. Fingerprints sort keys
(
sort_keys=True); cache keys are content-addressed and stable across the native/Python paths.
20. Observability¶
20.1 OpenTelemetry (src/avo/observability.py)¶
class OtelDisabledError(Exception): ...
def is_enabled() -> bool # AVO_OTEL_ENABLED gate
def configure_tracer(*, service_name: str | None = None) -> ...
def span_for_turn(run_id, provider=None, model=None) # span "avo.turn"
def record_tool_call(span, name, *, call_id=None) -> None
def record_usage(span, *, input_tokens=None, output_tokens=None, cost_usd=None) -> None
configure_tracer raises OtelDisabledError unless AVO_OTEL_ENABLED
is truthy, and if the OTel packages are missing:
"AVO_OTEL_ENABLED=1 requires the [otel] extra. Install withpip
install avo[otel].". Resource: service.name from arg /
AVO_OTEL_SERVICE_NAME (default "avo"), service.version; OTLP
gRPC BatchSpanProcessor, and an in-memory exporter when the OTLP
import fails. Span attributes: avo.run_id, gen_ai.system,
gen_ai.request.model.
20.2 Metrics (src/avo/metrics.py)¶
MetricsError; MetricsRegistry(*, max_cardinality: int = 1024) with
counter(name, ...), add(name, amount, ...) (amount ≥ 0),
gauge(...), observe(name, value, *, labels=None,
buckets=_DEFAULT_HISTOGRAM_BUCKETS), snapshot(), reset().
Snapshot dataclasses: CounterSnapshot, GaugeSnapshot,
HistogramSnapshot, MetricsSnapshot. Exceeding max_cardinality
distinct label sets raises MetricsError (bounded memory).
20.3 Logging (src/avo/logging_config.py)¶
def configure_logging(*, level: str = "INFO", json_mode: bool = False,
logger_name: str | None = None) -> None
# json_mode also engages when AVO_LOG_FORMAT=json
def install_json_handler(logger, *, level: str = "INFO") -> None
class JsonFormatter(logging.Formatter): ...
LogLevel # allowed level names
20.4 Run tracing (src/avo/tracing.py)¶
@dataclass(frozen=True)
class TraceEntry:
sequence: int # ge=1
created_at: datetime; event_type: EventType; summary: str
from_state: RunState | None; to_state: RunState | None
duration_ms: float | None; error: str | None
policy: str | None; payload: dict
@dataclass(frozen=True)
class RunTrace:
run_id: str; entries: list[TraceEntry]
final_state: RunState | None; stop_reason: StopReason | None
steps: int; duration_seconds: float | None
token_usage: TokenUsage; token_accounting_available: bool
def to_text(self) -> str
class TraceInspector:
def __init__(self, event_store: EventStore): ...
async def inspect(self, run_id: str) -> RunTrace
@staticmethod
def from_events(events) -> RunTrace
AgentRuntime.inspect() (§8) and avo runs inspect (§14) both render
RunTrace.to_text().
20.5 Notifications (src/avo/notifications.py)¶
NotifierError = ToolExecutionError
@dataclass(frozen=True)
class Notification: title: str; body: str; level: str = "info"
class Notifier(Protocol):
async def send(self, notification: Notification) -> None
class WebhookNotifier: # __init__(self, url: str, *, timeout: float = 5.0)
class DesktopNotifier: # __init__(self); _detect_command picks notify-send et al.
class NotificationDispatcher:
def __init__(self, notifiers: Sequence[Notifier] | None = None): ...
def register(self, notifier: Notifier) -> None
async def send(self, notification: Notification) -> None
@classmethod
def from_env(cls, environ=None) -> NotificationDispatcher
# AVO_NOTIFY_WEBHOOK -> WebhookNotifier; AVO_NOTIFY_DESKTOP == "1" -> DesktopNotifier
Third-party notifiers register via the avo.notifiers plugin group
(§18.4).
21. Integrations, chat REPL, web UI¶
21.1 Memory + LangChain bridges¶
integrations/lethe.py:
class MemoryProvider(Protocol):
async def recall(self, query: str, *, k: int = 5) -> ...
async def remember(self, ...) -> ...
class LetheMemoryAdapter:
def __init__(self, store, *, recall_k: int = 5): ...
def recall_text(self, query) -> list[str]
def remember_output(self, content, *, session_id) -> ...
AgentRuntime(memory=...) accepts it; the runtime injects a
"Relevant memories:" system message (§8).
integrations/langchain_bridge.py ([langchain] extra):
avo_messages_from_lc, avo_tool_from_lc,
AvoLangChainModel(BaseChatModel) (wrap an avo provider as a LangChain
chat model).
21.2 Chat subsystem¶
chat.py (run_repl, §14.3) plus chat_*.py collaborators:
chat_setup (provider/policy/workspace assembly, AVO_ROUTER_MODELS),
chat_stream (AVO_CHAT_STREAM gate, token streaming to the terminal),
chat_render (panels, SLASH_COMMANDS, version display), chat_tools
(binding the §10 catalog into the session registry), persona
(AVO_SYSTEM_PROMPT), planning (Task / SubTask /
ExecutionPlan / TaskGraph behind plan_tasks), repl_history
(persistent input history), snapshot (resume-from-checkpoint helpers,
avo runs resume / /undo), diff (avo runs diff), context_advisor
(§17.4), background (§18.7), mcp client loading from
~/.avo/mcp.json.
21.3 Workspace services and web UI¶
workspace/git.py (bounded git helpers used by the git tools) and
workspace/indexer.py (file index behind workspace_map). ui.py +
web_app.py / web_api.py / web_state.py (and companion web_*
modules) implement the server behind avo ui, reading the same SQLite
event store. bench.py powers avo bench; eval.py is the
"Golden-prompt eval framework"; doctor.py prints provider, model,
base URL / endpoint, API-style, and API key configured: yes/no lines
plus a config error: line when build_provider_from_env fails.
_pytest_plugin.py registers the --run-live flag (entry point
avo-live).
22. Quality gates¶
Every gate below is exactly what .github/workflows/ci.yml runs, on
Python 3.11 / 3.12 / 3.13 matrix (fail-fast: false), after
python -m pip install -e ".[dev,providers,sandbox,otel,langchain]".
Run them locally before pushing:
ruff check .
ruff format --check .
mypy src/avo
bandit -r src/avo -c pyproject.toml --severity-level medium
pip-audit --strict --requirement <(python -c "import tomllib; print('\n'.join(tomllib.loads(open('pyproject.toml').read())['project']['dependencies']))")
pytest
SOURCE_DATE_EPOCH="$(git log -1 --pretty=%ct)" python -m build
Settings of record (pyproject.toml):
- ruff —
line-length = 100,target-version = "py311", selectE, F, I, UP, B, SIM, ASYNC, RUF;*.mdexcluded from formatting; per-file ignores forexamples/,benchmark/(T201) andsrc/avo/mcp_servers/(ASYNC109/ASYNC210). - mypy —
strict = true,python_version = "3.11",packages = ["avo"], pydantic plugin;tests.*relaxesdisallow_untyped_defs;avo_nativeisAnywhen missing. - pytest —
addopts = "--strict-config --strict-markers --ignore=tests/integration",asyncio_mode = "auto", markerlive: opt-in tests that hit real HTTP endpoints; require --run-live. - coverage —
branch = true,fail_under = 90. - bandit — skips documented in
[tool.bandit](B101 asserts are invariants, B105token_budget_exceededis an error tag, B311 jitter only, B404/B603/B607 non-shell subprocess, B310/B608 MCP servers);exclude_dirs = ["src/avo/mcp_servers", "tests"]. - Build — hatchling, wheel
reproducible = true; CI pinsSOURCE_DATE_EPOCH(falls back togit log -1 --pretty=%ct) so wheel/sdist are byte-identical between runs; a separatesbomjob produces a CycloneDX JSON SBOM (anchore/sbom-action@v0) fordist/avo-*.whlanddist/avo-*.tar.gz.
release.yml publishes to PyPI via trusted publishing;
native-release.yml builds the avo_native abi3 wheels and sdist.
23. Conventions and anti-patterns¶
Conventions
- All runtime code is
async; sync seams only at boundaries (clocks, callbacks likestream_callback). - Models:
extra="forbid"everywhere (AvoModel), frozen where the value is a decision (LoopPolicy,CircuitBreakerPolicy,BudgetConfig,Notification),validate_assignment=Trueon the shared base. - Datetimes: timezone-aware UTC or die (
_require_aware). - New errors subclass
AvoError(or alias it) socli.maincan printavo: {exc}and exit 2 uniformly. - Optional heavy imports stay inside functions/guards with an actionable
error naming the extra to install (see
sandbox.py,observability.py). - Env vars are read from an injectable
environ: Mapping[str, str] | None = Noneparameter — neveros.environdirectly in factories, so tests can drive every branch.
Anti-patterns (each has broken something before or will)
- Do not reconstruct state from events in a caller — use
TraceInspector/RunTrace; ad-hoc replay diverges. - Do not add a second approval path next to
approval_callback(CLAUDE.md rule 5: tools fit the existing contract). - Do not call
subprocessfrom host-side tools; sandbox-only (CLAUDE.md rule 1). The git tools intentionally use boundedshell=Falsesubprocess against the workspace repo; anything model- driven goes throughrun_shell+ sandbox. - Do not mutate a
ToolResult/ModelResponseafter creation — build a new one (models are value objects; the event log assumes immutability). - Do not pass
completed_tool_call_ids=set()as a fake "fresh run" signal inside the runtime — the registry keyword is required and the runtime owns the set; only the MCP server (§13.2) deliberately passes an empty set per call. - Do not renumber this file's sections; CLAUDE.md and other docs cite them by number.
- Do not trust
docs/api-stability.mdfor enum members or default arguments — it currently drifts from source onRunState(§5) andToolRegistry.invoke(§3.2); §4–§6 here supersede it.
Generated from src/avo/ at version 0.1.7. If you add a public
symbol, an env var, a state, or an event and this file does not
mention it, this file is out of date — update it in the same PR.