# Default `docker compose up` = working slice: Postgres + model-router. # Full platform locally (no Azure): docker compose --profile full up # Or use the wrapper: scripts/local-stack.sh up # See .env.example and docs/local-development.md. # ── Router model-tier config (name + output cap + daily budget) ────────────── # The router registers a model tier only when it can map that tier to a concrete # model NAME plus a per-tier output cap and daily USD budget. Supplying the # *_BASE_URL / *_API_KEY for a tier in .env activates it; this anchor threads the # model name, max output tokens, and daily budget through to the router so a # configured tier registers cleanly instead of falling through to passthrough # (a tier with a URL/key but no model mapping can't resolve a deployment and the # call fails). Values are generic defaults; override any of them in .env. x-router-models: &router-models # Primary tier (gpt4o-mini): the model name is fixed in the router; these # only tune its output ceiling and per-day spend cap. GPT4O_MAX_TOKENS: ${GPT4O_MAX_TOKENS:-4096} GPT4O_DAILY_BUDGET_USD: ${GPT4O_DAILY_BUDGET_USD:-5.00} # Budget fallback tier (phi4): model name IS read from env, so it must be set # for the tier to register. PHI_MODEL: ${PHI_MODEL:-Phi-4} PHI_MAX_TOKENS: ${PHI_MAX_TOKENS:-2048} PHI_DAILY_BUDGET_USD: ${PHI_DAILY_BUDGET_USD:-0.50} # ── Roster Cost Gate — committed platform monthly cap ──────────────────────── # Config-time only: NOT merged into any service's environment (nothing at # runtime reads this — the *_DAILY_BUDGET_USD tiers above are the ones a # running container consumes). scripts/roster-cost-gate.py reads this value # straight from this file and compares it against the sum of every # agents/profiles/*.yaml daily_budget_usd ceiling, projected across a 30-day # month. That gate is what actually enforces this number means something; # editing it here has no effect on any running service. # docs/design/roster-cost-gate.md. x-roster-cost-gate: PLATFORM_MONTHLY_BUDGET_USD: ${PLATFORM_MONTHLY_BUDGET_USD:-200.00} services: postgres: image: pgvector/pgvector:pg16 environment: POSTGRES_USER: ${POSTGRES_USER:-aaf} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-localdev} POSTGRES_DB: ${POSTGRES_DB:-aaf} ports: ["${POSTGRES_PORT:-5432}:5432"] volumes: ["pgdata:/var/lib/postgresql/data"] healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-aaf} -d ${POSTGRES_DB:-aaf}"] interval: 5s timeout: 5s retries: 12 model-router: build: ./services/model-router environment: # Model-tier names, output caps, and daily budgets (see x-router-models). <<: *router-models # aaf-0005: the router fails closed (503 on every /v1/* call) when unset. # Every in-mesh caller below (paperclip, memory-governor) sends this same # value as its bearer — override ROUTER_API_KEY in .env to change it. ROUTER_API_KEY: ${ROUTER_API_KEY:-localdev-router-key} LLM_PROVIDER: ${LLM_PROVIDER:-azure_foundry} # Primary tier: set AZURE_FOUNDRY_ENDPOINT + AZURE_FOUNDRY_API_KEY, # or GPT4O_BASE_URL + GPT4O_API_KEY directly. Both pairs activate gpt4o-mini. AZURE_FOUNDRY_ENDPOINT: ${AZURE_FOUNDRY_ENDPOINT:-} AZURE_FOUNDRY_API_KEY: ${AZURE_FOUNDRY_API_KEY:-} GPT4O_BASE_URL: ${GPT4O_BASE_URL:-${AZURE_FOUNDRY_ENDPOINT:-}} GPT4O_API_KEY: ${GPT4O_API_KEY:-${AZURE_FOUNDRY_API_KEY:-}} # Budget fallback tier (phi4) endpoint + key; leave unset to skip the tier. PHI_BASE_URL: ${PHI_BASE_URL:-} PHI_API_KEY: ${PHI_API_KEY:-} OPENAI_COMPAT_BASE_URL: ${OPENAI_COMPAT_BASE_URL:-} OPENAI_COMPAT_API_KEY: ${OPENAI_COMPAT_API_KEY:-} # /v1/embeddings upstream (optional; 503 until the key is set). Base URL # unset -> api.openai.com; point it at an Azure AI Foundry /openai/v1 # endpoint instead — docs/walkthroughs/azure-foundry-embeddings.md. Keep # EMBEDDING_MODEL a bare model name: the router pins the openai/ LiteLLM # prefix itself (azure.com base URL + bare model otherwise trips LiteLLM's # AZURE provider -> api-key-header auth -> Foundry 400 unknown_model). EMBEDDING_API_KEY: ${EMBEDDING_API_KEY:-} EMBEDDING_BASE_URL: ${EMBEDDING_BASE_URL:-} EMBEDDING_MODEL: ${EMBEDDING_MODEL:-text-embedding-3-small} EMBEDDING_DAILY_BUDGET_USD: ${EMBEDDING_DAILY_BUDGET_USD:-1.00} # A6 acting budget enforcement: warn (default; pre-A6 behavior) | # downgrade (serve BUDGET_FALLBACK_TIER + X-Router-Budget-Downgrade # header) | block (429 budget_exceeded). Invalid values fail open to warn. BUDGET_ENFORCE_MODE: ${BUDGET_ENFORCE_MODE:-warn} BUDGET_FALLBACK_TIER: ${BUDGET_FALLBACK_TIER:-gpt4o-mini} # Optional multi-tenant reference path. Leave all three authority values # empty for ordinary single-tenant/local use. Partial configuration fails # closed. TENANT_BUDGET_TOKEN_SECRET verifies control-plane user tokens; # it is distinct from both router and control-plane API credentials. TENANT_BUDGET_CONTROL_PLANE_URL: ${TENANT_BUDGET_CONTROL_PLANE_URL:-} TENANT_BUDGET_API_KEY: ${TENANT_BUDGET_API_KEY:-} TENANT_BUDGET_TOKEN_SECRET: ${TENANT_BUDGET_TOKEN_SECRET:-} TENANT_BUDGET_RESERVATION_USD: ${TENANT_BUDGET_RESERVATION_USD:-0} TENANT_BUDGET_TIMEOUT_SECONDS: ${TENANT_BUDGET_TIMEOUT_SECONDS:-2} ports: ["${ROUTER_PORT:-8080}:8080"] healthcheck: test: ["CMD-SHELL", "python -c \"import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8080/health').status==200 else 1)\""] interval: 5s timeout: 5s retries: 12 # ── full profile ─────────────────────────────────────────────────────────── # One-shot: applies the governor migrations (agent_events, feature_flags, # memory_classes, …) to Postgres, then exits. Idempotent. Runs AFTER Honcho so # the `documents` table exists (0002 ALTERs it) — Honcho owns that table. migrate: profiles: ["full"] image: pgvector/pgvector:pg16 depends_on: postgres: condition: service_healthy # Honcho's entrypoint runs `alembic upgrade head` (creating `documents`) # before it serves /openapi.json, so honcho-healthy ⟹ the table exists. # (In Azure these migrations run as a later, post-deploy pipeline stage.) honcho: condition: service_healthy volumes: ["./infrastructure/migrations:/migrations:ro"] environment: PGHOST: postgres PGUSER: ${POSTGRES_USER:-aaf} PGPASSWORD: ${POSTGRES_PASSWORD:-localdev} PGDATABASE: ${POSTGRES_DB:-aaf} entrypoint: - sh - -c - | set -e for f in /migrations/*.sql; do echo "[migrate] applying $${f}" psql -v ON_ERROR_STOP=1 -f "$${f}" done echo "[migrate] all migrations applied" restart: "no" honcho: profiles: ["full"] build: context: . dockerfile: services/honcho/Dockerfile depends_on: postgres: condition: service_healthy environment: # Honcho reads DB_CONNECTION_URI (pydantic env_prefix DB_) with the # psycopg3 scheme — NOT DATABASE_URL. Wrong/unset → it falls back to its # localhost default and exits 1. # POSTGRES_PASSWORD_URLENC: percent-encoded twin of the password for URI # userinfo (libpq/SQLAlchemy reject raw special chars there); defaults to # the plain password, which is safe for the local dev default. DB_CONNECTION_URI: postgresql+psycopg://${POSTGRES_USER:-aaf}:${POSTGRES_PASSWORD_URLENC:-${POSTGRES_PASSWORD:-localdev}}@postgres:5432/${POSTGRES_DB:-aaf} # Honcho resolves an LLM client per specialist from its MODEL_CONFIG # (transport + model); the built-in 3.0.x defaults are openai/gpt-5.4-mini # for every specialist. Placeholder keys let the API BOOT with no real # creds (clients are built lazily at call time, not at import); the # deriver's actual memory processing needs real keys (override to drive it). LLM_ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-localdev-honcho-placeholder} LLM_OPENAI_API_KEY: ${OPENAI_API_KEY:-localdev-honcho-placeholder} LLM_GEMINI_API_KEY: ${GEMINI_API_KEY:-localdev-honcho-placeholder} # ── Per-specialist model config (Honcho >= 3.0.7 schema) ───────────────── # 3.0.7 REPLACED the flat SUMMARY_PROVIDER / DERIVER_PROVIDER / # DIALECTIC_LEVELS____PROVIDER form with a nested MODEL_CONFIG # (fields: transport + model). The old flat keys are now SILENTLY IGNORED # (pydantic-settings extra="ignore"), so an un-migrated config falls back to # the built-in default (openai/gpt-5.4-mini) instead of your intended model. # `transport` is one of openai|anthropic|gemini; to reach an OpenAI-compatible # proxy set
_MODEL_CONFIG__OVERRIDES__BASE_URL. See # services/honcho/README.md for the full migration note. SUMMARY_MODEL_CONFIG__TRANSPORT: ${SUMMARY_TRANSPORT:-openai} SUMMARY_MODEL_CONFIG__MODEL: ${SUMMARY_MODEL:-gpt-4o-mini} DERIVER_MODEL_CONFIG__TRANSPORT: ${DERIVER_TRANSPORT:-openai} DERIVER_MODEL_CONFIG__MODEL: ${DERIVER_MODEL:-gpt-4o-mini} # All five dialectic reasoning levels (minimal/low/medium/high/max) carry # built-in openai/gpt-5.4-mini defaults in 3.0.11, but we pin them to the # same small model for a predictable, low-cost local default. Nested path: # env_prefix DIALECTIC_ + "__" delimiter + the MODEL_CONFIG sub-model. DIALECTIC_LEVELS__minimal__MODEL_CONFIG__TRANSPORT: openai DIALECTIC_LEVELS__minimal__MODEL_CONFIG__MODEL: gpt-4o-mini DIALECTIC_LEVELS__minimal__MAX_TOOL_ITERATIONS: "1" DIALECTIC_LEVELS__low__MODEL_CONFIG__TRANSPORT: openai DIALECTIC_LEVELS__low__MODEL_CONFIG__MODEL: gpt-4o-mini DIALECTIC_LEVELS__low__MAX_TOOL_ITERATIONS: "2" DIALECTIC_LEVELS__medium__MODEL_CONFIG__TRANSPORT: openai DIALECTIC_LEVELS__medium__MODEL_CONFIG__MODEL: gpt-4o-mini DIALECTIC_LEVELS__medium__MAX_TOOL_ITERATIONS: "3" DIALECTIC_LEVELS__high__MODEL_CONFIG__TRANSPORT: openai DIALECTIC_LEVELS__high__MODEL_CONFIG__MODEL: gpt-4o-mini DIALECTIC_LEVELS__high__MAX_TOOL_ITERATIONS: "4" DIALECTIC_LEVELS__max__MODEL_CONFIG__TRANSPORT: openai DIALECTIC_LEVELS__max__MODEL_CONFIG__MODEL: gpt-4o-mini DIALECTIC_LEVELS__max__MAX_TOOL_ITERATIONS: "5" ports: ["${HONCHO_PORT:-8000}:8000"] # Bundles the Hermes runtime (the adapter spawns it per task in-container). paperclip: profiles: ["full"] build: context: . dockerfile: services/paperclip/Dockerfile args: PAPERCLIP_VERSION: ${PAPERCLIP_VERSION:-v2026.707.0} PAPERCLIP_EXPECTED_SHA: ${PAPERCLIP_EXPECTED_SHA:-390627b46eb333309d357004384b220ecf8a65af} depends_on: postgres: condition: service_healthy migrate: condition: service_completed_successfully model-router: condition: service_healthy honcho: condition: service_started environment: PORT: "3100" OPENAI_BASE_URL: http://model-router:8080/v1 # aaf-0005: must match model-router's ROUTER_API_KEY above, or every # agent call the Hermes adapter makes through the router 401s. OPENAI_API_KEY: ${ROUTER_API_KEY:-localdev-router-key} HONCHO_BASE_URL: http://honcho:8000 GOVERNOR_BASE_URL: http://memory-governor:8090 # A5: canonical user peer — the ONE deploy-time identity every writer and # reader of user-scoped memory resolves (pc-memory/pc-honcho helpers in # this container, the governor's /admit default, Terraform's # honcho_user_peer_id). Keep the value identical across services or one # human fragments across peers. docs/design/memory-system.md §18. HONCHO_USER_PEER_ID: ${HONCHO_USER_PEER_ID:-user} PAPERCLIP_AUTOMATION_JWT_SECRET: ${PAPERCLIP_AUTOMATION_JWT_SECRET:-localdev-automation-secret-change-me} PAPERCLIP_AGENT_JWT_SECRET: ${PAPERCLIP_AGENT_JWT_SECRET:-localdev-agent-secret-change-me} # Agents run INSIDE this container and authenticate with a local agent # JWT (iss "paperclip", agent secret) that ONLY the backend on :3099 # validates — the auth-proxy on :3100 accepts automation JWTs only. The # hermes adapter's paperclipApiUrl default is http://127.0.0.1:3100/api # (the proxy), so without this every agent-side API call (posting the # result comment, marking the issue done) 401s with "Invalid token" while # all health checks stay green. The agent-loop canary smoke # (scripts/smoke-canary.sh) exists to catch exactly this class of gap. PAPERCLIP_API_URL: http://127.0.0.1:3099/api # Must be a VALID email shape: better-auth's sign-up validator rejects # TLD-less addresses (admin@localhost → 400 "[body.email] Invalid email # address"), so the previous admin@localhost default could never be # registered and the auth-proxy's admin-session bootstrap was # broken-by-default on a fresh stack — caught by scripts/smoke-canary.sh. PAPERCLIP_ADMIN_EMAIL: ${PAPERCLIP_ADMIN_EMAIL:-admin@example.com} # better-auth derives its trusted origins from PAPERCLIP_ALLOWED_HOSTNAMES # plus the backend's LISTEN port (3099 behind the auth-proxy), so the # proxy's public origin http://localhost:3100 was NOT trusted and every # sign-up/sign-in through the proxy 403'd "Invalid origin". Two settings # are needed (both caught live by scripts/smoke-canary.sh): # - PAPERCLIP_PUBLIC_URL: what the auth-proxy rewrites Origin to (and # the entrypoint's config.json publicUrl). NOTE: PaperClip's server # rewrites a localhost base URL's port to its own LISTEN port # (rewriteLocalUrlPort), so this alone cannot whitelist the proxy # origin for better-auth. # - BETTER_AUTH_TRUSTED_ORIGINS: upstream's explicit escape hatch — # a comma list unioned verbatim into better-auth's trusted origins. PAPERCLIP_PUBLIC_URL: ${PAPERCLIP_PUBLIC_URL:-http://localhost:${PAPERCLIP_PORT:-3100}} BETTER_AUTH_TRUSTED_ORIGINS: ${BETTER_AUTH_TRUSTED_ORIGINS:-http://localhost:${PAPERCLIP_PORT:-3100}} PAPERCLIP_ADMIN_PASSWORD: ${PAPERCLIP_ADMIN_PASSWORD:-localdev-admin-change-me} ports: ["${PAPERCLIP_PORT:-3100}:3100"] volumes: ["paperclip-data:/paperclip"] # Governed-memory sidecar. Every behavior is flag-gated OFF (migrations seed # all flags false), so it idles and /admit returns "disabled" with a fresh DB. memory-governor: profiles: ["full"] build: ./services/memory-governor depends_on: postgres: condition: service_healthy migrate: condition: service_completed_successfully model-router: condition: service_healthy honcho: condition: service_started environment: # POSTGRES_PASSWORD_URLENC: percent-encoded password for URI userinfo # (raw special chars are rejected there); defaults to the plain password. DATABASE_URL: postgresql://${POSTGRES_USER:-aaf}:${POSTGRES_PASSWORD_URLENC:-${POSTGRES_PASSWORD:-localdev}}@postgres:5432/${POSTGRES_DB:-aaf} ROUTER_BASE_URL: http://model-router:8080/v1 # aaf-0005: bearer governor/llm.py sends the router — must match # model-router's ROUTER_API_KEY above. ROUTER_API_KEY: ${ROUTER_API_KEY:-localdev-router-key} HONCHO_BASE_URL: http://honcho:8000 GOVERNOR_API_KEY: ${GOVERNOR_API_KEY:-localdev} # A5: canonical user peer — /admit defaults `observed` to this when a # writer omits it. Must equal the value the writers (paperclip above) # resolve, hence the same ${HONCHO_USER_PEER_ID} input with the same # "user" fallback. docs/design/memory-system.md §18. HONCHO_USER_PEER_ID: ${HONCHO_USER_PEER_ID:-user} # The agent half of the expected peer set, plus the alias map applied at # admission. Both empty by default: no roster declared, nothing rewritten, # nothing reported. docs/design/memory-system.md §18. HONCHO_AGENT_PEER_IDS: ${HONCHO_AGENT_PEER_IDS:-} HONCHO_PEER_ALIASES: ${HONCHO_PEER_ALIASES:-} ports: ["${GOVERNOR_PORT:-8090}:8090"] # Self-improvement watchdog. A one-shot: it self-mints a JWT from the secret, # checks AGENT_EVENTS_ENABLED in the DB (seeded false), prints "nothing to do" # and exits 0. Present so the full platform is complete; inert until flags flip. watchdog: profiles: ["full"] build: ./services/watchdog depends_on: postgres: condition: service_healthy migrate: condition: service_completed_successfully environment: # POSTGRES_PASSWORD_URLENC: percent-encoded password for URI userinfo # (raw special chars are rejected there); defaults to the plain password. AGENT_EVENTS_DSN: postgresql://${POSTGRES_USER:-aaf}:${POSTGRES_PASSWORD_URLENC:-${POSTGRES_PASSWORD:-localdev}}@postgres:5432/${POSTGRES_DB:-aaf} WATCHDOG_COMPANY_ID: local WATCHDOG_BASE_URL: http://paperclip:3100 PAPERCLIP_AUTOMATION_JWT_SECRET: ${PAPERCLIP_AUTOMATION_JWT_SECRET:-localdev-automation-secret-change-me} GOVERNOR_BASE_URL: http://memory-governor:8090 GOVERNOR_API_KEY: ${GOVERNOR_API_KEY:-localdev} GOVERNOR_WORKSPACE: local restart: "no" volumes: pgdata: paperclip-data: