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
14 changes: 11 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -119,11 +119,19 @@ AGENT_TURN_TOOL_CALL_BUDGET=8
# Codex runtime model + reasoning effort. Mirrors AGENT_PRIMARY_MODEL
# / AGENT_POLICY_MODEL on the pydantic-ai side: env-driven so the
# operator dials cost / quality without code change. Empty / unset
# falls through to codex-cli's own default (today gpt-5.5; varies
# across cli versions). Accepted reasoning_effort values: low |
# medium | high (codex CLI semantics).
# falls through to codex's own default. Accepted reasoning_effort
# values: low | medium | high.
CODEX_PRIMARY_MODEL=
CODEX_REASONING_EFFORT=
# Writable CODEX_HOME for the shared codex app-server (sqlite, cache,
# logs, seeded auth.json). Defaults to ./.cache/codex_home locally;
# compose overrides to /var/codex_home. CODEX_BASE_HOME (default
# ~/.codex) is the read-only source the auth.json symlink points at.
# CODEX_HELPER_HOME is the separate home for the helper app-server
# (constitution gate / eval judge / repeat detector); defaults to
# ./.cache/codex_helper_home.
CODEX_HOME=
CODEX_HELPER_HOME=
# OpenRouter API key. Still required when AGENT_DEFAULT_PROVIDER is
# left at gemini, because the builder view's "openrouter" segment
# remains a one-click escape hatch for testing other free-tier
Expand Down
162 changes: 162 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
name: CI

# Per-service checks for the three planes (Rust data plane, Python agent
# plane, Next.js frontend) plus a proto wire-drift guard. Jobs run in
# parallel and are independent; a change touching one service only pays
# for that service's job via path-less triggering + internal cwd.
#
# NOT enforced here yet (the existing tree is not clean against them, so
# gating would be red on day one): `cargo fmt --check`, `cargo clippy
# -D warnings`. Land a formatting/lint sweep first, then promote them.

on:
push:
branches: [main]
pull_request:
branches: [main]

# A newer push to the same PR/branch cancels the in-flight run.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
backend:
name: backend (Rust data plane)
runs-on: ubuntu-latest
defaults:
run:
working-directory: backend
steps:
- uses: actions/checkout@v4
- name: Install librdkafka build deps
# rdkafka-sys builds librdkafka from source via CMake and needs
# these dev headers (curl.h, sasl, zlib). Mirrors the apt list in
# backend/Dockerfile so CI and the image build the same way.
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends cmake build-essential pkg-config libsasl2-dev zlib1g-dev libcurl4-openssl-dev
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
workspaces: backend
- name: Build
run: cargo build --locked
- name: Test
run: cargo test --locked

frontend:
name: frontend (Next.js)
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
# pnpm 10 matches the local toolchain (lockfileVersion 9.0) and
# tolerates a `pnpm-workspace.yaml` that carries only settings
# (`allowBuilds:`) with no `packages:` field; pnpm 9 errors on
# the missing `packages:` field.
version: 10
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
cache-dependency-path: frontend/pnpm-lock.yaml
- name: Install
run: pnpm install --frozen-lockfile
- name: Typecheck
run: pnpm exec tsc --noEmit
- name: Lint
# Non-blocking until the lint baseline is clean; surfaces issues
# without gating the merge.
run: pnpm lint
continue-on-error: true

wire-drift:
name: wire-drift (proto codegen is committed)
# Enforces the AGENTS.md contract: every cross-service type lives in
# proto/, generated artifacts are checked in, and `just
# regen-wire-types` output must equal what's committed. Regenerates
# all three trees and fails if `git diff` is non-empty.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: bufbuild/buf-setup-action@v1
with:
github_token: ${{ github.token }}
# The buf.gen.yaml `protoc_builtin: python` plugin shells out to
# protoc. Pin it to 34.1 to match the protobuf 7.34.1 the committed
# Python types were generated with, so regeneration is byte-identical
# and the drift check below stays meaningful.
- uses: arduino/setup-protoc@v3
with:
version: "34.1"
repo-token: ${{ github.token }}
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- uses: pnpm/action-setup@v4
with:
# pnpm 10 matches the local toolchain (lockfileVersion 9.0) and
# tolerates a `pnpm-workspace.yaml` that carries only settings
# (`allowBuilds:`) with no `packages:` field; pnpm 9 errors on
# the missing `packages:` field.
version: 10
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
cache-dependency-path: frontend/pnpm-lock.yaml
- name: Install TS codegen plugin (protoc-gen-es)
working-directory: frontend
run: pnpm install --frozen-lockfile
- name: Install Rust codegen plugins (buffa)
# Versions must match the `buffa` crate pinned in
# backend/Cargo.toml. Bump together.
run: cargo install protoc-gen-buffa --version 0.4.0 && cargo install protoc-gen-buffa-packaging --version 0.4.0
- name: Lint protos
run: buf lint
- name: Regenerate wire types
# Mirror of the `regen-wire-types` just recipe (just isn't
# installed in CI).
run: |
rm -rf backend/src/wire/generated agent-service/src/multichain frontend/src/lib/wire
mkdir -p backend/src/wire/generated agent-service/src/multichain frontend/src/lib/wire
buf generate
- name: Fail on drift
run: |
if ! git diff --exit-code; then
echo "::error::Generated wire types are stale. Run 'just regen-wire-types' and commit the result."
exit 1
fi

agent-service:
name: agent-service (Python agent plane)
# The codex dependency is now the `openai-codex` PyPI package, so
# `uv sync` + the no-LLM pytest baseline resolve and run in CI with
# no sibling-repo checkout. (`uv sync` downloads the bundled codex
# binary via `openai-codex-cli-bin`; the baseline tests never spawn
# an app-server, so no codex auth is needed.)
runs-on: ubuntu-latest
defaults:
run:
working-directory: agent-service
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
- name: Sync (no LLM, wiring-only deps)
run: uv sync --frozen
- name: Test (baseline budget <5s; no live LLM calls)
# The <5s no-LLM baseline is the unit suite (AGENTS.md). The
# integration suite (tests/integration) drives mocked HTTP
# against the data plane and has pre-existing pytest_httpx
# query-matching drift unrelated to this service's logic; it is
# not part of the baseline budget and is excluded here until
# those mocks are repaired in a dedicated change.
run: uv run pytest -q tests/unit
12 changes: 6 additions & 6 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@

data/

# Chunk 3.5: per-thread codex sqlite + config trees materialized by
# `codex-agent-driver.prepare_actor_codex_home`. Default host-side
# root sits at the repo root for visibility (`ls codex_homes/`
# shows one subtree per thread, named by thread_id). Production
# sets `CODEX_HOMES_HOST_PATH` in compose to point off-repo.
codex_homes/
# Writable CODEX_HOME for the codex app-server (sqlite, cache, logs,
# seeded auth.json), bind-mounted to the repo root by default for
# visibility. Production sets `CODEX_HOME_HOST_PATH` in compose to
# point off-repo. The local helper app-server home also lands here.
codex_home/
.cache/codex_helper_home/

tmp/

Expand Down
2 changes: 1 addition & 1 deletion SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ Snapshot vs delta protocol:
Two runtimes, one set of defenses. Both call the same typed-primitive surface; both emit the same `Claim` wire format; both pass through the same output gate before any byte reaches the browser.

- **pydantic-ai over HTTP `/primitive/*`** (binary protobuf). Per-role provider configurable; default `gemini-3.1-flash-lite` via the OpenAI-compat endpoint, free tier. See [docs/agent-design/01-agent-overview.md](docs/agent-design/01-agent-overview.md).
- **codex over `/mcp`** (JSON-RPC). Subscription auth via `~/.codex/auth.json`. Default runtime today (`AGENT_DEFAULT_RUNTIME=codex`). Spawned as a per-thread subprocess pool. See ADR [15-codex-as-agent-harness](architecture-decisions/15-codex-as-agent-harness.md).
- **codex over `/mcp`** (JSON-RPC). Subscription auth via `~/.codex/auth.json`. Default runtime today (`AGENT_DEFAULT_RUNTIME=codex`). Driven by the official `openai-codex` SDK as one shared app-server with native codex threads. See ADRs [15-codex-as-agent-harness](architecture-decisions/15-codex-as-agent-harness.md) and [17-codex-sdk-migration](architecture-decisions/17-codex-sdk-migration.md).

The runtime selector reads `AgentRequest.runtime`; unspecified falls through to `AGENT_DEFAULT_RUNTIME`. Hermetic eval cases pin runtime per case.

Expand Down
6 changes: 3 additions & 3 deletions agent-service/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ Root [../AGENTS.md](../AGENTS.md) carries the cross-service rules. This file is

## Stack

- **Python:** 3.14. Pinned because the `codex-agent-driver` path-dep requires `>=3.14`.
- **Python:** 3.14. Kept in lockstep with the dev venv; the `openai-codex` SDK only needs `>=3.10`, so this is a parity pin, not a hard floor.
- **Env + packaging:** `uv`. `pyproject.toml` + `uv.lock` are authoritative; no `requirements.txt`.
- **Agent runtimes (two, parity-checked):**
- `pydantic-ai-slim[openai,mcp]` for the pydantic-ai runtime. Consumes the Rust MCP server at `http://api:8004/mcp` via `MCPServerStreamableHTTP`.
- `codex-agent-driver` (sibling repo `second-brain/packages/codex-agent-driver`, editable path-dep). Primary runtime today; subprocess pool, MCP tools, subscription auth via `~/.codex/auth.json`.
- `openai-codex` (official OpenAI SDK, Beta-pinned, see [../docs/dependency-exceptions.md](../docs/dependency-exceptions.md)) for the codex runtime. Primary runtime today; drives one shared `AsyncCodex` app-server with native threads, MCP tools, subscription auth via `~/.codex/auth.json`. SDK config + lockdown overlay live in `codex_config.py`; notification parsing in `codex_events.py`; the turn driver in `codex_driver.py`.
- **HTTP + SSE:** `fastapi` + `uvicorn[standard]` + `sse-starlette`.
- **Logging:** `structlog`. Structured from the first log line; JSON in prod.
- **Wire types:** `protobuf` runtime (pinned `>=7.34.1` via `override-dependencies`). Generated package lives at `src/multichain/` and ships in the wheel; never hand-author a wire type.
Expand All @@ -29,7 +29,7 @@ Root [../AGENTS.md](../AGENTS.md) carries the cross-service rules. This file is
- **DeprecationWarning is an error.** `pytest` `filterwarnings = ["error::DeprecationWarning:agent_service.*"]` so codegen / pydantic / pydantic-ai drift bubbles up as a test failure, not silent rot.
- **Eval-judge family-leakage guard.** The judge model cannot share a family prefix with the agent's primary model unless `EVAL_ALLOW_SHARED_FAMILY=true`. Schema validation enforces this at YAML load time. ICLR 2026: same-family judge biases toward agreeing with itself.
- **ClickHouse queries are parameterized.** Always pass values through `clickhouse_connect`'s `parameters=` keyword. Never f-string a value into the SQL. The wrapper in `agent_service/evals/ch.py` enforces this.
- **Codex subprocess hygiene.** Each thread gets its own per-thread `codex_home` under `CODEX_HOME_ROOT`. Host `~/.codex` is mounted read-only; per-thread sqlite / logs / state are writable. Do not write to the host base from inside the container.
- **Codex thread + home hygiene.** Isolation is per native codex thread (`thread_start` / `thread_resume`), not per-thread `codex_home`. One shared `AsyncCodex` app-server serves all analyst chats; a separate helper app-server (own `CODEX_HOME`) serves the gates / judge / repeat detector so analyst MCP traffic never bleeds in. The built-in-tool lockdown + MCP mount ride on the per-thread `config` overlay (`codex_config.py`). Host `~/.codex` is mounted read-only (auth source); the app-servers write only under their writable `CODEX_HOME`. Do not write to the host base from inside the container.

## Output-gate discipline

Expand Down
63 changes: 12 additions & 51 deletions agent-service/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,62 +1,23 @@
# Phase 0 walking-skeleton Dockerfile. uv-based Python 3.14 image.
# Phase B optimises this with multistage caching once dep churn slows.
# uv-based Python image for the agent plane.
#
# Python pinned to 3.14 because the chunk 3 `codex-agent-driver`
# dep (sibling repo `second-brain/packages/codex-agent-driver`)
# requires-python = ">=3.14". The local dev venv was already on
# 3.14; this image bump brings docker into parity.

# Stage 1: pull the codex CLI from a real Node image so we don't
# carry npm in the final layer. @openai/codex is pinned to the
# version the host dev box is running (kept in lockstep so what
# works locally works in docker). Bump both when bumping codex.
FROM node:22-bookworm-slim AS codex-cli
ARG CODEX_VERSION=0.130.0
RUN npm install -g --no-fund --no-audit @openai/codex@${CODEX_VERSION}
# Python pinned to 3.14 to match the local dev venv. The `openai-codex`
# SDK requires-python is only >=3.10, so this pin is now a parity
# choice, not a hard floor; keep image and host on the same minor.
#
# The codex binary ships inside the `openai-codex` SDK via its
# `openai-codex-cli-bin` dependency (installed into the project venv by
# `uv sync`), so there is no separate codex CLI install / Node stage.

FROM ghcr.io/astral-sh/uv:python3.14-bookworm-slim AS base

# Carry the Node runtime + the codex CLI tree forward from the
# build stage. `node` is the interpreter the `codex` shebang line
# needs; `@openai/codex` is the JS package whose `bin/codex.js`
# becomes our `/usr/local/bin/codex` shim. No npm, no
# node_modules anywhere except the codex install tree, so the
# final image is the slim uv base plus ~70 MB of Node + codex
# rather than a full Node image.
COPY --from=codex-cli /usr/local/bin/node /usr/local/bin/node
COPY --from=codex-cli /usr/local/lib/node_modules/@openai/codex \
/usr/local/lib/node_modules/@openai/codex
RUN ln -s /usr/local/lib/node_modules/@openai/codex/bin/codex.js \
/usr/local/bin/codex && codex --version

# codex CLI's `--sandbox=read-only` (and `workspace-write`) wraps the
# child process in bubblewrap on Linux. The CLI ships a bundled bwrap
# binary but logs a noisy error and falls back to the system one when
# present, so installing the OS package is cleaner. Required by codex
# >= 0.120 on Linux for any non-`danger-full-access` sandbox mode.
# codex's `--sandbox=read-only` wraps the child process in bubblewrap
# on Linux. The bundled binary can fall back to a vendored bwrap but
# logs noisily when the system package is absent, so install it.
RUN apt-get update \
&& apt-get install -y --no-install-recommends bubblewrap \
&& rm -rf /var/lib/apt/lists/*

# In-image directory layout preserves the same depth as the host
# source tree so the relative `[tool.uv.sources] codex-agent-driver =
# { path = "../../second-brain/packages/codex-agent-driver" }` in
# `agent-service/pyproject.toml` resolves cleanly. Depth matters; the
# literal segment name does not.
#
# /repo/multi-chain-analysis-agent/agent-service/ <- WORKDIR
# /repo/second-brain/packages/codex-agent-driver/ <- path-dep target
#
# Without preserving the depth, uv refuses to normalize a relative
# path that escapes the project root (`/app/../../...` fails with
# "cannot normalize a relative path beyond the base directory").
WORKDIR /repo/multi-chain-analysis-agent/agent-service

# Chunk 3 path dep. The named build context comes from
# `docker-compose.yml`'s `additional_contexts.codex-agent-driver`,
# which points at `../second-brain/packages/codex-agent-driver`
# on the host.
COPY --from=codex-agent-driver . /repo/second-brain/packages/codex-agent-driver
WORKDIR /app

# Copy lockfile + manifest first for layer cache.
COPY pyproject.toml ./
Expand Down
29 changes: 18 additions & 11 deletions agent-service/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,19 +43,21 @@ dependencies = [
# wrapper that enforces this contract. Pinned <1 because 1.0 is in
# release-candidate stage as of 2026-04-22; bump after stable.
"clickhouse-connect>=0.15,<1",
# Chunk 3 dep. Codex JSON-RPC stdio driver from the sister
# `second-brain` repo. Provides `CodexAppServerDriver` +
# `CodexAgentProfile` + `prepare_actor_codex_home`; we bridge it
# behind `POST /agent/turn` when `AgentRequest.runtime ==
# AGENT_RUNTIME_CODEX`. Path source below pins to the sibling
# checkout for local dev; docker bake mounts the same tree at
# `/opt/codex-agent-driver`.
"codex-agent-driver",
# Official OpenAI Codex Python SDK. Drives `codex app-server` over
# JSON-RPC stdio (native threads, MCP host, sandbox, server-enforced
# `output_schema`) and bundles the codex binary via its
# `openai-codex-cli-bin` dependency, so the image needs no separate
# codex install. Used behind `POST /agent/turn` when
# `AgentRequest.runtime == AGENT_RUNTIME_CODEX` and for the helper
# calls routed through `llm_runtime.runtime_call`.
#
# Pinned to an exact version because the SDK is Beta (0.1.0bN): the
# accepted API-churn risk and the revisit trigger are recorded in
# `docs/dependency-exceptions.md`. Bump deliberately, re-running the
# codex output-schema smoke after each bump.
"openai-codex==0.1.0b3",
]

[tool.uv.sources]
codex-agent-driver = { path = "../../second-brain/packages/codex-agent-driver", editable = true }

[dependency-groups]
dev = [
# Wiring-only pytest layer. No LLM calls. The total baseline-test
Expand All @@ -79,6 +81,11 @@ packages = ["src/agent_service", "src/multichain"]

[tool.uv]
package = true
# The Beta codex SDK pulls a pre-release codex binary
# (`openai-codex-cli-bin==0.137.0aN`); allow pre-releases so resolution
# succeeds. Scoped intent is the codex SDK only; no other dep here is
# pre-release.
prerelease = "allow"

# Force protobuf 7.x through the dependency graph. opentelemetry-proto
# 1.41.x (latest as of Ship 1 of agent-observability, ADR 13) still
Expand Down
Loading
Loading