diff --git a/.dockerignore b/.dockerignore index 0e509f3e4..cb4984f63 100644 --- a/.dockerignore +++ b/.dockerignore @@ -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.* diff --git a/.env.example b/.env.example index f71eacf3b..4c1f1c7c8 100644 --- a/.env.example +++ b/.env.example @@ -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.) @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 13abb76f7..155a857fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) + +### 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) + ### 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) diff --git a/config/default.yaml b/config/default.yaml index d28e2d79c..8a3916274 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -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: @@ -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 @@ -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). diff --git a/docs/adr/023-aws-bedrock-mistral-llm-provider.md b/docs/adr/023-aws-bedrock-mistral-llm-provider.md new file mode 100644 index 000000000..7c38ee2c0 --- /dev/null +++ b/docs/adr/023-aws-bedrock-mistral-llm-provider.md @@ -0,0 +1,211 @@ +# ADR-023: AWS Bedrock as the configured LLM provider + +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.-v:` 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. diff --git a/docs/adr/README.md b/docs/adr/README.md index 213444f79..531b55b24 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -44,6 +44,7 @@ Each ADR follows the [Nygard format](https://adr.github.io/): | [020](020-secrets-vault.md) | Application-layer AES-256-GCM secrets vault in PostgreSQL — structural typing, per-invocation pre-warm cache, env-var fallback for incremental migration | Accepted | | [021](021-vault-only-secret-resolution.md) | Vault-only secret resolution — remove the env fallback; only the four vault-bootstrap values stay in `.env`, everything else seeds via `seed-vault` | Accepted | | [022](022-skill-agent-registry.md) | DB-gated skill/agent registry — install/enable lifecycle with startup reconciliation and restart-based enforcement | Accepted | +| [023](023-aws-bedrock-mistral-llm-provider.md) | AWS Bedrock as the configured LLM provider — third `LLMProvider` adapter via the Converse API (Mixtral fast tier, Claude 3 Sonnet standard/powerful after a live model search ruled out several candidates for unreliable/absent tool-calling); Anthropic no longer unconditionally required at boot | Accepted | ## Adding new ADRs diff --git a/docs/specs/02-agent-system.md b/docs/specs/02-agent-system.md index 2a65bb469..3af8206f4 100644 --- a/docs/specs/02-agent-system.md +++ b/docs/specs/02-agent-system.md @@ -213,11 +213,14 @@ Multi-provider from day one: ``` src/agents/llm/ - provider.ts # common interface - anthropic.ts # Claude API (Anthropic) - openrouter.ts # OpenRouter API (Gemini Flash, DeepSeek V3, GPT-4o, etc.) - ollama.ts # local models - model-registry.ts # ModelRegistry — centralized model metadata + provider.ts # common interface + anthropic.ts # Claude API (Anthropic) + openrouter.ts # OpenRouter API (Gemini Flash, DeepSeek V3, GPT-4o, etc.) + bedrock-mistral.ts # AWS Bedrock via the Converse API (Mistral, Claude, or any + # other Converse-compatible Bedrock model — filename predates + # the model choice; the class itself is not Mistral-specific) + ollama.ts # local models + model-registry.ts # ModelRegistry — centralized model metadata ``` Each provider implements: @@ -306,6 +309,7 @@ All agents receive a `## Current Date & Time` block in their system prompt on ev | LLM provider abstraction (`LLMProvider` interface, `provider.ts`) | Done | | Anthropic provider | Done | | OpenRouter provider (Gemini Flash, DeepSeek V3, GPT-4o via `OPENROUTER_API_KEY`) | Done | +| AWS Bedrock provider (Converse API — Mistral, Claude, or any Converse-compatible model) | Done | | Model registry — centralized pricing, context windows, capabilities for all models | Done | | Ollama (local model) provider | Not Done | | Fallback provider (`model.fallback` in agent config) | Not Done | diff --git a/package.json b/package.json index 63452df77..a542ac2cc 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "lint": "eslint src/ tests/", "migrate": "tsx --env-file=.env node_modules/node-pg-migrate/bin/node-pg-migrate.js up --migrations-dir src/db/migrations --migration-file-language sql", "smoke": "tsx --env-file=.env tests/smoke/cli.ts", + "smoke:bedrock-tools": "tsx --env-file=.env scripts/smoke-bedrock-tool-use.ts", "inspect-prompts": "tsx --env-file=.env scripts/inspect-prompts.ts", "render-coordinator-prompt": "tsx --env-file=.env scripts/render-coordinator-prompt.ts", "redteam": "promptfoo redteam run --config tests/redteam/promptfooconfig.yaml --env-file .env", @@ -34,6 +35,7 @@ "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.104.2", + "@aws-sdk/client-bedrock-runtime": "^3.1079.0", "@fastify/cookie": "^11.0.2", "@fastify/cors": "^11.2.0", "@fastify/rate-limit": "^11.0.0", @@ -73,6 +75,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@smithy/types": "^4.15.1", "@types/chokidar": "^2.1.7", "@types/js-yaml": "^4.0.9", "@types/luxon": "^3.7.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 446951672..11b7191e7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,6 +25,9 @@ importers: '@anthropic-ai/sdk': specifier: ^0.104.2 version: 0.104.2(zod@4.4.3) + '@aws-sdk/client-bedrock-runtime': + specifier: ^3.1079.0 + version: 3.1079.0 '@fastify/cookie': specifier: ^11.0.2 version: 11.0.2 @@ -122,6 +125,9 @@ importers: '@eslint/js': specifier: ^10.0.1 version: 10.0.1(eslint@10.5.0) + '@smithy/types': + specifier: ^4.15.1 + version: 4.15.1 '@types/chokidar': specifier: ^2.1.7 version: 2.1.7 @@ -333,6 +339,10 @@ packages: resolution: {integrity: sha512-zTWEIvCFDJ13VjAyK8UouphesohVgZr3u7r6f74w8rtypPkci2vZtmttKxj/eeX2GQipuyHNgPwut2eXoL28aA==} engines: {node: '>=20.0.0'} + '@aws-sdk/client-bedrock-runtime@3.1079.0': + resolution: {integrity: sha512-+LxXUhnDsQ4Yr+zSQbROKClMfIRA8mLPtZgQukb6omczIqmi0yvDmbLovdLJGnm9GRj83kKSxWV24Ms5aUgJfg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/client-s3@3.1063.0': resolution: {integrity: sha512-ETn+vvmZVK1MmOZwVBXmWANpmD5iTbzojIqyEIoZ86qo+8oWy35S8QyQNE/ZDI+WHgMU1dS+VSYbpRl1QkEySg==} engines: {node: '>=20.0.0'} @@ -345,46 +355,90 @@ packages: resolution: {integrity: sha512-JDYCPI0j7zGrzXTDFsLB346cxss7J/AxH7+O0MzWlqppJBEyB9Qe6TQXRL6iwLUo/xZkNv9KFmBL2hqElmwW0g==} engines: {node: '>=20.0.0'} + '@aws-sdk/core@3.974.27': + resolution: {integrity: sha512-WRWEgIq6vx+NU6ot3VrRu4Jovj9MIObitSi6of/GV5THDDPccBhivCRNkWJutMM+m3GvdeI3l/UbGNcoOobxOA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-env@3.972.44': resolution: {integrity: sha512-3hKJVrZ7bqXzDAXCQp+OaQ1ASN+vWstaNuEH418wQVl//cRZhqhfR9Bjk1qIWmgUGe8/D3gdO73PgidRj378EQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-env@3.972.53': + resolution: {integrity: sha512-+KDA3uc/HZ1vIneGu5QMQb0gAXDYrm2vOE60+BJ7lS0YinMQ5i2oV4PR1A16XkF6K1IbSwjEHd1hQIIgMsK48w==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-http@3.972.46': resolution: {integrity: sha512-VhwC9pGAZHhiQ2xSViyOPDFqvr9aRxGCAXZtADsUhU3R65nad7y//CwynE6mQnWNR+suRlqE79W36IVayL+m1g==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-http@3.972.55': + resolution: {integrity: sha512-1gBfkWY3RWeBlCoB9lIJjXMx45/54wxcgfzv6BY9otTmMrZPcNPi1v+MwZxxaCUg441NV3jsr1efnFNCXiW70g==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-ini@3.972.50': resolution: {integrity: sha512-09Xi6ovxiK42+De/qBGF71sT5F2bWgYM+1fFyDwSOpy1xpsQ5R/naIu7MVDpH6Dic36QNc8dAv4KADtMGK2JYg==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-ini@3.972.60': + resolution: {integrity: sha512-CV2md+PXvABwRjApWGhQ0wACy9WSFIhnUGrovLcjnjBCd/46TbuivLADtkF8IWNjtCQmQ+2IagSaxqBYqXBNAQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-login@3.972.49': resolution: {integrity: sha512-EfJF/1Fh9mI4pZyoheU2RY9xUhTcugIZNkD63+orXMkYj/QXacJNbKVDUK90Yv5hE+aX+rt9J/EZ9Qr3vKOa7g==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-login@3.972.59': + resolution: {integrity: sha512-JG4S9yyA1GFzJdJXqLKrUzZbyK+VDp2QIsJD7YOicJHAhqymfHpDJIok2dLnhOdVB0I37RjdC53uOwCMVS00gw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-node@3.972.52': resolution: {integrity: sha512-7QX+PbyiWBEOVipJq8Nke/TqXT6lAPLE7fvTaopa39/IVWuLfS+Fzdy71sZJONf/mLGgmtj6aU17+REw3+aRrw==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-node@3.972.62': + resolution: {integrity: sha512-S6Slq3Tx7bvFk5yc34XNADyZYTX2HUXvaFAnowGRQnhjBO8J/mP62Fn7lxvJwjaDyYm/7gh9h6HEHaltRyMFXw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-process@3.972.44': resolution: {integrity: sha512-V+UUhZpRP7QDRhi+qgBDisM9tUBnYmMje8Bk77A6MZsfeGeGdMsQXmaHP1CDYFcept0o/Rz5g2Y0TMeVlG9dzg==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-process@3.972.53': + resolution: {integrity: sha512-EhfH+MQlqOMCkXIVa8MMObPzAQqwTTtxA7KhEJiyPeuNVA8PLOOUpgK7nBrgaDaGiIDLN/9LpGdaHuDjomeRTw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-sso@3.972.49': resolution: {integrity: sha512-9QqOYGuh5tZ76OzaT68kwI78AH+5lS/uZGGvkfxb3fc8FzRrIz2jOufNTliEBEeSAwmgK2rWLNsK+IB3zbtNPA==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-sso@3.972.59': + resolution: {integrity: sha512-h8793pOjcImx0SB+VcLONcaQQ57VAvKVuqyewQMRKqqH+CSXsG2dwOeLMUJPMxLdNvL7dXOM0ueTukyNUnu5mA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-web-identity@3.972.49': resolution: {integrity: sha512-IYx1lN38MnnPXv+NBLpuATu0cZakbZ321TAfjW+aVkw7HIJF38YnEwdeEO55MSl3pl7hIX1IvvnD6EmnAzmAJw==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-web-identity@3.972.59': + resolution: {integrity: sha512-VoyO9+vl3XVmpZwn4obskrWIkrA/Jf3lSe1E3ZERlaN9u0D4YZ6+HywC3+L98QOXqZesEfedk67gRER8tK8+8w==} + engines: {node: '>=20.0.0'} + '@aws-sdk/eventstream-handler-node@3.972.20': resolution: {integrity: sha512-qr/S1iFCDIXlZwlZPaCqjKcHbJFr9scIFUhbh2+SrwPXZvRhyOUWjVDJpp8xoU4qrrMR0PqK1Yw5C2sSj7xAyw==} engines: {node: '>=20.0.0'} + '@aws-sdk/eventstream-handler-node@3.972.25': + resolution: {integrity: sha512-df7HN1ozwMrB9+59re9PM7tSLxLAcheMWc5u/KyfCPCAWtN/vP7y7RTUZOy48uT1K9MESisVeOPPzF3O1AW01A==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-eventstream@3.972.16': resolution: {integrity: sha512-KR2Gdui/QLbkdG9FxW3vk/vIa8KiDP5vQBNERo7MmlPHjn23GXJ53Cq5P/ok7/ALbTUiYZ78DiBHoDcvzPWvgQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-eventstream@3.972.21': + resolution: {integrity: sha512-HvLgDnxBLaHi9E5K++6Vuk+1+qqn7Pmn8zrlzd+NXH3jBzwujnuzZtAR9WHPkbUGPO92FkoQWj/M1IsdxTlBmQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-flexible-checksums@3.974.27': resolution: {integrity: sha512-bZqezPLdllFC4VAeV/f+EIc/hz56ab3TD/+4zNCgOgmG5ZHAE5dMHrX1gtTwdcQXbPr3KR7x3zTC3zuCTE6+ng==} engines: {node: '>=20.0.0'} @@ -397,22 +451,42 @@ packages: resolution: {integrity: sha512-foM3KvxGBHY9lRIm6C9JJJ5haodtXfJPPgJQcv5/c4A2pN4I7tlnOjh1o2d8Il1Y/j6GWOw3YeIYc2/VYjtGVQ==} engines: {node: '>= 14.0.0'} + '@aws-sdk/middleware-websocket@3.972.35': + resolution: {integrity: sha512-7/ZAlq5o5A9FnRsQEftZvcct9LVZf1YaYmWR3eOzyJAdKU66YpOKy5ahUVvyBvCTLyO4Ej4yWIk8dllWNXPBsw==} + engines: {node: '>= 14.0.0'} + '@aws-sdk/nested-clients@3.997.17': resolution: {integrity: sha512-lDRgraoTfKRawUyc176Ow93mrNrOho/x+EoK4C+lKU+vKkHWhNhzvSMVAx0WEJUJoeQxxDN5ZdKMfiGEyNejig==} engines: {node: '>=20.0.0'} + '@aws-sdk/nested-clients@3.997.27': + resolution: {integrity: sha512-A8PIePF9NIIOJ/4Lg1rl9xm/+QaKkHGetq+Z9wb5B+3Da31YYXRo8n7IDMh5C+HQI5eyEmjrwkGWVdYtnLtbXQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/signature-v4-multi-region@3.996.32': resolution: {integrity: sha512-llvApLcsWtmRFhG2wT3WIp1CmDeRaIYutqty1ZZXoMzK7TiJ6MOLOimk9eXUS8PwgG4ew4pa4QAbt0lfhn++1w==} engines: {node: '>=20.0.0'} + '@aws-sdk/signature-v4-multi-region@3.996.38': + resolution: {integrity: sha512-C379Sk+MiFZCfWZphKlMyLHKxV22OjoGM5KJjj5IJNJcOCWL4IGIpnEGzv1FQiRwhYXfq55SJMfxlqPE08JJ9g==} + engines: {node: '>=20.0.0'} + '@aws-sdk/token-providers@3.1063.0': resolution: {integrity: sha512-nYDaWWdzjKiDP5xj8k4oUgcYd4WPgzfAOgdU5vJsaqH/07Dfvm7ffisHCFJ+NEl7kUC9JEIUxh0kznvenbo3NQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/token-providers@3.1079.0': + resolution: {integrity: sha512-cbietrLlHPhhmbnMPTuDS4Zj/KNGhY+3vVhn6dwjO6Dqzrwothzg2srtcY34T9mlICsTXn34avDoWLHSntP54A==} + engines: {node: '>=20.0.0'} + '@aws-sdk/types@3.973.11': resolution: {integrity: sha512-YjS0qFuECClRh4qhEyW8XagW0fwEPBeZ1cfsW/gU73Kh/ExFILxbzxOfPCmzF/2DwEvhvsHYt0b0qnvStwKYrg==} engines: {node: '>=20.0.0'} + '@aws-sdk/types@3.973.15': + resolution: {integrity: sha512-IULn8uBV/SMtmOIANsm4WHXIOtVPBWfOWs3WGL0j/sI+KhaYehvOw0ET+9urnn8MBpiijuU/0JOpuwKOE451PQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/util-locate-window@3.965.6': resolution: {integrity: sha512-ZfHjfwSzeXj+Lg9AK5ZNmeDkXev6V+w2tn1t4kgDdRtUaRCthepTQiFwbD06EF9oNGH4LaLg+Mb6U16Ypv5bSw==} engines: {node: '>=20.0.0'} @@ -421,10 +495,18 @@ packages: resolution: {integrity: sha512-lI/l3c/vPvsxmspzV63NfS3x9q4CkMmdhJy4QiM+NThAufVkDvi/PZZQ6xETnICL0UD7jI808pY83gllf86RFg==} engines: {node: '>=20.0.0'} + '@aws-sdk/xml-builder@3.972.33': + resolution: {integrity: sha512-ezbwz9WpuLctm6o7P2t2naDhVVPI5jFGrVefVybhcKGjU57VIyT46pQVO0RI2RYkUdhdj2Z9uSIlAzGZE9NW9A==} + engines: {node: '>=20.0.0'} + '@aws/lambda-invoke-store@0.2.4': resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==} engines: {node: '>=18.0.0'} + '@aws/lambda-invoke-store@0.3.0': + resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==} + engines: {node: '>=18.0.0'} + '@azure-rest/core-client@1.4.0': resolution: {integrity: sha512-ozTDPBVUDR5eOnMIwhggbnVmOrka4fXCs8n8mvUo4WLLc38kki6bAOByDoVZZPz/pZy2jMt2kwfpvy/UjALj6w==} engines: {node: '>=18.0.0'} @@ -1800,14 +1882,26 @@ packages: resolution: {integrity: sha512-wBXDRup6UU97VKyaiRo8AssnfStPtG0oAAfpq/bC0a1YYau8pM86YB4kM6ccoVi1mS8l/UHbn9oDM+7uozr/ug==} engines: {node: '>=18.0.0'} + '@smithy/core@3.29.1': + resolution: {integrity: sha512-qoiY4nrk5OCu1+eIR1VB8l5DmON/oKiqrd5zZFAhXJXjJlLWQusKEW/SkBDAtGDcPaz86m9kfcE1lngU0GlM6A==} + engines: {node: '>=18.0.0'} + '@smithy/credential-provider-imds@4.3.8': resolution: {integrity: sha512-5cAM+KZC02sTqDt6NaLXyu50M/GNMd1eTzDVR8Lb0BBsVtu7RWHo47VPPEEv1vt3Yub6uzr+M5FHC+GtoT0USg==} engines: {node: '>=18.0.0'} + '@smithy/credential-provider-imds@4.4.6': + resolution: {integrity: sha512-B2WQ/PV/H6Jeg3lrIq6bKUfa6Hy01mtK7CGs6lhjzHA6k4aagldH6T6eEjnzKl4HI0cJnAsxfJ19pgb5PV+CVQ==} + engines: {node: '>=18.0.0'} + '@smithy/fetch-http-handler@5.4.6': resolution: {integrity: sha512-FEwEYJ1jlBKdhe9TPzfghEi1bP55ZeEImlDkEa62bBBYzUcnB6RUCyuiS2mqKt6ZVjUbBgcNhzfIctH+Hevx9g==} engines: {node: '>=18.0.0'} + '@smithy/fetch-http-handler@5.6.3': + resolution: {integrity: sha512-CwCc/7SMTj45y97MUnDTbTaxvtAsiNNRm81z3abROIuMbMsC2Iy5EKfkkVdsKrz8WExQAAMx1EJapq+9j4fFTQ==} + engines: {node: '>=18.0.0'} + '@smithy/is-array-buffer@2.2.0': resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} engines: {node: '>=14.0.0'} @@ -1816,12 +1910,20 @@ packages: resolution: {integrity: sha512-ZAFvHXrEk6K180EVhmZVg8GU5pUH5BSFqRs27JW3j1qEFx9YyYwWFx17x/MHcjALYimGAji7qEOlF1++be+G5A==} engines: {node: '>=18.0.0'} + '@smithy/node-http-handler@4.9.3': + resolution: {integrity: sha512-qZTa4gQFUo8RM02rk6q5UVTDLNrQ1oS20LsepBzqq1QBVc/EHJ03OOUADcqMZiXHArW+Y7+OGY0BpdTwZRq/Yg==} + engines: {node: '>=18.0.0'} + '@smithy/signature-v4@5.4.6': resolution: {integrity: sha512-Ojg4B6oIDlIr1R86xCDJt1zJWnYa0VINmqdjfe9qxWjdRivHalZ3iSlQgVqYbW0MdpFOC5XfHEWsnbmdnpIILQ==} engines: {node: '>=18.0.0'} - '@smithy/types@4.14.3': - resolution: {integrity: sha512-YupL0ZWmFtJexUN2cHzkvvF/b9pKrtAIfT1o7/oY/Ppu8IYeZ+lDPM5vZdQJaSeA132dJCqojjGC9NhXeF71VQ==} + '@smithy/signature-v4@5.6.2': + resolution: {integrity: sha512-QgHflghMoPxCJ9axiCVh8KZfbC9fuP6vkXXyK//E3cq7nLaSSyyLj0GAoqVWezYeDQmXIZhmlRvLE16jsqDK6g==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.15.1': + resolution: {integrity: sha512-x3L0XSACF6UYzKpa9biqiRMgvH5+wnFFew9Tm/grFYqgaupPwx/+ojDPpPJM8dZON3S9tjz5U+PQYsCBd1Mw5Q==} engines: {node: '>=18.0.0'} '@smithy/util-buffer-from@2.2.0': @@ -5520,7 +5622,7 @@ snapshots: '@aws-sdk/core': 3.974.18 '@aws-sdk/types': 3.973.11 '@smithy/core': 3.24.6 - '@smithy/types': 4.14.3 + '@smithy/types': 4.15.1 tslib: 2.8.1 optional: true @@ -5534,7 +5636,7 @@ snapshots: '@smithy/core': 3.24.6 '@smithy/fetch-http-handler': 5.4.6 '@smithy/node-http-handler': 4.7.7 - '@smithy/types': 4.14.3 + '@smithy/types': 4.15.1 tslib: 2.8.1 optional: true @@ -5552,10 +5654,25 @@ snapshots: '@smithy/core': 3.24.6 '@smithy/fetch-http-handler': 5.4.6 '@smithy/node-http-handler': 4.7.7 - '@smithy/types': 4.14.3 + '@smithy/types': 4.15.1 tslib: 2.8.1 optional: true + '@aws-sdk/client-bedrock-runtime@3.1079.0': + dependencies: + '@aws-sdk/core': 3.974.27 + '@aws-sdk/credential-provider-node': 3.972.62 + '@aws-sdk/eventstream-handler-node': 3.972.25 + '@aws-sdk/middleware-eventstream': 3.972.21 + '@aws-sdk/middleware-websocket': 3.972.35 + '@aws-sdk/token-providers': 3.1079.0 + '@aws-sdk/types': 3.973.15 + '@smithy/core': 3.29.1 + '@smithy/fetch-http-handler': 5.6.3 + '@smithy/node-http-handler': 4.9.3 + '@smithy/types': 4.15.1 + tslib: 2.8.1 + '@aws-sdk/client-s3@3.1063.0': dependencies: '@aws-crypto/sha1-browser': 5.2.0 @@ -5570,7 +5687,7 @@ snapshots: '@smithy/core': 3.24.6 '@smithy/fetch-http-handler': 5.4.6 '@smithy/node-http-handler': 4.7.7 - '@smithy/types': 4.14.3 + '@smithy/types': 4.15.1 tslib: 2.8.1 optional: true @@ -5584,7 +5701,7 @@ snapshots: '@smithy/core': 3.24.6 '@smithy/fetch-http-handler': 5.4.6 '@smithy/node-http-handler': 4.7.7 - '@smithy/types': 4.14.3 + '@smithy/types': 4.15.1 tslib: 2.8.1 optional: true @@ -5595,20 +5712,39 @@ snapshots: '@aws/lambda-invoke-store': 0.2.4 '@smithy/core': 3.24.6 '@smithy/signature-v4': 5.4.6 - '@smithy/types': 4.14.3 + '@smithy/types': 4.15.1 bowser: 2.14.1 tslib: 2.8.1 optional: true + '@aws-sdk/core@3.974.27': + dependencies: + '@aws-sdk/types': 3.973.15 + '@aws-sdk/xml-builder': 3.972.33 + '@aws/lambda-invoke-store': 0.3.0 + '@smithy/core': 3.29.1 + '@smithy/signature-v4': 5.6.2 + '@smithy/types': 4.15.1 + bowser: 2.14.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-env@3.972.44': dependencies: '@aws-sdk/core': 3.974.18 '@aws-sdk/types': 3.973.11 '@smithy/core': 3.24.6 - '@smithy/types': 4.14.3 + '@smithy/types': 4.15.1 tslib: 2.8.1 optional: true + '@aws-sdk/credential-provider-env@3.972.53': + dependencies: + '@aws-sdk/core': 3.974.27 + '@aws-sdk/types': 3.973.15 + '@smithy/core': 3.29.1 + '@smithy/types': 4.15.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-http@3.972.46': dependencies: '@aws-sdk/core': 3.974.18 @@ -5616,10 +5752,20 @@ snapshots: '@smithy/core': 3.24.6 '@smithy/fetch-http-handler': 5.4.6 '@smithy/node-http-handler': 4.7.7 - '@smithy/types': 4.14.3 + '@smithy/types': 4.15.1 tslib: 2.8.1 optional: true + '@aws-sdk/credential-provider-http@3.972.55': + dependencies: + '@aws-sdk/core': 3.974.27 + '@aws-sdk/types': 3.973.15 + '@smithy/core': 3.29.1 + '@smithy/fetch-http-handler': 5.6.3 + '@smithy/node-http-handler': 4.9.3 + '@smithy/types': 4.15.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-ini@3.972.50': dependencies: '@aws-sdk/core': 3.974.18 @@ -5633,20 +5779,45 @@ snapshots: '@aws-sdk/types': 3.973.11 '@smithy/core': 3.24.6 '@smithy/credential-provider-imds': 4.3.8 - '@smithy/types': 4.14.3 + '@smithy/types': 4.15.1 tslib: 2.8.1 optional: true + '@aws-sdk/credential-provider-ini@3.972.60': + dependencies: + '@aws-sdk/core': 3.974.27 + '@aws-sdk/credential-provider-env': 3.972.53 + '@aws-sdk/credential-provider-http': 3.972.55 + '@aws-sdk/credential-provider-login': 3.972.59 + '@aws-sdk/credential-provider-process': 3.972.53 + '@aws-sdk/credential-provider-sso': 3.972.59 + '@aws-sdk/credential-provider-web-identity': 3.972.59 + '@aws-sdk/nested-clients': 3.997.27 + '@aws-sdk/types': 3.973.15 + '@smithy/core': 3.29.1 + '@smithy/credential-provider-imds': 4.4.6 + '@smithy/types': 4.15.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-login@3.972.49': dependencies: '@aws-sdk/core': 3.974.18 '@aws-sdk/nested-clients': 3.997.17 '@aws-sdk/types': 3.973.11 '@smithy/core': 3.24.6 - '@smithy/types': 4.14.3 + '@smithy/types': 4.15.1 tslib: 2.8.1 optional: true + '@aws-sdk/credential-provider-login@3.972.59': + dependencies: + '@aws-sdk/core': 3.974.27 + '@aws-sdk/nested-clients': 3.997.27 + '@aws-sdk/types': 3.973.15 + '@smithy/core': 3.29.1 + '@smithy/types': 4.15.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-node@3.972.52': dependencies: '@aws-sdk/credential-provider-env': 3.972.44 @@ -5658,19 +5829,41 @@ snapshots: '@aws-sdk/types': 3.973.11 '@smithy/core': 3.24.6 '@smithy/credential-provider-imds': 4.3.8 - '@smithy/types': 4.14.3 + '@smithy/types': 4.15.1 tslib: 2.8.1 optional: true + '@aws-sdk/credential-provider-node@3.972.62': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.53 + '@aws-sdk/credential-provider-http': 3.972.55 + '@aws-sdk/credential-provider-ini': 3.972.60 + '@aws-sdk/credential-provider-process': 3.972.53 + '@aws-sdk/credential-provider-sso': 3.972.59 + '@aws-sdk/credential-provider-web-identity': 3.972.59 + '@aws-sdk/types': 3.973.15 + '@smithy/core': 3.29.1 + '@smithy/credential-provider-imds': 4.4.6 + '@smithy/types': 4.15.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-process@3.972.44': dependencies: '@aws-sdk/core': 3.974.18 '@aws-sdk/types': 3.973.11 '@smithy/core': 3.24.6 - '@smithy/types': 4.14.3 + '@smithy/types': 4.15.1 tslib: 2.8.1 optional: true + '@aws-sdk/credential-provider-process@3.972.53': + dependencies: + '@aws-sdk/core': 3.974.27 + '@aws-sdk/types': 3.973.15 + '@smithy/core': 3.29.1 + '@smithy/types': 4.15.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-sso@3.972.49': dependencies: '@aws-sdk/core': 3.974.18 @@ -5678,36 +5871,69 @@ snapshots: '@aws-sdk/token-providers': 3.1063.0 '@aws-sdk/types': 3.973.11 '@smithy/core': 3.24.6 - '@smithy/types': 4.14.3 + '@smithy/types': 4.15.1 tslib: 2.8.1 optional: true + '@aws-sdk/credential-provider-sso@3.972.59': + dependencies: + '@aws-sdk/core': 3.974.27 + '@aws-sdk/nested-clients': 3.997.27 + '@aws-sdk/token-providers': 3.1079.0 + '@aws-sdk/types': 3.973.15 + '@smithy/core': 3.29.1 + '@smithy/types': 4.15.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-web-identity@3.972.49': dependencies: '@aws-sdk/core': 3.974.18 '@aws-sdk/nested-clients': 3.997.17 '@aws-sdk/types': 3.973.11 '@smithy/core': 3.24.6 - '@smithy/types': 4.14.3 + '@smithy/types': 4.15.1 tslib: 2.8.1 optional: true + '@aws-sdk/credential-provider-web-identity@3.972.59': + dependencies: + '@aws-sdk/core': 3.974.27 + '@aws-sdk/nested-clients': 3.997.27 + '@aws-sdk/types': 3.973.15 + '@smithy/core': 3.29.1 + '@smithy/types': 4.15.1 + tslib: 2.8.1 + '@aws-sdk/eventstream-handler-node@3.972.20': dependencies: '@aws-sdk/types': 3.973.11 '@smithy/core': 3.24.6 - '@smithy/types': 4.14.3 + '@smithy/types': 4.15.1 tslib: 2.8.1 optional: true + '@aws-sdk/eventstream-handler-node@3.972.25': + dependencies: + '@aws-sdk/types': 3.973.15 + '@smithy/core': 3.29.1 + '@smithy/types': 4.15.1 + tslib: 2.8.1 + '@aws-sdk/middleware-eventstream@3.972.16': dependencies: '@aws-sdk/types': 3.973.11 '@smithy/core': 3.24.6 - '@smithy/types': 4.14.3 + '@smithy/types': 4.15.1 tslib: 2.8.1 optional: true + '@aws-sdk/middleware-eventstream@3.972.21': + dependencies: + '@aws-sdk/types': 3.973.15 + '@smithy/core': 3.29.1 + '@smithy/types': 4.15.1 + tslib: 2.8.1 + '@aws-sdk/middleware-flexible-checksums@3.974.27': dependencies: '@aws-sdk/checksums': 3.1000.2 @@ -5720,7 +5946,7 @@ snapshots: '@aws-sdk/signature-v4-multi-region': 3.996.32 '@aws-sdk/types': 3.973.11 '@smithy/core': 3.24.6 - '@smithy/types': 4.14.3 + '@smithy/types': 4.15.1 tslib: 2.8.1 optional: true @@ -5731,10 +5957,20 @@ snapshots: '@smithy/core': 3.24.6 '@smithy/fetch-http-handler': 5.4.6 '@smithy/signature-v4': 5.4.6 - '@smithy/types': 4.14.3 + '@smithy/types': 4.15.1 tslib: 2.8.1 optional: true + '@aws-sdk/middleware-websocket@3.972.35': + dependencies: + '@aws-sdk/core': 3.974.27 + '@aws-sdk/types': 3.973.15 + '@smithy/core': 3.29.1 + '@smithy/fetch-http-handler': 5.6.3 + '@smithy/signature-v4': 5.6.2 + '@smithy/types': 4.15.1 + tslib: 2.8.1 + '@aws-sdk/nested-clients@3.997.17': dependencies: '@aws-crypto/sha256-browser': 5.2.0 @@ -5745,34 +5981,66 @@ snapshots: '@smithy/core': 3.24.6 '@smithy/fetch-http-handler': 5.4.6 '@smithy/node-http-handler': 4.7.7 - '@smithy/types': 4.14.3 + '@smithy/types': 4.15.1 tslib: 2.8.1 optional: true + '@aws-sdk/nested-clients@3.997.27': + dependencies: + '@aws-sdk/core': 3.974.27 + '@aws-sdk/signature-v4-multi-region': 3.996.38 + '@aws-sdk/types': 3.973.15 + '@smithy/core': 3.29.1 + '@smithy/fetch-http-handler': 5.6.3 + '@smithy/node-http-handler': 4.9.3 + '@smithy/types': 4.15.1 + tslib: 2.8.1 + '@aws-sdk/signature-v4-multi-region@3.996.32': dependencies: '@aws-sdk/types': 3.973.11 '@smithy/signature-v4': 5.4.6 - '@smithy/types': 4.14.3 + '@smithy/types': 4.15.1 tslib: 2.8.1 optional: true + '@aws-sdk/signature-v4-multi-region@3.996.38': + dependencies: + '@aws-sdk/types': 3.973.15 + '@smithy/signature-v4': 5.6.2 + '@smithy/types': 4.15.1 + tslib: 2.8.1 + '@aws-sdk/token-providers@3.1063.0': dependencies: '@aws-sdk/core': 3.974.18 '@aws-sdk/nested-clients': 3.997.17 '@aws-sdk/types': 3.973.11 '@smithy/core': 3.24.6 - '@smithy/types': 4.14.3 + '@smithy/types': 4.15.1 tslib: 2.8.1 optional: true + '@aws-sdk/token-providers@3.1079.0': + dependencies: + '@aws-sdk/core': 3.974.27 + '@aws-sdk/nested-clients': 3.997.27 + '@aws-sdk/types': 3.973.15 + '@smithy/core': 3.29.1 + '@smithy/types': 4.15.1 + tslib: 2.8.1 + '@aws-sdk/types@3.973.11': dependencies: - '@smithy/types': 4.14.3 + '@smithy/types': 4.15.1 tslib: 2.8.1 optional: true + '@aws-sdk/types@3.973.15': + dependencies: + '@smithy/types': 4.15.1 + tslib: 2.8.1 + '@aws-sdk/util-locate-window@3.965.6': dependencies: tslib: 2.8.1 @@ -5780,14 +6048,21 @@ snapshots: '@aws-sdk/xml-builder@3.972.28': dependencies: - '@smithy/types': 4.14.3 + '@smithy/types': 4.15.1 fast-xml-parser: 5.7.3 tslib: 2.8.1 optional: true + '@aws-sdk/xml-builder@3.972.33': + dependencies: + '@smithy/types': 4.15.1 + tslib: 2.8.1 + '@aws/lambda-invoke-store@0.2.4': optional: true + '@aws/lambda-invoke-store@0.3.0': {} + '@azure-rest/core-client@1.4.0': dependencies: '@azure/abort-controller': 2.1.2 @@ -7102,24 +7377,41 @@ snapshots: '@smithy/core@3.24.6': dependencies: '@aws-crypto/crc32': 5.2.0 - '@smithy/types': 4.14.3 + '@smithy/types': 4.15.1 tslib: 2.8.1 optional: true + '@smithy/core@3.29.1': + dependencies: + '@smithy/types': 4.15.1 + tslib: 2.8.1 + '@smithy/credential-provider-imds@4.3.8': dependencies: '@smithy/core': 3.24.6 - '@smithy/types': 4.14.3 + '@smithy/types': 4.15.1 tslib: 2.8.1 optional: true + '@smithy/credential-provider-imds@4.4.6': + dependencies: + '@smithy/core': 3.29.1 + '@smithy/types': 4.15.1 + tslib: 2.8.1 + '@smithy/fetch-http-handler@5.4.6': dependencies: '@smithy/core': 3.24.6 - '@smithy/types': 4.14.3 + '@smithy/types': 4.15.1 tslib: 2.8.1 optional: true + '@smithy/fetch-http-handler@5.6.3': + dependencies: + '@smithy/core': 3.29.1 + '@smithy/types': 4.15.1 + tslib: 2.8.1 + '@smithy/is-array-buffer@2.2.0': dependencies: tslib: 2.8.1 @@ -7128,21 +7420,32 @@ snapshots: '@smithy/node-http-handler@4.7.7': dependencies: '@smithy/core': 3.24.6 - '@smithy/types': 4.14.3 + '@smithy/types': 4.15.1 tslib: 2.8.1 optional: true + '@smithy/node-http-handler@4.9.3': + dependencies: + '@smithy/core': 3.29.1 + '@smithy/types': 4.15.1 + tslib: 2.8.1 + '@smithy/signature-v4@5.4.6': dependencies: '@smithy/core': 3.24.6 - '@smithy/types': 4.14.3 + '@smithy/types': 4.15.1 tslib: 2.8.1 optional: true - '@smithy/types@4.14.3': + '@smithy/signature-v4@5.6.2': + dependencies: + '@smithy/core': 3.29.1 + '@smithy/types': 4.15.1 + tslib: 2.8.1 + + '@smithy/types@4.15.1': dependencies: tslib: 2.8.1 - optional: true '@smithy/util-buffer-from@2.2.0': dependencies: @@ -7681,8 +7984,7 @@ snapshots: boolean@3.2.0: optional: true - bowser@2.14.1: - optional: true + bowser@2.14.1: {} brace-expansion@1.1.15: dependencies: diff --git a/scripts/seed-vault.ts b/scripts/seed-vault.ts index 4f69e8dde..d319f2026 100644 --- a/scripts/seed-vault.ts +++ b/scripts/seed-vault.ts @@ -30,6 +30,8 @@ export const SEED_SECRET_NAMES = [ 'nylas_grant_id', 'nylas_self_email', 'signal_phone_number', + 'aws_access_key_id', + 'aws_secret_access_key', // skill-scoped (resolved at call time by ctx.secret) 'ceo_nylas_grant_id', 'ceo_self_email', @@ -37,14 +39,21 @@ export const SEED_SECRET_NAMES = [ ] as const; // The subset of secrets that MUST exist in the vault for a working install. Their -// absence is not "feature off" — it's a broken deploy: anthropic_api_key powers all -// agents, api_token gates HTTP auth (a missing token disables auth entirely, see -// src/channels/http/auth.ts and the boot guard in src/index.ts), and -// web_app_bootstrap_secret gates web login. setup.sh runs verifyRequiredSecrets() after -// seeding so a partial/failed seed fails loudly instead of producing a half-configured -// (and possibly auth-disabled) install (#911). +// absence is not "feature off" — it's a broken deploy: api_token gates HTTP auth +// (a missing token disables auth entirely, see src/channels/http/auth.ts and the +// boot guard in src/index.ts), and web_app_bootstrap_secret gates web login. +// setup.sh runs verifyRequiredSecrets() after seeding so a partial/failed seed +// fails loudly instead of producing a half-configured (and possibly auth-disabled) +// install (#911). +// +// LLM provider keys (anthropic_api_key, openrouter_api_key, aws_access_key_id/ +// aws_secret_access_key) are deliberately NOT in this list — no single provider +// is unconditionally required. src/index.ts validates at boot that every model +// referenced by model_routing.tiers in config/default.yaml has a registered +// provider, and fails loudly there if the deployment's chosen provider's +// credentials are missing. Which provider(s) a given install needs depends on +// how those tiers are configured, which this script doesn't know about. export const REQUIRED_SECRET_NAMES = [ - 'anthropic_api_key', 'api_token', 'web_app_bootstrap_secret', ] as const; diff --git a/scripts/smoke-bedrock-tool-use.ts b/scripts/smoke-bedrock-tool-use.ts new file mode 100644 index 000000000..e17cff474 --- /dev/null +++ b/scripts/smoke-bedrock-tool-use.ts @@ -0,0 +1,133 @@ +// smoke-bedrock-tool-use.ts — live smoke test for Bedrock tool-calling reliability. +// +// Exercises the real BedrockMistralProvider (not a raw-SDK bypass) against the +// live Bedrock endpoint, under a prompt/tool-count load shaped like the actual +// coordinator (see docs/adr/023-aws-bedrock-mistral-llm-provider.md — this is +// exactly the condition that silently broke tool-calling for +// mistral-large-2402: a large system prompt combined with many available +// tools caused the model to narrate an intended call as text instead of +// emitting a real Converse toolUse block). +// +// Requires live AWS credentials in the vault and a reachable Postgres — this +// is NOT part of `pnpm test` (which must run without live cloud credentials +// or network calls). Run on demand, or whenever the configured Bedrock model +// changes, via: +// +// pnpm run smoke:bedrock-tools +// +// Exits 0 on a confirmed real tool call, 1 on anything else (narrated-only +// text, provider error, or missing credentials), with a clear reason printed. +import { pathToFileURL } from 'node:url'; +import * as path from 'node:path'; +import pg from 'pg'; +import pino from 'pino'; +import { loadEncryptionKey } from '../src/secrets/crypto.js'; +import { SecretsService } from '../src/secrets/secrets-service.js'; +import { loadYamlConfig } from '../src/config.js'; +import { ModelRegistry } from '../src/agents/llm/model-registry.js'; +import { BedrockMistralProvider } from '../src/agents/llm/bedrock-mistral.js'; +import type { ToolDefinition } from '../src/skills/types.js'; + +const logger = pino({ name: 'smoke-bedrock-tool-use' }); + +// Shaped to approximate the real coordinator's load (spec 02 / agents/coordinator.yaml): +// a long system prompt and a large pinned-skill count. Exact wording doesn't matter — +// what matters is reproducing the token/tool-count scale that triggered the failure. +const REPRESENTATIVE_SYSTEM_PROMPT = 'You are a helpful executive assistant coordinator. '.repeat(400); +const DUMMY_TOOL_COUNT = 47; + +function makeDummyTool(i: number): ToolDefinition { + return { + name: `dummy_tool_${i}`, + description: `A placeholder tool number ${i} for load testing. It does not do anything real.`, + input_schema: { type: 'object', properties: { arg: { type: 'string', description: 'a placeholder argument' } } }, + }; +} + +const TARGET_TOOL: ToolDefinition = { + name: 'scheduler_list', + description: 'List all scheduled jobs, optionally filtered by status or agent_id.', + input_schema: { type: 'object', properties: { status: { type: 'string' }, agent_id: { type: 'string' } } }, +}; + +async function main(): Promise { + const databaseUrl = process.env.DATABASE_URL; + if (!databaseUrl) { + logger.error('DATABASE_URL is not set — cannot resolve AWS credentials from the vault'); + return 1; + } + + const encryptionKey = loadEncryptionKey(); + const pool = new pg.Pool({ connectionString: databaseUrl }); + const secrets = new SecretsService(pool, encryptionKey, logger); + + const [accessKeyId, secretAccessKey] = await Promise.all([ + secrets.get('aws_access_key_id'), + secrets.get('aws_secret_access_key'), + ]); + await pool.end(); + + if (!accessKeyId || !secretAccessKey) { + logger.warn('AWS credentials are not seeded in the vault — skipping (this deployment is not configured for Bedrock)'); + return 0; + } + + const region = process.env.AWS_REGION; + if (!region) { + logger.error('AWS_REGION is not set — cannot construct the Bedrock client'); + return 1; + } + const timeoutSeconds = parseInt(process.env.AWS_BEDROCK_TIMEOUT ?? '120', 10); + + const configDir = path.resolve(import.meta.dirname, '../config'); + const yamlConfig = loadYamlConfig(configDir); + const model = yamlConfig.model_routing?.tiers.standard.model; + if (!model) { + logger.error('config/default.yaml has no model_routing.tiers.standard.model configured'); + return 1; + } + + const modelRegistry = new ModelRegistry(logger); + const provider = new BedrockMistralProvider(accessKeyId, secretAccessKey, region, timeoutSeconds * 1000, logger, modelRegistry); + + const tools: ToolDefinition[] = [ + ...Array.from({ length: DUMMY_TOOL_COUNT }, (_, i) => makeDummyTool(i)), + TARGET_TOOL, + ]; + + logger.info({ model, toolCount: tools.length, systemPromptChars: REPRESENTATIVE_SYSTEM_PROMPT.length }, 'Sending load-shaped tool-use smoke test'); + + const result = await provider.chat({ + messages: [ + { role: 'system', content: REPRESENTATIVE_SYSTEM_PROMPT }, + { role: 'user', content: 'What scheduled jobs do you currently have running? List them with their cron schedules.' }, + ], + tools, + model, + }); + + if (result.type === 'tool_use' && result.toolCalls.some((tc) => tc.name === TARGET_TOOL.name)) { + logger.info({ model, toolCalls: result.toolCalls.map((tc) => tc.name) }, 'PASS — model emitted a real Converse tool_use call under production-shaped load'); + return 0; + } + + if (result.type === 'text') { + // BedrockMistralProvider itself already logged an 'error' via + // detectUncalledToolIntent if the text narrated an uncalled tool call — + // this is the mechanical PASS/FAIL gate on top of that observability signal. + logger.error({ model, content: result.content.slice(0, 300) }, 'FAIL — model returned text instead of a real tool_use call (narrated-call failure mode)'); + return 1; + } + + logger.error({ model, resultType: result.type }, 'FAIL — unexpected result type'); + return 1; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main() + .then((exitCode) => process.exit(exitCode)) + .catch((err: unknown) => { + logger.error({ err }, 'smoke-bedrock-tool-use: fatal error'); + process.exit(1); + }); +} diff --git a/src/agents/llm/bedrock-mistral.test.ts b/src/agents/llm/bedrock-mistral.test.ts new file mode 100644 index 000000000..c2b9ea4cc --- /dev/null +++ b/src/agents/llm/bedrock-mistral.test.ts @@ -0,0 +1,432 @@ +// bedrock-mistral.test.ts — tests for the AWS Bedrock (Mistral) LLM provider. +// +// Mocks BedrockRuntimeClient.send() so tests run without real AWS credentials. +// ConverseCommand is left as the real SDK class (a plain data container with +// an `.input` property) — only the client's network call needs stubbing. +// Follows the same pattern as anthropic.test.ts / openrouter.test.ts. + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { BedrockMistralProvider } from './bedrock-mistral.js'; +import { ModelRegistry } from './model-registry.js'; +import { createSilentLogger } from '../../logger.js'; + +const mockSend = vi.hoisted(() => vi.fn()); + +vi.mock('@aws-sdk/client-bedrock-runtime', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + // BedrockMistralProvider calls `new BedrockRuntimeClient({...})` in its + // constructor and `this.client.send(command, opts)` in chat(). + BedrockRuntimeClient: class { + send = mockSend; + }, + }; +}); + +const MODEL = 'mistral.mistral-large-2402-v1:0'; + +// A valid text-only Converse API response shape. +const makeTextResponse = () => ({ + output: { message: { role: 'assistant', content: [{ text: 'hello from bedrock' }] } }, + stopReason: 'end_turn', + usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 }, + $metadata: { requestId: 'req-test-123' }, +}); + +function makeProvider(logger = createSilentLogger()) { + return new BedrockMistralProvider( + 'test-access-key', + 'test-secret-key', + 'ca-central-1', + 120000, + logger, + new ModelRegistry(logger), + ); +} + +describe('BedrockMistralProvider', () => { + beforeEach(() => { + mockSend.mockReset(); + mockSend.mockResolvedValue(makeTextResponse()); + }); + + it('returns correct LLMResponse shape for a text response with usage and provenance', async () => { + const provider = makeProvider(); + const result = await provider.chat({ + messages: [{ role: 'user', content: 'Hello' }], + model: MODEL, + }); + + expect(result.type).toBe('text'); + if (result.type !== 'text') return; + + expect(result.content).toBe('hello from bedrock'); + + // No prompt-cache support on Bedrock Mistral — cache fields always 0. + expect(result.usage).toEqual({ + inputTokens: 10, + outputTokens: 5, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + }); + + // Converse doesn't report a distinct "actual model" — requested === actual. + expect(result.provenance.requestedModel).toBe(MODEL); + expect(result.provenance.actualModel).toBe(MODEL); + expect(result.provenance.providerRequestId).toBe('req-test-123'); + }); + + it('maps tool calls to Curia ToolCall shape', async () => { + mockSend.mockResolvedValue({ + output: { + message: { + role: 'assistant', + content: [{ toolUse: { toolUseId: 'call_abc123', name: 'search', input: { query: 'test' } } }], + }, + }, + stopReason: 'tool_use', + usage: { inputTokens: 20, outputTokens: 10, totalTokens: 30 }, + $metadata: { requestId: 'req-tool-456' }, + }); + + const provider = makeProvider(); + const result = await provider.chat({ + messages: [{ role: 'user', content: 'Search something' }], + model: MODEL, + tools: [{ name: 'search', description: 'Search', input_schema: { type: 'object', properties: {} } }], + }); + + expect(result.type).toBe('tool_use'); + if (result.type !== 'tool_use') return; + + expect(result.toolCalls).toHaveLength(1); + expect(result.toolCalls[0]).toEqual({ + id: 'call_abc123', + name: 'search', + input: { query: 'test' }, + }); + expect(result.content).toBeUndefined(); + + // Assert the tool spec was actually sent to Converse in the expected shape. + const command = mockSend.mock.calls[0]![0] as { input: { toolConfig?: { tools: unknown[] } } }; + expect(command.input.toolConfig?.tools).toEqual([ + { toolSpec: { name: 'search', description: 'Search', inputSchema: { json: { type: 'object', properties: {} } } } }, + ]); + }); + + it('handles mixed response (text + tool calls)', async () => { + mockSend.mockResolvedValue({ + output: { + message: { + role: 'assistant', + content: [ + { text: 'Let me look that up for you.' }, + { toolUse: { toolUseId: 'call_def456', name: 'lookup', input: { id: '42' } } }, + ], + }, + }, + stopReason: 'tool_use', + usage: { inputTokens: 15, outputTokens: 8, totalTokens: 23 }, + $metadata: { requestId: 'req-mixed-789' }, + }); + + const provider = makeProvider(); + const result = await provider.chat({ + messages: [{ role: 'user', content: 'Look up item 42' }], + model: MODEL, + tools: [{ name: 'lookup', description: 'Lookup', input_schema: { type: 'object', properties: {} } }], + }); + + expect(result.type).toBe('tool_use'); + if (result.type !== 'tool_use') return; + + expect(result.content).toBe('Let me look that up for you.'); + expect(result.toolCalls).toHaveLength(1); + expect(result.toolCalls[0]!.name).toBe('lookup'); + expect(result.toolCalls[0]!.input).toEqual({ id: '42' }); + }); + + it('catches exceptions and returns classified error response', async () => { + const apiError = Object.assign(new Error('ValidationException: The provided model identifier is invalid.'), { $fault: 'client' }); + mockSend.mockRejectedValue(apiError); + + const provider = makeProvider(); + const result = await provider.chat({ + messages: [{ role: 'user', content: 'Hello' }], + model: MODEL, + }); + + expect(result.type).toBe('error'); + if (result.type !== 'error') return; + expect(result.error.source).toBe('bedrock'); + }); + + it('returns error when no model is provided', async () => { + const provider = makeProvider(); + const result = await provider.chat({ + messages: [{ role: 'user', content: 'Hello' }], + }); + + expect(result.type).toBe('error'); + if (result.type !== 'error') return; + expect(result.error.message).toMatch(/requires a model/); + }); + + it('concatenates multiple system messages into a single Converse system block', async () => { + const provider = makeProvider(); + await provider.chat({ + model: MODEL, + messages: [ + { role: 'system', content: 'You are helpful.' }, + { role: 'system', content: 'Be concise.' }, + { role: 'user', content: 'Hello' }, + ], + }); + + const command = mockSend.mock.calls[0]![0] as { input: { system?: Array<{ text: string }>; messages: unknown[] } }; + expect(command.input.system).toEqual([{ text: 'You are helpful.\n\nBe concise.' }]); + expect(command.input.messages).toHaveLength(1); + }); + + it('maps base64 image content to Converse image bytes format', async () => { + const provider = makeProvider(); + await provider.chat({ + model: MODEL, + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: 'What is in this image?' }, + { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'iVBORw0KGgoAAAANSUhEUg==' } }, + ], + }, + ], + }); + + const command = mockSend.mock.calls[0]![0] as { + input: { messages: Array<{ content: Array<{ text?: string; image?: { format: string; source: { bytes: Uint8Array } } }> }> }; + }; + const userContent = command.input.messages[0]!.content; + expect(userContent[0]).toEqual({ text: 'What is in this image?' }); + expect(userContent[1]!.image?.format).toBe('png'); + expect(Buffer.from(userContent[1]!.image!.source.bytes).toString('base64')).toBe('iVBORw0KGgoAAAANSUhEUg=='); + }); + + describe('malformed conversation history normalization', () => { + // Reproduces the real production failure: a mid-history 'system'-role + // conversation-summary turn (written by WorkingMemory's summarization pass) + // gets stripped out by the system-message extraction above, leaving + // 'assistant' as the new first element — which Bedrock Converse rejects + // with "A conversation must start with a user message." + it('drops a leading assistant message left behind after system-message extraction', async () => { + const provider = makeProvider(); + await provider.chat({ + model: MODEL, + messages: [ + { role: 'system', content: '[Conversation summary] Anshula and the assistant discussed onboarding.' }, + { role: 'assistant', content: 'To share something with me over email, you can send it to...' }, + { role: 'user', content: 'What do you mean by "the prompt"?' }, + ], + }); + + const command = mockSend.mock.calls[0]![0] as { input: { messages: Array<{ role: string }> } }; + expect(command.input.messages[0]!.role).toBe('user'); + expect(command.input.messages.map((m) => m.role)).toEqual(['user']); + }); + + // Reproduces the other real production cause: a failed LLM call persists + // the user's turn before the call but never gets a paired assistant turn + // written back, so the next message stacks a second 'user' turn on top. + it('merges consecutive same-role messages instead of sending them separately', async () => { + const provider = makeProvider(); + await provider.chat({ + model: MODEL, + messages: [ + { role: 'user', content: "I'm confused" }, + { role: 'user', content: 'Hello?' }, + { role: 'assistant', content: 'Hello! How can I help?' }, + { role: 'user', content: 'Where do I find my contact details?' }, + ], + }); + + const command = mockSend.mock.calls[0]![0] as { input: { messages: Array<{ role: string; content: Array<{ text?: string }> }> } }; + expect(command.input.messages.map((m) => m.role)).toEqual(['user', 'assistant', 'user']); + expect(command.input.messages[0]!.content.map((c) => c.text)).toEqual(["I'm confused", 'Hello?']); + }); + + it('leaves a well-formed alternating sequence untouched', async () => { + const provider = makeProvider(); + await provider.chat({ + model: MODEL, + messages: [ + { role: 'user', content: 'Hi' }, + { role: 'assistant', content: 'Hello!' }, + { role: 'user', content: 'How are you?' }, + ], + }); + + const command = mockSend.mock.calls[0]![0] as { input: { messages: Array<{ role: string; content: Array<{ text?: string }> }> } }; + expect(command.input.messages.map((m) => m.role)).toEqual(['user', 'assistant', 'user']); + expect(command.input.messages.map((m) => m.content[0]!.text)).toEqual(['Hi', 'Hello!', 'How are you?']); + }); + }); + + it('drops url-sourced images with a warning (Converse only accepts raw bytes)', async () => { + const logger = createSilentLogger(); + const warnSpy = vi.spyOn(logger, 'warn'); + const provider = makeProvider(logger); + + await provider.chat({ + model: MODEL, + messages: [ + { + role: 'user', + content: [{ type: 'image', source: { type: 'url', url: 'https://example.com/cat.png' } }], + }, + ], + }); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringMatching(/URL source is not supported/)); + const command = mockSend.mock.calls[0]![0] as { input: { messages: Array<{ content: unknown[] }> } }; + expect(command.input.messages[0]!.content).toHaveLength(0); + }); + + it('logs warn when stopReason is "max_tokens" (response truncated)', async () => { + mockSend.mockResolvedValue({ + ...makeTextResponse(), + stopReason: 'max_tokens', + output: { message: { role: 'assistant', content: [{ text: 'This response was cut off mid-' }] } }, + }); + + const logger = createSilentLogger(); + const warnSpy = vi.spyOn(logger, 'warn'); + const provider = makeProvider(logger); + const result = await provider.chat({ + messages: [{ role: 'user', content: 'Write a long essay' }], + model: MODEL, + }); + + expect(result.type).toBe('text'); + if (result.type !== 'text') return; + expect(result.content).toBe('This response was cut off mid-'); + + expect(warnSpy).toHaveBeenCalledOnce(); + const [bindings, message] = warnSpy.mock.calls[0]! as [Record, string]; + expect(bindings).toMatchObject({ model: MODEL, stopReason: 'max_tokens' }); + expect(message).toMatch(/truncated/); + }); + + it('logs error when the model narrates an intended tool call instead of invoking it', async () => { + // Reproduces the real failure captured against production traffic: a large + // system prompt + many tools caused mistral-large-2402 to describe the call + // as prose/JSON instead of emitting a Converse toolUse block. + mockSend.mockResolvedValue({ + output: { + message: { + role: 'assistant', + content: [{ + text: 'To list the scheduled jobs, I will use the "scheduler_list" function.\n\n' + + '```json\n{\n "name": "scheduler_list",\n "arguments": {}\n}\n```\n' + + 'This will return a list of all scheduled jobs along with their cron schedules.', + }], + }, + }, + stopReason: 'end_turn', + usage: { inputTokens: 11875, outputTokens: 102, totalTokens: 11977 }, + $metadata: { requestId: 'req-narrated-999' }, + }); + + const logger = createSilentLogger(); + const errorSpy = vi.spyOn(logger, 'error'); + const provider = makeProvider(logger); + const result = await provider.chat({ + messages: [{ role: 'user', content: 'What scheduled jobs do you have running?' }], + model: MODEL, + tools: [{ name: 'scheduler_list', description: 'List scheduled jobs', input_schema: { type: 'object', properties: {} } }], + }); + + // The response itself is still returned as normal text — this is a detection/ + // observability improvement, not a behavior change to the response shape. + expect(result.type).toBe('text'); + + const call = errorSpy.mock.calls.find(([, msg]) => typeof msg === 'string' && msg.includes('described a tool call')); + expect(call).toBeDefined(); + const [bindings] = call! as [Record, string]; + expect(bindings).toMatchObject({ model: MODEL, uncalledTool: 'scheduler_list' }); + }); + + it('does not flag a text response that merely mentions a tool name in passing', async () => { + mockSend.mockResolvedValue({ + output: { + message: { + role: 'assistant', + content: [{ text: 'I have a scheduler_list tool available, but nothing is scheduled right now that I need to check.' }], + }, + }, + stopReason: 'end_turn', + usage: { inputTokens: 50, outputTokens: 20, totalTokens: 70 }, + $metadata: { requestId: 'req-benign-1' }, + }); + + const logger = createSilentLogger(); + const errorSpy = vi.spyOn(logger, 'error'); + const provider = makeProvider(logger); + await provider.chat({ + messages: [{ role: 'user', content: 'Anything scheduled?' }], + model: MODEL, + tools: [{ name: 'scheduler_list', description: 'List scheduled jobs', input_schema: { type: 'object', properties: {} } }], + }); + + const call = errorSpy.mock.calls.find(([, msg]) => typeof msg === 'string' && msg.includes('described a tool call')); + expect(call).toBeUndefined(); + }); + + it('does not log warn for normal end_turn stopReason', async () => { + const logger = createSilentLogger(); + const warnSpy = vi.spyOn(logger, 'warn'); + const provider = makeProvider(logger); + + await provider.chat({ + messages: [{ role: 'user', content: 'Hello' }], + model: MODEL, + }); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('falls back to options.model when model param is not provided', async () => { + const provider = makeProvider(); + await provider.chat({ + messages: [{ role: 'user', content: 'Hello' }], + options: { model: MODEL }, + }); + + const command = mockSend.mock.calls[0]![0] as { input: { modelId: string } }; + expect(command.input.modelId).toBe(MODEL); + }); + + it('uses explicit model param over options.model', async () => { + const provider = makeProvider(); + await provider.chat({ + messages: [{ role: 'user', content: 'Hello' }], + model: MODEL, + options: { model: 'mistral.mixtral-8x7b-instruct-v0:1' }, + }); + + const command = mockSend.mock.calls[0]![0] as { input: { modelId: string } }; + expect(command.input.modelId).toBe(MODEL); + }); + + it('passes an AbortSignal to client.send() for timeout enforcement', async () => { + const provider = makeProvider(); + await provider.chat({ + messages: [{ role: 'user', content: 'Hello' }], + model: MODEL, + }); + + const sendOpts = mockSend.mock.calls[0]![1] as { abortSignal?: AbortSignal }; + expect(sendOpts.abortSignal).toBeInstanceOf(AbortSignal); + expect(sendOpts.abortSignal?.aborted).toBe(false); + }); +}); diff --git a/src/agents/llm/bedrock-mistral.ts b/src/agents/llm/bedrock-mistral.ts new file mode 100644 index 000000000..de7733535 --- /dev/null +++ b/src/agents/llm/bedrock-mistral.ts @@ -0,0 +1,400 @@ +// bedrock-mistral.ts — AWS Bedrock (Mistral models) implementation of LLMProvider, +// via the Converse API (https://docs.aws.amazon.com/bedrock/latest/userguide/conversation-inference.html). +// +// Key design decisions: +// 1. Converse normalizes tool-use across every Bedrock model family into one +// shape (toolUse/toolResult content blocks), which maps directly onto +// Curia's provider-neutral ToolCall/ToolResult types — no per-model +// request/response quirks to handle, unlike Bedrock's raw InvokeModel API. +// 2. System messages are concatenated into Converse's dedicated `system` +// parameter, same pattern as anthropic.ts and openrouter.ts. +// 3. No prompt-cache support — cache token fields are always 0. +// 4. Image content blocks only support base64 sources (Converse takes raw +// bytes, not URLs); a url-sourced image is logged and dropped rather than +// fetched inline. +// 5. Timeout is enforced via AbortController + setTimeout rather than a +// NodeHttpHandler config, to avoid an extra SDK sub-dependency — combined +// with any caller-supplied AbortSignal (e.g. the outbound judge's timeout). +// 6. Errors are caught and returned as LLMResponse { type: 'error' } so +// callers never need try/catch around chat(). +// +// Basic text chat has been smoke-tested against the real ca-central-1 Bedrock +// endpoint (mistral.mistral-large-2402-v1:0), confirming credentials, the +// Converse request shape, and response parsing all work end-to-end. Tool-use +// specifically — a message that actually triggers a skill call — has NOT yet +// been exercised live; the agent execution path (the whole reason Converse was +// chosen over raw InvokeModel) still needs its own smoke test before relying +// on this in production. + +import { + BedrockRuntimeClient, + ConverseCommand, + type Message as BedrockMessage, + type ContentBlock as BedrockContentBlock, + type SystemContentBlock, + type Tool as BedrockTool, +} from '@aws-sdk/client-bedrock-runtime'; +import type { DocumentType } from '@smithy/types'; +import type { LLMProvider, LLMResponse, LLMUsage, LLMCallProvenance, Message, ContentBlock, ToolCall, ToolDefinition, ToolResult } from './provider.js'; +import type { Logger } from '../../logger.js'; +import { classifyError } from '../../errors/classify.js'; +import type { ModelRegistry } from './model-registry.js'; + +/** + * Scans a text response for a fenced JSON code block describing a call to one + * of the tools that were actually offered — the signature of a model that + * narrated an intended tool call instead of emitting a real Converse toolUse + * block. Deliberately conservative (requires a parseable JSON object with a + * `name` matching an offered tool) to avoid false positives on replies that + * merely mention a tool name in passing. Returns the matched tool name, or + * undefined if no such block is found. + */ +function detectUncalledToolIntent(content: string, offeredToolNames: string[]): string | undefined { + const codeBlockPattern = /```(?:json)?\s*([\s\S]*?)```/g; + for (const match of content.matchAll(codeBlockPattern)) { + const block = match[1]; + if (!block) continue; + let parsed: unknown; + try { + parsed = JSON.parse(block); + } catch { + continue; + } + if ( + typeof parsed === 'object' && + parsed !== null && + !Array.isArray(parsed) && + typeof (parsed as { name?: unknown }).name === 'string' && + offeredToolNames.includes((parsed as { name: string }).name) + ) { + return (parsed as { name: string }).name; + } + } + return undefined; +} + +/** + * Bedrock's Converse API strictly requires the message sequence to start with + * a 'user' message and to strictly alternate user/assistant — unlike + * Anthropic's and OpenAI's APIs, which are more forgiving. Curia's working + * memory can produce sequences that violate this: a failed LLM call persists + * the user's turn before the call but never gets an assistant turn written + * back (leaving consecutive 'user' turns on the next message), and the + * synthetic conversation-summary turn WorkingMemory's summarization pass + * writes is itself a mid-history 'system'-role row — once every provider + * strips system-role messages out to build the top-level system param, the + * turn immediately following the summary becomes the new first element, + * which can be 'assistant'. + * + * This is a defensive normalization at the provider boundary, not a fix for + * the underlying turn-persistence gap (see docs/adr/023) — dropping/merging + * here means a malformed conversation degrades gracefully instead of hard + * -failing every subsequent turn, but the root cause is upstream in + * AgentRuntime/WorkingMemory. + */ +function normalizeMessageSequence(sequence: BedrockMessage[], logger: Logger): BedrockMessage[] { + let start = 0; + while (start < sequence.length && sequence[start]!.role !== 'user') { + logger.warn( + { role: sequence[start]!.role }, + 'Dropping leading non-user message from conversation history — Bedrock Converse requires the sequence to start with a user message', + ); + start++; + } + + const merged: BedrockMessage[] = []; + for (const msg of sequence.slice(start)) { + const last = merged[merged.length - 1]; + if (last && last.role === msg.role) { + logger.warn( + { role: msg.role }, + 'Merging consecutive same-role messages — Bedrock Converse requires strict user/assistant alternation', + ); + last.content = [...(last.content ?? []), ...(msg.content ?? [])]; + } else { + merged.push({ role: msg.role, content: [...(msg.content ?? [])] }); + } + } + return merged; +} + +export class BedrockMistralProvider implements LLMProvider { + id = 'bedrock'; + private client: BedrockRuntimeClient; + private logger: Logger; + private readonly modelRegistry: ModelRegistry; + private readonly timeoutMs: number; + + constructor( + accessKeyId: string, + secretAccessKey: string, + region: string, + timeoutMs: number, + logger: Logger, + modelRegistry: ModelRegistry, + ) { + this.client = new BedrockRuntimeClient({ + region, + credentials: { accessKeyId, secretAccessKey }, + }); + this.logger = logger; + this.modelRegistry = modelRegistry; + this.timeoutMs = timeoutMs; + } + + async chat({ + messages, + tools, + toolResults, + model: modelOverride, + options, + }: { + messages: Message[]; + tools?: ToolDefinition[]; + toolResults?: ToolResult[]; + model?: string; + options?: Record; + }): Promise { + // Concatenate all system-role messages into Converse's dedicated `system` + // parameter — same convention as anthropic.ts/openrouter.ts. The runtime + // injects multiple role:'system' entries (main prompt, sender context, + // bullpen context); none should be silently dropped. + const systemContent = messages + .filter((m) => m.role === 'system') + .map((m) => { + if (typeof m.content !== 'string') { + this.logger.error( + { contentType: typeof m.content }, + 'System message has non-string content — skipping; caller must pass plain strings for system role', + ); + return ''; + } + return m.content; + }) + .filter(Boolean) + .join('\n\n'); + const system: SystemContentBlock[] | undefined = systemContent + ? [{ text: systemContent }] + : undefined; + + // Map our provider-neutral ContentBlock[] to Converse's ContentBlock union. + const mapContentBlock = (block: ContentBlock): BedrockContentBlock | undefined => { + if (block.type === 'text') { + return { text: block.text }; + } + if (block.type === 'tool_use') { + // Cast required: DocumentType is the AWS SDK's recursive JSON-value type; + // our input is a plain JSON-shaped object but TS can't structurally verify + // that against DocumentType's generated union (same pattern as anthropic.ts + // casting input_schema to the SDK's Tool['input_schema']). + return { toolUse: { toolUseId: block.id, name: block.name, input: block.input as DocumentType } }; + } + if (block.type === 'tool_result') { + return { + toolResult: { + toolUseId: block.tool_use_id, + content: [{ text: block.content }], + status: block.is_error ? 'error' : 'success', + }, + }; + } + if (block.type === 'image') { + if (block.source.type === 'url') { + // Converse only accepts raw bytes, not URLs. Fetching the URL inline + // would add a network dependency to every chat() call — out of scope + // for now. Drop with a warning rather than fail the whole call. + this.logger.warn('Image content block with a URL source is not supported by Bedrock Converse — dropped'); + return undefined; + } + const format = block.source.media_type.split('/')[1] as 'jpeg' | 'png' | 'gif' | 'webp'; + return { + image: { + format, + source: { bytes: Buffer.from(block.source.data, 'base64') }, + }, + }; + } + // Exhaustive guard — log if a new ContentBlock variant is added but not handled here. + this.logger.warn({ blockType: (block as { type: string }).type }, 'Unknown content block type — skipped'); + return undefined; + }; + + const conversationMessages: BedrockMessage[] = messages + .filter((m) => m.role !== 'system') + .map((m) => { + const role = m.role as 'user' | 'assistant'; + if (typeof m.content === 'string') { + return { role, content: [{ text: m.content }] }; + } + const content = m.content + .map(mapContentBlock) + .filter((b): b is BedrockContentBlock => b !== undefined); + return { role, content }; + }); + + // Legacy toolResults parameter — append as a user turn if provided. + // Prefer building tool_result blocks directly in the messages array instead. + if (toolResults && toolResults.length > 0) { + conversationMessages.push({ + role: 'user', + content: toolResults.map((tr) => ({ + toolResult: { + toolUseId: tr.id, + content: [{ text: tr.content }], + status: tr.is_error ? 'error' as const : 'success' as const, + }, + })), + }); + } + + // Prefer the explicit model param; fall back to options.model for backward compatibility. + const optionsModel = typeof options?.model === 'string' ? options.model : undefined; + const model = modelOverride ?? optionsModel; + + // Timeout enforcement: internal AbortController on a timer, combined with any + // caller-supplied signal (e.g. the outbound judge's timeout) so either one aborts the call. + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs); + const callerSignal = options?.signal instanceof AbortSignal ? options.signal : undefined; + const onCallerAbort = () => controller.abort(); + callerSignal?.addEventListener('abort', onCallerAbort); + + try { + if (!model) { + throw new Error('BedrockMistralProvider.chat() requires a model — no model was provided and no default is configured'); + } + + const modelMeta = this.modelRegistry.getModel(model); + const modelMaxTokens = modelMeta?.maxOutputTokens ?? 4096; + if (!modelMeta) { + this.logger.warn({ model, fallbackMaxTokens: 4096 }, 'Model not in registry — using fallback maxOutputTokens'); + } + const callerMaxTokens = typeof options?.max_tokens === 'number' && Number.isFinite(options.max_tokens) + ? Math.max(1, Math.floor(options.max_tokens as number)) + : undefined; + + let toolConfig: { tools: BedrockTool[] } | undefined; + if (tools && tools.length > 0) { + toolConfig = { + tools: tools.map((t): BedrockTool => ({ + toolSpec: { + name: t.name, + description: t.description, + // Cast required: ToolDefinition.input_schema is a narrower JSON-Schema + // shape than the SDK's DocumentType (same pattern as the toolUse cast above). + inputSchema: { json: t.input_schema as DocumentType }, + }, + })), + }; + } + + const command = new ConverseCommand({ + modelId: model, + messages: normalizeMessageSequence(conversationMessages, this.logger), + system, + toolConfig, + inferenceConfig: { + maxTokens: callerMaxTokens !== undefined ? Math.min(callerMaxTokens, modelMaxTokens) : modelMaxTokens, + }, + }); + + const response = await this.client.send(command, { abortSignal: controller.signal }); + + const outputContent = response.output?.message?.content ?? []; + const inputTokens = response.usage?.inputTokens ?? 0; + const outputTokens = response.usage?.outputTokens ?? 0; + + this.logger.debug( + { model, inputTokens, outputTokens, stopReason: response.stopReason }, + 'Bedrock Converse API call completed', + ); + + if (response.stopReason === 'max_tokens') { + this.logger.warn( + { model, outputTokens, stopReason: response.stopReason }, + 'Bedrock response truncated by max_tokens cap — output is incomplete. Consider increasing responseReserve or reducing input context.', + ); + } + + // No prompt-cache support on Bedrock Mistral. + const usage: LLMUsage = { + inputTokens, + outputTokens, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + }; + + // Converse does not report a distinct "actual model that ran" — requested + // and actual are the same value here (unlike OpenRouter, which may reroute). + const provenance: LLMCallProvenance = { + requestedModel: model, + actualModel: model, + providerRequestId: response.$metadata?.requestId ?? '', + }; + + // Converse's ContentBlock is a smithy "one-of" union: every member field + // (text, toolUse, image, ...) is optional on the same object shape, with a + // required $unknown fallback member — so a custom type-predicate narrowing + // to a subset of those fields isn't assignable to the SDK's own type. Access + // the optional fields directly instead of narrowing via a predicate. + const toolUseBlocks = outputContent + .map((b) => b.toolUse) + .filter((tu): tu is NonNullable => tu !== undefined); + if (toolUseBlocks.length > 0) { + const toolCalls: ToolCall[] = toolUseBlocks.map((tu) => ({ + id: tu.toolUseId ?? '', + name: tu.name ?? '', + input: (tu.input ?? {}) as Record, + })); + + const textBlock = outputContent.find((b) => typeof b.text === 'string'); + + return { + type: 'tool_use', + toolCalls, + content: textBlock?.text, + usage, + provenance, + }; + } + + const textBlock = outputContent.find((b) => typeof b.text === 'string'); + const content = textBlock?.text ?? ''; + if (!content) { + this.logger.error({ model, stopReason: response.stopReason }, 'LLM returned empty text response'); + } + + // Some models (observed with mistral-large-2402 under a large system prompt + // + many available tools — see docs/adr/023) silently degrade: instead of + // emitting a real Converse toolUse block, they describe the intended call as + // prose/JSON in the text response. Converse reports this as a normal + // successful end_turn — there's no API-level error to catch. Left + // undetected, this is a serious silent-failure mode: the coordinator + // believes (and tells the CEO) that an action was taken when no skill ever + // ran. Flag it loudly here so it's visible in logs/monitoring rather than + // indistinguishable from an intentional conversational reply. + if (tools && tools.length > 0) { + const uncalledTool = detectUncalledToolIntent(content, tools.map((t) => t.name)); + if (uncalledTool) { + this.logger.error( + { model, uncalledTool, contentPreview: content.slice(0, 300) }, + 'Model described a tool call in its text response instead of invoking it via Converse tool_use — the described action was NOT executed. This may indicate degraded tool-calling reliability under the current prompt/tool-count load.', + ); + } + } + + return { + type: 'text', + content, + usage, + provenance, + }; + } catch (err) { + this.logger.error({ err, model }, 'Bedrock Converse API call failed'); + return { type: 'error', error: classifyError(err, 'bedrock') }; + } finally { + clearTimeout(timeoutId); + callerSignal?.removeEventListener('abort', onCallerAbort); + } + } +} diff --git a/src/agents/llm/model-registry.ts b/src/agents/llm/model-registry.ts index 094981919..99e8f7e6d 100644 --- a/src/agents/llm/model-registry.ts +++ b/src/agents/llm/model-registry.ts @@ -130,6 +130,61 @@ const MODEL_REGISTRY: Record = { maxOutputTokens: 16_384, }, + // AWS Bedrock — models reachable via the Converse API (bedrock-mistral.ts; + // despite the filename, the provider class works for any Converse-compatible + // Bedrock model, not just Mistral — see docs/adr/023 for the model search + // that led here). + // + // + // mistral-large-2402 is kept registered (usable, real) but is no longer the + // coordinator's tier — see the Claude 3 Sonnet entry below for why. + 'mistral.mixtral-8x7b-instruct-v0:1': { + provider: 'bedrock', + contextWindow: 32_000, + pricing: { + inputPerMToken: 0.52, + outputPerMToken: 0.81, + }, + capabilities: ['coding'], + }, + 'mistral.mistral-large-2402-v1:0': { + provider: 'bedrock', + contextWindow: 32_000, + pricing: { + inputPerMToken: 4.60, + outputPerMToken: 13.80, + }, + capabilities: ['reasoning', 'coding'], + }, + // Claude 3 Sonnet via Bedrock — the standard/powerful tiers' model. + // Chosen after mistral-large-2402 was confirmed (via scripts/smoke-bedrock-tool-use.ts + // and manual load testing) to silently narrate tool calls as text instead of + // invoking them once the coordinator's real system prompt + full tool count are + // in play. This model was confirmed 6/6 across a no-load call plus 5 repeated + // calls under the exact same production-shaped load (large system prompt + 47 + // tools) — 100% real tool_use, no narrated-call failures. + // + // Deliberately the *original* Claude 3 Sonnet (2024-02-29), not a newer Claude + // version: newer Claude models on Bedrock in this account/region require a + // cross-region inference profile gated behind an additional AWS Marketplace + // subscription permission (aws-marketplace:ViewSubscriptions/Subscribe) beyond + // plain bedrock:InvokeModel — this older model is a native on-demand foundation + // model with no such gate. + // + // Pricing (3.00/15.00 per M input/output tokens) matches AWS's long-published + // on-demand rate for this model — higher confidence than the Mistral entries + // above, but still worth reconfirming against the current Bedrock pricing page + // if this stops matching your invoice. + 'anthropic.claude-3-sonnet-20240229-v1:0': { + provider: 'bedrock', + contextWindow: 200_000, + pricing: { + inputPerMToken: 3.00, + outputPerMToken: 15.00, + }, + capabilities: ['vision', 'reasoning', 'coding'], + }, + // OpenAI embedding model — used by EmbeddingService for semantic search and entity resolution. // inputPerMToken matches current OpenAI pricing for text-embedding-3-small. // outputPerMToken is 0: embeddings produce no billed output tokens. diff --git a/src/config.ts b/src/config.ts index 356f6381d..be808426b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -66,6 +66,14 @@ export interface Config { // that was registered via `signal-cli register` + `signal-cli verify`. signalSocketPath: string | undefined; signalPhoneNumber: string | undefined; + // AWS Bedrock (Mistral models) — credentials are vault-only (ADR-021), resolved + // by applyVaultSecrets() after the vault is constructed, same as anthropicApiKey. + awsAccessKeyId: string | undefined; + awsSecretAccessKey: string | undefined; + // Region and timeout are non-secret operational config — read directly from env, + // same as TIMEZONE/HTTP_PORT. + awsRegion: string | undefined; + awsBedrockTimeoutMs: number; } export interface TasksConfig { @@ -985,6 +993,14 @@ export function loadConfig(): Config { throw new Error(`Invalid TIMEZONE configuration: "${timezone}" is not a recognized IANA timezone`); } + // AWS_BEDROCK_TIMEOUT is expressed in seconds (matching how it reads in .env, + // e.g. "120"), converted to ms for the AWS SDK request abort timer. + const awsBedrockTimeoutSeconds = parseInt(process.env.AWS_BEDROCK_TIMEOUT ?? '120', 10); + if (isNaN(awsBedrockTimeoutSeconds) || awsBedrockTimeoutSeconds < 1) { + throw new Error(`AWS_BEDROCK_TIMEOUT must be a positive number of seconds, got: ${process.env.AWS_BEDROCK_TIMEOUT}`); + } + const awsBedrockTimeoutMs = awsBedrockTimeoutSeconds * 1000; + return { databaseUrl, // Bootstrap/config secrets are resolved from the vault by applyVaultSecrets() @@ -1015,5 +1031,11 @@ export function loadConfig(): Config { // Signal adapter with a bogus socket path or phone number. signalSocketPath: process.env.SIGNAL_SOCKET_PATH?.trim() || undefined, signalPhoneNumber: undefined, + // AWS Bedrock credentials are vault-only (ADR-021) — resolved by + // applyVaultSecrets() after the vault is constructed, same as anthropicApiKey. + awsAccessKeyId: undefined, + awsSecretAccessKey: undefined, + awsRegion: process.env.AWS_REGION?.trim() || undefined, + awsBedrockTimeoutMs, }; } diff --git a/src/index.ts b/src/index.ts index 512698901..1fab26a12 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,6 +27,7 @@ import { EventBus } from './bus/bus.js'; import { AuditLogger } from './audit/logger.js'; import { AnthropicProvider } from './agents/llm/anthropic.js'; import { OpenRouterProvider } from './agents/llm/openrouter.js'; +import { BedrockMistralProvider } from './agents/llm/bedrock-mistral.js'; import { AgentRuntime } from './agents/runtime.js'; import { Dispatcher } from './dispatch/dispatcher.js'; import { CliAdapter } from './channels/cli/cli-adapter.js'; @@ -396,25 +397,23 @@ async function main(): Promise { } const modelRegistry = new ModelRegistry(logger); - // 5. LLM provider — hard fail early rather than discovering the missing - // key only when the first user message arrives. - // modelRegistry must be instantiated first (above) — the provider uses it - // to look up maxOutputTokens per model rather than using a hardcoded constant. - if (!config.anthropicApiKey) { - logger.fatal('ANTHROPIC_API_KEY is required'); - process.exit(1); - } - const llmProvider = new AnthropicProvider(config.anthropicApiKey, logger, modelRegistry); - // estimateCostUsd is a closure pre-wired with the model registry. Passed to - // AgentRuntime as config (dependency injection) so the runtime stays testable - // without importing pricing.ts directly. - // Pass the configured standard tier model as the fallback so cost estimates - // for unrecognised models track operator routing rather than hardcoding Sonnet. + // 5. LLM providers — registered conditionally on which credentials are present. + // No provider is unconditionally required at this point; the tier-mapped-model + // validation loop below is what actually enforces "the models this deployment + // is configured to use must have a registered provider" — fail fast there + // instead of hardcoding one vendor as mandatory here. + // modelRegistry must be instantiated first (above) — providers use it to look + // up maxOutputTokens per model rather than using a hardcoded constant. const estimateCostUsd = createEstimateCostUsd(modelRegistry, modelRoutingConfig.tiers.standard.model); const modelRouter = new ModelRouter(modelRoutingConfig, modelRegistry, logger); - const providerRegistry = new Map([ - ['anthropic', llmProvider], - ]); + const providerRegistry = new Map(); + + // Anthropic — optional. Only instantiated when ANTHROPIC_API_KEY is present. + if (config.anthropicApiKey) { + const anthropicProvider = new AnthropicProvider(config.anthropicApiKey, logger, modelRegistry); + providerRegistry.set('anthropic', anthropicProvider); + logger.info('Anthropic provider registered'); + } // OpenRouter — optional second provider for non-Claude models. // Only instantiated when OPENROUTER_API_KEY is present. If absent, OpenRouter @@ -426,6 +425,27 @@ async function main(): Promise { logger.info('OpenRouter provider registered — non-Claude models available'); } + // AWS Bedrock (Mistral models) — optional. Only instantiated when both the vault- + // resolved AWS credentials and a region are present. Credentials are vault-only + // (ADR-021); region/timeout are non-secret operational config from env. + if (config.awsAccessKeyId && config.awsSecretAccessKey && config.awsRegion) { + const bedrockProvider = new BedrockMistralProvider( + config.awsAccessKeyId, + config.awsSecretAccessKey, + config.awsRegion, + config.awsBedrockTimeoutMs, + logger, + modelRegistry, + ); + providerRegistry.set('bedrock', bedrockProvider); + logger.info({ region: config.awsRegion }, 'AWS Bedrock provider registered — Mistral models available'); + } else if (config.awsAccessKeyId || config.awsSecretAccessKey) { + // Partial config (one of the two vault secrets present, or region missing) is + // almost certainly a misconfiguration — warn loudly rather than silently + // leaving Bedrock unavailable with no explanation. + logger.warn('AWS Bedrock credentials are incomplete (need aws_access_key_id + aws_secret_access_key in the vault, and AWS_REGION set) — Bedrock provider not registered'); + } + // Validate that every model mapped to a tier has a registered provider. // Models in the registry that aren't mapped to any tier are fine to leave // without a provider — they represent available models, not required ones. diff --git a/src/secrets/apply-vault-secrets.test.ts b/src/secrets/apply-vault-secrets.test.ts index 4db6e124c..60c9f01cf 100644 --- a/src/secrets/apply-vault-secrets.test.ts +++ b/src/secrets/apply-vault-secrets.test.ts @@ -27,6 +27,10 @@ function baseConfig(): Config { ceoSignalNumber: undefined, signalSocketPath: undefined, signalPhoneNumber: undefined, + awsAccessKeyId: undefined, + awsSecretAccessKey: undefined, + awsRegion: undefined, + awsBedrockTimeoutMs: 120000, }; } @@ -69,4 +73,17 @@ describe('applyVaultSecrets', () => { await applyVaultSecrets(config, fakeSecrets({}), logger); expect(config.nylasSelfEmail).toBe(''); }); + + it('resolves AWS Bedrock credentials from the vault', async () => { + const config = baseConfig(); + const secrets = fakeSecrets({ + aws_access_key_id: 'AKIA-vault', + aws_secret_access_key: 'secret-vault', + }); + + await applyVaultSecrets(config, secrets, logger); + + expect(config.awsAccessKeyId).toBe('AKIA-vault'); + expect(config.awsSecretAccessKey).toBe('secret-vault'); + }); }); diff --git a/src/secrets/apply-vault-secrets.ts b/src/secrets/apply-vault-secrets.ts index b558d26a5..23f44c972 100644 --- a/src/secrets/apply-vault-secrets.ts +++ b/src/secrets/apply-vault-secrets.ts @@ -23,6 +23,8 @@ export async function applyVaultSecrets( nylasGrantId, nylasSelfEmail, signalPhoneNumber, + awsAccessKeyId, + awsSecretAccessKey, ] = await Promise.all([ secrets.get('anthropic_api_key'), secrets.get('openai_api_key'), @@ -33,6 +35,8 @@ export async function applyVaultSecrets( secrets.get('nylas_grant_id'), secrets.get('nylas_self_email'), secrets.get('signal_phone_number'), + secrets.get('aws_access_key_id'), + secrets.get('aws_secret_access_key'), ]); // Normalize each vault value: trim surrounding whitespace (copy-paste artifacts) and @@ -59,6 +63,8 @@ export async function applyVaultSecrets( // `process.env.NYLAS_SELF_EMAIL ?? ''` behavior, not an env read. config.nylasSelfEmail = clean(nylasSelfEmail) ?? ''; config.signalPhoneNumber = clean(signalPhoneNumber); + config.awsAccessKeyId = clean(awsAccessKeyId); + config.awsSecretAccessKey = clean(awsSecretAccessKey); // Names only — never values. Lets an operator confirm what the vault supplied // vs. what's absent (feature-disabled), which is the whole debuggability win. @@ -74,6 +80,8 @@ export async function applyVaultSecrets( nylas_grant_id: clean(nylasGrantId) !== undefined, nylas_self_email: clean(nylasSelfEmail) !== undefined, signal_phone_number: clean(signalPhoneNumber) !== undefined, + aws_access_key_id: clean(awsAccessKeyId) !== undefined, + aws_secret_access_key: clean(awsSecretAccessKey) !== undefined, }; logger.info({ present }, 'Resolved bootstrap secrets from vault (vault-only, no env fallback)'); }