Runtime and data flows¶
This page follows data through the framework. Use it before changing control flow, error handling, message formats, streaming, or memory timing.
Construction and lazy dependencies¶
Creating an Agent performs local setup only:
get_settings()loads the current environment into an immutableSettings.- A provider name is normalized, or a supplied
BaseProviderinstance is kept. - A model and provider-specific tool-calling defaults are selected.
- A supplied
MemoryBackendis attached; otherwise a privateInMemoryMemorysession is created. - Tool callables are converted to
ToolDefinitionobjects and indexed by name.
Named providers are instantiated lazily by _get_provider() on the first run or
stream. The resulting provider object is cached in Agent._provider. Supplying a
provider instance skips named-provider construction and API-key lookup for that
provider.
Non-stream chat and tool loop¶
await Agent.run(message) is the full orchestration path. It supports provider
tool calls and records a completed turn in memory.
sequenceDiagram
participant H as Route handler
participant A as Agent
participant M as MemoryBackend
participant P as BaseProvider
participant T as Python tool
H->>A: await run(user message)
A->>M: read messages
A->>A: prepend system prompt and append current user message
loop up to max_tool_rounds + 1 provider calls
A->>P: chat(messages, tool schemas, tool settings)
alt provider returns tool calls
P-->>A: ProviderResponse(tool_calls)
A->>A: append assistant tool-call message to local list
loop each tool call, sequentially
A->>T: invoke parsed arguments
T-->>A: result or captured error text
A->>A: append tool result to local list
end
else provider returns final content
P-->>A: ProviderResponse(content)
A->>M: add user message
A->>M: add assistant content
A-->>H: final string
end
end
The exact call path is:
_conversation_messages()creates a new list containing the system prompt, the backend's current messages, and the new user message._get_provider()returns the injected/cached provider or creates one._tool_schemas()returnsNoneor all registered model-facing schemas.provider.chat()returns a normalizedProviderResponse.- If tool calls exist,
_execute_tool_calls()parses and invokes each matching function. Both synchronous and awaitable return values are supported. - Tool-call and tool-result messages are added to the local list for the next provider round.
- Once content is final, only the original user message and final assistant
content are persisted through
MemoryBackend.add().
Important consequences:
- Tool calls are executed sequentially even when the provider request includes
parallel_tool_calls=True. - Intermediate assistant/tool messages are not persisted by
Agent.run(). - Tool failures become tool-result text so the model can respond; they do not automatically abort the loop.
- An unknown tool name also becomes a tool-result message.
- Exhausting the loop stores and returns a fixed fallback message.
Tool registration and execution¶
The @tool decorator does not wrap or replace the target function. It attaches:
__agentapi_tool_name____agentapi_tool_description____agentapi_tool_context____agentapi_tool_schema__
Agent.add_tool() calls to_tool_definition() and stores the result in a dict
keyed by tool name. Registering a second tool with the same name replaces the
first.
_build_openai_tool_schema() is the canonical schema generator. It maps common
Python annotations to JSON Schema types, marks every declared property as
required for strict-mode compatibility, uses nullable types for defaulted
parameters, and forbids additional properties. Provider adapters may translate
or reduce this schema; for example, Gemini removes the unsupported
additionalProperties key.
Tool arguments cross the provider boundary as a JSON string. parse_tool_args()
returns a dictionary or raises the shared AgentProviderError for invalid JSON.
Streaming has two adaptation levels¶
AgentAPI supports two distinct streaming paths. Contributors should avoid wrapping a stream twice.
Agent-owned SSE¶
Agent.stream(message) returns a FastAPI StreamingResponse. Its internal
generator:
- Rebuilds the conversation just like
run(). - Iterates
provider.stream()and collects emitted text. - Converts each token into SSE
data:lines, preserving multi-line payloads. - On successful completion, stores the user message and concatenated assistant text.
- On an orchestration provider error, logs the exception and emits a sanitized
[ERROR]data event.
This path currently does not run tools and does not emit a [DONE] marker. When
returned from @app.chat, the existing StreamingResponse passes through as a
normal response object.
Application-owned SSE adaptation¶
If an @app.chat handler returns an async iterator directly, AgentAPI.chat()
detects __aiter__ and calls _to_sse_response(). @app.stream always requires
this iterator shape.
_to_sse_response():
- splits large provider fragments using
_sse_chunk_size; - optionally sends
: keepalivecomments after quiet intervals; - emits SSE error events for shared configuration/provider exceptions;
- emits
data: [DONE]when the source completes; and - sets no-cache, keep-alive, and nginx anti-buffering headers.
With heartbeats enabled, a producer task feeds an asyncio.Queue; cancellation
of the client-facing generator also cancels and awaits the producer.
HTTP decorator flow¶
Both route decorators preserve the original handler metadata and signature:
- The decorator captures
inspect.signature(func). - The generated async endpoint invokes sync or async handlers through
_invoke_handler(). endpoint.__signature__is reset so FastAPI sees the application's original parameters and builds the correct request/OpenAPI model.- The endpoint is registered with
self.post(path, **kwargs). - The decorator returns the original function, not the generated endpoint.
chat() maps shared AgentConfigurationError to HTTP 500 and shared
AgentProviderError to HTTP 502. stream() applies the same mapping before it
validates that the return value is an async iterator.
Error boundaries¶
There are currently two provider-error types in the codebase:
agentapi.errors.AgentProviderErroris the shared provider/transport error.agentapi.agent.agent.AgentAPIProviderErroris an orchestration wrapper that retains anoriginalexception.
Providers and tool argument parsing raise the shared type. Agent.run() and
Agent._stream_generator() currently wrap unexpected provider exceptions in the
orchestration-local type. The AgentAPI route decorators catch the shared type,
while Agent.stream() catches the orchestration-local type. This is an existing
implementation seam, not a recommendation: changes to error handling must test
both non-stream HTTP responses and errors raised during streaming.
Configuration errors are intentionally distinct. Missing named-provider keys are
raised by Agent._require_api_key(), while invalid configured values may fail
earlier during Settings construction.
Memory flow and scope resolution¶
The memory design separates identity, access, and storage:
flowchart LR
Identity[tenant/user/conversation/agent/source] --> Scope[MemoryScope]
Scope --> Session[MemorySession]
Store[Shared MemoryStore] --> Session
Agent -->|messages/add/reset| Session
Session -->|read/append/clear with scope| Store
MemoryScope.__post_init__() canonicalizes the conversation UUID and rejects
empty optional scope values. If all optional fields are absent, scope.key is
the raw UUID. Otherwise it is a SHA-256 hash of a stable JSON representation of
all fields. Human-readable metadata is persisted separately for administration.
MemorySession holds only _store and scope. Every operation delegates to the
store with that scope, which prevents a caller from accidentally reading a
different conversation through the same session object.
Backend behavior¶
InMemoryStoreguards its dictionaries with a thread lock and returns a copy of the stored list. It is safe across threads in one process, not across worker processes.RedisStorestores messages in a Redis list and metadata in a hash. Both use TTLs, but clearing a conversation deletes only the messages key.MongoDBStorestores one document perscope_key, appends with$push, and refreshes an optionalexpires_atTTL field. It indexes scope, user, tenant, and expiration fields.- The
*Memorycompatibility wrappers each create and own a private store. The*Store.session()API is the scalable path when many sessions should share one client or connection pool.
Provider translation flow¶
All providers consume the canonical message dictionaries and OpenAI-shaped tool
schemas. They return normalized data to Agent:
canonical messages + tool schemas
-> provider-specific request payload
-> remote API
-> ProviderResponse(content, ToolCall[], raw_message)
or AsyncIterator[str]
OpenAICompatibleProvider contains the reusable Chat Completions HTTP behavior.
OpenAI, OpenRouter, and Hugging Face only configure its URL and optional headers.
Gemini and Anthropic translate roles, tool declarations, tool results, response
blocks, and streaming formats in their own modules.
CLI flow¶
The console script points to agentapi.cli:main.
agentapi newvalidates project/provider input, creates a new directory, and writesmain.py,tools.py,agents.py, and.envfrom module constants.agentapi runbuilds apython -m uvicornsubprocess command and returns the child exit code.
CLI templates are public onboarding behavior. A change to a constructor, provider name, route decorator, or environment variable should be reflected in the templates and tested as part of the same contribution.