Skip to content
Open
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
4 changes: 4 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
node_modules
# macOS AppleDouble sidecar files (created on exFAT/FAT external drives) — Docker's
# build context loader fails outright on these (xattr operation not permitted),
# unlike git which just needed the .gitignore entry.
**/._*
dist
.env
.env.*
Expand Down
21 changes: 17 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,13 @@ DATABASE_URL=postgres://curia:your-db-password@localhost:5432/curia

# === Secrets: managed in the encrypted vault, NOT here ===
#
# All API keys and tokens (Anthropic, OpenAI, OpenRouter, Nylas, Tavily, the HTTP
# API_TOKEN, WEB_APP_BOOTSTRAP_SECRET, Signal number) live in the encrypted secrets
# vault, not in this file. `pnpm run setup` seeds them after migrations. To add or
# update one later, set it transiently and seed it:
# All API keys and tokens (Anthropic, OpenAI, OpenRouter, AWS Bedrock, Nylas, Tavily,
# the HTTP API_TOKEN, WEB_APP_BOOTSTRAP_SECRET, Signal number) live in the encrypted
# secrets vault, not in this file. `pnpm run setup` seeds them after migrations. To add
# or update one later, set it transiently and seed it:
#
# NYLAS_API_KEY=nyk_... pnpm run seed-vault
# AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=... pnpm run seed-vault
#
# Only the four values needed to reach and unlock the vault — plus non-secret config
# below — belong in .env. (Google Workspace OAuth secrets are migrated separately, #913.)
Expand Down Expand Up @@ -109,3 +110,15 @@ TIMEZONE=America/Toronto

# Logging
LOG_LEVEL=info

# === Optional: AWS Bedrock (Mistral models) ===
#
# AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY are vault-managed secrets — set them
# transiently and run `pnpm run seed-vault` (see the note near the top of this
# file), do NOT put them in .env directly.
#
# AWS_REGION and AWS_BEDROCK_TIMEOUT are non-secret operational config and do
# belong here. Which model each capability tier (fast/standard/powerful) routes
# to is configured in config/default.yaml's model_routing block, not here.
# AWS_REGION=ca-central-1
# AWS_BEDROCK_TIMEOUT=120
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ bus event types) are noted explicitly even in the `0.x` range.

## [Unreleased]

### Added

- **AWS Bedrock LLM provider** — `BedrockMistralProvider` adds a third `LLMProvider` (alongside Anthropic and OpenRouter) via Bedrock's Converse API. Fast tier runs Mixtral 8x7B; standard/powerful run Claude 3 Sonnet via Bedrock, chosen after live load testing showed a candidate Mistral Large model silently narrating tool calls as text instead of invoking them under real prompt/tool-count load. Anthropic is no longer unconditionally required at boot — provider requirements now follow from which models `config/default.yaml`'s `model_routing` tiers actually reference. AWS credentials resolve from the secrets vault (ADR-021), not `.env`. A new `pnpm run smoke:bedrock-tools` script guards against this failure mode regressing. (ADR-023)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a nit, but I've been trying to keep the CHANGELOG additions to <15 words where possible (I've found a lot of the coding harnesses get super wordy). Assuming you're using an agent, can you ask it to shorten these to <15 words?


### Fixed

- **Bedrock: malformed conversation history crashed every subsequent turn** — a conversation that had accumulated orphaned turns (from earlier failed LLM calls not persisting an assistant reply) or a mid-history summarization turn could violate Bedrock Converse's strict "starts with user, strictly alternates" requirement, throwing `ValidationException` on every message. `BedrockMistralProvider` now normalizes the sequence (drops leading non-user messages, merges consecutive same-role turns) before sending it. The underlying gap in `AgentRuntime`/`WorkingMemory` — not persisting an assistant turn after a failed call, and not hoisting mid-history system turns — still needs its own fix; this is a defensive mitigation in the Bedrock adapter only. (ADR-023)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same as above


### Changed

- **Coordinator Drive moves** — pinned `update_drive_file` to the coordinator and documented reparenting (`add_parents`/`remove_parents`), so "move this doc into a folder" requests are performed instead of declined. (#1062)
Expand Down
26 changes: 18 additions & 8 deletions config/default.yaml

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@AnshulaChowdhury love that you're adding bedrock support! Rather than modifying the default.yaml, it would be better to override in your own local.yaml (not committed to git). That way you have bedrock support out of the gate, but it doesn't break new installs for others.

As-is, committing this change will break new installs - the install wizard currently only asks for anthropic credentials, so wouldn't be able to call these models.

Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,15 @@
model_routing:
tiers:
fast:
model: claude-haiku-4-5
model: mistral.mixtral-8x7b-instruct-v0:1
# standard/powerful: mistral-large-2402 silently narrates tool calls as text
# instead of invoking them under the coordinator's real prompt/tool-count
# load (see docs/adr/023) — Claude 3 Sonnet via Bedrock confirmed 6/6 real
# tool_use under the same load and needs no extra IAM/Marketplace grant.
standard:
model: claude-sonnet-4-6
model: anthropic.claude-3-sonnet-20240229-v1:0
powerful:
model: claude-opus-4-6
model: anthropic.claude-3-sonnet-20240229-v1:0
default_tier: standard

channels:
Expand Down Expand Up @@ -291,14 +295,16 @@ intentDrift:
filter:
llmJudge:
enabled: true
# Default works out of the box (claude-haiku-4-5 — smaller/cheaper than the opus coordinator).
#
# >>> STRONGLY RECOMMENDED <<<: point this at a DIFFERENT vendor/family than the
# fast/standard/powerful agent tiers above (e.g. an OpenRouter Gemini or DeepSeek
# model such as 'google/gemini-3.1-flash-lite'). Model diversity is the security
# value here: an attack crafted to fool the Claude coordinator should not also fool
# value here: an attack crafted to fool the coordinator should not also fool
# the reviewer. Requires the corresponding provider API key (e.g. OPENROUTER_API_KEY).
model: claude-haiku-4-5
#
# Pinned to Mixtral (fast tier) — a genuinely different model family from the
# standard/powerful tier's Claude 3 Sonnet, so this still satisfies the
# diversity property above despite both running on the same Bedrock account.
model: mistral.mixtral-8x7b-instruct-v0:1
timeout_ms: 5000
# split (default) | open | closed. See src/config.ts for semantics.
failMode: split
Expand All @@ -316,7 +322,11 @@ filter:
escalation:
judge:
enabled: true
model: claude-haiku-4-5
# Pinned to Mixtral (fast tier) — a genuinely different model family from the
# standard/powerful tier's Claude 3 Sonnet, so this still satisfies the
# diversity property recommended above despite both running on the same
# Bedrock account.
model: mistral.mixtral-8x7b-instruct-v0:1
timeout_ms: 5000

# Dream engine — background knowledge graph maintenance (issue #27).
Expand Down
211 changes: 211 additions & 0 deletions docs/adr/023-aws-bedrock-mistral-llm-provider.md

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the ADR - this is helpful 👍

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've finished reading through the ADR - any chance you can rework it to apply to all readers of the code?

It's heavily anchored on your deployment, so mixes together broadly-applicable things (adding support for Bedrock and 3 new models) and deployment-specific things (configuring your coordinator and the 3 model tiers selected.

Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
# ADR-023: AWS Bedrock as the configured LLM provider

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@AnshulaChowdhury I also just realized this PR was numbered, before others were merged in. Can you renumber to 26, which I'll reserve for you?


Date: 2026-07-06
Status: Accepted

## Context

ADR-007 established Anthropic as Curia's primary LLM provider behind a
provider-agnostic `LLMProvider` interface specifically so that switching or
adding providers would be a config change, not an architecture change. The
operator running this deployment wants to run entirely on AWS Bedrock, serving
Mistral models, instead of the Anthropic API — this allows for easier swapping between models.

This is exactly the scenario ADR-007 anticipated: "different agents may
benefit from different providers" and "switching the primary provider... requires
only a config change, not code changes," backed by "a thin adapter per
provider." `OpenRouterProvider` already demonstrated that a second adapter
plugs in cleanly. This ADR records the decision to add a third.

Two implementation questions were resolved before building:

1. **Bedrock access path.** Calls go directly to Bedrock via `@aws-sdk/client-bedrock-runtime`.
2. **Credential storage.** ADR-021 (vault-only secret resolution) requires
every provider credential except the four vault-bootstrap values to live in
the encrypted secrets vault, not `.env`. The AWS access key and secret
follow that path (`aws_access_key_id`/`aws_secret_access_key` vault keys,
resolved by `applyVaultSecrets()` like every other provider key). Region and
request timeout are non-secret operational config and stay in `.env`,
matching `TIMEZONE`/`HTTP_PORT`.

## Decision

Add `BedrockMistralProvider` (`src/agents/llm/bedrock-mistral.ts` — the
filename predates the final model choice below; the class itself is not
Mistral-specific, it works for any Converse-compatible Bedrock model)
implementing `LLMProvider` via Bedrock's **Converse API**, register it in
`providerRegistry` in `src/index.ts` alongside (not instead of, at the code
level) Anthropic and OpenRouter.

Final `config/default.yaml` `model_routing.tiers`:
- **fast**: `mistral.mixtral-8x7b-instruct-v0:1`
- **standard/powerful**: `anthropic.claude-3-sonnet-20240229-v1:0` — served
via Bedrock, not the direct Anthropic API.

**Why Claude via Bedrock, not a Mistral model, for the coordinator's tiers:**
this went through a real model search, not a single guess, because the
consequence of guessing wrong here is a silent safety failure (see the
"narrated tool call" finding below), not just a validation error. In order:

1. `mistral.mistral-large-3-675b-instruct` (originally configured) — rejected
outright by Bedrock (`ValidationException: The provided model identifier is
invalid`); the ID didn't match Bedrock's usual
`mistral.<name>-v<major>:<minor>` shape.
2. `mistral.mistral-large-2402-v1:0` (Mistral Large, Feb 2024) — a real,
working model. **Confirmed via isolated load testing to silently fail at
tool-calling under the coordinator's actual conditions**: with a large
system prompt (~11.8K tokens) and many available tools (~48, matching the
coordinator's real pinned-skill count), it narrates an intended tool call as
prose/JSON text instead of emitting a real Converse `toolUse` block —
confirmed via 5 isolated test runs (100% failure) and one live production
call, where the coordinator told the CEO it would check scheduled jobs and
never actually called `scheduler-list`. The same model in isolation (one
tool, no large prompt) calls tools correctly — the failure is specifically
load-dependent, not a blanket incapability.
3. `us.anthropic.claude-sonnet-4-6` (a cross-region inference profile) — a
real tool call was observed once, but the model requires an AWS Marketplace
subscription grant (`aws-marketplace:ViewSubscriptions`/`Subscribe`) beyond
plain `bedrock:InvokeModel`, which wasn't available on this account; repeated
attempts after adding the base IAM permission still failed consistently.
4. `meta.llama3-8b-instruct-v1:0` — valid model, but Bedrock returns
`ValidationException: This model doesn't support tool use` outright. The
original Llama 3 (pre-3.1) instruct models have no Converse tool-calling
support at all.
5. `anthropic.claude-3-sonnet-20240229-v1:0` — **the model shipped.** An older,
native on-demand foundation model (no inference profile, no Marketplace
gate). Confirmed 6/6 across an unloaded call plus 5 repeated calls under the
full production-shaped load (47 tools + ~20K-char system prompt) — 100% real
`tool_use`, zero narrated-call failures.

A useful side effect: because the coordinator's tiers now run Claude while
`filter.llmJudge`/`escalation.judge` stay pinned to Mixtral (fast tier, a
genuinely different model family), the model-diversity property those judges
were designed around (see their config comments) is intact — not a
degradation, despite everything running through the same Bedrock account.

**Why Converse over Bedrock's raw `InvokeModel` API:** Converse normalizes tool
use, message roles, and response shape across every model family Bedrock
hosts, into one API. That maps directly onto Curia's existing
provider-neutral `ToolCall`/`ToolResult`/`ContentBlock` types — the same shape
`OpenRouterProvider` already maps onto the OpenAI-compatible chat API. Using
Bedrock's raw per-model invoke format instead would mean hand-rolling each
model family's native request/response schema and reimplementing tool-calling
semantics Converse already provides uniformly — and, per the search above,
still needing per-model validation regardless.

**A necessary side effect: Anthropic is no longer unconditionally required at
boot.** `src/index.ts` previously hard-failed startup if `ANTHROPIC_API_KEY`
(now `anthropic_api_key` in the vault) was absent, regardless of whether any
agent tier actually used a Claude model. That assumption breaks for an
all-Bedrock deployment. Anthropic, OpenRouter, and Bedrock are now registered
symmetrically — each conditional on its own credentials being present — and
the existing "every tier-mapped model has a registered provider" validation
loop is what enforces that *this deployment's actual configuration* has what
it needs, rather than hardcoding one vendor as mandatory for all deployments.
`seed-vault.ts`'s `REQUIRED_SECRET_NAMES` was updated to match — provider keys
were removed from that list for the same reason.

**New safety net: `detectUncalledToolIntent()`.** Since a model can return a
perfectly valid, successful Converse response that still represents a silent
failure (narrating a tool call instead of making one), `bedrock-mistral.ts`
now scans text responses (when tools were offered) for a fenced JSON block
matching an offered tool's name, and logs at `error` level if found —
deliberately conservative to avoid false alarms on replies that merely mention
a tool by name. This is a detector, not a fix: it makes the failure mode
visible in logs rather than catching every phrasing (a live test found a
narrated-call variant with no JSON block at all, which this detector does not
catch — see `scripts/smoke-bedrock-tool-use.ts` below for the mechanism that
actually gates on this). We may want to use a LLM-as-judge pass over responses
that should invoke a tool call in future to be able to catch and fix these
issues in real-time for the user.

**New regression gate: `pnpm run smoke:bedrock-tools`**
(`scripts/smoke-bedrock-tool-use.ts`). Runs the real `BedrockMistralProvider`
(not a bypassing raw-SDK script) against live Bedrock, reproducing the same
production-shaped load (large system prompt + ~48 tools) that exposed the
Mistral failure, and asserts a real `tool_use` call comes back. Requires live
AWS credentials and a reachable Postgres, so it's deliberately **not** part of
`pnpm test` — same reasoning as `tests/integration` needing Docker Postgres,
or `redteam` needing its own setup. Run it whenever the configured
standard-tier model changes.

**New safety net: `normalizeMessageSequence()`.** After shipping, a real
production conversation broke every subsequent turn with
`ValidationException: A conversation must start with a user message.` Root
cause was two compounding, pre-existing bugs in `AgentRuntime`/`WorkingMemory`,
neither introduced by this change:

1. A failed LLM call persists the user's turn to `working_memory` *before* the
call, but never writes an assistant turn back on failure — repeated
failures during this deployment's own model-search debugging (above) left
several orphaned `user` turns with no paired `assistant` reply, so later
messages stacked up as consecutive `user` turns.
2. `WorkingMemory`'s summarization pass writes its synthetic summary as a
`role: 'system'` row *inline* in conversation history, not hoisted to the
top-level system prompt. Every provider (Anthropic, OpenRouter, Bedrock)
strips `role: 'system'` messages out of the regular array when building the
top-level system param — so whatever turn immediately followed that
mid-history system row became the new first element, which in this case was
`assistant`.

Anthropic's and OpenAI's APIs are apparently lenient enough not to reject
either pattern; Bedrock's Converse API is strict about both (must start with
`user`, must strictly alternate) and was the first provider to surface this.
Fixed defensively at the Bedrock provider boundary —
`normalizeMessageSequence()` drops leading non-`user` messages and merges
consecutive same-role messages — rather than in `AgentRuntime`/`WorkingMemory`,
which is out of scope here and where the actual root cause still lives (see
Consequences). 3 tests reproduce the exact production sequences found.

## Consequences

**Easier:**
- Confirms ADR-007's hypothesis: adding a third provider took one new adapter file, a
few conditional-registration lines mirroring the existing OpenRouter pattern,
and no changes to `AgentRuntime`, `ModelRouter`, or any agent/skill code.
- Deployments can now run entirely on Bedrock, entirely on Anthropic, entirely
on OpenRouter, or any mix per capability tier, with no provider treated as
load-bearing infrastructure by the bootstrap sequence itself.
- AWS credentials follow the same vault-only handling as every other provider
key (ADR-021) — no new plaintext-secret exception was introduced.
- Tool-use is no longer an open question — confirmed both by the live
reliability probing above and by a real end-to-end production call
(`scheduler-list` actually invoked, real data returned, correctly
synthesized into the reply — verified against the audit log).
- Cost telemetry works for the shipped model: `anthropic.claude-3-sonnet-20240229-v1:0`
carries real, well-documented pricing ($3.00/$15.00 per M input/output
tokens), unlike the Mistral entries below.

**Harder / accepted trade-offs:**
- **Is pricing for tokens best placed in the code?.** Given the dynamic
nature of token pricing, it seems that for users to be able to trust
costing dashboards it may be better to add a frontend UI for users to
enter costing information directly.
- **Mixtral's context window (32K) is a documented spec, not independently
confirmed against this account/region.** Given that we can now swap out
models as needed, this is an okay tradeoff for now.
- **No prompt-cache support on Bedrock** (Mistral or Claude-via-Bedrock) —
cache-related cost savings that exist for direct-API Claude traffic don't
apply here.
- **`detectUncalledToolIntent()` is best-effort, not exhaustive** — it catches
the JSON-fenced narration pattern observed with mistral-large-2402, not
every possible way a model could describe an uncalled action in prose. The
real regression gate against this failure mode is
`pnpm run smoke:bedrock-tools`, which knows the expected outcome ahead of
time and can therefore assert on it precisely; the in-provider detector is
an observability signal on top, useful in production where the expected
outcome isn't known in advance.
- **`normalizeMessageSequence()` masks a real upstream bug rather than fixing
it.** The underlying gaps — failed LLM calls not persisting an assistant
turn, and the summarization pass writing its synthetic turn inline instead
of hoisted — still exist in `AgentRuntime`/`WorkingMemory` and could
resurface in new ways (e.g. a conversation could still read strangely to the
model even though Bedrock no longer rejects it outright, since dropped/merged
turns lose information — see the real example where a merged multi-question
blob caused the coordinator to answer a stale question instead of the latest
one). Anthropic and OpenRouter are not protected by this fix at all, since it
lives in the Bedrock adapter only. This may be worth its own fix in
`AgentRuntime`/ `WorkingMemory` — persist a turn (even an error marker)
after every failed call, and hoist mid-history system turns into the top-level
system prompt before they ever reach a provider — rather than every provider
adapter needing its own defensive normalization.
Loading