Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
929c4de
fix(langchain): preserve message fidelity across checkpoints, REST, a…
claude Aug 24, 2026
e36952b
fix(security,storage,pipelines,optimize,deploy): production-readiness…
claude Aug 24, 2026
0184928
fix(security): close Studio API auth bypass and control-plane disable…
claude Aug 24, 2026
8f85dae
fix: repair LangChain history/tool-call plumbing, CLI run, and OpenAP…
claude Aug 24, 2026
e9dd0bc
fix(security): confine ingestion paths, sanitise API errors, redact s…
claude Aug 24, 2026
f6dcd1c
fix(templates): repair broken plugin scaffold and make all 16 render …
claude Aug 24, 2026
00d8aec
fix(scaffold,studio): stop leaking server paths, fix resume crash, qu…
claude Aug 24, 2026
c785447
fix(plugins): make scaffolded ML plugins actually serve, and stop sil…
claude Aug 24, 2026
9c6bc57
fix(logging,storage): one log line per request, one separator, one DD…
claude Aug 24, 2026
88905dc
fix(cli): honour --dir as a parent directory for `new`, clarify query…
claude Aug 24, 2026
8b25f1f
security: sweep the two bypass classes exhaustively, not by inspection
claude Aug 24, 2026
b8909de
security: close the async-path exception leaks the first sanitisation…
claude Aug 24, 2026
248ed14
fix(api): store-dependent routes returned a bare 500 when no store is…
claude Aug 24, 2026
a0ac17d
fix(api,studio): two more unhandled 500s, and sweep every route so th…
claude Aug 24, 2026
e557237
fix(studio,api): repair a checkpoint regression and five more error l…
claude Aug 24, 2026
dc48981
fix(studio): stop the run stream emitting two lifecycle events per run
claude Aug 24, 2026
1f07c31
fix(cli,core): survive a version-skewed image and honour log_level at…
claude Aug 24, 2026
6b5e741
fix(deploy): make the distroless image actually start
claude Aug 24, 2026
429a567
docs(storage): correct the checkpointer serialization note
claude Aug 24, 2026
8208eba
docs(langgraph): say when Studio's State and History panels stay empty
claude Aug 24, 2026
a59aa62
fix(a2a,cli): stop dropping A2A message text, and name the deepagent …
claude Aug 24, 2026
dedcb8d
test: sweep every route in the store-backed posture too
claude Aug 24, 2026
9a4e0a0
fix(auth): make require_auth_globally with an API key a working confi…
claude Aug 24, 2026
8ea26c2
feat(auth): let AUTH__JWKS_URL and the stack actually configure JWT v…
claude Aug 24, 2026
8188325
fix(pipelines): discover the layout the CLI actually scaffolds
claude Aug 24, 2026
b9a1cda
docs(rate-limit): state where the proxy-header trust boundary actuall…
claude Aug 24, 2026
3388434
test: stop requiring the openai extra to run the suite
claude Aug 24, 2026
54b5dc3
build: pin hatchling below the metadata version twine rejects
claude Aug 24, 2026
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
15 changes: 13 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ agentomatic deploy --profile minimal --stack remote # production-lean
| Command | Purpose |
| --- | --- |
| `agentomatic new NAME` | Scaffold a full project (alias for `init --project`) |
| `agentomatic init NAME [--project] [--template basic\|full\|class\|rag\|chatbot\|coordinator\|extraction\|deepagent\|legacy_dict\|ingestion]` | Scaffold an agent (or project) |
| `agentomatic init NAME [--project] [--template basic\|full\|class\|rag\|chatbot\|coordinator\|extraction\|deepagent\|legacy_dict\|ingestion\|langchain]` | Scaffold an agent (or project) |
| `agentomatic add connection\|ingestion NAME` | Add components to an existing agent |
| `agentomatic run [--studio/--no-studio] [--with-ui] [--port 8000] [--ssl-certfile ...] [--require-auth-globally]` | Start the platform (prefers `main:app` when present) |
| `agentomatic deploy [--profile full\|minimal] [--minimal] [--distroless] [--stack NAME]` | Generate Dockerfile/compose/.env |
Expand Down Expand Up @@ -103,6 +103,13 @@ fully-featured, **env-driven** `app` — nothing is silently dropped.

Agents mount at **`/api/v1/{agent_name}/invoke`** (no `/agents/` segment).

!!! note "`query` on the wire, `current_query` in state"

The REST body field is **`query`** (`{"query": "..."}`); posting
`current_query` returns 422. The framework normalises it, so the dict your
`input_to_state` receives has **`current_query`** — which is why the example
above reads `data.get("current_query", "")`. `/chat` uses `content` instead.

### Deploy profiles

- **full** (default): everything on — REST API, Swagger, Studio UI, health,
Expand All @@ -123,12 +130,16 @@ into the image/compose that drive the same `main.py`.
| `AGENTOMATIC_ENABLE_METRICS` | Prometheus metrics (default on) |
| `AGENTOMATIC_LOG_LEVEL` | Log level (default `INFO`; `WARNING` in minimal) |
| `AGENTOMATIC_ENABLE_AUTH` / `AGENTOMATIC_API_KEY` | API-key auth |
| `AGENTOMATIC_ENABLE_JWT` | JWT auth (configure JWKS via stack) |
| `AGENTOMATIC_ENABLE_JWT` | JWT auth (JWKS from `AUTH__JWKS_URL` / `AUTH__ISSUER` / `AUTH__AUDIENCE`, or the active stack's `auth:` block) |
| `AGENTOMATIC_REQUIRE_AUTH` | Require auth globally (implies JWT + zero-trust) |
| `AGENTOMATIC_ENABLE_CONTROL_PLANE` / `AGENTOMATIC_CONTROL_TOKEN` | Control plane |
| `AGENTOMATIC_ENABLE_RATE_LIMIT` | Rate limiting |
| `AGENTOMATIC_LOGS_HISTORY` | Persist per-agent invoke/chat/stream history (default off) |
| `AGENTOMATIC_ALLOW_LOGSLLM_ANALYSIS` | Enable LLM analysis over those logs (default off) |
| `AGENTOMATIC_INGESTION_ROOT` | Confine ingestion source/output paths to this dir (default: cwd) |
| `AGENTOMATIC_DEBUG_ERRORS` | Return raw exception text in API errors (default off — dev only) |
| `AGENTOMATIC_OTEL_CONSOLE` | Print OpenTelemetry spans to stdout (default off) |
| `AGENTOMATIC_RATE_LIMIT_TRUST_PROXY_HEADERS` | Honour X-Forwarded-For for rate-limit keys (default off) |
| `AGENTOMATIC_TITLE` | Platform title |
| `AGENTOMATIC_STACK` | Active stack name |
| `AGENTOMATIC_AGENTS` | Comma-separated allow-list scoping agent discovery |
Expand Down
5 changes: 5 additions & 0 deletions Dockerfile.distroless
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,8 @@ EXPOSE 8000
# distroless images boot through the same ``agentomatic run`` entrypoint.
ENTRYPOINT ["/app/.venv/bin/python", "/app/.venv/bin/agentomatic"]
CMD ["run", "--agents-dir", "agents", "--host", "0.0.0.0", "--port", "8000"]

# No shell and no curl in this image — hit /health with the venv Python
# instead (exec form, so no shell is needed to run this CMD either).
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD ["/app/.venv/bin/python", "-c", "import urllib.request as u; u.urlopen('http://localhost:8000/health', timeout=5)"]
12 changes: 11 additions & 1 deletion docs/architecture/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -376,10 +376,20 @@ Submit an A2A protocol task.

| Field | Type | Required | Description |
|---|---|---|---|
| `message` | `object` | ✅ | A2A message with `content` field |
| `message` | `object` | ✅ | A2A message — protocol `parts`, or a `content` / `text` string |
| `metadata` | `object` | | Extra metadata |

The protocol form carries text in `parts`; the simplified `content` string is
also accepted. A message with no readable text is rejected with `422` rather
than silently running the agent on an empty query.

```bash
# Protocol form
curl -X POST http://localhost:8000/api/v1/my_agent/a2a/tasks \
-H "Content-Type: application/json" \
-d '{"message": {"role": "user", "parts": [{"type": "text", "text": "Analyze this dataset"}]}}'

# Simplified form
curl -X POST http://localhost:8000/api/v1/my_agent/a2a/tasks \
-H "Content-Type: application/json" \
-d '{"message": {"content": "Analyze this dataset"}}'
Expand Down
25 changes: 24 additions & 1 deletion docs/getting-started/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,30 @@ If you prefer a lightweight install, you can select only the modules and depende
| `studio` | `pip install agentomatic[studio]` | Agentomatic Studio visual debugger |
| `optimize` | `pip install agentomatic[optimize]` | DSPy-style optimizer loop + DeepEval validation |
| `telemetry` | `pip install agentomatic[telemetry]` | OpenTelemetry APM tracing exporters |
| `all` | `pip install agentomatic[all]` | Installs all components and drivers above |
| `all` | `pip install "agentomatic[all]"` | Everything except the vendor LLM drivers and the Chainlit UI — see the note below |

!!! warning "What `all` does *not* include"

`all` covers `langgraph`, `ollama`, `metrics`, `db`, `cli`, `studio`,
`optimize`, `telemetry`, `dotenv`, `security`, `swarm` and `vector`.

It deliberately leaves out the vendor LLM drivers — `openai`, `azure`,
`vertex` — which follow the provider-agnostic principle: you install the
SDK for the backend you actually use. It also leaves out `db-postgres`
(an alternative to `db`) and `ui` (Chainlit), which is a heavy dependency.

So `agentomatic ui` needs `pip install "agentomatic[ui]"` even after an
`all` install. Add what you need alongside it:

```bash
pip install "agentomatic[all,openai,ui]"
```

!!! tip "Quote the extras in zsh/bash"

Square brackets are glob syntax in most shells, so quote them:
`pip install "agentomatic[all]"`. Unquoted, zsh fails with
`no matches found`.

!!! note "Combining extras"
You can combine multiple extras in a single install command:
Expand Down
22 changes: 22 additions & 0 deletions docs/guide/control-plane.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,28 @@ flowchart LR
- A disabled agent → only that agent's routes return `503`.
- The control plane itself remains reachable so you can turn things back on.

!!! warning "Control-plane state is per-process and does not survive a restart"

`maintenance_mode` and the set of disabled agents live in memory for the
lifetime of the process. They are **not** persisted, so any restart — a
redeploy, a crash-restart, a rolling update, or scaling to a new replica —
resets them to "maintenance off, nothing disabled".

Two consequences worth planning around in production:

- An agent you drained comes back **live** after a deploy. If a drain has
to outlast a restart, enforce it upstream (load balancer, ingress rule,
or by removing the agent from `AGENTOMATIC_AGENTS`) rather than relying
on the control plane alone.
- With more than one replica, a control call only affects the **replica
that served it**. Route control requests to every replica, or treat the
control plane as a per-instance debugging tool rather than a
fleet-wide switch.

Both toggles are enforced on the hot request path (not merely reported),
and an agent mounted under both its folder name and its manifest slug is
disabled under both aliases.

## Typical rollout flow

1. Enable maintenance mode before a risky migration.
Expand Down
43 changes: 43 additions & 0 deletions docs/guide/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,14 @@ agentomatic deploy --profile minimal --stack remote --distroless
agentomatic deploy --minimal --stack remote # shorthand
```

!!! note "Distroless images pin Python 3.11"
`gcr.io/distroless/python3-debian12` is Debian 12's Python **3.11**, so the
distroless build stage uses `python:3.11-slim` and installs dependencies
with `pip --target=/app/deps` (on `PYTHONPATH`) rather than into a
virtualenv — a venv's `bin/python` points at the *build* stage's
interpreter, which does not exist in the runtime image. If you customise
the generated Dockerfile, keep the two Python versions in step.

!!! warning "Swagger is always available"
`--profile minimal` **never** disables `/docs`, `/redoc`, or
`/openapi.json`. It only sets `AGENTOMATIC_ENABLE_STUDIO=0` and
Expand Down Expand Up @@ -477,6 +485,41 @@ Run it:
`AGENTOMATIC_TITLE`, `AGENTOMATIC_LOG_LEVEL`), so `uvicorn main:app` in the
generated Dockerfile drops no functionality versus running the CLI.

!!! tip "Turning on verified JWT auth"
`AGENTOMATIC_ENABLE_JWT=1` alone gives you a middleware with nothing to
verify against. Point it at your identity provider's JWKS endpoint with
`AUTH__JWKS_URL` (plus `AUTH__ISSUER` / `AUTH__AUDIENCE`), or set the same
values in the active stack's `auth:` block — `agentomatic deploy` writes
them into the generated `.env` for you. Environment wins over the stack.

```bash
AGENTOMATIC_REQUIRE_AUTH=1
AUTH__JWKS_URL=https://idp.example.com/.well-known/jwks.json
AUTH__ISSUER=https://idp.example.com/
AUTH__AUDIENCE=agentomatic
```

With `AGENTOMATIC_REQUIRE_AUTH=1` and no JWKS, the platform will not accept
unsigned tokens: it enforces with your API key if one is configured
(`AGENTOMATIC_ENABLE_AUTH=1` + `AGENTOMATIC_API_KEY`), and refuses to start
if neither is set.

!!! warning "Rate limiting behind a proxy"
The limiter keys on the client address, so behind a reverse proxy every
request looks like it comes from the proxy — set
`AGENTOMATIC_RATE_LIMIT_TRUST_PROXY_HEADERS=1` so `X-Forwarded-For` is used
instead. Leave it **off** when the platform is exposed directly: the header
is caller-controlled, and rotating it per request would bypass the limiter.

That flag governs Agentomatic's own reading of the header. Uvicorn's
`--proxy-headers` (on by default) separately rewrites `request.client` from
`X-Forwarded-For` for peers in `--forwarded-allow-ips` (default
`127.0.0.1`), before any middleware runs. Keep that list limited to your
real proxy — a caller connecting *from* an allowed peer address can steer
the rate-limit key regardless of the flag above. The generated
`nginx.conf` sits on that trusted hop, which is why it is the right place
to set the header.

!!! warning "Workers and in-memory state"
Connection pools and per-process caches live **per worker**. Keep shared
state (threads, memory, cache) in external services (Postgres, redis) so it
Expand Down
20 changes: 18 additions & 2 deletions docs/guide/langgraph.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,16 @@ metadata: Annotated[dict, _merge_dicts]

The Studio adapter extracts state from the LangGraph checkpointer automatically:

!!! warning "Requires a checkpointer"
These endpoints read the graph's checkpointer, and nothing else writes
checkpoints. An agent whose `build_graph` returns `self.new_graph().compile()`
(the native `GraphBuilder` runtime) has no checkpointer, so `/state` returns
`{}` and `/history` returns `[]` — Studio's State and History panels stay
empty even though the thread's chat messages are persisted separately.
To get thread state and time-travel, build the graph with LangGraph's
`StateGraph` and compile it with `AgentomaticCheckpointer` as shown in
[Storage](storage.md#checkpointer-api).

```bash
# Get current thread state
curl http://localhost:8000/studio/agents/my_agent/threads/thread_001/state
Expand Down Expand Up @@ -524,7 +534,7 @@ sequenceDiagram
participant ST as BaseStore (Memory/SQL)

LG->>CP: aput(config, checkpoint, metadata)
CP->>CP: _ensure_json_serializable(checkpoint)
CP->>CP: encode_for_storage(checkpoint)
CP->>ST: save_checkpoint(thread_id, ns, id, data)
ST-->>CP: saved
CP-->>LG: RunnableConfig
Expand Down Expand Up @@ -574,7 +584,13 @@ graph = builder.compile(checkpointer=checkpointer)
```

!!! tip "Safe Serialization"
The checkpointer automatically handles non-JSON-serializable objects (datetimes, bytes, custom classes) via `_ensure_json_serializable()`. No extra configuration needed.
The checkpointer automatically handles non-JSON-serializable objects — datetimes,
bytes, custom classes, and LangChain `BaseMessage` objects (`HumanMessage`,
`AIMessage`, `ToolMessage`, ...) — via LangGraph's own `JsonPlusSerializer`
(`encode_for_storage()` / `decode_from_storage()`). Messages round-trip back
into real message objects, not stringified reprs, so `add_messages` and
`prompt | llm` chains keep working across checkpoint resumes. No extra
configuration needed.

### Replaying from Checkpoints

Expand Down
26 changes: 20 additions & 6 deletions docs/guide/platform-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -353,17 +353,31 @@ platform.register_after_node_hook(audit_output_hook)

## 🛡️ 8. Safe Checkpoint Serialization

LangGraph checkpoints can contain non-JSON-serializable Python objects (datetimes, bytes, custom classes). The `AgentomaticCheckpointer` automatically handles this via a safe JSON round-trip:
LangGraph checkpoints can contain non-JSON-serializable Python objects — datetimes,
bytes, custom classes, and (critically) LangChain `BaseMessage` objects
(`HumanMessage`, `AIMessage`, `ToolMessage`, ...) sitting in channel values like
`messages`. The `AgentomaticCheckpointer` automatically handles this using
LangGraph's own `JsonPlusSerializer`, which round-trips these objects **without
losing their type or structure**:

```python
from agentomatic.storage.checkpointer import AgentomaticCheckpointer, _ensure_json_serializable
from agentomatic.storage.checkpointer import decode_from_storage, encode_for_storage

# Objects like datetimes, bytes, and custom classes are safely converted
data = _ensure_json_serializable({"ts": datetime.now(), "raw": b"bytes"})
# → {"ts": "2026-06-14 12:00:00", "raw": "b'bytes'"}
# Rich objects are encoded into a JSON-safe wrapper for storage...
data = encode_for_storage({"ts": datetime.now(), "raw": b"bytes"})
# → {"__agentomatic_serde_type__": "msgpack", "__agentomatic_serde_data__": "<base64>"}

# ...and decoded back into the *original* Python objects, not strings.
restored = decode_from_storage(data)
# → {"ts": datetime(...), "raw": b"bytes"}
```

This happens transparently inside `aput()` — no user configuration needed. All values are round-tripped through `json.dumps(obj, default=str)` → `json.loads()`.
This happens transparently inside `aput()`/`aget_tuple()` — no user configuration
needed. Unlike a naive `json.dumps(obj, default=str)`, a `HumanMessage`/`AIMessage`
stored in `channel_values["messages"]` comes back as a real message object (with
`tool_calls`, `tool_call_id`, etc. intact) on the next `graph.ainvoke()`, so the
LangGraph `add_messages` reducer and any `prompt | llm` chain keep working across
checkpoint resumes.

---

Expand Down
9 changes: 7 additions & 2 deletions docs/guide/storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -404,8 +404,13 @@ The checkpointer implements the full LangGraph `BaseCheckpointSaver` interface:
| `list(config, ...)` | List checkpoints (sync wrapper, returns `Iterator`) |
| `alist(config, ...)` | List checkpoints (async, returns `AsyncIterator`) |

!!! info "JSON Serialization"
The checkpointer automatically ensures all checkpoint and metadata values are JSON-serializable using a `default=str` fallback. Custom objects, datetimes, and bytes are converted to their string representation.
!!! info "Serialization"
Checkpoints and metadata are serialized with LangGraph's own
`JsonPlusSerializer`, so LangChain `BaseMessage` subclasses, pydantic
models, dataclasses, datetimes, and bytes round-trip as themselves — a
reloaded thread gives you back real `HumanMessage` / `AIMessage` objects,
not their `repr()`. Payloads written before this used a lossy
`default=str` fallback and are returned as-is on read (best effort).

---

Expand Down
7 changes: 6 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,12 @@ docs = [
]

[build-system]
requires = ["hatchling"]
# Upper-bounded deliberately: hatchling 1.30+ emits ``Metadata-Version: 2.5``,
# which current twine rejects ("'2.5' is not a valid metadata version") — and
# the release workflow publishes through pypa/gh-action-pypi-publish, which
# verifies metadata with twine before upload. Lift the bound once twine and
# PyPI accept 2.5.
requires = ["hatchling>=1.27,<1.30"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
Expand Down
Binary file modified src/__pycache__/__init__.cpython-312.pyc
Binary file not shown.
7 changes: 6 additions & 1 deletion src/agentomatic/agents/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1258,7 +1258,12 @@ async def node_fn(state: dict[str, Any]) -> dict[str, Any]:
"""Node function adapter for registry."""
input_data = {
"query": state.get("current_query", ""),
**{k: v for k, v in state.items() if k not in ("messages", "thread_id")},
# Forward ``messages``/``thread_id`` too — see the rationale in
# ``agentomatic.core.agent_invoke._input_from_state``: they are
# required by LangChain-style agents (MessagesPlaceholder
# history and RunnableConfig thread_id) and were previously
# unreachable from ``input_to_state``.
**state,
}
return await self.atransform(input_data)

Expand Down
35 changes: 26 additions & 9 deletions src/agentomatic/agents/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@

from loguru import logger

from agentomatic.core.errors import client_safe_detail

from .types import StateT, TraceEvent

# Sentinel for "end of graph"
Expand Down Expand Up @@ -308,11 +310,18 @@ async def astream(self, state: StateT) -> AsyncGenerator[dict[str, Any], None]:
import copy

def _state_to_dict(s: Any) -> dict[str, Any]:
from agentomatic.langchain_adapter import to_jsonable

if hasattr(s, "model_dump"):
return s.model_dump()
if hasattr(s, "__dict__"):
return vars(s)
return dict(s) if isinstance(s, dict) else {}
raw = s.model_dump()
elif hasattr(s, "__dict__"):
raw = vars(s)
else:
raw = dict(s) if isinstance(s, dict) else {}
# Convert any LangChain BaseMessage objects in state fields (e.g.
# ``messages: list[BaseMessage]``) to plain dicts so this event
# payload is safe to json.dumps downstream (SSE / REST).
return cast(dict[str, Any], to_jsonable(raw))

errors = self.validate()
if errors:
Expand Down Expand Up @@ -380,11 +389,18 @@ def _now_iso() -> str:
return datetime.now(UTC).isoformat()

def _state_to_dict(s: Any) -> dict[str, Any]:
from agentomatic.langchain_adapter import to_jsonable

if hasattr(s, "model_dump"):
return s.model_dump()
if hasattr(s, "__dict__"):
return vars(s)
return dict(s) if isinstance(s, dict) else {}
raw = s.model_dump()
elif hasattr(s, "__dict__"):
raw = vars(s)
else:
raw = dict(s) if isinstance(s, dict) else {}
# Convert any LangChain BaseMessage objects in state fields (e.g.
# ``messages: list[BaseMessage]``) to plain dicts so this event
# payload is safe to json.dumps downstream (SSE / REST).
return cast(dict[str, Any], to_jsonable(raw))

yield {
"event": "run_start",
Expand Down Expand Up @@ -435,7 +451,8 @@ def _state_to_dict(s: Any) -> dict[str, Any]:
"run_id": run_id,
"node": current_node_name,
"timestamp": _now_iso(),
"data": {"error": str(exc)},
# Streamed to Studio clients — sanitise (full detail is logged).
"data": client_safe_detail(exc, context="Node failed"),
}
raise RuntimeError(f"Node '{current_node_name}' failed: {exc}") from exc

Expand Down
Loading
Loading