Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions api/entrypoints/routers.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@

from oss.src.middlewares.auth import auth_middleware
from oss.src.middlewares.analytics import analytics_middleware
from oss.src.middlewares.prefix import ApiPrefixStripMiddleware

from oss.src.core.auth.supertokens.config import init_supertokens

Expand Down Expand Up @@ -482,6 +483,10 @@ async def lifespan(*args, **kwargs):
allow_headers=["Content-Type"] + get_all_supertokens_cors_headers(),
)


# Added last => outermost: normalizes the path before auth/routing see it.
app.add_middleware(ApiPrefixStripMiddleware)

if ee and is_ee():
app = ee.extend_main(app)

Expand Down
27 changes: 27 additions & 0 deletions api/oss/src/middlewares/prefix.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
class ApiPrefixStripMiddleware:
"""Strip leading `/api` prefixes so hops that don't strip it (e.g. an ALB) still route.

Local traefik strips `/api` before the app; a direct container hop has no prefix; an AWS
ALB forwards the public `/api/...` path verbatim. Routes live at root (`root_path="/api"`
is docs metadata), so accepting both shapes here makes every topology work with one URL.
Strips in a loop, not once: a double-prefixed caller (`/api/api/...`) still routes.
"""

def __init__(self, app):
self.app = app

async def __call__(self, scope, receive, send):
if scope["type"] in ("http", "websocket"):
path = scope.get("path", "")
raw = scope.get("raw_path")
stripped = False
while path == "/api" or path.startswith("/api/"):
path = path[4:] or "/"
if isinstance(raw, (bytes, bytearray)) and raw[:4] == b"/api":
raw = bytes(raw)[4:] or b"/"
stripped = True
if stripped:
scope = dict(scope)
scope["path"] = path
scope["raw_path"] = raw
Comment on lines +23 to +26
await self.app(scope, receive, send)
Empty file.
64 changes: 64 additions & 0 deletions api/oss/tests/pytest/unit/middlewares/test_prefix.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import pytest

from oss.src.middlewares.prefix import ApiPrefixStripMiddleware


def _scope(path: str, raw_path: bytes | None = None) -> dict:
scope = {"type": "http", "path": path}
if raw_path is not None:
scope["raw_path"] = raw_path
return scope


async def _run(scope: dict) -> dict:
captured: dict = {}

async def app(inner_scope, receive, send):
captured["scope"] = inner_scope

async def receive():
return {}

async def send(message):
pass

await ApiPrefixStripMiddleware(app)(scope, receive, send)
return captured["scope"]


@pytest.mark.asyncio
async def test_strips_single_api_prefix():
scope = await _run(_scope("/api/sessions/streams/", b"/api/sessions/streams/"))
assert scope["path"] == "/sessions/streams/"
assert scope["raw_path"] == b"/sessions/streams/"


@pytest.mark.asyncio
async def test_strips_double_api_prefix():
# The bug this middleware exists to guard against: a caller that double-prefixes must
# still route rather than 404 on a single strip pass.
scope = await _run(
_scope("/api/api/sessions/streams/", b"/api/api/sessions/streams/")
)
assert scope["path"] == "/sessions/streams/"
assert scope["raw_path"] == b"/sessions/streams/"


@pytest.mark.asyncio
async def test_bare_api_root_strips_to_slash():
scope = await _run(_scope("/api"))
assert scope["path"] == "/"

Comment on lines +47 to +51

@pytest.mark.asyncio
async def test_no_prefix_left_untouched():
scope = await _run(_scope("/sessions/streams/", b"/sessions/streams/"))
assert scope["path"] == "/sessions/streams/"
assert scope["raw_path"] == b"/sessions/streams/"


@pytest.mark.asyncio
async def test_path_that_merely_starts_with_api_word_is_untouched():
# `/apiary` must not be mistaken for a prefixed `/api` path.
scope = await _run(_scope("/apiary/thing"))
assert scope["path"] == "/apiary/thing"
6 changes: 3 additions & 3 deletions docs/design/agent-workflows/documentation/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ services container (Python)
agent workflow handler
services/oss/src/agent/app.py
|
| POST /run over HTTP (AGENTA_RUNNER_URL set)
| POST /run over HTTP (AGENTA_RUNNER_INTERNAL_URL set)
| or spawn the runner CLI in a source checkout
v
agent runner sidecar (Node)
Expand All @@ -56,7 +56,7 @@ agent runner sidecar (Node)
The services container owns Agenta concerns: workflow routing, config parsing, provider
secret resolution, tool resolution, and trace context. The sidecar owns the agent run. It
drives a harness over ACP through the sandbox-agent daemon. In Docker Compose the sidecar is
named `sandbox-agent`, and the service reaches it through `AGENTA_RUNNER_URL`
named `sandbox-agent`, and the service reaches it through `AGENTA_RUNNER_INTERNAL_URL`
(`services/oss/src/agent/config.py:46`).
Comment on lines 58 to 60

The sidecar does not inherit the full stack environment. The service resolves provider keys
Expand Down Expand Up @@ -154,7 +154,7 @@ The sidecar serves one contract on two entrypoints (`services/agent/README.md`):
- `src/server.ts`: a long-lived HTTP server on `:8765` with `GET /health` and `POST /run`.
This is the dockerized sidecar the service calls over HTTP.
- `src/cli.ts`: one JSON request on stdin, one result on stdout. The SDK adapters use this
subprocess transport when `AGENTA_RUNNER_URL` is unset (a source checkout).
subprocess transport when `AGENTA_RUNNER_INTERNAL_URL` is unset (a source checkout).

Both route to the one engine, the sandbox-agent ACP path. The `harness` field on the request
selects the ACP agent (`services/agent/src/server.ts`).
Expand Down
80 changes: 41 additions & 39 deletions docs/design/agent-workflows/documentation/running-the-agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ This page explains how the agent workflow runs in practice. There is no agent-sp
`hosting/docker-compose/run.sh`. This page covers that script, the agent pieces it starts,
the ports, the env vars, and the two ways to run the Node runner outside Docker.

All file:line citations were verified against the code on 2026-06-23.
All file:line citations were verified against the code on 2026-07-03.

## There are two agent processes

Expand All @@ -16,14 +16,14 @@ The agent workflow is split across two services. Know which is which.
`/invoke` and `/inspect`, parses the config, resolves tools and secrets server-side, and
then calls the runner (`services/oss/src/agent/app.py`).

2. The Node runner sidecar. It lives in `services/agent/`. Its compose service name is
`sandbox-agent`. It runs the agent loop with the real harnesses (Pi, Claude, the
`sandbox-agent` package). It listens on `:8765` and serves `GET /health` and `POST /run`
(`services/agent/src/server.ts`). The Python service calls it over HTTP.
2. The Node runner sidecar. It lives in `services/runner/`. Its compose service name is
`runner`. It runs the agent loop with the real harnesses (Pi, Claude, the `sandbox-agent`
package). It listens on `:8765` and serves `GET /health` and `POST /run`
(`services/runner/src/server.ts`). The Python service calls it over HTTP.

The Python service finds the runner through `AGENTA_RUNNER_URL`, which defaults to
`http://sandbox-agent:8765` in every compose stage (for example
`hosting/docker-compose/ee/docker-compose.dev.yml:421`).
The Python service finds the runner through `AGENTA_RUNNER_INTERNAL_URL`, which defaults to
`http://runner:8765` in every compose stage (for example
`hosting/docker-compose/ee/docker-compose.dev.yml:565`).

## The script: hosting/docker-compose/run.sh

Expand Down Expand Up @@ -89,8 +89,8 @@ From the main checked-out branch:
```

This is the dev default from `hosting/CLAUDE.md`. It brings up the full EE stack in dev mode,
including the `services` container (which hosts the Python agent service) and the
`sandbox-agent` container (the Node runner).
including the `services` container (which hosts the Python agent service) and the `runner`
container (the Node runner).

From a git worktree, prefix a distinct project name and use a per-worktree env file so the
two stacks do not collide:
Expand All @@ -111,25 +111,25 @@ To stop the stack without removing volumes:
In the EE dev compose, the relevant services are:

- `services`. Runs uvicorn on port `8080` inside the container
(`hosting/docker-compose/ee/docker-compose.dev.yml:383`). It hosts the Python agent
service. Traefik routes `/services/` to it. It sets `AGENTA_RUNNER_URL` to
`http://sandbox-agent:8765` and `AGENTA_AGENT_ENABLE_MCP` to `false` by default (lines 421
to 422). It depends on `sandbox-agent` being healthy (line 430).

- `sandbox-agent`. The Node runner (lines 444 onward). In dev it runs
`tsx src/server.ts` after rebuilding the Pi extension. It listens on `8765`. Its health
check hits `http://127.0.0.1:8765/health` (line 492). It is not behind a compose profile,
so it always comes up.

The `sandbox-agent` service ships in every stage. It is present in dev, gh, and gh.ssl for
both oss and ee. For example the gh stage defines it at
`hosting/docker-compose/ee/docker-compose.gh.yml:317` and
`hosting/docker-compose/oss/docker-compose.gh.yml:344`. In gh it uses a prebuilt ghcr image
(`hosting/docker-compose/ee/docker-compose.dev.yml:519`). It hosts the Python agent
service. Traefik routes `/services/` to it. It sets `AGENTA_RUNNER_INTERNAL_URL` to
`http://runner:8765` and `AGENTA_AGENT_MCPS_ENABLED` to `false` by default (lines 564 to
565). It depends on `runner` being healthy (lines 573 to 574).

- `runner`. The Node runner (line 588 onward). In dev it runs `tsx src/server.ts` after
rebuilding the Pi extension. It listens on `8765`. Its health check hits
`http://127.0.0.1:8765/health` (line 652). It is not behind a compose profile, so it always
comes up.

The `runner` service ships in every stage. It is present in dev, gh, and gh.ssl for both oss
and ee. For example the gh stage defines it at
`hosting/docker-compose/ee/docker-compose.gh.yml:451` and
`hosting/docker-compose/oss/docker-compose.gh.yml:493`. In gh it uses a prebuilt ghcr image
instead of building from source.

### The dev sandbox-agent command, explained
### The dev runner command, explained

The dev compose overrides the image CMD with a shell command (around line 455):
The dev compose overrides the image CMD with a shell command (around line 600):

```sh
mkdir -p /pi-agent && cp -a /pi-agent-ro/. /pi-agent/ 2>/dev/null || true;
Expand Down Expand Up @@ -160,12 +160,14 @@ env file points the chat slice at
## Agent env vars

These are the agent-relevant variables. The example file lists them commented out
(`hosting/docker-compose/ee/env.ee.dev.example`, lines 119 onward).

- `AGENTA_RUNNER_URL`. Where the Python service finds the runner. Default
`http://sandbox-agent:8765`. When unset, the Python service spawns the runner CLI locally
instead (see `runner_url` and `select_backend` in `services/oss/src/agent/`).
- `AGENTA_AGENT_ENABLE_MCP`. Gates MCP server resolution. Default `false`.
(`hosting/docker-compose/ee/env.ee.dev.example`, "Core endpoints" and "Agenta - Agent"
sections).

- `AGENTA_RUNNER_INTERNAL_URL`. Where the Python service finds the runner. Default
`http://runner:8765`. When unset, the Python service spawns the runner CLI locally instead
(see `runner_url` in `services/oss/src/agent/config.py` and `select_backend` in
`services/oss/src/agent/app.py`).
- `AGENTA_AGENT_MCPS_ENABLED`. Gates MCP server resolution. Default `false`.
- `SANDBOX_AGENT_PROVIDER`. `local` or `daytona`. Default `local`.
- `SANDBOX_AGENT_DAYTONA_API_KEY`, `_API_URL`, `_TARGET`, `_SNAPSHOT`, `_IMAGE`,
`_INSTALL_PI`. Daytona credentials the runner reads for the `daytona` sandbox provider.
Expand All @@ -175,16 +177,16 @@ These are the agent-relevant variables. The example file lists them commented ou
teardown) self-reaps instead of burning credit. Values below `1` fall back to the default
(a `0` would re-disable auto-stop and reintroduce the leak).

The `sandbox-agent` container deliberately has no `env_file`. The harness sandbox must not
inherit the stack's secrets. The compose block comments explain this
(`hosting/docker-compose/ee/docker-compose.dev.yml`, around line 459). Tools run server-side
The `runner` container deliberately has no `env_file`. The harness sandbox must not inherit
the stack's secrets. The compose block comments explain this
(`hosting/docker-compose/ee/docker-compose.dev.yml`, around line 604). Tools run server-side
in the Python service, so the sandbox only needs its own port, the Pi login, an OTLP export
fallback, and the Daytona credentials.

## Running the Node runner outside Docker

You can run the runner directly. From `services/agent/`, with Node 24 on PATH
(`services/agent/AGENTS.md`):
You can run the runner directly. From `services/runner/`, with Node 24 on PATH
(`services/runner/AGENTS.md`):

```bash
pnpm install
Expand All @@ -196,9 +198,9 @@ This is a standalone pnpm package. It is not part of the web workspace. It runs
with no compile step. The only build is `pnpm run build:extension`, which bundles the Pi
extension into `dist/`.

When the Python service runs in a source checkout with `AGENTA_RUNNER_URL` unset, it
When the Python service runs in a source checkout with `AGENTA_RUNNER_INTERNAL_URL` unset, it
spawns this runner through the CLI path instead of calling it over HTTP. See `select_backend`
in `services/oss/src/agent/app.py:49` and `runner_url` in `services/oss/src/agent/config.py`.
in `services/oss/src/agent/app.py:192` and `runner_url` in `services/oss/src/agent/config.py:46`.

## See also

Expand Down
2 changes: 1 addition & 1 deletion docs/docs/self-host/02-configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ Services API. `SANDBOX_AGENT_*` variables are read by the separate `runner` serv

| Env var | Component | values.yaml path |
|---|---|---|
| `AGENTA_RUNNER_URL` | Services API | `agentRunner.externalUrl` or generated from `agentRunner.enabled` |
| `AGENTA_RUNNER_INTERNAL_URL` | Services API | `agentRunner.externalUrl` or generated from `agentRunner.enabled` |
| `AGENTA_RUNNER_TIMEOUT_SECONDS` | Services API / SDK | n/a |
| `AGENTA_AGENT_MCPS_ENABLED` | Services API | `agentRunner.enableMcp` |
| `SANDBOX_AGENT_PROVIDER` | `runner` | `agentRunner.provider` |
Expand Down
6 changes: 3 additions & 3 deletions docs/docs/self-host/guides/07-deploy-the-agent-runner.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ description: Configure the runner service for self-hosted agent workflows
---

Agenta agent workflows run through a separate `runner` service. The Services
API sends agent runs to this service through `AGENTA_RUNNER_URL`.
API sends agent runs to this service through `AGENTA_RUNNER_INTERNAL_URL`.

The runner owns the harness process lifecycle and runner-scoped sandbox provider
settings. It should not inherit the full stack environment file.
Expand All @@ -16,7 +16,7 @@ The bundled Compose files include `runner` by default. The Services API points t
inside the Compose network:

```bash
AGENTA_RUNNER_URL=http://runner:8765
AGENTA_RUNNER_INTERNAL_URL=http://runner:8765
```

Use these variables when you need to override the image or default sandbox provider:
Expand All @@ -39,7 +39,7 @@ agentRunner:
```

When `agentRunner.enabled=true`, the chart creates a `runner` Deployment and Service
and injects the in-cluster URL into the Services pod as `AGENTA_RUNNER_URL`.
and injects the in-cluster URL into the Services pod as `AGENTA_RUNNER_INTERNAL_URL`.

To use an external runner instead:

Expand Down
4 changes: 2 additions & 2 deletions docs/docs/self-host/infrastructure/01-architecture.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ Agenta uses a microservices architecture deployed as Docker containers. The diag
- **Port**: 8765 (internal)
- **Purpose**: Executes agent workflows on behalf of the Services API

The runner receives `/run` requests from the Services API (routed via `AGENTA_RUNNER_URL`) and starts harness processes (Pi, Claude Code, or other supported adapters) in local or remote sandboxes. It mounts durable working directories from the store into each sandbox and relays server-side tools back to the Services API without exposing the full stack environment to the harness.
The runner receives `/run` requests from the Services API (routed via `AGENTA_RUNNER_INTERNAL_URL`) and starts harness processes (Pi, Claude Code, or other supported adapters) in local or remote sandboxes. It mounts durable working directories from the store into each sandbox and relays server-side tools back to the Services API without exposing the full stack environment to the harness.

Sandbox matrix:
- `local` — in-process on the runner host; the default for compose and Kubernetes deployments.
Expand Down Expand Up @@ -214,7 +214,7 @@ API Service depends on:
Services API depends on:
├── PostgreSQL (agent and service state)
├── LLM providers (model calls)
└── runner sidecar (agent workflow execution via AGENTA_RUNNER_URL)
└── runner sidecar (agent workflow execution via AGENTA_RUNNER_INTERNAL_URL)
```

### Worker Dependencies
Expand Down
6 changes: 3 additions & 3 deletions docs/docs/self-host/infrastructure/02-networking.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ Agenta uses a Docker-based network architecture with a dedicated bridge network
│ │ Web │ │ API │ │ Services API │ │
│ │ :3000 │ │ :8000 │ │ :8080 │ │
│ └─────────────┘ └──────┬──────┘ └─────────┬──────────┘ │
│ │ │ AGENTA_RUNNER_URL
│ │ │
│ │ Redis queues ▼ │
│ │ / streams ┌─────────────────┐ │
│ │ │ runner :8765 │ │
Expand Down Expand Up @@ -91,7 +91,7 @@ API Container:
Services API Container:
├── → postgres:5432 (agent and service state)
├── → LLM providers (model calls)
└── → runner:8765 (agent run dispatch via AGENTA_RUNNER_URL)
└── → runner:8765 (agent run dispatch via AGENTA_RUNNER_INTERNAL_URL)

Runner:
├── → seaweedfs:8333 or external S3 endpoint (durable storage mount)
Expand Down Expand Up @@ -134,7 +134,7 @@ These variables configure how containers communicate internally. Use `REDIS_URI`
| `REDIS_URI_VOLATILE` | Redis for caches/channels | `redis://redis-volatile:6379/0` | Falls back to `REDIS_URI` |
| `REDIS_URI_DURABLE` | Redis for queues/streams | `redis://redis-durable:6381/0` | Falls back to `REDIS_URI` |
| `SUPERTOKENS_CONNECTION_URI` | Auth service | `http://supertokens:3567` | SuperTokens service URL |
| `AGENTA_RUNNER_URL` | Runner URL | `http://runner:8765` | Points the Services API at the agent runner; default in compose, generated from `agentRunner.*` in Helm |
| `AGENTA_RUNNER_INTERNAL_URL` | Runner URL | `http://runner:8765` | Points the Services API at the agent runner; default in compose, generated from `agentRunner.*` in Helm |

:::note Daytona sandboxes and the remote compose profile
Compose deployments using Daytona remote sandboxes require the `remote` compose profile, which starts an ngrok tunnel. The remote sandbox mounts durable storage over the public internet, so the store endpoint must be reachable. Railway and Kubernetes deployments expose the store endpoint publicly and do not need ngrok.
Expand Down
2 changes: 1 addition & 1 deletion docs/docs/self-host/upgrades/runner-and-store.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ existing self-hosted Agenta deployment that predates the runner and store featur
A `200` response confirms the runner is up. If you get a connection error, check
`docker compose logs runner`.

4. The `AGENTA_RUNNER_URL=http://runner:8765` value is included in the updated Compose
4. The `AGENTA_RUNNER_INTERNAL_URL=http://runner:8765` value is included in the updated Compose
files by default. If you maintain a custom env file, add it there.

5. (Optional) Enable durable agent workspaces. Set the store credentials in your env file:
Expand Down
Loading
Loading