Immutable agent architecture with UUID-based identity
Project description
ImmAgent
An immutable agent architecture for Python. Every state transition creates a new agent with a fresh UUID—the old agent remains unchanged.
Quick Start
SimpleAgent
For quick scripts and experimentation—no database, no UUIDs, just chat:
import asyncio
from immagent import SimpleAgent
async def main():
agent = SimpleAgent(
name="Assistant",
system_prompt="You are helpful.",
model="anthropic/claude-3-5-haiku-20241022",
)
agent = await agent.advance("Hello!")
agent = await agent.advance("What's 2+2?")
for msg in agent.messages:
print(f"{msg.role}: {msg.content}")
asyncio.run(main())
PersistentAgent
For production use with full history and database persistence:
import asyncio
import immagent
async def main():
async with await immagent.Store.connect("postgresql://...") as store:
await store.init_schema()
# Create an agent (auto-saved)
agent = await store.create_agent(
name="Assistant",
system_prompt="You are helpful.",
model=immagent.Model.CLAUDE_3_5_HAIKU,
)
# Advance returns a NEW agent with a new ID (auto-saved)
agent = await agent.advance("Hello!")
# Get messages
for msg in await agent.get_messages():
print(f"{msg.role}: {msg.content}")
asyncio.run(main())
Public API
SimpleAgent
| Method | Description |
|---|---|
SimpleAgent(name, system_prompt, model) |
Create an in-memory agent |
agent.advance(input) |
Process input and return new agent |
agent.messages() |
Get all messages |
agent.last_response() |
Get last assistant response |
agent.token_usage() |
Get (input_tokens, output_tokens) |
PersistentAgent (with Store)
| Method | Description |
|---|---|
Store.connect(dsn) |
Connect to PostgreSQL |
store.close() |
Close connection pool |
store.ping() |
Check if database connection is alive |
store.init_schema() |
Create tables if not exist |
store.create_agent() |
Create and save a new agent |
store.load_agent(id) |
Load agent by UUID |
store.list_agents() |
List agents with pagination |
store.count_agents() |
Count total agents |
store.find_by_name(name) |
Find agents by exact name |
store.delete(agent) |
Delete an agent |
store.gc() |
Remove orphaned assets |
store.clear_cache() |
Clear in-memory cache |
agent.advance(input, temperature=, max_tokens=, top_p=) |
Call LLM and return new agent |
agent.get_messages() |
Get conversation messages |
agent.get_lineage() |
Walk agent's parent chain |
agent.clone() |
Clone agent with new ID |
immagent.Model |
Constants for common LLM models |
immagent.MCPManager |
MCP tool server manager |
Core Concept
# Every advance returns a NEW agent with a new ID
new_agent = await agent.advance("Hello!")
assert new_agent.id != agent.id # Different UUIDs
assert new_agent.parent_id == agent.id # Linked
Because everything is immutable:
- Safe caching — once loaded, assets never change
- Full history — follow
parent_idto trace the agent's lineage - Reproducibility — given an agent ID, you can reconstruct its exact state
Two Agent Types
SimpleAgent |
PersistentAgent |
|
|---|---|---|
| Use case | Quick scripts, experimentation | Production with full history |
| Persistence | None (in-memory only) | PostgreSQL |
| API | advance() returns new agent |
advance() returns new agent |
| IDs | None | UUID for every state |
| Lineage | None | Full parent chain |
Both use the same LLM orchestration under the hood (advance.py).
Installation
uv add immagent
Or for development:
git clone https://github.com/kgk9000/immagent-py
cd immagent-py
uv sync --all-extras
Architecture
Store
The Store is the main interface. It combines:
- Database — PostgreSQL persistence
- Cache — Weak reference cache (auto-cleanup when assets are dropped)
async with await immagent.Store.connect("postgresql://...") as store:
await store.init_schema()
# ... use store ...
Connection pool configuration:
store = await immagent.Store.connect(
"postgresql://...",
min_size=2, # Min pool connections (default: 2)
max_size=10, # Max pool connections (default: 10)
max_inactive_connection_lifetime=300, # Idle timeout in seconds (default: 300)
)
Assets
Everything is an Asset with a UUID and timestamp:
@dataclass(frozen=True)
class Asset:
id: UUID
created_at: datetime
Asset types:
SystemPrompt— the agent's system promptMessage— user, assistant, or tool messagesPersistentAgent— the agent itself
PersistentAgent
@dataclass(frozen=True)
class PersistentAgent(Asset):
name: str
system_prompt_id: UUID # References a SystemPrompt
parent_id: UUID | None # Previous agent state
conversation_id: UUID # References a Conversation
model: str # LiteLLM model string
Agents are bound to a Store and have methods for interaction. When you create or load an agent, it's automatically registered with that Store via an internal weak-reference registry. This lets you call agent.advance() directly without passing the store—the agent knows which store it belongs to. When the agent is garbage collected, the binding is automatically cleaned up.
# Create via store (auto-saved)
agent = await store.create_agent(
name="Bot",
system_prompt="You are helpful.",
model=immagent.Model.CLAUDE_3_5_HAIKU,
)
# Interact via agent methods (auto-saved)
agent = await agent.advance("Hello!")
messages = await agent.get_messages()
lineage = await agent.get_lineage()
Advancing
agent.advance() is the main entry point:
- Load conversation history and system prompt
- Add the user message
- Call the LLM (via LiteLLM)
- If tool calls requested, execute via MCP and loop
- Create new
Conversationwith all messages - Create new
PersistentAgentwithparent_idpointing to the old agent - Save to database and cache
- Return the new agent
Configuration options:
agent = await agent.advance(
"Hello",
max_retries=3, # Retry on transient failures (default: 3)
timeout=120.0, # Request timeout in seconds (default: 120)
max_tool_rounds=10, # Max tool-use loops (default: 10)
)
Auto-Save
All mutations are automatically persisted. There's no need to call save() manually:
agent = await store.create_agent(...) # Saved immediately
agent = await agent.advance("Hello") # Saved immediately
The cache uses weak references: items are saved to the database first, then cached. When you drop an asset, it's automatically removed from the cache.
Token Tracking
Assistant messages include token usage from each LLM call:
messages = await agent.get_messages()
for msg in messages:
if msg.role == "assistant":
print(f"Input: {msg.input_tokens}, Output: {msg.output_tokens}")
# Total usage for a conversation
total_input = sum(m.input_tokens or 0 for m in messages)
total_output = sum(m.output_tokens or 0 for m in messages)
LLM Providers
Uses LiteLLM for multi-provider support. Use the Model enum for common models:
# Anthropic
immagent.Model.CLAUDE_3_5_HAIKU
immagent.Model.CLAUDE_SONNET_4
immagent.Model.CLAUDE_OPUS_4
# OpenAI
immagent.Model.GPT_4O
immagent.Model.GPT_4O_MINI
immagent.Model.O1
immagent.Model.O1_MINI
Or pass any LiteLLM model string directly:
model="bedrock/anthropic.claude-3-sonnet-20240229-v1:0"
Set the appropriate API key:
export ANTHROPIC_API_KEY=sk-ant-...
export OPENAI_API_KEY=sk-...
MCP Tools
Agents can use tools via Model Context Protocol:
async with immagent.MCPManager() as mcp:
await mcp.connect(
"filesystem",
command="npx",
args=["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
)
agent = await agent.advance("List files in /tmp", mcp=mcp)
The agent will automatically discover and use available tools.
Writing MCP Servers
You can create custom MCP servers in Python. See examples/weather_server.py for a complete example:
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import Tool, TextContent
server = Server("my-server")
@server.list_tools()
async def list_tools() -> list[Tool]:
return [
Tool(
name="my_tool",
description="Does something useful",
inputSchema={"type": "object", "properties": {...}},
)
]
@server.call_tool()
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
# Implement your tool logic here
return [TextContent(type="text", text="Result")]
async def main():
async with stdio_server() as (read, write):
await server.run(read, write, server.create_initialization_options())
Error Handling
Custom exceptions for precise error handling:
try:
agent = await store.create_agent(name="", ...)
except immagent.ValidationError as e:
print(f"Invalid {e.field}: {e}") # "Invalid name: name: must not be empty"
try:
agent = await agent.advance("Hello")
except immagent.LLMError as e:
print(f"LLM call failed: {e}")
except immagent.ImmAgentError as e:
print(f"Agent error: {e}")
Exception hierarchy:
ImmAgentError— base exceptionValidationError— input validation failedAssetNotFoundError— asset lookup failedAgentNotFoundErrorMessageNotFoundError
AgentNotRegisteredError— agent not associated with a storeLLMError— LLM call failedToolExecutionError— MCP tool execution failed
Logging
Enable logging for debugging and observability:
import logging
# Enable debug logging for immagent
logging.basicConfig(level=logging.DEBUG)
# Or configure specifically
immagent_logger = logging.getLogger("immagent")
immagent_logger.setLevel(logging.DEBUG)
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter("%(name)s - %(levelname)s - %(message)s"))
immagent_logger.addHandler(handler)
Log output includes:
- LLM requests/responses with timing and token usage
- MCP tool connections and executions
- Agent state transitions
Development
# Install dev dependencies
make dev
# Format code
make fmt
# Lint
make lint
# Type check
make typecheck
# Run all checks (lint + typecheck)
make check
# Run tests (requires Docker for PostgreSQL)
make test
# Run with coverage
make test-cov
API Keys
For LLM integration tests, create a .env file with your API key:
# Option 1: Create .env directly
echo 'export ANTHROPIC_API_KEY=sk-ant-...' > .env
# Option 2: Symlink to existing env file
ln -s ~/.env/anthropic.env .env
See .env.example for the expected format. The .env file is gitignored.
Running Tests
Tests use testcontainers to spin up PostgreSQL in Docker:
# Make sure Docker is running
docker ps
# Run all tests (sources .env automatically)
make test
Project Structure
src/immagent/
├── __init__.py # Public API exports
├── advance.py # Pure LLM orchestration (no persistence)
├── assets.py # Asset base class, SystemPrompt
├── exceptions.py # Custom exception types
├── llm.py # LiteLLM wrapper with retries/timeout
├── logging.py # Logging configuration
├── mcp.py # MCP client for tools
├── messages.py # Message, ToolCall, Conversation
├── persistent.py # PersistentAgent dataclass
├── registry.py # Agent-to-store mapping (WeakKeyDictionary)
├── simple.py # SimpleAgent for quick scripts
└── store.py # Store (main interface - cache + db)
Design Decisions
- Frozen dataclasses — Simple, Pythonic, no ORM magic
- Weak ref cache — Auto-cleanup when assets are dropped, no size tuning
- Write-through — Save to DB immediately, then cache; losing cache entries is safe
- Agent-store binding — WeakKeyDictionary maps agents to stores; auto-cleanup when agents are garbage collected
- Pure advance function — LLM orchestration is a pure function (data in, messages out); Store handles persistence around it
- Persistence on assets — Each asset type knows how to serialize itself (
from_row,to_insert_params) - Token tracking —
input_tokens/output_tokenson assistant messages - MCP for tools — Standard protocol instead of custom tool system
- LiteLLM — Multi-provider LLM support without custom abstractions
- Testcontainers — Examples and tests work without manual DB setup
Why Immutability?
PersistentAgent ──→ Conversation ──→ [Message UUIDs] ──→ Messages
│
├──→ SystemPrompt
│
└──→ parent_id (previous agent state)
- Safe caching — Assets never change after creation
- Full history — Walk
parent_idchain to trace lineage - Efficient sharing — Ancestors share messages via UUID references
- No partial state — If
advance()fails, original agent is unchanged
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file immagent-0.1.2.tar.gz.
File metadata
- Download URL: immagent-0.1.2.tar.gz
- Upload date:
- Size: 22.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b65258c0f7e258b64047b4c61e734d386775dfe77b2528fbaa63156d84798d99
|
|
| MD5 |
5467660c2c0a3a8d0d7e4e8a78d447b1
|
|
| BLAKE2b-256 |
5ff8b959c5daae6ee5907cc160ddde4c3c51fa20c74115d51f8ef3ce3901df86
|
Provenance
The following attestation bundles were made for immagent-0.1.2.tar.gz:
Publisher:
publish.yml on kgk9000/immagent-py
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
immagent-0.1.2.tar.gz -
Subject digest:
b65258c0f7e258b64047b4c61e734d386775dfe77b2528fbaa63156d84798d99 - Sigstore transparency entry: 791473731
- Sigstore integration time:
-
Permalink:
kgk9000/immagent-py@91084a3377a82bea51d02eec15ef1556adef3ca3 -
Branch / Tag:
refs/tags/v0.1.2 - Owner: https://github.com/kgk9000
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@91084a3377a82bea51d02eec15ef1556adef3ca3 -
Trigger Event:
release
-
Statement type:
File details
Details for the file immagent-0.1.2-py3-none-any.whl.
File metadata
- Download URL: immagent-0.1.2-py3-none-any.whl
- Upload date:
- Size: 29.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
bb242fc3a9ee0b1bec901ad26d16df193d02bf5c153adcd996900c9c1ff02b50
|
|
| MD5 |
f7d5e230ed32fd2dced4dbcba329a225
|
|
| BLAKE2b-256 |
4e46df4e7658907b8a5f43a0e2c9f30b7ed219a3c3e475f6eaf9f3b7f5117e3d
|
Provenance
The following attestation bundles were made for immagent-0.1.2-py3-none-any.whl:
Publisher:
publish.yml on kgk9000/immagent-py
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
immagent-0.1.2-py3-none-any.whl -
Subject digest:
bb242fc3a9ee0b1bec901ad26d16df193d02bf5c153adcd996900c9c1ff02b50 - Sigstore transparency entry: 791473739
- Sigstore integration time:
-
Permalink:
kgk9000/immagent-py@91084a3377a82bea51d02eec15ef1556adef3ca3 -
Branch / Tag:
refs/tags/v0.1.2 - Owner: https://github.com/kgk9000
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@91084a3377a82bea51d02eec15ef1556adef3ca3 -
Trigger Event:
release
-
Statement type: