Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 36 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,41 @@
# ==========================================

# PostgreSQL & Redis Configuration
# When running locally: localhost:5432 and localhost:6379
# When running in Docker Compose: postgres:5432 and redis:6379
# When running locally: localhost:5432 and localhost:6379. Under Docker Compose the
# service names (postgres:5432, redis:6379) are supplied by the compose file itself.
#
# Credentials are configured as components, not inside connection URLs. Generate each
# password with `openssl rand -base64 24`; the generated value contains '/', '+' and '=',
# which would corrupt a hand-built URL. AgentKit escapes every component while it builds
# the URL, so the password can be used exactly as generated. An explicitly configured
# DATABASE_URL / DATABASE_READONLY_URL still wins over the components below.
# Compose refuses to start with an empty or placeholder password, and
# `scripts/check_deploy_credentials.py` rejects the image defaults (`postgres`), documented
# placeholders and passwords under 12 characters.
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_PASSWORD=change_me_generate_with_openssl_rand
POSTGRES_DB=agentkit
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/agentkit

# Dedicated read-only role for the sql_readonly tool. It must NOT be the application role:
# the tool refuses to run without it and never falls back to the application credentials.
# Compose provisions it with `scripts/init_readonly_role.py`; for a local database run that
# script yourself.
POSTGRES_READONLY_USER=agentkit_readonly
POSTGRES_READONLY_PASSWORD=change_me_generate_with_openssl_rand

# Redis requires a password in the compose stack. REDIS_PASSWORD is authoritative: when
# REDIS_URL carries no credentials of its own, the password is embedded (percent-encoded)
# automatically, so it never needs to appear inside the URL.
REDIS_PASSWORD=change_me_generate_with_openssl_rand
REDIS_URL=redis://localhost:6379/0

# Host interface the datastore ports bind to. Loopback by default; 0.0.0.0 is an explicit
# opt-in that exposes PostgreSQL and Redis to the network (see docs/DEPLOY.md).
POSTGRES_BIND=127.0.0.1
REDIS_BIND=127.0.0.1

# LLM Provider Configuration
# Supported providers: "gemini", "openai", "fake"
LLM_PROVIDER=gemini
Expand Down Expand Up @@ -40,11 +67,16 @@ MAX_STEPS=10
RUN_TIMEOUT_S=60
TOOL_TIMEOUT_S=15
SESSION_TTL_S=3600
# Maximum messages retained per session (system prompt included). Truncation never leaves a
# tool result without the assistant tool-call turn it answers.
SESSION_MAX_MESSAGES=40
TOOL_OUTPUT_MAX_CHARS=2000

# Real-infrastructure test suite (optional, test-only)
# `pytest` skips the live tier unless AGENTKIT_REAL_INFRA=1. Point these at a
# DEDICATED test database: the suite truncates runs/steps.
# REDIS_PASSWORD above is reused to authenticate the suite, so REDIS_TEST_URL needs no
# credentials; disclose them in the URL only to point at a Redis with a different password.
# AGENTKIT_REAL_INFRA=1
# DATABASE_TEST_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/agentkit_test
# REDIS_TEST_URL=redis://localhost:6379/15
19 changes: 15 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Designed from first principles for reliability, auditability, and production dep
- **Declarative Type-Safe Tools**: Register Python functions with the `@tool` decorator. Function signatures, type hints, and docstrings are introspected into strict JSON Schema and dynamic Pydantic argument validators.
- **Production Built-in Tools**:
- `calculator`: AST-parsed mathematical evaluator with strict operator whitelisting and recursion/exponent limits.
- `sql_readonly`: Read-only SQL executor with single-SELECT AST validation, comment stripping, and DDL/DML blocking.
- `sql_readonly`: Read-only SQL executor over a dedicated non-superuser role (`DATABASE_READONLY_URL`, `default_transaction_read_only`, `SELECT`-only grants, fail-closed when unconfigured). A statement lexer accepts a single `SELECT`/`WITH`, rejects comments, DDL/DML and privileged built-ins (`pg_read_file`, `setval`, `query_to_xml`, …), and clamps `LIMIT ALL`/oversized limits in SQL.
- `read_file`: Path traversal and symlink escape defended sandboxed file reader.
- `http_fetch`: SSRF-protected HTTP client blocking private subnets, loopbacks, and cloud metadata endpoints.
- `web_search`: Search provider adapter with result truncation.
Expand Down Expand Up @@ -106,8 +106,17 @@ LLM_MODEL=gemini-3.1-flash-lite
GEMINI_API_KEY=your_gemini_api_key_here
OPENAI_API_KEY=your_openai_api_key_here

# Persistence
DATABASE_URL=postgresql+asyncpg://agentkit:agentkit_password@localhost:5432/agentkit
# Persistence. Credentials are components, not parts of a URL: AgentKit escapes them
# while it builds DATABASE_URL / DATABASE_READONLY_URL, so a password may contain
# '/', '+' or '=' (as `openssl rand -base64 24` output does).
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
POSTGRES_USER=agentkit
POSTGRES_PASSWORD=agentkit_password
POSTGRES_DB=agentkit
POSTGRES_READONLY_USER=agentkit_readonly
POSTGRES_READONLY_PASSWORD=agentkit_readonly_password
REDIS_PASSWORD=redis_password
REDIS_URL=redis://localhost:6379/0

# Security (API_KEYS is required; the app refuses to start without it)
Expand Down Expand Up @@ -337,13 +346,15 @@ Redis, covering behaviour the hermetic tier structurally cannot assert:
# Start the services the tier needs
docker compose up -d postgres redis

# Run only the real-infrastructure suite
# Run only the real-infrastructure suite. REDIS_PASSWORD is completed into a
# credential-less REDIS_TEST_URL, so a hardened (--requirepass) Redis authenticates.
AGENTKIT_REAL_INFRA=1 pytest -m real_infra tests/real

# Or point it at any other PostgreSQL / Redis
AGENTKIT_REAL_INFRA=1 \
DATABASE_TEST_URL=postgresql+asyncpg://user:pass@localhost:5432/agentkit_test \
REDIS_TEST_URL=redis://localhost:6379/15 \
REDIS_PASSWORD=their_password \
pytest -m real_infra tests/real
```

Expand Down
28 changes: 25 additions & 3 deletions agentkit/api/auth.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""API key authentication dependency validating X-API-Key header."""

import hmac
import logging

from fastapi import Depends, Request, Security
Expand All @@ -23,8 +24,29 @@ async def verify_api_key(
api_key: str | None = Security(api_key_header),
settings: Settings = Depends(get_current_settings),
) -> str:
"""Validate client API key from X-API-Key header against configured authorized keys."""
if not api_key or api_key not in settings.api_keys_list:
"""Validate client API key from X-API-Key header against configured authorized keys.

The presented key is normalised the same way configured keys are, so surrounding
whitespace does not change the outcome. Comparison goes through
:func:`hmac.compare_digest` for every configured key without short-circuiting: a plain
``in`` check on a list is a timing oracle that leaks how much of a key matched.

Returns:
The normalised key, which is what rate limiting and logs use.

Raises:
AuthenticationError: If the key is missing or matches no configured key.
"""
presented = (api_key or "").strip()
matched = False
if presented:
presented_bytes = presented.encode("utf-8")
for expected in settings.api_keys_list:
# Deliberately not short-circuiting: comparing every key keeps the work (and so
# the timing) independent of which key matched.
matched |= hmac.compare_digest(presented_bytes, expected.encode("utf-8"))

if not matched:
logger.warning("Unauthorized API request with missing or invalid key.")
raise AuthenticationError("Invalid or missing API key.")
return api_key
return presented
19 changes: 17 additions & 2 deletions agentkit/api/deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,11 @@ def get_memory_store(request: Request) -> PostgresMemoryStore | MemoryStore:
)

run_store = PostgresMemoryStore(factory)
session_store = RedisMemoryStore(redis_client, ttl_s=settings.SESSION_TTL_S)
session_store = RedisMemoryStore(
redis_client,
ttl_s=settings.SESSION_TTL_S,
max_messages=settings.SESSION_MAX_MESSAGES,
)
return TieredMemoryStore(session_store=session_store, run_store=run_store)


Expand Down Expand Up @@ -150,15 +154,25 @@ def get_llm_client(
if override is not None:
return override

# Built once per application, not once per request: every provider client owns its own
# HTTP transport and connection pool, so per-request construction leaked sockets and
# defeated pooling. The lifespan closes this instance on shutdown.
managed: LLMClient | None = getattr(request.app.state, "managed_llm_client", None)
if managed is not None:
return managed

try:
return create_llm_client_from_settings(settings)
client = create_llm_client_from_settings(settings)
except Exception as exc:
logger.error("Failed to initialize LLM provider %s: %s", settings.LLM_PROVIDER, exc)
raise ServiceUnavailableError(
"llm",
f"LLM provider '{settings.LLM_PROVIDER}' could not be initialized.",
) from exc

request.app.state.managed_llm_client = client
return client


def get_agent(
request: Request,
Expand All @@ -182,6 +196,7 @@ def get_agent(
max_steps=settings.MAX_STEPS,
run_timeout_s=settings.RUN_TIMEOUT_S,
tool_timeout_s=settings.TOOL_TIMEOUT_S,
tool_output_max_chars=settings.TOOL_OUTPUT_MAX_CHARS,
)
return Agent(
llm=llm,
Expand Down
12 changes: 11 additions & 1 deletion agentkit/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,13 +63,23 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
try:
yield
finally:
# A provider client built lazily by get_llm_client is owned by this application, so
# its transport is closed here. An app.state.llm_client override is caller-owned.
managed_llm: Any = getattr(app.state, "managed_llm_client", None)
if managed_llm is not None:
try:
await managed_llm.aclose()
except Exception as exc:
logger.warning("Failed to close LLM client: %s", exc)
app.state.managed_llm_client = None

aclose_fn = getattr(redis_client, "aclose", None)
if callable(aclose_fn):
await aclose_fn()
else:
await redis_client.close()
await engine.dispose()
logger.info("Application lifespan torn down (DB & Redis resources released).")
logger.info("Application lifespan torn down (LLM, DB & Redis resources released).")


def create_app(settings: Settings | None = None) -> FastAPI:
Expand Down
39 changes: 25 additions & 14 deletions agentkit/api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
ToolsListResponse,
)
from agentkit.core.agent import Agent
from agentkit.core.errors import RunNotFoundError
from agentkit.core.errors import PersistenceError, RunNotFoundError
from agentkit.core.trace import StepTrace, TraceSink
from agentkit.memory.base import MemoryStore
from agentkit.memory.pg_store import PostgresMemoryStore
Expand Down Expand Up @@ -60,20 +60,31 @@ async def create_run(

if hasattr(memory_store, "get_run"):
run_record = await memory_store.get_run(result.run_id)
if run_record is not None:
return RunResponse(
run_id=run_record.id,
session_id=run_record.session_id,
goal=run_record.goal,
status=run_record.status,
answer=run_record.final_answer,
failure_reason=run_record.failure_reason,
total_input_tokens=run_record.total_input_tokens,
total_output_tokens=run_record.total_output_tokens,
created_at=run_record.created_at,
finished_at=run_record.finished_at,
if run_record is None:
# The agent reported a finished run that the store does not have. Synthesising a
# 200 here is what made a lost run look successful; surface the broken guarantee
# instead, so the client never believes in a record it cannot fetch.
logger.error(
"Run %s finished but could not be read back from the store.", result.run_id
)

raise PersistenceError(
f"Run {result.run_id} could not be read back after execution.",
run_id=result.run_id,
)
return RunResponse(
run_id=run_record.id,
session_id=run_record.session_id,
goal=run_record.goal,
status=run_record.status,
answer=run_record.final_answer,
failure_reason=run_record.failure_reason,
total_input_tokens=run_record.total_input_tokens,
total_output_tokens=run_record.total_output_tokens,
created_at=run_record.created_at,
finished_at=run_record.finished_at,
)

# Stores without run persistence (in-memory test doubles) can only echo the result.
now = datetime.now(UTC)
return RunResponse(
run_id=result.run_id,
Expand Down
8 changes: 7 additions & 1 deletion agentkit/api/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

from pydantic import BaseModel, ConfigDict, Field

from agentkit.db.models import SESSION_ID_MAX_LENGTH


class RunCreateRequest(BaseModel):
"""Payload to initiate a new agent execution run."""
Expand All @@ -14,7 +16,11 @@ class RunCreateRequest(BaseModel):
goal: str = Field(..., min_length=1, description="Goal or task description for the agent.")
session_id: str | None = Field(
default=None,
description="Optional conversation session ID to preserve context across multiple runs.",
max_length=SESSION_ID_MAX_LENGTH,
description=(
"Optional conversation session ID to preserve context across multiple runs. "
"Bounded to the width of the session_id column and the Redis key it becomes."
),
)


Expand Down
Loading
Loading