From 9c127357e0758e13a1bb359da88f09c026f90676 Mon Sep 17 00:00:00 2001 From: Phil Merrell Date: Fri, 10 Jul 2026 12:35:32 -0600 Subject: [PATCH] Release/1.3.0 (#622) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Tighten admin-route input handling and data-layer ownership edges (#484) A handful of related cleanups across admin and data-edge routes that share a common shape: each one either accepted ill-formed input that went on to crash a downstream call, leaked upstream error detail in 500 bodies, or didn't verify ownership before mutating shared records. Admin/Bedrock model listing GET /admin/bedrock/models filter parameters now use Pydantic Literal/regex types so out-of-shape values are refused at the request boundary with a generic 422 (no input echo, no enum-set reflection). The route's bespoke ClientError/BotoCoreError/Exception handlers are removed in favour of the app-wide register_aws_client_error_handler installed at startup, which maps ValidationException-class codes to a generic 400 and other ClientErrors to a generic 502. A new register_validation_error_handler replaces FastAPI's default 422 body with the same generic body. Memory-record delete DELETE /memory/{record_id} now fetches the record's metadata via GetMemoryRecord and verifies the calling user appears in the /actors/{user_id} segment of the record's namespace before issuing batch_delete_memory_records. Non-owners (and missing records) get a generic 404. Two new helpers — get_memory_record_owner and record_namespace_owner — encapsulate the lookup and namespace parse. OIDC discovery + auth-provider connectivity tests POST /admin/auth-providers/discover now runs the supplied issuer URL through the SSRF validator (https-only, no loopback / link-local / private / metadata addresses) before fetching .well-known/openid- configuration. POST /admin/auth-providers/{id}/test now applies the same validator to each stored URL — jwks_uri, token_endpoint, and the issuer-derived discovery URL — independently; a URL that fails validation is reported unreachable in the per-endpoint result map and is not contacted. Files pagination cursor GET /files now refuses cursors whose embedded PK doesn't match the caller's USER#{user_id} partition. The repository raises a new InvalidCursorError; the route maps that to a generic 400 instead of the previous 500 the cross-partition DynamoDB query produced. Tests: - test_admin_bedrock_models.py (16) — Pydantic rejection of bad enum/regex filters short-circuits before any boto3 call; AWS ValidationException maps to 400 with no message reflection; other ClientError maps to 502 generic; legitimate filters reach AWS; unhandled exception → 500 with no exception detail in body. - test_memory_delete_ownership.py (4) — owner can delete; non-owner gets 404 and no AWS call; record without namespaces treated as not owned; missing record → 404 with no AWS call. - test_auth_providers_ssrf.py (12) — discover refuses 8 disallowed issuer shapes (loopback, link-local, RFC1918, IPv6 loopback, http://, file://, javascript:); accepts a legitimate https issuer; test endpoint skips disallowed jwks_uri / token_endpoint / issuer URLs individually, marking them unreachable without contacting them. - test_files_cursor_validation.py (5) — cursor with another user's PK rejected at repository, garbage cursor rejected, cursor without PK rejected, owner's own cursor still works, route maps the error to 400. - Two existing auth_providers tests updated to mock DNS through the validator's getaddrinfo so their fake hostnames continue to work. * ci(artifacts): plumb CDK_ARTIFACTS_EXTRA_FRAME_ANCESTORS through deploy workflows (#485) The artifacts iframe CSP frame-ancestors list is built from the deployed SPA origin plus config.artifacts.extraFrameAncestors (fed by CDK_ARTIFACTS_EXTRA_FRAME_ANCESTORS), and is enforced on both the artifacts CloudFront response-headers-policy and the render Lambda's FRAME_ANCESTOR_ORIGIN. But neither platform.yml nor nightly-deploy-pipeline.yml passed that var through, so there was no way to allow a local SPA (http://localhost:4200) to embed artifacts served by a deployed dev distribution — the iframe fails with "refused to connect". Mirror the existing CDK_MCP_SANDBOX_EXTRA_FRAME_ANCESTORS passthrough. Unset by default (config falls back to []), so prod is a no-op. Co-authored-by: Claude Opus 4.8 * Sanitize admin error paths and pin viewer-facing TLS to 1.2+ baseline Three small, independent cleanups: Fine-tuning jobs listing — tolerant deserialization GET /admin/fine-tuning/jobs now serializes records one at a time; any record that fails JobResponse validation is dropped with a WARN log naming the offending job_id, and the listing returns 200 with the well-formed records. Repository-level failures (the table unreachable, etc.) still surface as 500 — only per-record validation drops are tolerated. External model listing — generic 503 on missing credentials GET /admin/gemini/models and GET /admin/openai/models now respond with 503 'External model provider not configured.' when the required API key environment variable is unset. The specific env var name (GOOGLE_API_KEY / GOOGLE_GEMINI_API_KEY / OPENAI_API_KEY) is logged server-side at ERROR for operator diagnostics, never echoed to the response body. Transport security baseline CloudFront SPA distribution now pins MinimumProtocolVersion to TLSv1.2_2021 when serving a custom domain — drops TLS 1.0/1.1 entirely and prunes the cipher set to AEAD-only. ALB HTTPS listener now pins SslPolicy to ELBSecurityPolicy-TLS13-1- 2-Res-2021-06 — TLS 1.2 minimum, modern ciphers only, no CBC. Tests: - test_admin_lows.py (8) — fine-tuning listing skips malformed records and returns 200; well-formed-only and all-malformed cases; repository failure still surfaces as 500; gemini and openai unconfigured each return 503 with no env-var-name reflection in the body. - transport-security.test.ts (2) — CDK synth assertions that the CloudFront distribution sets MinimumProtocolVersion=TLSv1.2_2021 and the ALB HTTPS listener sets a 2021-vintage SslPolicy. * fix(skills): resolve and persist subset-scoped external MCP tool bindings (#486) * fix(skills): resolve subset-scoped external MCP bindings at fold time A skill binding a *subset* of an external MCP server (scoped ids like `canvas::courses`) resolved to no client at fold time, so the skill folded zero tools and the model reported the server "not connected" — the OAuth consent gate never fired because no FoldedMCPTool existed. - base_agent: register/look up external tools by their *base* catalog id (the catalog and tool filter both key on the base), deduping scoped ids for the same server so a per-tool binding is classified and loaded. - ExternalMCPIntegration.get_client: collapse a scoped lookup id to its base and match the cached client, tolerating the `|allow:` subset cache-key suffix. Co-Authored-By: Claude Opus 4.8 * fix(skills): reset stale tool folds when rebuilding skill agents MCP clients are process-global and reused across agent builds, and the fold set persists on them (set_folded_tool_names only ever adds). resolve_mcp_bindings enumerates an external server through that same fold-filtered list_tools_sync, so a stale fold from a prior build makes a re-bind see zero tools — the bound tool "works once, then disappears" on the next turn. - mcp_tool_folding: add reset_folded_tool_names to clear a client's fold set and cached tool list. - skill_agent: reset every client this build will (re)bind before resolving, then let the build recompute and re-apply the fold. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 * fix(deps): remediate all 22 HIGH Dependabot findings (#487) Bumps vulnerable dependencies across backend, scripts, and frontend to patched versions. All 22 open HIGH-severity Dependabot alerts addressed. Backend (pyproject.toml + uv.lock): - cryptography 47.0.0 -> 48.0.1 (GHSA-537c-gmf6-5ccf) - starlette 1.0.0 -> 1.3.1 (CVE-2026-48818, CVE-2026-54283) - python-multipart 0.0.27 -> 0.0.30 (CVE-2026-53539) - pyjwt[crypto] 2.12.1 -> 2.13.0 (CVE-2026-48526) - urllib3 pinned 2.7.0 (CVE-2026-44431, CVE-2026-44432) Scripts (backup-data, restore-data): - add uv constraint cryptography>=48.0.1 -> 49.0.0 (GHSA-537c-gmf6-5ccf) Frontend (package.json + package-lock.json): - @angular/* framework 21.2.11 -> 21.2.17 (CVE-2026-50170/50171/54266/54267/54268) (cdk 21.2.14, build/cli 21.2.16 -- latest existing patch for those packages) - hono override -> 4.12.26 (CVE-2026-54290) - piscina override -> 5.2.0 (CVE-2026-55388) - undici override -> 7.28.0 (CVE-2026-9697) - vite override -> 8.0.16 (CVE-2026-53571) Verified: backend pytest 3935 passed/3 skipped; frontend build OK; frontend unit tests 1216 passed. All locked versions >= patched thresholds. * Fix/dependabot quick fixes (#488) * fix(deps): remediate all 22 HIGH Dependabot findings Bumps vulnerable dependencies across backend, scripts, and frontend to patched versions. All 22 open HIGH-severity Dependabot alerts addressed. Backend (pyproject.toml + uv.lock): - cryptography 47.0.0 -> 48.0.1 (GHSA-537c-gmf6-5ccf) - starlette 1.0.0 -> 1.3.1 (CVE-2026-48818, CVE-2026-54283) - python-multipart 0.0.27 -> 0.0.30 (CVE-2026-53539) - pyjwt[crypto] 2.12.1 -> 2.13.0 (CVE-2026-48526) - urllib3 pinned 2.7.0 (CVE-2026-44431, CVE-2026-44432) Scripts (backup-data, restore-data): - add uv constraint cryptography>=48.0.1 -> 49.0.0 (GHSA-537c-gmf6-5ccf) Frontend (package.json + package-lock.json): - @angular/* framework 21.2.11 -> 21.2.17 (CVE-2026-50170/50171/54266/54267/54268) (cdk 21.2.14, build/cli 21.2.16 -- latest existing patch for those packages) - hono override -> 4.12.26 (CVE-2026-54290) - piscina override -> 5.2.0 (CVE-2026-55388) - undici override -> 7.28.0 (CVE-2026-9697) - vite override -> 8.0.16 (CVE-2026-53571) Verified: backend pytest 3935 passed/3 skipped; frontend build OK; frontend unit tests 1216 passed. All locked versions >= patched thresholds. * fix(deps): remediate easy MEDIUM/LOW Dependabot findings Straightforward in-range/direct dependency bumps for remaining alerts. Risky or blocked findings deliberately left out (see below). Backend (pyproject.toml + uv.lock): - aiohttp 3.13.5 -> 3.14.1 - authlib 1.7.0 -> 1.7.1 - python-multipart 0.0.30 -> 0.0.31 - idna pinned 3.15 (transitive) Scripts (backup-data, restore-data): - pytest 8.4.2 -> 9.0.3 (dev) Frontend (package.json + package-lock.json): - mermaid 11.14.0 -> 11.15.0 (direct) - @babel/core override ">=7.29.6 <8.0.0" -> 7.29.7 (bounded to 7.x; open-ended pulled babel 8 which broke the Angular build) Infrastructure (package-lock.json, lock-only): - @babel/core -> 7.29.7 (within range) Deliberately NOT included (not "easy"): - esbuild 0.28.1: pinned by vite 8; override risks build breakage (low sev) - js-yaml 4.2.0: major bump (consumers pin ^3) - dompurify: one alert has no patched version published - infra fast-uri (HIGH) / brace-expansion / js-yaml: bundled inside aws-cdk-lib@2.251.0/node_modules; npm overrides can't reach them. Requires an aws-cdk-lib upgrade -- separate, deliberate change. Verified: backend pytest 3935 passed/3 skipped; frontend build OK + 1216 unit tests passed; infra tsc build OK + 396 jest tests passed. * Fix/dependabot quick fixes (#489) * fix(deps): remediate all 22 HIGH Dependabot findings Bumps vulnerable dependencies across backend, scripts, and frontend to patched versions. All 22 open HIGH-severity Dependabot alerts addressed. Backend (pyproject.toml + uv.lock): - cryptography 47.0.0 -> 48.0.1 (GHSA-537c-gmf6-5ccf) - starlette 1.0.0 -> 1.3.1 (CVE-2026-48818, CVE-2026-54283) - python-multipart 0.0.27 -> 0.0.30 (CVE-2026-53539) - pyjwt[crypto] 2.12.1 -> 2.13.0 (CVE-2026-48526) - urllib3 pinned 2.7.0 (CVE-2026-44431, CVE-2026-44432) Scripts (backup-data, restore-data): - add uv constraint cryptography>=48.0.1 -> 49.0.0 (GHSA-537c-gmf6-5ccf) Frontend (package.json + package-lock.json): - @angular/* framework 21.2.11 -> 21.2.17 (CVE-2026-50170/50171/54266/54267/54268) (cdk 21.2.14, build/cli 21.2.16 -- latest existing patch for those packages) - hono override -> 4.12.26 (CVE-2026-54290) - piscina override -> 5.2.0 (CVE-2026-55388) - undici override -> 7.28.0 (CVE-2026-9697) - vite override -> 8.0.16 (CVE-2026-53571) Verified: backend pytest 3935 passed/3 skipped; frontend build OK; frontend unit tests 1216 passed. All locked versions >= patched thresholds. * fix(deps): remediate easy MEDIUM/LOW Dependabot findings Straightforward in-range/direct dependency bumps for remaining alerts. Risky or blocked findings deliberately left out (see below). Backend (pyproject.toml + uv.lock): - aiohttp 3.13.5 -> 3.14.1 - authlib 1.7.0 -> 1.7.1 - python-multipart 0.0.30 -> 0.0.31 - idna pinned 3.15 (transitive) Scripts (backup-data, restore-data): - pytest 8.4.2 -> 9.0.3 (dev) Frontend (package.json + package-lock.json): - mermaid 11.14.0 -> 11.15.0 (direct) - @babel/core override ">=7.29.6 <8.0.0" -> 7.29.7 (bounded to 7.x; open-ended pulled babel 8 which broke the Angular build) Infrastructure (package-lock.json, lock-only): - @babel/core -> 7.29.7 (within range) Deliberately NOT included (not "easy"): - esbuild 0.28.1: pinned by vite 8; override risks build breakage (low sev) - js-yaml 4.2.0: major bump (consumers pin ^3) - dompurify: one alert has no patched version published - infra fast-uri (HIGH) / brace-expansion / js-yaml: bundled inside aws-cdk-lib@2.251.0/node_modules; npm overrides can't reach them. Requires an aws-cdk-lib upgrade -- separate, deliberate change. Verified: backend pytest 3935 passed/3 skipped; frontend build OK + 1216 unit tests passed; infra tsc build OK + 396 jest tests passed. * fix(deps): upgrade aws-cdk-lib 2.251.0 -> 2.260.0 to clear bundled CVEs The remaining infra Dependabot HIGH/MEDIUM alerts were in deps bundled inside aws-cdk-lib's own node_modules (unreachable by npm overrides): - fast-uri 3.1.0 -> 3.1.2 (HIGH, 2 advisories) — bundled via ajv - brace-expansion 5.0.5 -> 5.0.6 (MEDIUM) — bundled via minimatch Bumping aws-cdk-lib to 2.260.0 (latest 2.x) re-bundles both at patched versions. constructs 10.6.0 already satisfies the ^10.5.0 peer (unchanged). Verified: infra tsc build clean; jest 396 passed/18 suites (full PlatformStack construction + Template.fromStack assertions, no template regressions). cdk-lib v2 minor bump, backward compatible. * ci: add pull_request test gate (backend/frontend/infra) (#490) The deploy workflows (backend.yml, platform.yml, frontend-deploy.yml) only run on push to develop/main and run their test jobs as a pre-deploy gate, so unit tests never executed on the PR itself. PRs into develop ran only skip-auth-guard. Adds .github/workflows/ci.yml triggered on pull_request -> [develop, main] with three parallel test jobs reusing the existing commands: - test-backend: uv sync + uv run pytest tests/ - test-frontend: npm ci + npm run test:ci (vitest) - test-infra: npm ci + npx jest No build/deploy/AWS steps — deploys stay push-only. Actions are SHA-pinned with the shared checkout SHA, runners pinned to ubuntu-24.04, and cancel-in-progress: true (safe; no CDK deploy). Conforms to all tests/supply_chain checks (31 passed). * fix(infra): shared CloudFront cert + consistent domain/cert handling across edge origins (#491) Collapse the three-separate-cert first-deploy footgun into one shared wildcard, and make cert handling consistent and fail-loud across all CloudFront origins. - config.ts: add CDK_CLOUDFRONT_CERTIFICATE_ARN (top-level cloudfrontCertificateArn). frontend/artifacts/mcpSandbox certs fall back to it when their section-specific ARN is unset; section-specific wins. ALB cert stays separate (region-specific). One us-east-1 wildcard ({domain}+*.{domain}) now satisfies all three origins. - artifacts-distribution-construct: add domain-set-but-cert-missing guard mirroring mcp-sandbox (replaces the false 'config.ts already enforced' comment + opaque fromCertificateArn(undefined) crash), and add a domain-less fallback to the CloudFront default domain so domain-less synth no longer crashes with 'reading startsWith'. - load-env.sh: forward cloudfrontCertificateArn + mcpSandbox.certificateArn context params (were missing, breaking the cdk.context.json path). - workflows: wire CDK_CLOUDFRONT_CERTIFICATE_ARN job-level env in platform / nightly / teardown. - docs: step-02/step-03/ACTIONS-REFERENCE recommend the single shared cert and reframe per-origin vars as optional overrides; troubleshooting entry for the synth cert-guard failure. - tests: CloudFront cert resolution (config), artifacts cert guard + domain-less fallback, and end-to-end shared-cert PlatformStack synth. Full infra suite: 20 suites / 406 tests green. * Fix/cdk cli version and deploy node pin (#492) * fix(infra): bump aws-cdk CLI 2.1120.0 -> 2.1128.0 to match aws-cdk-lib 2.260.0 aws-cdk-lib 2.260.0 emits cloud-assembly schema 54.0.0, but the pinned aws-cdk CLI (2.1120.0) only reads up to schema 53 — so synth/deploy failed with 'CDK CLI is not compatible with the CDK library ... Maximum schema version supported is 53.x.x, but found 54.0.0. You need at least CLI version 2.1128.0'. The library was bumped without bumping the CLI. Scripts invoke 'npx cdk', which resolves the local aws-cdk devDependency on the CI runner, so bumping the pin + regenerating the lockfile is the fix. Also bump the devcontainer global CDK pin (Dockerfile) and the version tables (README, dev-environment steering) that are documented to track package.json, so the interactive 'cdk' in the container doesn't drift and reproduce the same error locally. Verified in the devcontainer: npx cdk --version -> 2.1128.0; a synth of an aws-cdk-lib 2.260.0 assembly (manifest schema 54.0.0) is read by the 2.1128.0 CLI with exit 0; full infra suite 20 suites / 406 tests green. * ci(platform): pin Node 22 in the PlatformStack deploy jobs The deploy jobs in platform.yml and nightly-deploy-pipeline.yml were the only jobs without actions/setup-node — they ran scripts/platform/deploy.sh (which does npm ci + cdk via deploy.sh -> scripts/cdk/install.sh) on the runner's ambient Node instead of the Node 22 every other job and the devcontainer pin. Add setup-node (node 22 + npm cache) so the deploy toolchain is pinned and reproducible. Note: deps were already being installed (deploy.sh calls install.sh -> npm ci); the recent schema-mismatch failure was the stale aws-cdk pin (2.1120.0), fixed in 4339f261 by bumping to 2.1128.0. This change is the toolchain-pinning gap the #396 refactor left in the deploy jobs. * Fix/deploy fixes (#494) * fix(infra): auto-generate IAM role names to avoid first-deploy collisions Drop explicit roleName from the AgentCore memory/code-interpreter/browser/ gateway/runtime execution roles and the SageMaker execution role. Fixed physical names collide with orphaned roles left by a rolled-back/partial deploy. Every consumer references these roles by .roleArn (or resolves them at runtime via GetGateway / SAGEMAKER_EXECUTION_ROLE_ARN), so auto-generated names are safe. * chore(infra): replace deprecated pointInTimeRecovery with pointInTimeRecoverySpecification Silences the aws_dynamodb.TableOptions#pointInTimeRecovery deprecation warnings across all DynamoDB table constructs. Synthesized CloudFormation output is unchanged (still PointInTimeRecoveryEnabled: true). * fix(deploy): re-seed image-tag SSM param when the referenced ECR image is missing The seed guard skipped any URI-shaped value, trusting the build pipeline. But image-tag params are not CFN-managed and survive teardown, so a stale project-repo URI could outlive its ECR repo and break the AgentCore Runtime / ECS task def with 'repository does not exist'. Verify the image actually exists (ecr describe-images) before skipping; otherwise overwrite with the bootstrap URI. * fix(infra): grant Cognito group + delete actions for first-boot admin setup Add cognito-idp:CreateGroup and AdminAddUserToGroup (the first-boot flow creates the system_admin group and adds the initial admin) plus AdminDeleteUser (so the rollback path doesn't orphan a Cognito user and block retry with UsernameExistsException) to the app-api task role. * fix(infra): restore stable IAM role names for AgentCore execution roles (#495) Reverts the roleName removal from 7107cf98 for the AgentCore memory/ code-interpreter/browser/gateway/runtime and SageMaker execution roles. Auto-generating these names is unsafe on an already-deployed stack: the role ARN feeds create-only properties on the AgentCore resources (BrowserCustom/CodeInterpreterCustom executionRoleArn, Memory memoryExecutionRoleArn, Gateway/Runtime roleArn). Renaming the role replaces it (new ARN) -> forces replacement of the dependent AgentCore resource -> CFN re-creates it with the same create-only Name -> 'already exists' collision -> UPDATE_ROLLBACK. Confirmed via ai-sbmt-api-PlatformStack events (BrowserCustom/CodeInterpreterCustom DELETE_COMPLETE with empty PhysicalResourceId during rollback). Orphaned fixed-name roles on a *fresh* deploy are handled by deleting the orphans before deploying, not by renaming. Added comments on each role to prevent re-introducing the auto-name change. * fix(ci): build arm64 images on native ARM runners (#496) The RAG ingestion Lambda and AgentCore Runtime are arm64, but the post-refactor backend.yml ran their build jobs on amd64 (ubuntu-24.04). inference-api compensated with QEMU emulation (platform: linux/arm64); rag-ingestion had neither the platform input nor a build-one.sh PLATFORM, so it produced an amd64 image -> the arm64 Lambda failed every invoke with Runtime.InvalidEntrypoint (file uploads stuck 'uploading', no embeddings). Restore the pre-refactor (main) approach: build both arm images on native ubuntu-24.04-arm runners instead of emulating on amd64. - backend.yml: build-inference-api and build-rag-ingestion -> runs-on ubuntu-24.04-arm; drop the QEMU-triggering platform input from inference-api (native build needs no emulation). - build-one.sh: rag-ingestion PLATFORM=linux/arm64 (explicit native build). Deploy jobs stay on ubuntu-24.04 (API-only, no docker build). Note: the stale amd64 rag-ingestion ECR image must be deleted once so the content-hash build doesn't skip the corrected arm64 build. * feat(skills): gate skills feature behind SKILLS_ENABLED flag (default off) (#497) * feat(skills): gate skills feature behind SKILLS_ENABLED flag (default off) Defer the skills feature (user picker, admin catalog, skills mode) for a release without removing any merged code. A new apis/shared/feature_flags.py::skills_enabled() reads SKILLS_ENABLED (default false), mirroring the FINE_TUNING_ENABLED precedent. Deployed environments go dark automatically because the env var is absent; set SKILLS_ENABLED=true on both app-api and inference-api to re-enable. Gated surfaces (code and data left intact): - app-api main.py: user-facing skills router mounted only when enabled. - app-api admin/routes.py: admin skills + chat-mode-policy routers gated. - app-api system/routes.py: GET /system/chat-settings reports chat/no-toggle/ skillsEnabled:false when off (new skills_enabled field on the response). - inference-api chat/routes.py: force skill -> chat when off (voice/other agent types untouched). - SPA: ChatModeService.skillsEnabled drives admin nav hide; mode toggle and skills section auto-hide via allowModeToggle:false; SkillService eager load gated by an effect so a disabled env never fires the now-404 GET /skills/. Tests default off; skills-mode tests opt in via SKILLS_ENABLED=true and new off-behavior tests cover the forced-chat path and admin mount gating. Co-Authored-By: Claude Opus 4.8 * test(model-settings): mock Skill/ChatMode services to stop teardown rejection model-settings.spec instantiated the real SkillService and ChatModeService, which fire /skills/ (now via an effect) and /system/chat-settings on construction. With no httpMock those requests fail asynchronously, and the SKILLS_ENABLED change shifted the timing so a console.error landed during worker teardown — surfacing as an unhandled EnvironmentTeardownError that failed the vitest run with exit 1 even though all 1218 tests passed. Provide minimal mocks for both services so the spec fires no stray async work. Full suite exits 0. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 * feat(tools): forward admin OIDC token on MCP tool discovery (#498) The admin "discover from server" button signs its request as the app-api task role (SigV4, service=lambda), but that role has no lambda:InvokeFunctionUrl — only inference-api does. Against an AuthType=AWS_IAM Lambda Function URL the signed request is rejected with 403, which surfaces during MCP client init as an anyio TaskGroup ExceptionGroup and falls through to a generic 502. For same-team MCP servers that validate a forwarded user JWT (Lambda URL AuthType=NONE), discovery should mirror the runtime forward_auth_token path and sign with the admin's own OIDC token instead of SigV4. Add a forward_auth_token flag to MCPDiscoverRequest; when set, the discover route forwards admin.raw_token as the bearer (400 if unavailable) and skips SigV4. Provider-gated OAuth (3LO) discovery is still rejected — the admin session can't supply an end-user provider token. Wire the flag through the admin tool form's discover call so the existing "Forward app authentication token" checkbox governs discovery too. Co-authored-by: Claude Opus 4.8 * fix(nightly): auto-teardown ephemeral nightly deploys; fix teardown for single-stack (#499) Reconcile the nightly pipeline and teardown scripts with the single PlatformStack, API-driven-deploy architecture. nightly-deploy-pipeline.yml: restore the workflow_call input contract the orchestrator still passes (ref, project-prefix, alb-subdomain, skip-teardown, label, source-project-prefix, run-e2e). Every job now checks out inputs.ref and deploys to the ephemeral inputs.project-prefix (never the shared environment). Add an always() teardown job (needs all deploy/test jobs, gated on skip-teardown) so every nightly stack is destroyed even on partial/failed deploys -- no paying for idle resources. Ephemeral env runs with no custom domain and an unset CDK_COGNITO_DOMAIN_PREFIX (defaults to the unique prefix). scripts/nightly/teardown.sh: delete -PlatformStack via cloudformation delete-stack + wait (was a dead cdk-destroy loop over removed per-stack names). scripts/teardown/destroy.sh: add PlatformStack to the foundation phase while keeping legacy InfrastructureStack/app-stack handling, so the manual teardown works for both single-stack and legacy deployments. * fix(app-api): grant secretsmanager:PutSecretValue on auth-provider secret (#501) The admin auth-providers endpoints (POST/DELETE /admin/auth-providers) write the provider client-secret bag back to the auth-provider-secrets secret via PutSecretValue (apis/shared/auth_providers/repository.py), but the App API task role was only granted GetSecretValue. Configuring/removing an auth provider failed with AccessDeniedException on secretsmanager:PutSecretValue. Add a least-privilege PutSecretValue statement scoped to just the auth-provider-secrets secret (trailing wildcard matches the random ARN suffix). No other runtime-written secret needs this. * Fix/nightly (#500) * fix(nightly): auto-teardown ephemeral nightly deploys; fix teardown for single-stack Reconcile the nightly pipeline and teardown scripts with the single PlatformStack, API-driven-deploy architecture. nightly-deploy-pipeline.yml: restore the workflow_call input contract the orchestrator still passes (ref, project-prefix, alb-subdomain, skip-teardown, label, source-project-prefix, run-e2e). Every job now checks out inputs.ref and deploys to the ephemeral inputs.project-prefix (never the shared environment). Add an always() teardown job (needs all deploy/test jobs, gated on skip-teardown) so every nightly stack is destroyed even on partial/failed deploys -- no paying for idle resources. Ephemeral env runs with no custom domain and an unset CDK_COGNITO_DOMAIN_PREFIX (defaults to the unique prefix). scripts/nightly/teardown.sh: delete -PlatformStack via cloudformation delete-stack + wait (was a dead cdk-destroy loop over removed per-stack names). scripts/teardown/destroy.sh: add PlatformStack to the foundation phase while keeping legacy InfrastructureStack/app-stack handling, so the manual teardown works for both single-stack and legacy deployments. * fix(nightly): restore test-infra/backend/frontend gates in deploy pipeline The pipeline rewrite for ephemeral auto-teardown dropped the per-pipeline test gates, breaking infrastructure/test/repo-shape.test.ts which requires: - build-*/code-deploy-* jobs gate on test-backend - deploy-frontend gates on test-frontend - deploy-platform gates on test-infra Re-add the three test jobs (checking out inputs.ref) and the needs edges, keeping the input-driven ephemeral deploy + always() teardown intact. Also add the test jobs to teardown's needs so teardown waits for the whole graph. Verified: npx jest repo-shape passes (49/49); both nightly workflow YAMLs parse and all orchestrator calls pass only declared inputs. * docs(infra): correct stale SSM comment on mcp-sandbox origin wiring (#502) The McpSandboxDistributionConstruct doc comment claimed it publishes the proxy origin to SSM at `/{prefix}/mcp-sandbox/origin`. That was true of the pre-#396 standalone McpSandboxStack, but the single-stack consolidation dropped the SSM publication: the origin is now exposed as `proxyOrigin` and threaded through PlatformComputeRefs straight into inference-api's `AGENTCORE_MCP_APPS_SANDBOX_ORIGIN` env var. The stale comment misleads anyone debugging the sandbox (a missing SSM param looks like a broken deploy when it is expected). Update the comment to match the code. Co-authored-by: Claude Opus 4.8 * chore(kaizen): weekly research scan 2026-06-19 (#493) Generated by the kaizen-research skill. Top 5 ideas appended to docs/kaizen/review-queue.md for the kaizen-review-prep run later this morning. Co-authored-by: Claude Opus 4.8 (1M context) * fix(mcp-apps): accept spec-array content in ui/message (#503) The ui/message bridge handler read `params.content` as a single block ({type,text}), but per SEP-1865 / the ext-apps SDK the View sends an ARRAY of content blocks (content: [{type:'text',text}]). Every spec-compliant widget message was therefore rejected with -32000 "Invalid ui/message params" (e.g. an MCP App's app.sendMessage()). Read `content` as an array and concatenate its text blocks (mirrors the ui/update-model-context handler). Update MessageParams to the array shape, fix the two bridge specs that sent single objects, and add a multi-block concatenation regression test. Co-authored-by: Claude Opus 4.8 * fix(mcp-apps): retry transient TLS/connect failures on MCP client start (#504) A single TLS handshake blip (e.g. SSLV3_ALERT_HANDSHAKE_FAILURE from a TLS-inspecting middlebox) or connection reset when starting an external MCP client otherwise fails the whole agent build: Strands' start() raises MCPClientInitializationError, the tool fails to load, and agent creation errors out for the user. UICapableMCPClient.start() now retries transient transport failures — ConnectError/SSLError/timeouts, detected by walking the MCPClientInitializationError -> ExceptionGroup -> httpx.ConnectError chain — up to 3 attempts with exponential backoff. Non-transient errors (bad URL, auth, protocol) are re-raised on the first attempt. Strands resets its init future + background thread on failure (stop()), so re-invoking start() is safe. Covers both external and gateway clients. Co-authored-by: Claude Opus 4.8 * fix(mcp-apps): give widget ui/message turns the loading + scroll affordances (#505) A user turn initiated by an MCP App widget (ui/message → submitChatRequest) appeared in the conversation but skipped the two affordances the composer path triggers: the loading indicator (the page sets chatLoading before submitting) and the scroll-to-top of the new user message (chat-container's post-submit setTimeout). The widget delegate called the service directly, bypassing the chat-input → chat-container → page chain. - ChatStateService: add a `scrollToLastUserTick` signal + `requestScrollToLastUser()`. - ChatContainer: react to the tick by scrolling the last user message to the top (mirrors onMessageSubmitted; skips the initial 0 so it doesn't scroll on mount). - mcp-app-frame widget delegate: set chatLoading(true) and request the scroll around submitChatRequest (the user message is added synchronously inside it). The composer path is untouched (it keeps its own setTimeout scroll), so no existing behavior changes. Co-authored-by: Claude Opus 4.8 * feat(connectors): scaffold export-target adapters for saving conversations to connected apps (#507) Extends the connector/adapter pattern that powers Google Drive document import (read) to the write direction, so a connector can also be a *destination* a user saves content to (e.g. saving a conversation transcript to Drive). This is PR-1 of the plan in docs/specs/conversation-export-connectors.md: data + capability scaffold only, no user-facing behavior yet. The connector auth layer (OAuthProvider + AgentCore Identity + consent UX) is direction-agnostic and reused unchanged; only the capability layer is new. Rather than bolting a write method onto the read-shaped FileSourceAdapter, this adds a parallel ExportTargetAdapter registry. - Add export_target_adapter_id to OAuthProvider (model, Dynamo serializers, repository apply_metadata_update) and the Create/Update/Response API models, mirroring file_source_adapter_id. A connector may now be both a file source and an export target. - New apis/app_api/export_targets/ package: ExportTargetAdapter contract, ExportTargetMetadata (with supported_formats), ExportFormat / ExportDestination / CreatedFile models, and an ExportTargetRegistry singleton (intentionally empty until the Google Drive export adapter lands in PR-2). - New admin GET /admin/export-target-adapters endpoint and _validate_export_target_adapter enforced on connector create/update. - Tests mirror the file-source adapter tests, using a stub adapter under a test-only key so it never collides with the future shipped adapter. Full backend suite green (3978 passed, 3 skipped). Co-authored-by: Claude Opus 4.8 * feat(export-targets): Google Drive export adapter + transcript renderer (#508) PR-2 of the Save Conversations to Connected Apps feature (docs/specs/conversation-export-connectors.md). Builds the write-side capability on top of PR-1's scaffold: the first ExportTargetAdapter, the transcript renderer, and the connector resolve/token helpers. Still no user-facing endpoint — that lands in PR-3. - GoogleDriveExportAdapter (drive.file scope, least privilege): multipart /related upload to /upload/drive/v3/files. GOOGLE_DOC uploads an HTML body against the Google Doc MIME type (Drive converts to a native Doc); MARKDOWN uploads a plain .md file. When no destination folder is given, find-or-creates a single app folder ("AI Conversations", overridable via EXPORT_DRIVE_FOLDER_NAME) — which works under drive.file because the search only matches the app's own files. Now registered in the registry. - render.py: pure render_transcript(title, messages, fmt, include) -> RenderedDocument. Markdown is the intermediate representation; GOOGLE_DOC converts it to HTML via markdown-it-py (already a dependency), so Markdown structure maps to real Docs styling. Honors the ExportInclude checkboxes (tool calls / images / citations on; reasoning / timestamps off). Raw HTML in message text is escaped, not injected (CommonMark's default passthrough is forced off). - service.py: resolve_export_target / require_export_target_token / http_error_for_export_target_error, mirroring the file-source service (404 not-a-target, 403 RBAC, 409 not-connected, 503 no-workload). - ExportInclude model added; PR-1 admin tests updated now the registry ships the Drive adapter. PDF is deferred: no PDF renderer is in the dependency set, so the Drive adapter advertises only google_doc + markdown for now. Tests: render, Drive adapter (httpx.MockTransport), and service helpers. Full backend suite green (4012 passed, 3 skipped). Co-authored-by: Claude Opus 4.8 * feat(export-targets): export endpoint + conversation-export receipts (#508 follow-up) (#509) Add the PR-3 export endpoint for saving a conversation transcript out to a connected app (Google Drive being the first export target): - POST /sessions/{session_id}/export — ownership check, resolve connector to export-target adapter, validate the requested format against the adapter, mint the user's OAuth token (409 = consent needed, the SPA's retry hook), page the full transcript, render it, and create the document. Adapter failures map to 502/403/404. - GET /export-targets — catalog mirroring GET /file-sources, surfacing per connector `connected` and `supportedFormats` so the SPA dialog can build its connector + format pickers without extra backend work. Persist an export receipt on the session (spec R-2): - ExportReceipt model + export_receipts on SessionMetadata and SessionMetadataResponse so a "Saved · Open" affordance survives a reload. - add_export_receipt: race-free list_append/if_not_exists writer mirroring add_pending_interrupt; best-effort so a metadata write never fails an export that already succeeded. Tests: 16 route cases (catalog gating/connected/formats; export success + receipt; multi-page transcript ordering; 404/403/409/503/422/502 matrix). Full backend suite green. Co-authored-by: Claude Opus 4.8 * feat(export-targets): "Save to…" conversation export SPA + admin mapping dropdown (PR-4) (#510) Frontend for the conversation-export feature: a "Save to…" action that pushes a conversation transcript out to a connected app (Google Drive first), plus the admin surface to map a connector as an export target. Save-to dialog (session/): - ExportService + ExportError client for GET /export-targets and POST /sessions/{id}/export (mirrors FileSourceService: OAuth2CallbackUrl header, suppressed error toast, HTTP-status-carrying error). - ExportDialogComponent (CDK dialog): destination picker, format choice gated by each connector's supportedFormats, include-checkbox group (messages locked on; tool calls / images / citations on; reasoning / timestamps off). A not-connected or 409-expired destination runs the shared OAuth consent popup and retries the save automatically; success shows "Open in ". - "Save to…" item wired into the session-list overflow menu beside Share. Admin mapping dropdown (admin/connectors/): - exportTargetAdapterId on the connector model + create/update requests, an Export-target adapter resource over GET /admin/export-target-adapters, and an "Export Target" dropdown on the connector form with the same provider-compatibility filter, scope-coverage warning, and tri-state save semantics as the file-source dropdown. Tests: export.service.spec (4) + export-dialog.component.spec (6) covering catalog load, auto-select, save success, include toggles, and the consent-then-retry path. Production build + affected specs green. Depends on the app-api export endpoints in #509 for the runtime save path; the admin dropdown works against the already-merged admin adapter endpoint. Co-authored-by: Claude Opus 4.8 * feat(export-targets): destination folder picker for "Save to…" (PR-5) (#511) Reuse the import file-source browse dialog as a destination folder picker so a conversation export can target a specific Drive folder instead of only the default app folder. Unblocked by the combined-scope Drive connector (D-4): `drive.readonly` lets the existing roots/browse endpoints power the picker, `drive.file` writes the file — `parentId` already flowed through the export endpoint and request. Backend - Add a `browsable` flag to the `GET /export-targets` catalog, computed from the connector's `file_source_adapter_id` + the file-source registry. Only a connector that is also a shipped file source can back the picker; export-only connectors hide it and keep landing in the app folder. Frontend - Generalize `file-source-browser-dialog` with a `mode: 'import' | 'pick-folder'`. In pick-folder mode it shows folders only, tracks the current folder name client-side, and closes with a `FolderSelection` ("Use this folder"). Import mode is unchanged. - Wire the picker into the export dialog: a "Folder" row (shown only for browsable targets) opens the picker and threads the chosen `parentId` into the save request; default stays the app folder. A successful pick reconciles the connected flag. Tests: backend export-route catalog (browsable true/false/unshipped); picker specs for both dialogs. Full AOT build + architecture import-boundary tests green. Co-authored-by: Claude Opus 4.8 * feat(infra): support external (cross-account) Route53 hosted zones (#512) Add a manageDnsRecords flag (CDK_MANAGE_DNS_RECORDS env / manageDnsRecords context, default true). When false, the stack still attaches the custom domain + ACM cert to the ALB, SPA, artifacts, and mcp-sandbox origins but skips the in-account HostedZone.fromLookup + record creation that would fail when the zone for domainName lives in another AWS account. In that mode each origin emits CfnOutputs with the record name and alias target so an operator can create the records by hand. - config.ts: add manageDnsRecords to AppConfig + loadConfig, log it - zones/alb-dns + spa/artifacts/mcp-sandbox distribution constructs: guard record creation, emit manual-DNS CfnOutputs when unmanaged - load-env.sh: export + validate CDK_MANAGE_DNS_RECORDS - workflows (platform, nightly): pass the flag through - cdk.context.json + test mock-config: add the field - docs: document external-R53 deployment Co-authored-by: Colin * Release/1.0.0 (#513) * Release/v1.0.0 beta.25 (#282) * Potential fix for pull request finding 'Unused local variable' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> Signed-off-by: Colin Smith <7762103+colinmxs@users.noreply.github.com> * Potential fix for pull request finding 'Unused local variable' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> Signed-off-by: Colin Smith <7762103+colinmxs@users.noreply.github.com> * docs(readme): update version badges and tech stack to v1.0.0-beta.18 - Update release badge from v1.0.0-beta.17 to v1.0.0-beta.18 - Bump Tailwind CSS version from v4.1 to v4.2 in all references - Update current release version in release notes section - Reflect latest dependency versions in architecture and tech stack documentation * feat(embeddings): add optional token validation bypass for search queries - Add skip_token_validation parameter to generate_embeddings function - Allow skipping tiktoken-based token validation for short inputs where tiktoken may not be installed - Update search_assistant_knowledgebase to skip validation for query embeddings - Enables embedding generation in environments where tiktoken is unavailable (e.g., search Lambda) * refactor(embeddings): extract shared embedding logic to separate module - Move core embedding generation and vector store operations to apis.shared.embeddings - Create new shared bedrock_embeddings module with generate_embeddings, store_embeddings_in_s3, search_assistant_knowledgebase, and delete_vectors_for_document - Extract vector search logic to new apis.shared.assistants.vector_search module - Keep ingestion-specific token validation (tiktoken-based) in app_api embeddings module - Update ingestion embeddings module to re-export shared functions for backward compatibility - Simplify bedrock_embeddings in ingestion pipeline to focus on chunk validation and splitting - Update imports across documents routes and rag_service to use new shared modules - Reduces code duplication and establishes clear separation between shared RAG infrastructure and ingestion-specific concerns * docs(release-notes): document v1.0.0-beta.19 features and fixes - Add Angular production build optimization section explaining minification and tree-shaking enablement - Document embeddings refactor extracting shared logic to apis.shared.embeddings module - Add skip_token_validation parameter documentation for generate_embeddings function - Update highlights section to mention Angular production build optimization - Clarify CodeQL workflow improvements and unused import/variable cleanup - Enable optimization flag in angular.json production configuration for reduced bundle size * docs(release-notes): remove Angular optimization section and revert config - Remove "Frontend Production Build Optimization" section from release notes - Revert optimization flag removal from angular.json production configuration - Align documentation with actual production build configuration state * feat: add API Keys section to README for programmatic access to AI models * fix(model_config): comment out caching configuration due to Bedrock limitations * feat(create-training-job): enhance file upload with drag-and-drop support and update dataset upload instructions * feat(create-training-job): add support for custom HuggingFace models and enhance model search functionality * fix(test_model_config): remove caching mock and update test for Bedrock config caching behavior * feat(create-training-job): add tests for custom HuggingFace model selection and submission * feat: refactor session compaction and enable by default (#86) * feat: update compaction configuration and enhance session manager tests * fix: update tests for compaction defaults and commented-out caching - Update compaction model test to expect enabled=True and protected_turns=3 - Fix caching test to reflect cache_config being commented out due to Bedrock limitations Co-Authored-By: Claude Opus 4.6 * feat(create-training-job): enhance file upload with drag-and-drop support and update dataset upload instructions * feat(create-training-job): add support for custom HuggingFace models and enhance model search functionality * fix(test_model_config): remove caching mock and update test for Bedrock config caching behavior * feat(create-training-job): add tests for custom HuggingFace model selection and submission * fix: update tests for compaction defaults and commented-out caching - Update compaction model test to expect enabled=True and protected_turns=3 - Fix caching test to reflect cache_config being commented out due to Bedrock limitations Co-Authored-By: Claude Opus 4.6 --------- Signed-off-by: Phil Merrell Co-authored-by: Claude Opus 4.6 * test(to_bedrock_config): add missing result assignment in caching disabled test * Potential fix for code scanning alert no. 41: Clear-text logging of s… (#85) * Potential fix for code scanning alert no. 41: Clear-text logging of sensitive information Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Signed-off-by: Colin Smith <7762103+colinmxs@users.noreply.github.com> * ci: Add explicit read-only permissions to all workflows - Add `permissions: contents: read` to 13 GitHub Actions workflows - Workflows updated: app-api, bootstrap-data-seeding, codeql, frontend, gateway, inference-api, infrastructure, nightly-deploy-pipeline, nightly, rag-ingestion, release, sagemaker-fine-tuning, version-check - Implements principle of least privilege by explicitly declaring minimal required permissions - Improves security posture and aligns with GitHub Actions best practices * fix(security): Redact sensitive information from logs - Mask client ID in seed_auth_provider output, showing only first 8 characters - Redact full Secrets ARN in seed_auth_provider, displaying only resource name - Replace full exception objects with error codes in seed_bootstrap_data error messages - Downgrade MCP client configuration logging from info to debug level - Remove user ID from OAuth token retrieval and re-auth status log messages - Add URL validation to OAuth callback redirect to prevent open redirect vulnerabilities - Prevents accidental exposure of credentials and sensitive identifiers in application logs * fix(security): Resolve remaining CodeQL clear-text logging alerts - seed_auth_provider: Fully redact Secrets Manager ARN from output - external_mcp_client: Remove server URL from logs, decouple oauth_token from log expressions - oauth_tool_service: Isolate decrypted token into _try_get_token() to prevent taint bleed, use lazy log formatting - config.ts: Remove AWS account ID and CORS origins from CDK config log output * Potential fix for code scanning alert no. 499: Clear-text logging of sensitive information Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Signed-off-by: Colin Smith <7762103+colinmxs@users.noreply.github.com> * Potential fix for code scanning alert no. 496: Clear-text logging of sensitive information Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Signed-off-by: Colin Smith <7762103+colinmxs@users.noreply.github.com> * Potential fix for code scanning alert no. 498: Clear-text logging of sensitive information Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Signed-off-by: Colin Smith <7762103+colinmxs@users.noreply.github.com> * Potential fix for code scanning alert no. 497: Clear-text logging of sensitive information Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Signed-off-by: Colin Smith <7762103+colinmxs@users.noreply.github.com> --------- Signed-off-by: Colin Smith <7762103+colinmxs@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: colinmxs * feat(frontend): enable production optimization, branch-aware BUILD_CONFIG - Remove optimization: false from base options (was blocking prod override) - Production: optimization, no source maps, extract licenses - Fix anyComponentStyle budget from 4kB to 200kB for Tailwind - BUILD_CONFIG: main→production, develop→development, dispatch→manual input Production build: 4.96 MB initial (871 KB gzip) vs 8.85 MB unoptimized * fix: move Google Fonts import to index.html to prevent CI build failure * ci: skip docker builds and CDK synth on pull requests * implement conversation sharing. (#87) * implement conversation sharing. * Potential fix for code scanning alert no. 509: Log Injection Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Signed-off-by: ofilson * Potential fix for code scanning alert no. 510: Log Injection Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Signed-off-by: ofilson * fix github warnings * fix log issue --------- Signed-off-by: ofilson Co-authored-by: Oscar Filson Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * Allow for private share (only with yourself) * release: v1.0.0-beta.19 * fix float error on sharing * Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> Signed-off-by: Colin Smith <7762103+colinmxs@users.noreply.github.com> * ci: skip redundant stack dependency checks on PRs (keep infrastructure only) * ci: skip install on PRs for rag-ingestion (no downstream jobs) * ci: revert check-stack-deps skip on workflows with PR jobs, skip entire gateway/sagemaker on PRs * fix(security): resolve CodeQL log-injection, unused-import, and unused-variable alerts - Remove user-controlled values from 180 log f-strings (py/log-injection) - Remove 87 unused Python imports (py/unused-import) - Remove 27 unused JS/TS variables (js/unused-local-variable) - Fix 3 useless assignments (js/useless-assignment-to-local) - Fix 1 incompatible type comparison (js/comparison-between-incompatible-types) * fix(tests): remove stale AgentCoreMemorySessionManager patch from session factory tests The CodeQL commit removed the unused AgentCoreMemorySessionManager import from session_factory.py, breaking two tests that patched it at that path. Removed the unnecessary patch decorator since TurnBasedSessionManager was already being patched separately. * chore(docker): add shared embeddings module to rag-ingestion Lambda image - Copy shared embeddings package to Lambda task root directory - Add apis/__init__.py to ensure proper Python package structure - Enable ingestion embeddings to access re-exported shared embeddings module - Resolves import errors when bedrock_embeddings.py loads shared embeddings * fix(quality): resolve all open CodeQL findings on develop Empty excepts (5 fixes): - url_fetcher: narrow bare except to Exception, add comment - code_interpreter_diagram_tool: narrow bare except to Exception - tool_result_processor: add explanatory comment to JSONDecodeError catch - users/service: log warning on invalid pagination cursor - event_formatter: log warning instead of silently swallowing errors Catch BaseException (2 fixes): - url_fetcher: narrowed to Exception (same fix as empty except) - code_interpreter_diagram_tool: narrowed to Exception Unreachable code (1 fix): - stream_processor: remove dead if result_seen: break (never set to True) Redundant assignment (1 fix): - fine_tuning/routes: remove unused job = on create_inference_job Print during import (1 fix): - inference_api/main: replace print() with logging Commented-out code (1 fix): - inference_api/chat/models: remove commented InvocationRequest class Unnecessary lambdas (2 fixes): - job_repository, inference_repository: lambda v: int(v) → int Unused local variables (13 fixes): - Remove or rename: period, user_id, error_msg, matches, requested_set, exception_type, updated, limit, preferences, execution_output, next_month, next_year across 10 files Unused imports (3 fixes): - compaction_models: remove unused field import - bedrock_embeddings: remove dead re-exports, clean up __init__.py - timezone: use find_spec for pytz availability check Cyclic import (1 fix): - Move get_metadata_storage() factory from metadata_storage.py to storage/__init__.py, breaking the metadata_storage ↔ dynamodb_storage cycle. Update 3 callers to import from apis.app_api.storage. Dismissed as false positives (11 alerts): - 9x untrusted-checkout on nightly workflows (schedule/dispatch only) - 1x non-iterable for-loop (Enum is iterable) - 1x unused global _generic_validator_initialized (global stmt tracking) * fix(deps): patch Dependabot security vulnerabilities - requests 2.32.5 → 2.33.0 (insecure temp file reuse, CVE) - picomatch 4.0.3 → 4.0.4 (frontend, ReDoS + method injection, via override) - picomatch 2.3.1 → 2.3.2 (infrastructure, method injection, via override) - diff 4.0.x → patched (infrastructure, DoS in parsePatch, via audit fix) Unfixable: - yaml 1.10.2 bundled inside aws-cdk-lib 2.244.0 (latest) — awaiting AWS CDK update - Pygments 2.19.2 (latest) — no patched version released yet * fix(rag-ingestion): ensure Lambda uses latest image digest on deploy - Add FUNCTION_NAME variable to capture Lambda function identifier - Update Lambda function code explicitly after image push to force digest refresh - Add wait condition to ensure function update completes before deployment succeeds - Remove outdated next steps logging that duplicated deployment completion message - Resolve issue where CDK's SSM-resolved image tags don't trigger updates when underlying image layers change, causing CloudFormation to report no changes despite fresh image push * fix share issues and icon tweaks * release: v1.0.0-beta.20 * fix(rag-ingestion): restore shared embedding re-exports for Lambda handler The CodeQL fix removed re-exports from bedrock_embeddings.py, but the RAG ingestion Lambda handler imports generate_embeddings and store_embeddings_in_s3 from embeddings.bedrock_embeddings (Lambda task root path). Restored re-exports with __all__ and explanatory comments. * feat(documents): add upload failure reporting and assistant cleanup - Add ReportUploadFailureRequest model for client-side upload error reporting - Implement POST /{document_id}/upload-failed endpoint to mark documents as failed - Add update_document_status service function to update document status and error details - Implement background cleanup of vectors and S3 objects when assistant is deleted - Add delete_vectors_for_assistant function to remove embeddings from vector store - Update document routes to import new models and service functions - Add start.sh to .gitignore - Update bedrock_embeddings to support vector deletion by assistant ID - Enhance frontend document service to handle upload failure reporting - Improve assistant deletion flow with proper resource cleanup and error handling * feat(assistants): remove archive functionality and simplify deletion - Remove archive_assistant service function and endpoint - Simplify delete operation to single hard delete without archive option - Remove include_archived query parameter from list assistants endpoint - Remove ARCHIVED status from assistant status enum - Update frontend assistant model and services to remove archive references - Simplify assistant lifecycle by consolidating soft and hard delete into single delete operation - Update API documentation and test examples to reflect deletion changes * feat(frontend): upgrade Analog.js testing dependencies and remove vitest config - Add @analogjs/vite-plugin-angular and @analogjs/vitest-angular v3.0.0-alpha.18 - Update package-lock.json with new dependency tree and transitive dependencies - Remove vitest.config.ts in favor of Analog.js configuration - Update app.config.spec.ts and tool-rail.component.spec.ts test files - Modernize Angular testing setup with latest Analog.js tooling * feat(documents): implement reliable deletion with soft-delete and cleanup retries - Add deleting status to document lifecycle and TTL field for auto-expiry - Create cleanup_service.py with retry logic for S3 vectors and source file deletion - Implement soft-delete pattern: mark documents as deleting, return immediately, cleanup asynchronously - Update search path to filter out non-complete documents and prevent stale results - Add batch soft-delete for assistant deletion with background cleanup - Implement deterministic vector key generation for reliable cleanup - Add comprehensive property-based and integration tests for deletion flows - Update RAG service to cross-check document status during search - Configure DynamoDB TTL as backstop for failed cleanups (7-day expiry) - Add Kiro spec documentation for reliable document deletion design * test(assistants): remove archive assistant test and fix package dependencies - Remove test_archive_assistant test case as archive functionality was removed - Update package-lock.json to fix dependency flags for Angular DevKit and related packages - Change chokidar dev flag to devOptional to reflect optional development dependency - Remove unnecessary dev flags from multiple dependencies (ajv, chalk, cli-cursor, fast-deep-equal, and others) - Align package metadata with current project configuration * chore(frontend): pin Analog.js dependencies to exact versions - Remove caret (^) version specifiers from @analogjs/vite-plugin-angular - Remove caret (^) version specifiers from @analogjs/vitest-angular - Lock both packages to 3.0.0-alpha.18 for consistent builds - Prevent unexpected minor/patch updates that could introduce breaking changes * chore(frontend): pin Analog.js devDependencies to exact versions - Update @analogjs/vite-plugin-angular from ^3.0.0-alpha.18 to 3.0.0-alpha.18 - Update @analogjs/vitest-angular from ^3.0.0-alpha.18 to 3.0.0-alpha.18 - Remove caret (^) prefix to lock exact versions and ensure consistent builds * feat(fine-tuning-dashboard): add informational section about fine-tuning and update icons * chore(deps)(deps): bump the frontend-minor-patch group (#101) Bumps the frontend-minor-patch group in /frontend/ai.client with 10 updates: | Package | From | To | | --- | --- | --- | | [@ng-icons/core](https://github.com/ng-icons/ng-icons) | `33.1.0` | `33.2.0` | | [@ng-icons/heroicons](https://github.com/ng-icons/ng-icons) | `33.1.0` | `33.2.0` | | [katex](https://github.com/KaTeX/KaTeX) | `0.16.33` | `0.16.44` | | [marked](https://github.com/markedjs/marked) | `17.0.3` | `17.0.5` | | [mermaid](https://github.com/mermaid-js/mermaid) | `11.12.3` | `11.13.0` | | [@tailwindcss/postcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-postcss) | `4.2.1` | `4.2.2` | | [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) | `4.0.18` | `4.1.2` | | [postcss](https://github.com/postcss/postcss) | `8.5.6` | `8.5.8` | | [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.2.1` | `4.2.2` | | [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.0.18` | `4.1.2` | Updates `@ng-icons/core` from 33.1.0 to 33.2.0 - [Release notes](https://github.com/ng-icons/ng-icons/releases) - [Changelog](https://github.com/ng-icons/ng-icons/blob/main/CHANGELOG.md) - [Commits](https://github.com/ng-icons/ng-icons/commits/v33.2.0) Updates `@ng-icons/heroicons` from 33.1.0 to 33.2.0 - [Release notes](https://github.com/ng-icons/ng-icons/releases) - [Changelog](https://github.com/ng-icons/ng-icons/blob/main/CHANGELOG.md) - [Commits](https://github.com/ng-icons/ng-icons/commits/v33.2.0) Updates `katex` from 0.16.33 to 0.16.44 - [Release notes](https://github.com/KaTeX/KaTeX/releases) - [Changelog](https://github.com/KaTeX/KaTeX/blob/main/CHANGELOG.md) - [Commits](https://github.com/KaTeX/KaTeX/compare/v0.16.33...v0.16.44) Updates `marked` from 17.0.3 to 17.0.5 - [Release notes](https://github.com/markedjs/marked/releases) - [Commits](https://github.com/markedjs/marked/compare/v17.0.3...v17.0.5) Updates `mermaid` from 11.12.3 to 11.13.0 - [Release notes](https://github.com/mermaid-js/mermaid/releases) - [Commits](https://github.com/mermaid-js/mermaid/compare/mermaid@11.12.3...mermaid@11.13.0) Updates `@tailwindcss/postcss` from 4.2.1 to 4.2.2 - [Release notes](https://github.com/tailwindlabs/tailwindcss/releases) - [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.2.2/packages/@tailwindcss-postcss) Updates `@vitest/coverage-v8` from 4.0.18 to 4.1.2 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.2/packages/coverage-v8) Updates `postcss` from 8.5.6 to 8.5.8 - [Release notes](https://github.com/postcss/postcss/releases) - [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/postcss/postcss/compare/8.5.6...8.5.8) Updates `tailwindcss` from 4.2.1 to 4.2.2 - [Release notes](https://github.com/tailwindlabs/tailwindcss/releases) - [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.2.2/packages/tailwindcss) Updates `vitest` from 4.0.18 to 4.1.2 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.2/packages/vitest) --- updated-dependencies: - dependency-name: "@ng-icons/core" dependency-version: 33.2.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: frontend-minor-patch - dependency-name: "@ng-icons/heroicons" dependency-version: 33.2.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: frontend-minor-patch - dependency-name: katex dependency-version: 0.16.44 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: frontend-minor-patch - dependency-name: marked dependency-version: 17.0.5 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: frontend-minor-patch - dependency-name: mermaid dependency-version: 11.13.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: frontend-minor-patch - dependency-name: "@tailwindcss/postcss" dependency-version: 4.2.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: frontend-minor-patch - dependency-name: "@vitest/coverage-v8" dependency-version: 4.1.2 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: frontend-minor-patch - dependency-name: postcss dependency-version: 8.5.8 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: frontend-minor-patch - dependency-name: tailwindcss dependency-version: 4.2.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: frontend-minor-patch - dependency-name: vitest dependency-version: 4.1.2 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: frontend-minor-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps)(deps): bump the aws-cdk group (#90) Bumps the aws-cdk group in /infrastructure with 2 updates: [aws-cdk-lib](https://github.com/aws/aws-cdk/tree/HEAD/packages/aws-cdk-lib) and [aws-cdk](https://github.com/aws/aws-cdk-cli/tree/HEAD/packages/aws-cdk). Updates `aws-cdk-lib` from 2.244.0 to 2.245.0 - [Release notes](https://github.com/aws/aws-cdk/releases) - [Changelog](https://github.com/aws/aws-cdk/blob/main/CHANGELOG.v2.alpha.md) - [Commits](https://github.com/aws/aws-cdk/commits/v2.245.0/packages/aws-cdk-lib) Updates `aws-cdk` from 2.1113.0 to 2.1115.0 - [Release notes](https://github.com/aws/aws-cdk-cli/releases) - [Commits](https://github.com/aws/aws-cdk-cli/commits/aws-cdk@v2.1115.0/packages/aws-cdk) --- updated-dependencies: - dependency-name: aws-cdk-lib dependency-version: 2.245.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-cdk - dependency-name: aws-cdk dependency-version: 2.1115.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: aws-cdk ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps)(deps): bump actions/setup-node from 5.0.0 to 6.3.0 (#100) Bumps [actions/setup-node](https://github.com/actions/setup-node) from 5.0.0 to 6.3.0. - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/a0853c24544627f65ddf259abe73b1d18a591444...53b83947a5a98c8d113130e565377fae1a50d02f) --- updated-dependencies: - dependency-name: actions/setup-node dependency-version: 6.3.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps)(deps): bump github/codeql-action (#95) Bumps the actions-minor-patch group with 1 update: [github/codeql-action](https://github.com/github/codeql-action). Updates `github/codeql-action` from 4.34.1 to 4.35.1 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/38697555549f1db7851b81482ff19f1fa5c4fedc...c10b8064de6f491fea524254123dbe5e09572f13) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: 4.35.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: actions-minor-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps)(deps-dev): bump @types/node in /infrastructure (#94) Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 24.10.1 to 25.5.0. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) --- updated-dependencies: - dependency-name: "@types/node" dependency-version: 25.5.0 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps)(deps-dev): bump jsdom in /frontend/ai.client (#102) Bumps [jsdom](https://github.com/jsdom/jsdom) from 27.4.0 to 29.0.1. - [Release notes](https://github.com/jsdom/jsdom/releases) - [Commits](https://github.com/jsdom/jsdom/compare/v27.4.0...v29.0.1) --- updated-dependencies: - dependency-name: jsdom dependency-version: 29.0.1 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps)(deps): bump ng2-charts in /frontend/ai.client (#105) Bumps [ng2-charts](https://github.com/valor-software/ng2-charts) from 8.0.0 to 10.0.0. - [Release notes](https://github.com/valor-software/ng2-charts/releases) - [Commits](https://github.com/valor-software/ng2-charts/compare/v8.0.0...v10.0.0) --- updated-dependencies: - dependency-name: ng2-charts dependency-version: 10.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps)(deps): bump the angular group (#97) Bumps the angular group in /frontend/ai.client with 10 updates: | Package | From | To | | --- | --- | --- | | [@angular/cdk](https://github.com/angular/components) | `21.2.3` | `21.2.4` | | [@angular/common](https://github.com/angular/angular/tree/HEAD/packages/common) | `21.2.5` | `21.2.6` | | [@angular/compiler](https://github.com/angular/angular/tree/HEAD/packages/compiler) | `21.2.5` | `21.2.6` | | [@angular/core](https://github.com/angular/angular/tree/HEAD/packages/core) | `21.2.5` | `21.2.6` | | [@angular/forms](https://github.com/angular/angular/tree/HEAD/packages/forms) | `21.2.5` | `21.2.6` | | [@angular/platform-browser](https://github.com/angular/angular/tree/HEAD/packages/platform-browser) | `21.2.5` | `21.2.6` | | [@angular/router](https://github.com/angular/angular/tree/HEAD/packages/router) | `21.2.5` | `21.2.6` | | [@angular/build](https://github.com/angular/angular-cli) | `21.2.3` | `21.2.5` | | [@angular/cli](https://github.com/angular/angular-cli) | `21.2.3` | `21.2.5` | | [@angular/compiler-cli](https://github.com/angular/angular/tree/HEAD/packages/compiler-cli) | `21.2.5` | `21.2.6` | Updates `@angular/cdk` from 21.2.3 to 21.2.4 - [Release notes](https://github.com/angular/components/releases) - [Changelog](https://github.com/angular/components/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/components/compare/v21.2.3...v21.2.4) Updates `@angular/common` from 21.2.5 to 21.2.6 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/v21.2.6/packages/common) Updates `@angular/compiler` from 21.2.5 to 21.2.6 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/v21.2.6/packages/compiler) Updates `@angular/core` from 21.2.5 to 21.2.6 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/v21.2.6/packages/core) Updates `@angular/forms` from 21.2.5 to 21.2.6 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/v21.2.6/packages/forms) Updates `@angular/platform-browser` from 21.2.5 to 21.2.6 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/v21.2.6/packages/platform-browser) Updates `@angular/router` from 21.2.5 to 21.2.6 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/v21.2.6/packages/router) Updates `@angular/build` from 21.2.3 to 21.2.5 - [Release notes](https://github.com/angular/angular-cli/releases) - [Changelog](https://github.com/angular/angular-cli/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular-cli/compare/v21.2.3...v21.2.5) Updates `@angular/cli` from 21.2.3 to 21.2.5 - [Release notes](https://github.com/angular/angular-cli/releases) - [Changelog](https://github.com/angular/angular-cli/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular-cli/compare/v21.2.3...v21.2.5) Updates `@angular/compiler-cli` from 21.2.5 to 21.2.6 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/v21.2.6/packages/compiler-cli) --- updated-dependencies: - dependency-name: "@angular/cdk" dependency-version: 21.2.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/common" dependency-version: 21.2.6 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/compiler" dependency-version: 21.2.6 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/core" dependency-version: 21.2.6 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/forms" dependency-version: 21.2.6 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/platform-browser" dependency-version: 21.2.6 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/router" dependency-version: 21.2.6 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/build" dependency-version: 21.2.5 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/cli" dependency-version: 21.2.5 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/compiler-cli" dependency-version: 21.2.6 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: angular ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps)(deps-dev): bump jest and @types/jest in /infrastructure (#92) Bumps [jest](https://github.com/jestjs/jest/tree/HEAD/packages/jest) and [@types/jest](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/jest). These dependencies needed to be updated together. Updates `jest` from 29.7.0 to 30.3.0 - [Release notes](https://github.com/jestjs/jest/releases) - [Changelog](https://github.com/jestjs/jest/blob/main/CHANGELOG.md) - [Commits](https://github.com/jestjs/jest/commits/v30.3.0/packages/jest) Updates `@types/jest` from 29.5.14 to 30.0.0 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/jest) --- updated-dependencies: - dependency-name: jest dependency-version: 30.3.0 dependency-type: direct:development update-type: version-update:semver-major - dependency-name: "@types/jest" dependency-version: 30.0.0 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * add conversation deleting handling for shared conversations + bug fix * chore(deps)(deps): bump constructs (#91) Bumps the infra-minor-patch group in /infrastructure with 1 update: [constructs](https://github.com/aws/constructs). Updates `constructs` from 10.5.1 to 10.6.0 - [Release notes](https://github.com/aws/constructs/releases) - [Commits](https://github.com/aws/constructs/compare/v10.5.1...v10.6.0) --- updated-dependencies: - dependency-name: constructs dependency-version: 10.6.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: infra-minor-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * feat(messages): displayText support for RAG-augmented and file attachment messages (#107) * feat(messages): add displayText support for RAG-augmented messages - Add original_message parameter to stream_async and StreamCoordinator to preserve user input before RAG augmentation - Store displayText in message metadata when original message differs from augmented version - Add display_text field to MessageMetadata model with displayText alias for JSON serialization - Update chat_stream route to pass original message when RAG augmentation is applied - Enhance metadata retrieval to query both cost records (C#) and display text records (D#) from DynamoDB - Add store_user_display_text function to persist original message text for clean UI display - Update .gitignore to exclude local dev scripts (start.sh) - Improves user experience by showing original unaugmented messages in conversation UI while maintaining RAG-enhanced context for agent processing * feat(messages): add displayText support for file attachments and local runtime override - Add LOCAL_RUNTIME_ENDPOINT_URL environment variable support for development runtime override in auth routes - Extend displayText storage to handle file attachment content block modifications, not just RAG augmentation - Add message_will_be_modified logic to determine when original message should be stored as displayText - Implement showDebugOutput local settings signal for toggling debug information display - Update user message component to display original text when displayText is available - Add debug output toggle to chat preferences settings page - Update session metadata documentation to clarify displayText usage for all prompt modifications - Ensure original user message is preserved for UI display while augmented prompt remains in AgentCore Memory * test(metadata): add displayText (D# record) tests for store and retrieval * chore(deps): bump fast-check from 3.23.2 to 4.6.0 - Update fast-check to version 4.6.0 with caret constraint for minor/patch updates - Update pure-rand dependency to 4.6.0's requirement of ^8.0.0 (from ^6.1.0) - Increase minimum Node.js requirement from 8.0.0 to 12.17.0 - Migrate auth-pbt.spec.ts to use fast-check 4.x API (stringOf → string with unit parameter) * chore(deps)(deps): bump actions/upload-artifact from 6.0.0 to 7.0.0 (#99) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6.0.0 to 7.0.0. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/b7c566a772e6b6bfb58ed0dc250532a479d7789f...bbbca2ddaa5d8feaa63e36b76fdaad77386f024f) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: 7.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps)(deps): bump actions/download-artifact from 7.0.0 to 8.0.1 (#98) Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 7.0.0 to 8.0.1. - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/37930b1c2abaa49bbe596cd826c3c89aef350131...3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c) --- updated-dependencies: - dependency-name: actions/download-artifact dependency-version: 8.0.1 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps)(deps): bump the python-minor-patch group in /backend with 10 updates (#96) * chore(deps)(deps): bump the python-minor-patch group Bumps the python-minor-patch group in /backend with 10 updates: | Package | From | To | | --- | --- | --- | | [uvicorn](https://github.com/Kludex/uvicorn) | `0.35.0` | `0.42.0` | | [boto3](https://github.com/boto/boto3) | `1.42.73` | `1.42.78` | | [strands-agents](https://github.com/strands-agents/sdk-python) | `1.32.0` | `1.33.0` | | [strands-agents-tools](https://github.com/strands-agents/tools) | `0.2.23` | `0.3.0` | | [aws-opentelemetry-distro](https://github.com/aws-observability/aws-otel-python-instrumentation) | `0.14.2` | `0.16.0` | | [bedrock-agentcore](https://github.com/aws/bedrock-agentcore-sdk-python) | `1.4.7` | `1.4.8` | | [openai](https://github.com/openai/openai-python) | `2.29.0` | `2.30.0` | | [google-genai](https://github.com/googleapis/python-genai) | `1.68.0` | `1.69.0` | | [hypothesis](https://github.com/HypothesisWorks/hypothesis) | `6.151.9` | `6.151.10` | | [ruff](https://github.com/astral-sh/ruff) | `0.15.7` | `0.15.8` | Updates `uvicorn` from 0.35.0 to 0.42.0 - [Release notes](https://github.com/Kludex/uvicorn/releases) - [Changelog](https://github.com/Kludex/uvicorn/blob/main/docs/release-notes.md) - [Commits](https://github.com/Kludex/uvicorn/compare/0.35.0...0.42.0) Updates `boto3` from 1.42.73 to 1.42.78 - [Release notes](https://github.com/boto/boto3/releases) - [Commits](https://github.com/boto/boto3/compare/1.42.73...1.42.78) Updates `strands-agents` from 1.32.0 to 1.33.0 - [Release notes](https://github.com/strands-agents/sdk-python/releases) - [Commits](https://github.com/strands-agents/sdk-python/compare/v1.32.0...v1.33.0) Updates `strands-agents-tools` from 0.2.23 to 0.3.0 - [Release notes](https://github.com/strands-agents/tools/releases) - [Commits](https://github.com/strands-agents/tools/compare/v0.2.23...v0.3.0) Updates `aws-opentelemetry-distro` from 0.14.2 to 0.16.0 - [Release notes](https://github.com/aws-observability/aws-otel-python-instrumentation/releases) - [Changelog](https://github.com/aws-observability/aws-otel-python-instrumentation/blob/main/CHANGELOG.md) - [Commits](https://github.com/aws-observability/aws-otel-python-instrumentation/compare/v0.14.2...v0.16.0) Updates `bedrock-agentcore` from 1.4.7 to 1.4.8 - [Release notes](https://github.com/aws/bedrock-agentcore-sdk-python/releases) - [Changelog](https://github.com/aws/bedrock-agentcore-sdk-python/blob/main/CHANGELOG.md) - [Commits](https://github.com/aws/bedrock-agentcore-sdk-python/compare/v1.4.7...v1.4.8) Updates `openai` from 2.29.0 to 2.30.0 - [Release notes](https://github.com/openai/openai-python/releases) - [Changelog](https://github.com/openai/openai-python/blob/main/CHANGELOG.md) - [Commits](https://github.com/openai/openai-python/compare/v2.29.0...v2.30.0) Updates `google-genai` from 1.68.0 to 1.69.0 - [Release notes](https://github.com/googleapis/python-genai/releases) - [Changelog](https://github.com/googleapis/python-genai/blob/main/CHANGELOG.md) - [Commits](https://github.com/googleapis/python-genai/compare/v1.68.0...v1.69.0) Updates `hypothesis` from 6.151.9 to 6.151.10 - [Release notes](https://github.com/HypothesisWorks/hypothesis/releases) - [Commits](https://github.com/HypothesisWorks/hypothesis/compare/hypothesis-python-6.151.9...hypothesis-python-6.151.10) Updates `ruff` from 0.15.7 to 0.15.8 - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.15.7...0.15.8) --- updated-dependencies: - dependency-name: uvicorn dependency-version: 0.42.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: python-minor-patch - dependency-name: boto3 dependency-version: 1.42.78 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: python-minor-patch - dependency-name: strands-agents dependency-version: 1.33.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: python-minor-patch - dependency-name: strands-agents-tools dependency-version: 0.3.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: python-minor-patch - dependency-name: aws-opentelemetry-distro dependency-version: 0.16.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: python-minor-patch - dependency-name: bedrock-agentcore dependency-version: 1.4.8 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: python-minor-patch - dependency-name: openai dependency-version: 2.30.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: python-minor-patch - dependency-name: google-genai dependency-version: 1.69.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: python-minor-patch - dependency-name: hypothesis dependency-version: 6.151.10 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: python-minor-patch - dependency-name: ruff dependency-version: 0.15.8 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: python-minor-patch ... Signed-off-by: dependabot[bot] * chore(deps): downgrade cachetools to 6.2.4 - Downgrade cachetools from 7.0.5 to 6.2.4 in backend dependencies - Resolves compatibility issues with OAuth provider management --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: colinmxs * chore(deps): pin fast-check to exact version 4.6.0 - Remove caret (^) version constraint from fast-check dependency - Update package.json to use exact version 4.6.0 - Update package-lock.json to reflect pinned version - Ensure consistent dependency resolution across environments * feat: add fine-tuning cost dashboard and user cost breakdown (#108) * feat: add fine-tuning cost dashboard and user cost breakdown - Introduced new models for cost dashboard and user cost breakdown in the admin API. - Implemented endpoint to retrieve aggregated cost data for fine-tuning jobs. - Enhanced fine-tuning access control to support default monthly quota hours for users without explicit grants. - Added new routes and frontend components for displaying fine-tuning costs and usage statistics. - Updated infrastructure configuration to include default quota hours for fine-tuning. - Added tests to ensure proper functionality of new features and configurations. * fix(logging): improve log message formatting for cost dashboard request * fix(logging): sanitize period string in cost dashboard log message * check in share conversations specs * chore(docs): update versioning documentation and release notes for v1.0.0-beta.20 - Update versioning skill and rule documentation to include README.md version badge and "Current release" text in sync script scope - Update Kiro steering guide to document README.md and lockfile updates in version sync process - Bump version badge in README.md from v1.0.0-beta.19 to v1.0.0-beta.20 - Update "Current release" text in README.md to v1.0.0-beta.20 - Add comprehensive release notes for v1.0.0-beta.20 with highlights on document deletion, displayText system, fine-tuning cost dashboard, and dependency updates - Ensure all AI assistant rule files reflect current versioning workflow * ci(frontend): remove common scripts from workflow triggers and restrict CDK jobs to non-PR events - Remove 'scripts/common/**' from push and pull_request trigger paths - Add condition to synth-cdk job to skip execution on pull_request events - Update test-cdk job condition to exclude pull_request events while preserving skip_tests logic - Prevents unnecessary CDK synthesis and testing during pull requests to reduce workflow overhead * Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> Signed-off-by: Colin Smith <7762103+colinmxs@users.noreply.github.com> * Release 1.0.0-beta.20: Document soft-delete, displayText, fine-tuning costs, CodeQL remediation, dependency refresh (#118) Reliable document deletion, displayText for RAG-augmented messages, fine-tuning cost dashboard, assistant archive removal, and a full dependency refresh across Python, npm, and GitHub Actions. Features: - Soft-delete document lifecycle with background cleanup, retry logic, DynamoDB TTL backstop, and search filtering for mid-deletion docs - Upload failure reporting endpoint for client-side error tracking - DisplayText system preserving original user messages when RAG augmentation or file attachments modify the prompt sent to the agent - Debug output toggle in chat preferences for prompt inspection - Fine-tuning cost dashboard with per-user breakdowns and default monthly quota hours - Shared conversation cascade deletion on session delete Removals: - Assistant archive functionality (ARCHIVED status, archive endpoint, include_archived parameter) replaced with single delete operation Security & Code Quality: - All CodeQL findings resolved (180 log injection fixes, 5 silent exception fixes, cyclic import elimination, 13 unused variables) - Four Dependabot security patches (requests, picomatch, diff) CI/CD: - CDK synth skipped on PRs for app-api and frontend workflows - scripts/common/** removed from frontend workflow path triggers - GitHub Actions bumped (upload-artifact v7, download-artifact v8, setup-node v6, codeql-action latest) Testing: - Analog.js testing migration for frontend (vitest config removed) - fast-check v4.6.0 added for property-based frontend tests - 4,200+ lines of new backend tests for document deletion flows Tooling: - sync-version.sh now auto-updates README badge and current release text - Versioning steering docs updated across Kiro, Cursor, and Claude - Release notes steering doc added (fileMatch on RELEASE_NOTES.md) Dependencies: - Python: uvicorn 0.42.0, strands-agents 1.33.0, strands-agents-tools 0.3.0, aws-opentelemetry-distro 0.16.0, bedrock-agentcore 1.4.8, openai 2.30.0, cachetools downgraded to 6.2.4 for compatibility - Frontend: Angular 21.2.6, @angular/cdk 21.2.4 - Infrastructure: aws-cdk group bumped, constructs bumped * Purge outdated AI specs and documentation (#121) * spring cleaning. AI spec file and outdated documentation purge * spring cleanup --> purging old ai specs and outdated docs --------- Co-authored-by: colinmxs * Feat/cognito first boot auth (#125) * feat: replace multi-step auth bootstrap with Cognito first-boot experience - Add Cognito User Pool, App Client, and Domain to CDK infrastructure - Implement first-boot backend with race-condition-safe DynamoDB writes - Add CognitoJWTValidator replacing GenericOIDCJWTValidator - Add federated identity provider management via Cognito IdP APIs - Migrate frontend to Cognito OAuth 2.0 + PKCE flow - Add first-boot setup page with admin account creation - Update AgentCore Runtime to single Cognito JWT authorizer - Remove runtime-provisioner and runtime-updater Lambdas - Remove hardcoded Entra ID configuration from CDK and scripts - Remove auth provider seeding from bootstrap workflow - Wire SSM parameters across stacks for Cognito config - Update GitHub Actions workflows for Cognito context values * FOR TESTING ONLY< REVERT BEFORE MERGING * Feat/cognito first boot auth (#123) * test(auth-sweep): add system status endpoints to public route patterns - Add /system/status to PUBLIC_ROUTE_PATTERNS for unauthenticated access - Add /system/first-boot to PUBLIC_ROUTE_PATTERNS for unauthenticated access - These endpoints should be accessible without authentication for system initialization and health checks * chore(deps): add cognitoidp extra to moto dev dependency - Add cognitoidp extra to moto[dynamodb] in pyproject.toml dev dependencies - Update uv.lock to include cognitoidp extra across all moto references - Add joserfc package as transitive dependency for cognitoidp support - Enable Cognito IDP mocking capabilities for development and testing * test(auth-guard,config-service): add missing service mocks and config properties - Add SystemService mock to auth.guard.spec.ts test setup - Import SystemService dependency in auth guard test file - Add checkStatus mock method to systemService test double - Register SystemService provider in TestBed configuration - Add inferenceApiUrl property to validConfig test fixture in config.service.spec.ts - Ensure test doubles accurately reflect service dependencies for proper test isolation * feat(system-admin): add JWT role mapping for system_admin Cognito group - Add JWT_MAPPING#system_admin item to DynamoDB during bootstrap seeding - Update system_admin role jwtRoleMappings to include "system_admin" group - Implement add_user_to_group method in CognitoService to manage group membership - Add user to system_admin Cognito group during first_boot with rollback on failure - Update test assertions to verify JWT mapping creation and role configuration - Enables Cognito to include system_admin group in JWT cognito:groups claim for RBAC resolution * feat(infrastructure): add Cognito user and group management permissions - Add cognito-idp:AdminDeleteUser permission for user deletion operations - Add cognito-idp:AdminAddUserToGroup permission for group membership management - Add cognito-idp:CreateGroup permission for group creation operations - Enables system admin functionality for managing Cognito user pools and groups * refactor(auth): replace user email with name in logging and events - Replace user.email with user.name in quota event recorder metadata - Update admin cost dashboard logging to use user.name instead of email - Update admin users routes logging to use user.name instead of email - Update file upload routes logging to use user.name instead of email - Update model routes logging to use user.name instead of email - Update tools routes logging to use user.name instead of email - Update OAuth routes logging to use user.name instead of email - Update user models and routes logging to use user.name instead of email - Update auth service and user service in frontend to use name field - Standardize user identification across backend and frontend to use name for privacy and consistency * feat(frontend): update logos and URL-encode inference API ARN - Update logo-dark.png and logo-light.png assets - Add URL encoding for ARN portion in inferenceApiUrl computed signal - Prevent URL parsing errors caused by colons and slashes in AgentCore runtime ARNs - Improve config service documentation with encoding behavior explanation * fix(frontend): add /invocations path to inference API endpoints - Update preview-chat.service.ts to include /invocations path in runtime endpoint URL - Update chat-http.service.ts to include /invocations path in runtime endpoint URL - Fixes inference API calls by using correct endpoint path with qualifier parameter * feat(inference-api): add Authorization header to ALB request configuration - Add requestHeaderConfiguration to ALB listener rule - Include Authorization header in requestHeaderAllowlist - Enable proper header propagation for authenticated requests to inference API * refactor(auth): consolidate RBAC to AppRole-based authorization - Replace multiple role-checking functions with single require_app_roles dependency - Remove require_roles, require_all_roles, has_any_role, has_all_roles, and role-specific decorators (require_faculty, require_staff, require_developer, require_aws_ai_access) - Update rbac.py to resolve permissions through AppRoleService instead of hardcoded JWT groups - Simplify auth module exports to only expose require_app_roles and require_admin - Update admin routes to remove unused role imports - Add comprehensive docstring explaining AppRole system as single source of truth for permissions - Update tests to reflect new authorization flow via AppRoleService * feat(inference-api): add SSM parameters and environment variables to AgentCore runtime - Import DynamoDB table names from SSM parameters for users, RBAC, auth, OAuth, quota, cost tracking, and file uploads - Import S3 bucket and vector index names for RAG functionality - Import gateway URL and frontend CORS origins from SSM parameters - Add comprehensive environment variables to AgentCore runtime configuration including DynamoDB table mappings, authentication settings, OAuth configuration, AgentCore resource IDs, and directory paths - Enable authentication and quota enforcement in runtime environment - Configure frontend URL and CORS origins for cross-origin requests * feat(inference-api): remove gateway URL parameter and simplify CORS origins - Remove SSM parameter import for gateway URL from InferenceApiStack - Remove GATEWAY_URL environment variable from AgentCore runtime configuration - Replace SSM-imported CORS origins with config-based construction to avoid circular dependency between InferenceApiStack and FrontendStack - Construct CORS origins dynamically from config.domainName (https://{domain}) with localhost fallback for development - Eliminates circular dependency: InferenceApiStack ↔ FrontendStack by removing reliance on FrontendStack SSM parameters * chore(frontend): update favicon and logo assets - Remove redundant favicon PNG variants (android-chrome, apple-touch-icon, favicon-16x16, favicon-32x32) - Update favicon.ico with new design - Update logo-dark.png with refreshed branding - Consolidate favicon assets to reduce redundancy and improve maintainability * feat(inference-api): add AWS Marketplace permissions for Bedrock model access - Add MarketplaceModelAccess policy statement to runtime execution role - Grant aws-marketplace:ViewSubscriptions and aws-marketplace:Subscribe actions - Enable foundation model access for marketplace-gated models like Anthropic Claude - Required for subscription validation before Bedrock model invocation * Release 1.0.0-beta.19: Conversation sharing, session compaction, fine-tuning enhancements, CI optimization ## Features - Conversation sharing with public/email-restricted access via shareable URLs - Session compaction enabled by default (100K token threshold, 3 protected turns) - Fine-tuning: drag-and-drop dataset upload, custom HuggingFace model support ## Security - Resolve all CodeQL clear-text logging alerts (secrets, tokens, ARNs redacted) - OAuth redirect URL validation to prevent open redirects - Explicit read-only permissions on all 13 GitHub Actions workflows ## Performance - Frontend production build optimized: 8.85 MB → 4.96 MB (871 KB gzipped) - PR workflows trimmed: skip Docker builds, CDK synth, and redundant jobs ## Infrastructure - New shared-conversations DynamoDB table with SessionShare and OwnerShare GSIs - Bedrock prompt caching temporarily disabled due to provider limitations ## Bug Fixes - Google Fonts moved to index.html to fix CI build failure - Private sharing support (owner-only shares) * Release 1.0.0-beta.20: Document soft-delete, displayText, fine-tuning costs, CodeQL remediation, dependency refresh Reliable document deletion, displayText for RAG-augmented messages, fine-tuning cost dashboard, assistant archive removal, and a full dependency refresh across Python, npm, and GitHub Actions. Features: - Soft-delete document lifecycle with background cleanup, retry logic, DynamoDB TTL backstop, and search filtering for mid-deletion docs - Upload failure reporting endpoint for client-side error tracking - DisplayText system preserving original user messages when RAG augmentation or file attachments modify the prompt sent to the agent - Debug output toggle in chat preferences for prompt inspection - Fine-tuning cost dashboard with per-user breakdowns and default monthly quota hours - Shared conversation cascade deletion on session delete Removals: - Assistant archive functionality (ARCHIVED status, archive endpoint, include_archived parameter) replaced with single delete operation Security & Code Quality: - All CodeQL findings resolved (180 log injection fixes, 5 silent exception fixes, cyclic import elimination, 13 unused variables) - Four Dependabot security patches (requests, picomatch, diff) CI/CD: - CDK synth skipped on PRs for app-api and frontend workflows - scripts/common/** removed from frontend workflow path triggers - GitHub Actions bumped (upload-artifact v7, download-artifact v8, setup-node v6, codeql-action latest) Testing: - Analog.js testing migration for frontend (vitest config removed) - fast-check v4.6.0 added for property-based frontend tests - 4,200+ lines of new backend tests for document deletion flows Tooling: - sync-version.sh now auto-updates README badge and current release text - Versioning steering docs updated across Kiro, Cursor, and Claude - Release notes steering doc added (fileMatch on RELEASE_NOTES.md) Dependencies: - Python: uvicorn 0.42.0, strands-agents 1.33.0, strands-agents-tools 0.3.0, aws-opentelemetry-distro 0.16.0, bedrock-agentcore 1.4.8, openai 2.30.0, cachetools downgraded to 6.2.4 for compatibility - Frontend: Angular 21.2.6, @angular/cdk 21.2.4 - Infrastructure: aws-cdk group bumped, constructs bumped * refactor(inference-api): remove underscore prefix from containerImageUri variable - Remove underscore prefix from containerImageUri variable name - Improve code clarity by following standard naming conventions - Variable is used throughout the stack and should follow public naming patterns * chore(deps): add cognitoidp moto extra and update infrastructure tests - Add cognitoidp extra to moto dependency in uv.lock for Cognito IDP mocking support - Update moto dependency extras to include both cognitoidp and dynamodb - Refactor IAM policy assertions in inference-api-stack tests to search both inline and managed policies - Simplify policy verification logic to use findResources and filter by policy attributes - Remove S3 bucket and S3 Vector Store test sections from app-api-stack tests - Update test assertions to be more flexible with policy resource types * chore(frontend): update favicon and logo assets - Add Android Chrome favicon variants (192x192 and 512x512) - Add Apple Touch Icon for iOS devices - Add favicon sizes for 16x16 and 32x32 resolutions - Update favicon.ico with new design - Update logo-dark.png with refreshed branding - Update logo-light.png with refreshed branding - Improve cross-platform icon support and visual consistency * fix(auth-providers): remove OIDC discovery endpoint and add JSON parsing error handling - Remove POST /discover endpoint for OIDC endpoint discovery from admin routes - Add try-except blocks to handle JSON parsing errors in AuthProviderRepository - Gracefully default to empty dict when SecretString is invalid or malformed - Improve resilience when retrieving auth provider secrets from AWS Secrets Manager --------- Co-authored-by: Colin * Feat/cognito first boot auth (#124) * test(auth-sweep): add system status endpoints to public route patterns - Add /system/status to PUBLIC_ROUTE_PATTERNS for unauthenticated access - Add /system/first-boot to PUBLIC_ROUTE_PATTERNS for unauthenticated access - These endpoints should be accessible without authentication for system initialization and health checks * chore(deps): add cognitoidp extra to moto dev dependency - Add cognitoidp extra to moto[dynamodb] in pyproject.toml dev dependencies - Update uv.lock to include cognitoidp extra across all moto references - Add joserfc package as transitive dependency for cognitoidp support - Enable Cognito IDP mocking capabilities for development and testing * test(auth-guard,config-service): add missing service mocks and config properties - Add SystemService mock to auth.guard.spec.ts test setup - Import SystemService dependency in auth guard test file - Add checkStatus mock method to systemService test double - Register SystemService provider in TestBed configuration - Add inferenceApiUrl property to validConfig test fixture in config.service.spec.ts - Ensure test doubles accurately reflect service dependencies for proper test isolation * feat(system-admin): add JWT role mapping for system_admin Cognito group - Add JWT_MAPPING#system_admin item to DynamoDB during bootstrap seeding - Update system_admin role jwtRoleMappings to include "system_admin" group - Implement add_user_to_group method in CognitoService to manage group membership - Add user to system_admin Cognito group during first_boot with rollback on failure - Update test assertions to verify JWT mapping creation and role configuration - Enables Cognito to include system_admin group in JWT cognito:groups claim for RBAC resolution * feat(infrastructure): add Cognito user and group management permissions - Add cognito-idp:AdminDeleteUser permissio… * Backmerge/main into develop (#514) * Release/v1.0.0 beta.25 (#282) * Potential fix for pull request finding 'Unused local variable' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> Signed-off-by: Colin Smith <7762103+colinmxs@users.noreply.github.com> * Potential fix for pull request finding 'Unused local variable' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> Signed-off-by: Colin Smith <7762103+colinmxs@users.noreply.github.com> * docs(readme): update version badges and tech stack to v1.0.0-beta.18 - Update release badge from v1.0.0-beta.17 to v1.0.0-beta.18 - Bump Tailwind CSS version from v4.1 to v4.2 in all references - Update current release version in release notes section - Reflect latest dependency versions in architecture and tech stack documentation * feat(embeddings): add optional token validation bypass for search queries - Add skip_token_validation parameter to generate_embeddings function - Allow skipping tiktoken-based token validation for short inputs where tiktoken may not be installed - Update search_assistant_knowledgebase to skip validation for query embeddings - Enables embedding generation in environments where tiktoken is unavailable (e.g., search Lambda) * refactor(embeddings): extract shared embedding logic to separate module - Move core embedding generation and vector store operations to apis.shared.embeddings - Create new shared bedrock_embeddings module with generate_embeddings, store_embeddings_in_s3, search_assistant_knowledgebase, and delete_vectors_for_document - Extract vector search logic to new apis.shared.assistants.vector_search module - Keep ingestion-specific token validation (tiktoken-based) in app_api embeddings module - Update ingestion embeddings module to re-export shared functions for backward compatibility - Simplify bedrock_embeddings in ingestion pipeline to focus on chunk validation and splitting - Update imports across documents routes and rag_service to use new shared modules - Reduces code duplication and establishes clear separation between shared RAG infrastructure and ingestion-specific concerns * docs(release-notes): document v1.0.0-beta.19 features and fixes - Add Angular production build optimization section explaining minification and tree-shaking enablement - Document embeddings refactor extracting shared logic to apis.shared.embeddings module - Add skip_token_validation parameter documentation for generate_embeddings function - Update highlights section to mention Angular production build optimization - Clarify CodeQL workflow improvements and unused import/variable cleanup - Enable optimization flag in angular.json production configuration for reduced bundle size * docs(release-notes): remove Angular optimization section and revert config - Remove "Frontend Production Build Optimization" section from release notes - Revert optimization flag removal from angular.json production configuration - Align documentation with actual production build configuration state * feat: add API Keys section to README for programmatic access to AI models * fix(model_config): comment out caching configuration due to Bedrock limitations * feat(create-training-job): enhance file upload with drag-and-drop support and update dataset upload instructions * feat(create-training-job): add support for custom HuggingFace models and enhance model search functionality * fix(test_model_config): remove caching mock and update test for Bedrock config caching behavior * feat(create-training-job): add tests for custom HuggingFace model selection and submission * feat: refactor session compaction and enable by default (#86) * feat: update compaction configuration and enhance session manager tests * fix: update tests for compaction defaults and commented-out caching - Update compaction model test to expect enabled=True and protected_turns=3 - Fix caching test to reflect cache_config being commented out due to Bedrock limitations Co-Authored-By: Claude Opus 4.6 * feat(create-training-job): enhance file upload with drag-and-drop support and update dataset upload instructions * feat(create-training-job): add support for custom HuggingFace models and enhance model search functionality * fix(test_model_config): remove caching mock and update test for Bedrock config caching behavior * feat(create-training-job): add tests for custom HuggingFace model selection and submission * fix: update tests for compaction defaults and commented-out caching - Update compaction model test to expect enabled=True and protected_turns=3 - Fix caching test to reflect cache_config being commented out due to Bedrock limitations Co-Authored-By: Claude Opus 4.6 --------- Signed-off-by: Phil Merrell Co-authored-by: Claude Opus 4.6 * test(to_bedrock_config): add missing result assignment in caching disabled test * Potential fix for code scanning alert no. 41: Clear-text logging of s… (#85) * Potential fix for code scanning alert no. 41: Clear-text logging of sensitive information Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Signed-off-by: Colin Smith <7762103+colinmxs@users.noreply.github.com> * ci: Add explicit read-only permissions to all workflows - Add `permissions: contents: read` to 13 GitHub Actions workflows - Workflows updated: app-api, bootstrap-data-seeding, codeql, frontend, gateway, inference-api, infrastructure, nightly-deploy-pipeline, nightly, rag-ingestion, release, sagemaker-fine-tuning, version-check - Implements principle of least privilege by explicitly declaring minimal required permissions - Improves security posture and aligns with GitHub Actions best practices * fix(security): Redact sensitive information from logs - Mask client ID in seed_auth_provider output, showing only first 8 characters - Redact full Secrets ARN in seed_auth_provider, displaying only resource name - Replace full exception objects with error codes in seed_bootstrap_data error messages - Downgrade MCP client configuration logging from info to debug level - Remove user ID from OAuth token retrieval and re-auth status log messages - Add URL validation to OAuth callback redirect to prevent open redirect vulnerabilities - Prevents accidental exposure of credentials and sensitive identifiers in application logs * fix(security): Resolve remaining CodeQL clear-text logging alerts - seed_auth_provider: Fully redact Secrets Manager ARN from output - external_mcp_client: Remove server URL from logs, decouple oauth_token from log expressions - oauth_tool_service: Isolate decrypted token into _try_get_token() to prevent taint bleed, use lazy log formatting - config.ts: Remove AWS account ID and CORS origins from CDK config log output * Potential fix for code scanning alert no. 499: Clear-text logging of sensitive information Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Signed-off-by: Colin Smith <7762103+colinmxs@users.noreply.github.com> * Potential fix for code scanning alert no. 496: Clear-text logging of sensitive information Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Signed-off-by: Colin Smith <7762103+colinmxs@users.noreply.github.com> * Potential fix for code scanning alert no. 498: Clear-text logging of sensitive information Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Signed-off-by: Colin Smith <7762103+colinmxs@users.noreply.github.com> * Potential fix for code scanning alert no. 497: Clear-text logging of sensitive information Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Signed-off-by: Colin Smith <7762103+colinmxs@users.noreply.github.com> --------- Signed-off-by: Colin Smith <7762103+colinmxs@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: colinmxs * feat(frontend): enable production optimization, branch-aware BUILD_CONFIG - Remove optimization: false from base options (was blocking prod override) - Production: optimization, no source maps, extract licenses - Fix anyComponentStyle budget from 4kB to 200kB for Tailwind - BUILD_CONFIG: main→production, develop→development, dispatch→manual input Production build: 4.96 MB initial (871 KB gzip) vs 8.85 MB unoptimized * fix: move Google Fonts import to index.html to prevent CI build failure * ci: skip docker builds and CDK synth on pull requests * implement conversation sharing. (#87) * implement conversation sharing. * Potential fix for code scanning alert no. 509: Log Injection Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Signed-off-by: ofilson * Potential fix for code scanning alert no. 510: Log Injection Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Signed-off-by: ofilson * fix github warnings * fix log issue --------- Signed-off-by: ofilson Co-authored-by: Oscar Filson Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * Allow for private share (only with yourself) * release: v1.0.0-beta.19 * fix float error on sharing * Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> Signed-off-by: Colin Smith <7762103+colinmxs@users.noreply.github.com> * ci: skip redundant stack dependency checks on PRs (keep infrastructure only) * ci: skip install on PRs for rag-ingestion (no downstream jobs) * ci: revert check-stack-deps skip on workflows with PR jobs, skip entire gateway/sagemaker on PRs * fix(security): resolve CodeQL log-injection, unused-import, and unused-variable alerts - Remove user-controlled values from 180 log f-strings (py/log-injection) - Remove 87 unused Python imports (py/unused-import) - Remove 27 unused JS/TS variables (js/unused-local-variable) - Fix 3 useless assignments (js/useless-assignment-to-local) - Fix 1 incompatible type comparison (js/comparison-between-incompatible-types) * fix(tests): remove stale AgentCoreMemorySessionManager patch from session factory tests The CodeQL commit removed the unused AgentCoreMemorySessionManager import from session_factory.py, breaking two tests that patched it at that path. Removed the unnecessary patch decorator since TurnBasedSessionManager was already being patched separately. * chore(docker): add shared embeddings module to rag-ingestion Lambda image - Copy shared embeddings package to Lambda task root directory - Add apis/__init__.py to ensure proper Python package structure - Enable ingestion embeddings to access re-exported shared embeddings module - Resolves import errors when bedrock_embeddings.py loads shared embeddings * fix(quality): resolve all open CodeQL findings on develop Empty excepts (5 fixes): - url_fetcher: narrow bare except to Exception, add comment - code_interpreter_diagram_tool: narrow bare except to Exception - tool_result_processor: add explanatory comment to JSONDecodeError catch - users/service: log warning on invalid pagination cursor - event_formatter: log warning instead of silently swallowing errors Catch BaseException (2 fixes): - url_fetcher: narrowed to Exception (same fix as empty except) - code_interpreter_diagram_tool: narrowed to Exception Unreachable code (1 fix): - stream_processor: remove dead if result_seen: break (never set to True) Redundant assignment (1 fix): - fine_tuning/routes: remove unused job = on create_inference_job Print during import (1 fix): - inference_api/main: replace print() with logging Commented-out code (1 fix): - inference_api/chat/models: remove commented InvocationRequest class Unnecessary lambdas (2 fixes): - job_repository, inference_repository: lambda v: int(v) → int Unused local variables (13 fixes): - Remove or rename: period, user_id, error_msg, matches, requested_set, exception_type, updated, limit, preferences, execution_output, next_month, next_year across 10 files Unused imports (3 fixes): - compaction_models: remove unused field import - bedrock_embeddings: remove dead re-exports, clean up __init__.py - timezone: use find_spec for pytz availability check Cyclic import (1 fix): - Move get_metadata_storage() factory from metadata_storage.py to storage/__init__.py, breaking the metadata_storage ↔ dynamodb_storage cycle. Update 3 callers to import from apis.app_api.storage. Dismissed as false positives (11 alerts): - 9x untrusted-checkout on nightly workflows (schedule/dispatch only) - 1x non-iterable for-loop (Enum is iterable) - 1x unused global _generic_validator_initialized (global stmt tracking) * fix(deps): patch Dependabot security vulnerabilities - requests 2.32.5 → 2.33.0 (insecure temp file reuse, CVE) - picomatch 4.0.3 → 4.0.4 (frontend, ReDoS + method injection, via override) - picomatch 2.3.1 → 2.3.2 (infrastructure, method injection, via override) - diff 4.0.x → patched (infrastructure, DoS in parsePatch, via audit fix) Unfixable: - yaml 1.10.2 bundled inside aws-cdk-lib 2.244.0 (latest) — awaiting AWS CDK update - Pygments 2.19.2 (latest) — no patched version released yet * fix(rag-ingestion): ensure Lambda uses latest image digest on deploy - Add FUNCTION_NAME variable to capture Lambda function identifier - Update Lambda function code explicitly after image push to force digest refresh - Add wait condition to ensure function update completes before deployment succeeds - Remove outdated next steps logging that duplicated deployment completion message - Resolve issue where CDK's SSM-resolved image tags don't trigger updates when underlying image layers change, causing CloudFormation to report no changes despite fresh image push * fix share issues and icon tweaks * release: v1.0.0-beta.20 * fix(rag-ingestion): restore shared embedding re-exports for Lambda handler The CodeQL fix removed re-exports from bedrock_embeddings.py, but the RAG ingestion Lambda handler imports generate_embeddings and store_embeddings_in_s3 from embeddings.bedrock_embeddings (Lambda task root path). Restored re-exports with __all__ and explanatory comments. * feat(documents): add upload failure reporting and assistant cleanup - Add ReportUploadFailureRequest model for client-side upload error reporting - Implement POST /{document_id}/upload-failed endpoint to mark documents as failed - Add update_document_status service function to update document status and error details - Implement background cleanup of vectors and S3 objects when assistant is deleted - Add delete_vectors_for_assistant function to remove embeddings from vector store - Update document routes to import new models and service functions - Add start.sh to .gitignore - Update bedrock_embeddings to support vector deletion by assistant ID - Enhance frontend document service to handle upload failure reporting - Improve assistant deletion flow with proper resource cleanup and error handling * feat(assistants): remove archive functionality and simplify deletion - Remove archive_assistant service function and endpoint - Simplify delete operation to single hard delete without archive option - Remove include_archived query parameter from list assistants endpoint - Remove ARCHIVED status from assistant status enum - Update frontend assistant model and services to remove archive references - Simplify assistant lifecycle by consolidating soft and hard delete into single delete operation - Update API documentation and test examples to reflect deletion changes * feat(frontend): upgrade Analog.js testing dependencies and remove vitest config - Add @analogjs/vite-plugin-angular and @analogjs/vitest-angular v3.0.0-alpha.18 - Update package-lock.json with new dependency tree and transitive dependencies - Remove vitest.config.ts in favor of Analog.js configuration - Update app.config.spec.ts and tool-rail.component.spec.ts test files - Modernize Angular testing setup with latest Analog.js tooling * feat(documents): implement reliable deletion with soft-delete and cleanup retries - Add deleting status to document lifecycle and TTL field for auto-expiry - Create cleanup_service.py with retry logic for S3 vectors and source file deletion - Implement soft-delete pattern: mark documents as deleting, return immediately, cleanup asynchronously - Update search path to filter out non-complete documents and prevent stale results - Add batch soft-delete for assistant deletion with background cleanup - Implement deterministic vector key generation for reliable cleanup - Add comprehensive property-based and integration tests for deletion flows - Update RAG service to cross-check document status during search - Configure DynamoDB TTL as backstop for failed cleanups (7-day expiry) - Add Kiro spec documentation for reliable document deletion design * test(assistants): remove archive assistant test and fix package dependencies - Remove test_archive_assistant test case as archive functionality was removed - Update package-lock.json to fix dependency flags for Angular DevKit and related packages - Change chokidar dev flag to devOptional to reflect optional development dependency - Remove unnecessary dev flags from multiple dependencies (ajv, chalk, cli-cursor, fast-deep-equal, and others) - Align package metadata with current project configuration * chore(frontend): pin Analog.js dependencies to exact versions - Remove caret (^) version specifiers from @analogjs/vite-plugin-angular - Remove caret (^) version specifiers from @analogjs/vitest-angular - Lock both packages to 3.0.0-alpha.18 for consistent builds - Prevent unexpected minor/patch updates that could introduce breaking changes * chore(frontend): pin Analog.js devDependencies to exact versions - Update @analogjs/vite-plugin-angular from ^3.0.0-alpha.18 to 3.0.0-alpha.18 - Update @analogjs/vitest-angular from ^3.0.0-alpha.18 to 3.0.0-alpha.18 - Remove caret (^) prefix to lock exact versions and ensure consistent builds * feat(fine-tuning-dashboard): add informational section about fine-tuning and update icons * chore(deps)(deps): bump the frontend-minor-patch group (#101) Bumps the frontend-minor-patch group in /frontend/ai.client with 10 updates: | Package | From | To | | --- | --- | --- | | [@ng-icons/core](https://github.com/ng-icons/ng-icons) | `33.1.0` | `33.2.0` | | [@ng-icons/heroicons](https://github.com/ng-icons/ng-icons) | `33.1.0` | `33.2.0` | | [katex](https://github.com/KaTeX/KaTeX) | `0.16.33` | `0.16.44` | | [marked](https://github.com/markedjs/marked) | `17.0.3` | `17.0.5` | | [mermaid](https://github.com/mermaid-js/mermaid) | `11.12.3` | `11.13.0` | | [@tailwindcss/postcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-postcss) | `4.2.1` | `4.2.2` | | [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) | `4.0.18` | `4.1.2` | | [postcss](https://github.com/postcss/postcss) | `8.5.6` | `8.5.8` | | [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.2.1` | `4.2.2` | | [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.0.18` | `4.1.2` | Updates `@ng-icons/core` from 33.1.0 to 33.2.0 - [Release notes](https://github.com/ng-icons/ng-icons/releases) - [Changelog](https://github.com/ng-icons/ng-icons/blob/main/CHANGELOG.md) - [Commits](https://github.com/ng-icons/ng-icons/commits/v33.2.0) Updates `@ng-icons/heroicons` from 33.1.0 to 33.2.0 - [Release notes](https://github.com/ng-icons/ng-icons/releases) - [Changelog](https://github.com/ng-icons/ng-icons/blob/main/CHANGELOG.md) - [Commits](https://github.com/ng-icons/ng-icons/commits/v33.2.0) Updates `katex` from 0.16.33 to 0.16.44 - [Release notes](https://github.com/KaTeX/KaTeX/releases) - [Changelog](https://github.com/KaTeX/KaTeX/blob/main/CHANGELOG.md) - [Commits](https://github.com/KaTeX/KaTeX/compare/v0.16.33...v0.16.44) Updates `marked` from 17.0.3 to 17.0.5 - [Release notes](https://github.com/markedjs/marked/releases) - [Commits](https://github.com/markedjs/marked/compare/v17.0.3...v17.0.5) Updates `mermaid` from 11.12.3 to 11.13.0 - [Release notes](https://github.com/mermaid-js/mermaid/releases) - [Commits](https://github.com/mermaid-js/mermaid/compare/mermaid@11.12.3...mermaid@11.13.0) Updates `@tailwindcss/postcss` from 4.2.1 to 4.2.2 - [Release notes](https://github.com/tailwindlabs/tailwindcss/releases) - [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.2.2/packages/@tailwindcss-postcss) Updates `@vitest/coverage-v8` from 4.0.18 to 4.1.2 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.2/packages/coverage-v8) Updates `postcss` from 8.5.6 to 8.5.8 - [Release notes](https://github.com/postcss/postcss/releases) - [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/postcss/postcss/compare/8.5.6...8.5.8) Updates `tailwindcss` from 4.2.1 to 4.2.2 - [Release notes](https://github.com/tailwindlabs/tailwindcss/releases) - [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.2.2/packages/tailwindcss) Updates `vitest` from 4.0.18 to 4.1.2 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.2/packages/vitest) --- updated-dependencies: - dependency-name: "@ng-icons/core" dependency-version: 33.2.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: frontend-minor-patch - dependency-name: "@ng-icons/heroicons" dependency-version: 33.2.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: frontend-minor-patch - dependency-name: katex dependency-version: 0.16.44 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: frontend-minor-patch - dependency-name: marked dependency-version: 17.0.5 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: frontend-minor-patch - dependency-name: mermaid dependency-version: 11.13.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: frontend-minor-patch - dependency-name: "@tailwindcss/postcss" dependency-version: 4.2.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: frontend-minor-patch - dependency-name: "@vitest/coverage-v8" dependency-version: 4.1.2 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: frontend-minor-patch - dependency-name: postcss dependency-version: 8.5.8 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: frontend-minor-patch - dependency-name: tailwindcss dependency-version: 4.2.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: frontend-minor-patch - dependency-name: vitest dependency-version: 4.1.2 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: frontend-minor-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps)(deps): bump the aws-cdk group (#90) Bumps the aws-cdk group in /infrastructure with 2 updates: [aws-cdk-lib](https://github.com/aws/aws-cdk/tree/HEAD/packages/aws-cdk-lib) and [aws-cdk](https://github.com/aws/aws-cdk-cli/tree/HEAD/packages/aws-cdk). Updates `aws-cdk-lib` from 2.244.0 to 2.245.0 - [Release notes](https://github.com/aws/aws-cdk/releases) - [Changelog](https://github.com/aws/aws-cdk/blob/main/CHANGELOG.v2.alpha.md) - [Commits](https://github.com/aws/aws-cdk/commits/v2.245.0/packages/aws-cdk-lib) Updates `aws-cdk` from 2.1113.0 to 2.1115.0 - [Release notes](https://github.com/aws/aws-cdk-cli/releases) - [Commits](https://github.com/aws/aws-cdk-cli/commits/aws-cdk@v2.1115.0/packages/aws-cdk) --- updated-dependencies: - dependency-name: aws-cdk-lib dependency-version: 2.245.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-cdk - dependency-name: aws-cdk dependency-version: 2.1115.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: aws-cdk ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps)(deps): bump actions/setup-node from 5.0.0 to 6.3.0 (#100) Bumps [actions/setup-node](https://github.com/actions/setup-node) from 5.0.0 to 6.3.0. - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/a0853c24544627f65ddf259abe73b1d18a591444...53b83947a5a98c8d113130e565377fae1a50d02f) --- updated-dependencies: - dependency-name: actions/setup-node dependency-version: 6.3.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps)(deps): bump github/codeql-action (#95) Bumps the actions-minor-patch group with 1 update: [github/codeql-action](https://github.com/github/codeql-action). Updates `github/codeql-action` from 4.34.1 to 4.35.1 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/38697555549f1db7851b81482ff19f1fa5c4fedc...c10b8064de6f491fea524254123dbe5e09572f13) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: 4.35.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: actions-minor-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps)(deps-dev): bump @types/node in /infrastructure (#94) Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 24.10.1 to 25.5.0. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) --- updated-dependencies: - dependency-name: "@types/node" dependency-version: 25.5.0 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps)(deps-dev): bump jsdom in /frontend/ai.client (#102) Bumps [jsdom](https://github.com/jsdom/jsdom) from 27.4.0 to 29.0.1. - [Release notes](https://github.com/jsdom/jsdom/releases) - [Commits](https://github.com/jsdom/jsdom/compare/v27.4.0...v29.0.1) --- updated-dependencies: - dependency-name: jsdom dependency-version: 29.0.1 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps)(deps): bump ng2-charts in /frontend/ai.client (#105) Bumps [ng2-charts](https://github.com/valor-software/ng2-charts) from 8.0.0 to 10.0.0. - [Release notes](https://github.com/valor-software/ng2-charts/releases) - [Commits](https://github.com/valor-software/ng2-charts/compare/v8.0.0...v10.0.0) --- updated-dependencies: - dependency-name: ng2-charts dependency-version: 10.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps)(deps): bump the angular group (#97) Bumps the angular group in /frontend/ai.client with 10 updates: | Package | From | To | | --- | --- | --- | | [@angular/cdk](https://github.com/angular/components) | `21.2.3` | `21.2.4` | | [@angular/common](https://github.com/angular/angular/tree/HEAD/packages/common) | `21.2.5` | `21.2.6` | | [@angular/compiler](https://github.com/angular/angular/tree/HEAD/packages/compiler) | `21.2.5` | `21.2.6` | | [@angular/core](https://github.com/angular/angular/tree/HEAD/packages/core) | `21.2.5` | `21.2.6` | | [@angular/forms](https://github.com/angular/angular/tree/HEAD/packages/forms) | `21.2.5` | `21.2.6` | | [@angular/platform-browser](https://github.com/angular/angular/tree/HEAD/packages/platform-browser) | `21.2.5` | `21.2.6` | | [@angular/router](https://github.com/angular/angular/tree/HEAD/packages/router) | `21.2.5` | `21.2.6` | | [@angular/build](https://github.com/angular/angular-cli) | `21.2.3` | `21.2.5` | | [@angular/cli](https://github.com/angular/angular-cli) | `21.2.3` | `21.2.5` | | [@angular/compiler-cli](https://github.com/angular/angular/tree/HEAD/packages/compiler-cli) | `21.2.5` | `21.2.6` | Updates `@angular/cdk` from 21.2.3 to 21.2.4 - [Release notes](https://github.com/angular/components/releases) - [Changelog](https://github.com/angular/components/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/components/compare/v21.2.3...v21.2.4) Updates `@angular/common` from 21.2.5 to 21.2.6 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/v21.2.6/packages/common) Updates `@angular/compiler` from 21.2.5 to 21.2.6 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/v21.2.6/packages/compiler) Updates `@angular/core` from 21.2.5 to 21.2.6 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/v21.2.6/packages/core) Updates `@angular/forms` from 21.2.5 to 21.2.6 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/v21.2.6/packages/forms) Updates `@angular/platform-browser` from 21.2.5 to 21.2.6 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/v21.2.6/packages/platform-browser) Updates `@angular/router` from 21.2.5 to 21.2.6 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/v21.2.6/packages/router) Updates `@angular/build` from 21.2.3 to 21.2.5 - [Release notes](https://github.com/angular/angular-cli/releases) - [Changelog](https://github.com/angular/angular-cli/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular-cli/compare/v21.2.3...v21.2.5) Updates `@angular/cli` from 21.2.3 to 21.2.5 - [Release notes](https://github.com/angular/angular-cli/releases) - [Changelog](https://github.com/angular/angular-cli/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular-cli/compare/v21.2.3...v21.2.5) Updates `@angular/compiler-cli` from 21.2.5 to 21.2.6 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/v21.2.6/packages/compiler-cli) --- updated-dependencies: - dependency-name: "@angular/cdk" dependency-version: 21.2.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/common" dependency-version: 21.2.6 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/compiler" dependency-version: 21.2.6 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/core" dependency-version: 21.2.6 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/forms" dependency-version: 21.2.6 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/platform-browser" dependency-version: 21.2.6 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/router" dependency-version: 21.2.6 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/build" dependency-version: 21.2.5 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/cli" dependency-version: 21.2.5 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/compiler-cli" dependency-version: 21.2.6 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: angular ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps)(deps-dev): bump jest and @types/jest in /infrastructure (#92) Bumps [jest](https://github.com/jestjs/jest/tree/HEAD/packages/jest) and [@types/jest](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/jest). These dependencies needed to be updated together. Updates `jest` from 29.7.0 to 30.3.0 - [Release notes](https://github.com/jestjs/jest/releases) - [Changelog](https://github.com/jestjs/jest/blob/main/CHANGELOG.md) - [Commits](https://github.com/jestjs/jest/commits/v30.3.0/packages/jest) Updates `@types/jest` from 29.5.14 to 30.0.0 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/jest) --- updated-dependencies: - dependency-name: jest dependency-version: 30.3.0 dependency-type: direct:development update-type: version-update:semver-major - dependency-name: "@types/jest" dependency-version: 30.0.0 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * add conversation deleting handling for shared conversations + bug fix * chore(deps)(deps): bump constructs (#91) Bumps the infra-minor-patch group in /infrastructure with 1 update: [constructs](https://github.com/aws/constructs). Updates `constructs` from 10.5.1 to 10.6.0 - [Release notes](https://github.com/aws/constructs/releases) - [Commits](https://github.com/aws/constructs/compare/v10.5.1...v10.6.0) --- updated-dependencies: - dependency-name: constructs dependency-version: 10.6.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: infra-minor-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * feat(messages): displayText support for RAG-augmented and file attachment messages (#107) * feat(messages): add displayText support for RAG-augmented messages - Add original_message parameter to stream_async and StreamCoordinator to preserve user input before RAG augmentation - Store displayText in message metadata when original message differs from augmented version - Add display_text field to MessageMetadata model with displayText alias for JSON serialization - Update chat_stream route to pass original message when RAG augmentation is applied - Enhance metadata retrieval to query both cost records (C#) and display text records (D#) from DynamoDB - Add store_user_display_text function to persist original message text for clean UI display - Update .gitignore to exclude local dev scripts (start.sh) - Improves user experience by showing original unaugmented messages in conversation UI while maintaining RAG-enhanced context for agent processing * feat(messages): add displayText support for file attachments and local runtime override - Add LOCAL_RUNTIME_ENDPOINT_URL environment variable support for development runtime override in auth routes - Extend displayText storage to handle file attachment content block modifications, not just RAG augmentation - Add message_will_be_modified logic to determine when original message should be stored as displayText - Implement showDebugOutput local settings signal for toggling debug information display - Update user message component to display original text when displayText is available - Add debug output toggle to chat preferences settings page - Update session metadata documentation to clarify displayText usage for all prompt modifications - Ensure original user message is preserved for UI display while augmented prompt remains in AgentCore Memory * test(metadata): add displayText (D# record) tests for store and retrieval * chore(deps): bump fast-check from 3.23.2 to 4.6.0 - Update fast-check to version 4.6.0 with caret constraint for minor/patch updates - Update pure-rand dependency to 4.6.0's requirement of ^8.0.0 (from ^6.1.0) - Increase minimum Node.js requirement from 8.0.0 to 12.17.0 - Migrate auth-pbt.spec.ts to use fast-check 4.x API (stringOf → string with unit parameter) * chore(deps)(deps): bump actions/upload-artifact from 6.0.0 to 7.0.0 (#99) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6.0.0 to 7.0.0. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/b7c566a772e6b6bfb58ed0dc250532a479d7789f...bbbca2ddaa5d8feaa63e36b76fdaad77386f024f) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: 7.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps)(deps): bump actions/download-artifact from 7.0.0 to 8.0.1 (#98) Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 7.0.0 to 8.0.1. - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/37930b1c2abaa49bbe596cd826c3c89aef350131...3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c) --- updated-dependencies: - dependency-name: actions/download-artifact dependency-version: 8.0.1 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps)(deps): bump the python-minor-patch group in /backend with 10 updates (#96) * chore(deps)(deps): bump the python-minor-patch group Bumps the python-minor-patch group in /backend with 10 updates: | Package | From | To | | --- | --- | --- | | [uvicorn](https://github.com/Kludex/uvicorn) | `0.35.0` | `0.42.0` | | [boto3](https://github.com/boto/boto3) | `1.42.73` | `1.42.78` | | [strands-agents](https://github.com/strands-agents/sdk-python) | `1.32.0` | `1.33.0` | | [strands-agents-tools](https://github.com/strands-agents/tools) | `0.2.23` | `0.3.0` | | [aws-opentelemetry-distro](https://github.com/aws-observability/aws-otel-python-instrumentation) | `0.14.2` | `0.16.0` | | [bedrock-agentcore](https://github.com/aws/bedrock-agentcore-sdk-python) | `1.4.7` | `1.4.8` | | [openai](https://github.com/openai/openai-python) | `2.29.0` | `2.30.0` | | [google-genai](https://github.com/googleapis/python-genai) | `1.68.0` | `1.69.0` | | [hypothesis](https://github.com/HypothesisWorks/hypothesis) | `6.151.9` | `6.151.10` | | [ruff](https://github.com/astral-sh/ruff) | `0.15.7` | `0.15.8` | Updates `uvicorn` from 0.35.0 to 0.42.0 - [Release notes](https://github.com/Kludex/uvicorn/releases) - [Changelog](https://github.com/Kludex/uvicorn/blob/main/docs/release-notes.md) - [Commits](https://github.com/Kludex/uvicorn/compare/0.35.0...0.42.0) Updates `boto3` from 1.42.73 to 1.42.78 - [Release notes](https://github.com/boto/boto3/releases) - [Commits](https://github.com/boto/boto3/compare/1.42.73...1.42.78) Updates `strands-agents` from 1.32.0 to 1.33.0 - [Release notes](https://github.com/strands-agents/sdk-python/releases) - [Commits](https://github.com/strands-agents/sdk-python/compare/v1.32.0...v1.33.0) Updates `strands-agents-tools` from 0.2.23 to 0.3.0 - [Release notes](https://github.com/strands-agents/tools/releases) - [Commits](https://github.com/strands-agents/tools/compare/v0.2.23...v0.3.0) Updates `aws-opentelemetry-distro` from 0.14.2 to 0.16.0 - [Release notes](https://github.com/aws-observability/aws-otel-python-instrumentation/releases) - [Changelog](https://github.com/aws-observability/aws-otel-python-instrumentation/blob/main/CHANGELOG.md) - [Commits](https://github.com/aws-observability/aws-otel-python-instrumentation/compare/v0.14.2...v0.16.0) Updates `bedrock-agentcore` from 1.4.7 to 1.4.8 - [Release notes](https://github.com/aws/bedrock-agentcore-sdk-python/releases) - [Changelog](https://github.com/aws/bedrock-agentcore-sdk-python/blob/main/CHANGELOG.md) - [Commits](https://github.com/aws/bedrock-agentcore-sdk-python/compare/v1.4.7...v1.4.8) Updates `openai` from 2.29.0 to 2.30.0 - [Release notes](https://github.com/openai/openai-python/releases) - [Changelog](https://github.com/openai/openai-python/blob/main/CHANGELOG.md) - [Commits](https://github.com/openai/openai-python/compare/v2.29.0...v2.30.0) Updates `google-genai` from 1.68.0 to 1.69.0 - [Release notes](https://github.com/googleapis/python-genai/releases) - [Changelog](https://github.com/googleapis/python-genai/blob/main/CHANGELOG.md) - [Commits](https://github.com/googleapis/python-genai/compare/v1.68.0...v1.69.0) Updates `hypothesis` from 6.151.9 to 6.151.10 - [Release notes](https://github.com/HypothesisWorks/hypothesis/releases) - [Commits](https://github.com/HypothesisWorks/hypothesis/compare/hypothesis-python-6.151.9...hypothesis-python-6.151.10) Updates `ruff` from 0.15.7 to 0.15.8 - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.15.7...0.15.8) --- updated-dependencies: - dependency-name: uvicorn dependency-version: 0.42.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: python-minor-patch - dependency-name: boto3 dependency-version: 1.42.78 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: python-minor-patch - dependency-name: strands-agents dependency-version: 1.33.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: python-minor-patch - dependency-name: strands-agents-tools dependency-version: 0.3.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: python-minor-patch - dependency-name: aws-opentelemetry-distro dependency-version: 0.16.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: python-minor-patch - dependency-name: bedrock-agentcore dependency-version: 1.4.8 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: python-minor-patch - dependency-name: openai dependency-version: 2.30.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: python-minor-patch - dependency-name: google-genai dependency-version: 1.69.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: python-minor-patch - dependency-name: hypothesis dependency-version: 6.151.10 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: python-minor-patch - dependency-name: ruff dependency-version: 0.15.8 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: python-minor-patch ... Signed-off-by: dependabot[bot] * chore(deps): downgrade cachetools to 6.2.4 - Downgrade cachetools from 7.0.5 to 6.2.4 in backend dependencies - Resolves compatibility issues with OAuth provider management --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: colinmxs * chore(deps): pin fast-check to exact version 4.6.0 - Remove caret (^) version constraint from fast-check dependency - Update package.json to use exact version 4.6.0 - Update package-lock.json to reflect pinned version - Ensure consistent dependency resolution across environments * feat: add fine-tuning cost dashboard and user cost breakdown (#108) * feat: add fine-tuning cost dashboard and user cost breakdown - Introduced new models for cost dashboard and user cost breakdown in the admin API. - Implemented endpoint to retrieve aggregated cost data for fine-tuning jobs. - Enhanced fine-tuning access control to support default monthly quota hours for users without explicit grants. - Added new routes and frontend components for displaying fine-tuning costs and usage statistics. - Updated infrastructure configuration to include default quota hours for fine-tuning. - Added tests to ensure proper functionality of new features and configurations. * fix(logging): improve log message formatting for cost dashboard request * fix(logging): sanitize period string in cost dashboard log message * check in share conversations specs * chore(docs): update versioning documentation and release notes for v1.0.0-beta.20 - Update versioning skill and rule documentation to include README.md version badge and "Current release" text in sync script scope - Update Kiro steering guide to document README.md and lockfile updates in version sync process - Bump version badge in README.md from v1.0.0-beta.19 to v1.0.0-beta.20 - Update "Current release" text in README.md to v1.0.0-beta.20 - Add comprehensive release notes for v1.0.0-beta.20 with highlights on document deletion, displayText system, fine-tuning cost dashboard, and dependency updates - Ensure all AI assistant rule files reflect current versioning workflow * ci(frontend): remove common scripts from workflow triggers and restrict CDK jobs to non-PR events - Remove 'scripts/common/**' from push and pull_request trigger paths - Add condition to synth-cdk job to skip execution on pull_request events - Update test-cdk job condition to exclude pull_request events while preserving skip_tests logic - Prevents unnecessary CDK synthesis and testing during pull requests to reduce workflow overhead * Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> Signed-off-by: Colin Smith <7762103+colinmxs@users.noreply.github.com> * Release 1.0.0-beta.20: Document soft-delete, displayText, fine-tuning costs, CodeQL remediation, dependency refresh (#118) Reliable document deletion, displayText for RAG-augmented messages, fine-tuning cost dashboard, assistant archive removal, and a full dependency refresh across Python, npm, and GitHub Actions. Features: - Soft-delete document lifecycle with background cleanup, retry logic, DynamoDB TTL backstop, and search filtering for mid-deletion docs - Upload failure reporting endpoint for client-side error tracking - DisplayText system preserving original user messages when RAG augmentation or file attachments modify the prompt sent to the agent - Debug output toggle in chat preferences for prompt inspection - Fine-tuning cost dashboard with per-user breakdowns and default monthly quota hours - Shared conversation cascade deletion on session delete Removals: - Assistant archive functionality (ARCHIVED status, archive endpoint, include_archived parameter) replaced with single delete operation Security & Code Quality: - All CodeQL findings resolved (180 log injection fixes, 5 silent exception fixes, cyclic import elimination, 13 unused variables) - Four Dependabot security patches (requests, picomatch, diff) CI/CD: - CDK synth skipped on PRs for app-api and frontend workflows - scripts/common/** removed from frontend workflow path triggers - GitHub Actions bumped (upload-artifact v7, download-artifact v8, setup-node v6, codeql-action latest) Testing: - Analog.js testing migration for frontend (vitest config removed) - fast-check v4.6.0 added for property-based frontend tests - 4,200+ lines of new backend tests for document deletion flows Tooling: - sync-version.sh now auto-updates README badge and current release text - Versioning steering docs updated across Kiro, Cursor, and Claude - Release notes steering doc added (fileMatch on RELEASE_NOTES.md) Dependencies: - Python: uvicorn 0.42.0, strands-agents 1.33.0, strands-agents-tools 0.3.0, aws-opentelemetry-distro 0.16.0, bedrock-agentcore 1.4.8, openai 2.30.0, cachetools downgraded to 6.2.4 for compatibility - Frontend: Angular 21.2.6, @angular/cdk 21.2.4 - Infrastructure: aws-cdk group bumped, constructs bumped * Purge outdated AI specs and documentation (#121) * spring cleaning. AI spec file and outdated documentation purge * spring cleanup --> purging old ai specs and outdated docs --------- Co-authored-by: colinmxs * Feat/cognito first boot auth (#125) * feat: replace multi-step auth bootstrap with Cognito first-boot experience - Add Cognito User Pool, App Client, and Domain to CDK infrastructure - Implement first-boot backend with race-condition-safe DynamoDB writes - Add CognitoJWTValidator replacing GenericOIDCJWTValidator - Add federated identity provider management via Cognito IdP APIs - Migrate frontend to Cognito OAuth 2.0 + PKCE flow - Add first-boot setup page with admin account creation - Update AgentCore Runtime to single Cognito JWT authorizer - Remove runtime-provisioner and runtime-updater Lambdas - Remove hardcoded Entra ID configuration from CDK and scripts - Remove auth provider seeding from bootstrap workflow - Wire SSM parameters across stacks for Cognito config - Update GitHub Actions workflows for Cognito context values * FOR TESTING ONLY< REVERT BEFORE MERGING * Feat/cognito first boot auth (#123) * test(auth-sweep): add system status endpoints to public route patterns - Add /system/status to PUBLIC_ROUTE_PATTERNS for unauthenticated access - Add /system/first-boot to PUBLIC_ROUTE_PATTERNS for unauthenticated access - These endpoints should be accessible without authentication for system initialization and health checks * chore(deps): add cognitoidp extra to moto dev dependency - Add cognitoidp extra to moto[dynamodb] in pyproject.toml dev dependencies - Update uv.lock to include cognitoidp extra across all moto references - Add joserfc package as transitive dependency for cognitoidp support - Enable Cognito IDP mocking capabilities for development and testing * test(auth-guard,config-service): add missing service mocks and config properties - Add SystemService mock to auth.guard.spec.ts test setup - Import SystemService dependency in auth guard test file - Add checkStatus mock method to systemService test double - Register SystemService provider in TestBed configuration - Add inferenceApiUrl property to validConfig test fixture in config.service.spec.ts - Ensure test doubles accurately reflect service dependencies for proper test isolation * feat(system-admin): add JWT role mapping for system_admin Cognito group - Add JWT_MAPPING#system_admin item to DynamoDB during bootstrap seeding - Update system_admin role jwtRoleMappings to include "system_admin" group - Implement add_user_to_group method in CognitoService to manage group membership - Add user to system_admin Cognito group during first_boot with rollback on failure - Update test assertions to verify JWT mapping creation and role configuration - Enables Cognito to include system_admin group in JWT cognito:groups claim for RBAC resolution * feat(infrastructure): add Cognito user and group management permissions - Add cognito-idp:AdminDeleteUser permission for user deletion operations - Add cognito-idp:AdminAddUserToGroup permission for group membership management - Add cognito-idp:CreateGroup permission for group creation operations - Enables system admin functionality for managing Cognito user pools and groups * refactor(auth): replace user email with name in logging and events - Replace user.email with user.name in quota event recorder metadata - Update admin cost dashboard logging to use user.name instead of email - Update admin users routes logging to use user.name instead of email - Update file upload routes logging to use user.name instead of email - Update model routes logging to use user.name instead of email - Update tools routes logging to use user.name instead of email - Update OAuth routes logging to use user.name instead of email - Update user models and routes logging to use user.name instead of email - Update auth service and user service in frontend to use name field - Standardize user identification across backend and frontend to use name for privacy and consistency * feat(frontend): update logos and URL-encode inference API ARN - Update logo-dark.png and logo-light.png assets - Add URL encoding for ARN portion in inferenceApiUrl computed signal - Prevent URL parsing errors caused by colons and slashes in AgentCore runtime ARNs - Improve config service documentation with encoding behavior explanation * fix(frontend): add /invocations path to inference API endpoints - Update preview-chat.service.ts to include /invocations path in runtime endpoint URL - Update chat-http.service.ts to include /invocations path in runtime endpoint URL - Fixes inference API calls by using correct endpoint path with qualifier parameter * feat(inference-api): add Authorization header to ALB request configuration - Add requestHeaderConfiguration to ALB listener rule - Include Authorization header in requestHeaderAllowlist - Enable proper header propagation for authenticated requests to inference API * refactor(auth): consolidate RBAC to AppRole-based authorization - Replace multiple role-checking functions with single require_app_roles dependency - Remove require_roles, require_all_roles, has_any_role, has_all_roles, and role-specific decorators (require_faculty, require_staff, require_developer, require_aws_ai_access) - Update rbac.py to resolve permissions through AppRoleService instead of hardcoded JWT groups - Simplify auth module exports to only expose require_app_roles and require_admin - Update admin routes to remove unused role imports - Add comprehensive docstring explaining AppRole system as single source of truth for permissions - Update tests to reflect new authorization flow via AppRoleService * feat(inference-api): add SSM parameters and environment variables to AgentCore runtime - Import DynamoDB table names from SSM parameters for users, RBAC, auth, OAuth, quota, cost tracking, and file uploads - Import S3 bucket and vector index names for RAG functionality - Import gateway URL and frontend CORS origins from SSM parameters - Add comprehensive environment variables to AgentCore runtime configuration including DynamoDB table mappings, authentication settings, OAuth configuration, AgentCore resource IDs, and directory paths - Enable authentication and quota enforcement in runtime environment - Configure frontend URL and CORS origins for cross-origin requests * feat(inference-api): remove gateway URL parameter and simplify CORS origins - Remove SSM parameter import for gateway URL from InferenceApiStack - Remove GATEWAY_URL environment variable from AgentCore runtime configuration - Replace SSM-imported CORS origins with config-based construction to avoid circular dependency between InferenceApiStack and FrontendStack - Construct CORS origins dynamically from config.domainName (https://{domain}) with localhost fallback for development - Eliminates circular dependency: InferenceApiStack ↔ FrontendStack by removing reliance on FrontendStack SSM parameters * chore(frontend): update favicon and logo assets - Remove redundant favicon PNG variants (android-chrome, apple-touch-icon, favicon-16x16, favicon-32x32) - Update favicon.ico with new design - Update logo-dark.png with refreshed branding - Consolidate favicon assets to reduce redundancy and improve maintainability * feat(inference-api): add AWS Marketplace permissions for Bedrock model access - Add MarketplaceModelAccess policy statement to runtime execution role - Grant aws-marketplace:ViewSubscriptions and aws-marketplace:Subscribe actions - Enable foundation model access for marketplace-gated models like Anthropic Claude - Required for subscription validation before Bedrock model invocation * Release 1.0.0-beta.19: Conversation sharing, session compaction, fine-tuning enhancements, CI optimization ## Features - Conversation sharing with public/email-restricted access via shareable URLs - Session compaction enabled by default (100K token threshold, 3 protected turns) - Fine-tuning: drag-and-drop dataset upload, custom HuggingFace model support ## Security - Resolve all CodeQL clear-text logging alerts (secrets, tokens, ARNs redacted) - OAuth redirect URL validation to prevent open redirects - Explicit read-only permissions on all 13 GitHub Actions workflows ## Performance - Frontend production build optimized: 8.85 MB → 4.96 MB (871 KB gzipped) - PR workflows trimmed: skip Docker builds, CDK synth, and redundant jobs ## Infrastructure - New shared-conversations DynamoDB table with SessionShare and OwnerShare GSIs - Bedrock prompt caching temporarily disabled due to provider limitations ## Bug Fixes - Google Fonts moved to index.html to fix CI build failure - Private sharing support (owner-only shares) * Release 1.0.0-beta.20: Document soft-delete, displayText, fine-tuning costs, CodeQL remediation, dependency refresh Reliable document deletion, displayText for RAG-augmented messages, fine-tuning cost dashboard, assistant archive removal, and a full dependency refresh across Python, npm, and GitHub Actions. Features: - Soft-delete document lifecycle with background cleanup, retry logic, DynamoDB TTL backstop, and search filtering for mid-deletion docs - Upload failure reporting endpoint for client-side error tracking - DisplayText system preserving original user messages when RAG augmentation or file attachments modify the prompt sent to the agent - Debug output toggle in chat preferences for prompt inspection - Fine-tuning cost dashboard with per-user breakdowns and default monthly quota hours - Shared conversation cascade deletion on session delete Removals: - Assistant archive functionality (ARCHIVED status, archive endpoint, include_archived parameter) replaced with single delete operation Security & Code Quality: - All CodeQL findings resolved (180 log injection fixes, 5 silent exception fixes, cyclic import elimination, 13 unused variables) - Four Dependabot security patches (requests, picomatch, diff) CI/CD: - CDK synth skipped on PRs for app-api and frontend workflows - scripts/common/** removed from frontend workflow path triggers - GitHub Actions bumped (upload-artifact v7, download-artifact v8, setup-node v6, codeql-action latest) Testing: - Analog.js testing migration for frontend (vitest config removed) - fast-check v4.6.0 added for property-based frontend tests - 4,200+ lines of new backend tests for document deletion flows Tooling: - sync-version.sh now auto-updates README badge and current release text - Versioning steering docs updated across Kiro, Cursor, and Claude - Release notes steering doc added (fileMatch on RELEASE_NOTES.md) Dependencies: - Python: uvicorn 0.42.0, strands-agents 1.33.0, strands-agents-tools 0.3.0, aws-opentelemetry-distro 0.16.0, bedrock-agentcore 1.4.8, openai 2.30.0, cachetools downgraded to 6.2.4 for compatibility - Frontend: Angular 21.2.6, @angular/cdk 21.2.4 - Infrastructure: aws-cdk group bumped, constructs bumped * refactor(inference-api): remove underscore prefix from containerImageUri variable - Remove underscore prefix from containerImageUri variable name - Improve code clarity by following standard naming conventions - Variable is used throughout the stack and should follow public naming patterns * chore(deps): add cognitoidp moto extra and update infrastructure tests - Add cognitoidp extra to moto dependency in uv.lock for Cognito IDP mocking support - Update moto dependency extras to include both cognitoidp and dynamodb - Refactor IAM policy assertions in inference-api-stack tests to search both inline and managed policies - Simplify policy verification logic to use findResources and filter by policy attributes - Remove S3 bucket and S3 Vector Store test sections from app-api-stack tests - Update test assertions to be more flexible with policy resource types * chore(frontend): update favicon and logo assets - Add Android Chrome favicon variants (192x192 and 512x512) - Add Apple Touch Icon for iOS devices - Add favicon sizes for 16x16 and 32x32 resolutions - Update favicon.ico with new design - Update logo-dark.png with refreshed branding - Update logo-light.png with refreshed branding - Improve cross-platform icon support and visual consistency * fix(auth-providers): remove OIDC discovery endpoint and add JSON parsing error handling - Remove POST /discover endpoint for OIDC endpoint discovery from admin routes - Add try-except blocks to handle JSON parsing errors in AuthProviderRepository - Gracefully default to empty dict when SecretString is invalid or malformed - Improve resilience when retrieving auth provider secrets from AWS Secrets Manager --------- Co-authored-by: Colin * Feat/cognito first boot auth (#124) * test(auth-sweep): add system status endpoints to public route patterns - Add /system/status to PUBLIC_ROUTE_PATTERNS for unauthenticated access - Add /system/first-boot to PUBLIC_ROUTE_PATTERNS for unauthenticated access - These endpoints should be accessible without authentication for system initialization and health checks * chore(deps): add cognitoidp extra to moto dev dependency - Add cognitoidp extra to moto[dynamodb] in pyproject.toml dev dependencies - Update uv.lock to include cognitoidp extra across all moto references - Add joserfc package as transitive dependency for cognitoidp support - Enable Cognito IDP mocking capabilities for development and testing * test(auth-guard,config-service): add missing service mocks and config properties - Add SystemService mock to auth.guard.spec.ts test setup - Import SystemService dependency in auth guard test file - Add checkStatus mock method to systemService test double - Register SystemService provider in TestBed configuration - Add inferenceApiUrl property to validConfig test fixture in config.service.spec.ts - Ensure test doubles accurately reflect service dependencies for proper test isolation * feat(system-admin): add JWT role mapping for system_admin Cognito group - Add JWT_MAPPING#system_admin item to DynamoDB during bootstrap seeding - Update system_admin role jwtRoleMappings to include "system_admin" group - Implement add_user_to_group method in CognitoService to manage group membership - Add user to system_admin Cognito group during first_boot with rollback on failure - Update test assertions to verify JWT mapping creation and role configuration - Enables Cognito to include system_admin group in JWT cognito:groups claim for RBAC resolution * feat(infrastructure): add Cognito user and group management permissions - Add cognito-idp:AdminDelete… * feat(assistants): re-enable tool use for assistants (revert KB-only) (#517) Assistants previously ran tool-free (KB-grounded only) — the consumer chat forced enabled_tools=[] and the system prompt told the model it had no external tools (#382). This reverts that so assistants can leverage the user's enabled MCP/tools again. Backend (inference_api/chat/routes.py): - Drop the enabled_tools=[] override in the rag_assistant_id branch so the client's tool selection flows through to the agent. - Remove the "Knowledge Base Grounding / no external tools" directive from both the with-instructions and no-instructions prompt paths, restoring the pre-#382 system prompt composition. KB context is still pre-stuffed into the user message; assistant instructions still apply. Frontend (chat-request.service.ts): - Stop force-sending enabled_tools=[] on assistant turns; the user's tool-picker selection now rides along (skills mode stays gated on non-assistant turns). Preview parity (preview-chat.service.ts): - Forward the owner's enabled tools instead of [] so the editor preview exercises tools exactly like a consumer chat. - Build the streaming assistant message as ordered content blocks (text interleaved with tool use) and wire onToolUse/onToolResult so the shared message-list renders tool cards in the preview. Specs updated to assert tools are forwarded on assistant + preview turns. Note: the backend test_chat_endpoint is dead code (no caller) and still passes enabled_tools=None; left for a separate cleanup/removal. Co-authored-by: Claude Opus 4.8 * fix(nightly): repoint test/install jobs to post-refactor script paths (#518) The single-stack refactor (#396) removed scripts/stack-app-api/ and scripts/stack-frontend/, but nightly.yml's coverage jobs still called them, so test-backend/test-frontend/install-frontend failed with 'No such file or directory'. repo-shape.test.ts forbids recreating the stack-* dirs, so port the three scripts to the sanctioned new layout: scripts/stack-app-api/test.sh -> scripts/backend/test.sh scripts/stack-frontend/install.sh -> scripts/frontend/install.sh scripts/stack-frontend/test.sh -> scripts/frontend/test.sh Behavior preserved 1:1 (uv install+sync + pytest --cov for backend; npm ci for frontend+infra; ng test --no-watch --coverage). Only the self-referential help-text paths were updated. * fix(deps): remediate 6 open Dependabot alerts (#520) docs-site (npm): - astro 6.3.1 -> 6.4.8: fixes reflected XSS via slot name (#241, GHSA-8hv8-536x-4wqp), host-header SSRF in prerendered error page (#242, GHSA-2pvr-wf23-7pc7), and XSS via unescaped spread attribute names (#243, GHSA-jrpj-wcv7-9fh9). - esbuild -> 0.28.1 via overrides: fixes dev-server arbitrary file read (#240, GHSA-g7r4-m6w7-qqqr). frontend/ai.client (npm): - esbuild -> 0.28.1 via overrides (transitive through @angular/build 21.2.16): fixes dev-server arbitrary file read (#149, GHSA-g7r4-m6w7-qqqr). backend (pip/uv): - pydantic-settings 2.13.1 -> 2.14.2 (transitive): fixes NestedSecretsSettingsSource symlink traversal / local file read (#234, GHSA-4xgf-cpjx-pc3j). Verified: docs-site astro build OK; frontend prod build + 1239 unit tests OK; backend 4003 tests OK; npm audit clean for both npm projects. * fix(security): remediate CodeQL alerts (HIGH/MEDIUM + NOTE cleanups) (#521) Security fixes (with regression tests): - HIGH py/incomplete-url-substring-sanitization: external_mcp_client now parses the URL host (urlparse) and matches an anchored suffix instead of substring-checking the whole URL, so a marker in a path/query/userinfo can't trick SigV4 signing into attaching IAM creds to a non-AWS host. Added TestAwsUrlHostSanitization (adversarial URLs). - HIGH js/regex/missing-regexp-anchor: admin-tool.model parses the host (new URL) and anchors the AWS-endpoint regexes ($). Added admin-tool.model.spec.ts (13 cases incl. spoofed hosts). - MEDIUM py/log-injection (24 sites/16 files): new apis.shared.security.scrub_log() neutralizes CR/LF/control chars; wrapped user-controlled values at every flagged log call. Added test_log_sanitize.py. - MEDIUM actions/untrusted-checkout: added persist-credentials:false to all inputs.ref checkouts in nightly-deploy-pipeline.yml. - WARNING py/regex/duplicate-in-character-class: removed a bare '[' from a re.VERBOSE comment that the regex parser misread as a character class. NOTE cleanups: removed unused imports (py + 17 TS constructs), a dead local, redundant '...' after abstractmethod docstrings, unused test imports; added explanatory comments to 6 intentional empty-except blocks; reworded commented-out code. Left intentionally: 4 py/unused-global-variable alerts are FALSE POSITIVES (_cached_signing_key/_cached_bucket/_client_secret_cache are working lazy caches — verified read+written); recommend dismissing in the UI. Verified: backend 4045 passed (3 pre-existing test_backup_coverage failures are unrelated — a skill-resources backup-coverage gap on develop); frontend build OK + 1252 unit tests; infra tsc + 406 jest tests; npm/ruff clean for touched files. * Release/1.0.2 (#522) (#523) #517 (headline) — assistants can use tools again, reverting the 1.0.0 KB-only restriction (#382). Filed under ✨ Improved, with a deployment-note callout since it's a user-visible behavior change. - #521 — CodeQL sweep (2 HIGH URL/host findings, 24-site log-injection pass, hardened CI checkout) → 🔒 Security. - #520 — 6 Dependabot CVEs (Astro, esbuild, pydantic-settings) → 📦 Dependencies table. - #518 — nightly pipeline script-path fix → 🐛 Fixed. * chore: prune dead workflow tracks, dedupe test gates, fix/fork-gate d… (#524) * chore: prune dead workflow tracks, dedupe test gates, fix/fork-gate docs deploy - nightly: remove never-useful AI coverage analysis + merge-validation tracks (relevant only to the old multi-stack era); delete orphaned ai-coverage-analysis.py - remove dead source-project-prefix input from nightly-deploy-pipeline and the now-orphaned promote-ecr-image.sh it fed - extract duplicated test gates into a reusable tests.yml; wire into ci, platform, backend, frontend-deploy, and nightly-deploy-pipeline - docs-deploy: publish from main (was develop); fork-gate so forks don't auto-publish - release: fork-gate so forks syncing main don't auto-create releases Verified: repo-shape jest 49/49, supply_chain pytest 31/31. * ci: re-enable push-triggered deploys, path-scoped Restore auto-deploy on push to develop/main for the platform, backend, and frontend workflows (was workflow_dispatch-only since v1.0.0). Each push trigger is scoped to that workflow's own surface so unrelated changes don't redeploy: - backend: backend/**, scripts/build/**, the workflow file - platform: infrastructure/lib (stack/constructs/config), bin, bootstrap-assets, scripts/platform/**, the workflow file - frontend: frontend/**, scripts/frontend/**, the workflow file develop -> development env, main -> production env (existing per-job mapping). Forks without AWS secrets fail safely at the credential step; forks that configured their own secrets get the intended fork-and-deploy behavior, and manual workflow_dispatch remains available everywhere. docs-deploy was already scoped (docs-site/** + its workflow file). * docs: trim verbose/misleading workflow comments Cut the trigger and fork-gate comments down to one-liners and drop the inaccurate 'secrets make it fork-safe' framing (forks must explicitly enable Actions regardless). * ci: serialize platform + backend deploys via shared concurrency group (#525) Put platform.yml and backend.yml in one repo-global concurrency group (deploy-) so a CloudFormation deploy and the API-driven backend code deploys can't run at the same time and stomp on the same ECS service / AgentCore Runtime / Lambda. They queue instead; order doesn't matter post-initial-deploy since either order converges. Frontend stays independent. cancel-in-progress stays false. * Chore/cleanup dependabot codeql 2026 06 (#526) * fix(deps,codeql): bump joserfc to 1.7.2; drop unused imports - joserfc 1.6.3->1.7.2 (backend/uv.lock), 1.6.5->1.7.2 (scripts/backup-data/uv.lock) remediates Dependabot GHSA-wphv-vfrh-23q5 / CVE-2026-48990 (#244, #245). Targeted 'uv lock --upgrade-package joserfc'; no other packages changed. - Remove unused 'Optional' import in agents/main_agent/agent_types.py (CodeQL #721). - Remove unused 'ssm' import in app-api/app-api-environment.ts (CodeQL #722). * fix(ci): render reusable test-gate job names statically The reusable tests.yml jobs used an inline label expression in their 'name:'. GitHub does not evaluate name expressions for SKIPPED jobs, so callers that run only one suite (backend.yml, platform.yml, frontend-deploy.yml, nightly-deploy-pipeline.yml) showed the raw ${{ ... }} text as job labels for the unselected (skipped) jobs. Make the three job names static and relocate the nightly track-label prefix onto the (never-skipped) caller jobs in nightly-deploy-pipeline.yml, which matches the existing '[label] Deploy PlatformStack' convention and evaluates reliably. Drops the now-unused 'label' input from tests.yml. * Release/1.0.3 Patch release: CI/CD pipeline cleanup, re-enabled path-scoped auto-deploys, serialized platform+backend deploys, and a joserfc CVE / CodeQL dependency sweep. No application code or user-facing behavior changes. - Bump VERSION to 1.0.3; sync manifests + lockfiles - Brief RELEASE_NOTES.md and CHANGELOG.md entries (#524, #525, #526) - Steering: scale release-notes depth to release size (brief patches, deep features) * fix: add missing iam permission for agent core gateway * fix: add lambda:InvokeFunction to gateway role for MCP targets AWS docs require an identity-based policy on the gateway service role in addition to the per-target resource policy (lambda:AddPermission). Without this the Gateway gets 403 when trying to invoke Lambda targets. * fix(iam): grant bedrock-agentcore:GetMemory to app-api + runtime roles AgentCore Memory strategy discovery (MemoryClient.get_memory_strategies) calls bedrock-agentcore:GetMemory, but neither the App API task role nor the AgentCore Runtime execution role allowed it — every other memory data-plane action was granted. The denied GetMemory made strategy discovery fail silently: - App API: GET /memory returned empty facts/preferences with 200, so the Settings memories/preferences page rendered blank despite stored records. - Runtime: _discover_strategy_ids() failed, leaving retrieval config empty, so the agent kept writing events (CreateEvent) but never recalled long-term memories. Add bedrock-agentcore:GetMemory to the AgentCoreMemoryAccess statement on both roles (app-api scoped to the memory ARN; runtime scoped to memory/*). No other actions changed. Validated against the AWS Service Authorization Reference (GetMemory = Read on the memory resource type) and the live AccessDenied in app-api logs. Bumps VERSION to 1.0.4 + brief RELEASE_NOTES/CHANGELOG entries. Infra (IAM) change — deploys via the platform (CDK) pipeline. * fix: keep tool card after OAuth/tool-approval resume An interrupt-resume turn (OAuth consent or tool approval) has no new user message, and the resumed Strands stream does not replay the interrupted tool_use block — it emits only the tool_result plus a fresh assistant message with the final text. The default sync truncates everything after the last user message and replaces it with the resumed stream, discarding the assistant message that holds the paused tool card. The reset parser also drops the incoming tool_result (no matching tool_use in its fresh builder). Net: the tool card disappears even though the call succeeded. Both resume paths now: - use beginContinuationStreaming() instead of startStreaming(), pinning the existing messages (including the tool card) as a prefix and appending the resume after them — same pattern as the max_tokens "Continue" flow. - reconcile from persisted memory after the resume stream closes via the new MessageMapService.reloadMessagesForSession(), so the card flips from "Running..." to its completed result (memory holds tool_use + tool_result). reloadMessagesForSession force re-fetches (bypasses the "already loaded" guard) without flipping the skeleton loading state; the shared fetch/reconcile logic is extracted into fetchAndApplyMessages(). Removed the now-unused StreamParserService injection from ChatRequestService. Co-Authored-By: Claude Opus 4.8 * chore(kaizen): weekly research scan 2026-07-03 Generated by the kaizen-research skill. Top 5 ideas appended to docs/kaizen/review-queue.md for the kaizen-review-prep run later this morning. Co-Authored-By: Claude Opus 4.8 (1M context) * chore(kaizen): weekly review prep 2026-07-03 Catch-up review consuming this morning's fresh research/2026-07-03.md (no 06-12/06-19 reviews ran). 10 proposals ranked; top item is the bedrock-agentcore 1.17.0 bump (closes the #482 SSE deadlock we're exposed to today + retires a queued guard). Queue trimmed: superseded Strands/agentcore bump duplicates consolidated into the two 07-03 bumps, Fable 5 un-withdrawn (reinstated Jul 1), starlette + PR-gate marked shipped. Stacked on the research PR (#534). Co-Authored-By: Claude Opus 4.8 (1M context) * feat: isolate chat streaming per conversation The chat streaming layer used global singletons that broke when two conversations streamed concurrently (ask in A, navigate to B, ask again). Make streaming state per-session end to end, and fix the UX gaps that surfaced once background streaming actually worked. Per-session streaming: - ChatStateService holds per-session state (loading, stop reason, cost/context aggregates, Continue affordance, AbortController) behind a viewed-session facade, so existing consumers are unchanged. - StreamParserService keeps a Map with a per-stream id; ChatHttpService captures it per request and drops late events from a superseded stream (same-session resubmit guard). - MessageMapService runs one sync effect per active stream; endStreaming, abort, and loading are all keyed by session. Stop and double-submit only affect their own conversation; navigating away no longer aborts the in-flight stream (backend still completes and persists the turn). Navigation scroll policy: - New ScrollPositionService remembers per-conversation scroll offset for the SPA session. Returning restores where you were; first open (or after a reload) anchors the latest turn instead of the top. Streaming-text replay fix: - StreamingTextComponent seeds its display from whatever text already exists at mount, so navigating back to a streaming conversation shows the current state instead of re-typing the whole response from character zero. Sidebar in-progress indicator: - Conversation rows show a pulsing dot while a response streams (reads the same per-session loading state), replacing the hover ellipsis menu while in progress so the two never overlap and actions can't target a mid-response conversation. Co-Authored-By: Claude Fable 5 * feat: default export to messages only Include only user and assistant messages by default in conversation exports. Tool calls, images, and citations must now be explicitly selected by the user. Co-Authored-By: Claude Haiku 4.5 * feat: expose session options menu on top-nav title Add a dropdown to the top-nav session title (arrow/chevron trigger) that offers the same actions as the sidenav ellipsis menu — Rename, Share, Save to…, and Delete — reusing SessionService and the existing dialogs. Also fixes title-transition polish surfaced while building this: - Consolidate the two instances (skeleton vs loaded branch) in chat-container into a single persistent instance so the fixed .chat-topnav-wrapper no longer remounts on skeleton→loaded, which was restarting its left-transition and sweeping the whole bar ~288px. - Optimistically set currentSession on sidenav click so the title updates instantly instead of lingering until metadata loads. - Optimistic inline rename (revert on failure) to remove the save lag, and reload the sessions resource so the sidenav list reflects the new title. - Gentle keyed slide+fade title entry animation (component-scoped). - Title skeleton while the session metadata is loading (hard refresh) or a brand-new chat's title is still generating. Co-Authored-By: Claude Opus 4.8 * test: update onSessionClick spec for optimistic currentSession set onSessionClick now takes the clicked session and optimistically sets it as currentSession; update the spec to pass a session and assert the set. Co-Authored-By: Claude Opus 4.8 * fix: clear OAuth consent state on session switch The OAuth "Authorization needed" banner is held in a root-singleton OAuthConsentService keyed by providerId (not sessionId), and its pending() signal drives the banner regardless of the originating session. Every other per-session UI surface (compaction, artifacts, MCP app frames/cards/consent) is reset in the route subscription on conversation change, but OAuthConsentService was not — so a prompt raised in one session persisted onto the next, including the blank welcome screen. Clear it fail-closed alongside the other resets. This runs before loadMessagesForSession, which re-seeds the new session's own pending interrupts from persisted server metadata, so a session that legitimately needs authorization still shows its banner. Clearing seenInterruptIds also fixes a latent bug where a genuinely-needed re-prompt for the same provider in a later session was silently suppressed by the dedup guard. Co-Authored-By: Claude Opus 4.8 * feat: push session title mid-stream via session_title SSE event New sessions were only renamed from "New Conversation" after the agent stream closed, even though the backend already generates the title concurrently with the stream. This surfaces that title while the response is still pending. - inference-api keeps the concurrent title-generation task handle and interleaves a one-shot `session_title` SSE event between agent events once it resolves (non-blocking done-check, same drain pattern as the MCP Apps broker). Never emits the "New Conversation" placeholder. - Fix latent bug: generate_conversation_title ran sync boto3 converse on the event loop, stalling the live agent stream for the whole Nova Micro round-trip. Now runs via asyncio.to_thread. - SPA parses session_title, applying it to both the sidebar cache and the currentSession signal (new SessionService.applyServerTitle) so the top-nav header renames too. Allowlisted past Completed-state gating since it can arrive just after `done`. - Post-close refreshTitleFromServer remains the fallback for streams that outrun generation. Documented the new event in the CLAUDE.md SSE contract table. Adds backend + frontend test coverage. Co-Authored-By: Claude Fable 5 * feat: skeleton + fallback for pending session titles in sidebar and top-nav Surfaces the "title generating" state cleanly now that titles arrive mid-stream via the session_title SSE event. Sidebar (session-list): - Show a shimmer skeleton instead of "Untitled Session" while a new conversation's title is still generating (titleless + streaming). - Darken the shimmer only on the selected row, whose active highlight (bg-gray-200 / dark:bg-white/5) would otherwise hide a light skeleton. Keyed off a plain .session-row--active sentinel added to the same routerLinkActive that paints the highlight, coloured in component CSS (no arbitrary Tailwind variant), with class-based dark handled via :host-context(html.dark). - Row is now flex/min-h-8 so the skeleton→title swap causes no vertical jank, and the generated title reveals with the top-nav's slide+fade (session-title-enter), kept truncatable via min-w-0. Top-nav: - Gate the title skeleton on a proper titlePending signal (metadata loading OR the session actively streaming) so it can no longer shimmer forever; once resolved with no title it falls back to "Untitled Session" instead of an eternal skeleton. Adds/updates topnav + session-list specs. Co-Authored-By: Claude Fable 5 * feat: persist interrupted-turn context so aborted responses aren't orphaned When a response is interrupted (user Stop, refresh, or dropped socket) the user turn was already persisted but the assistant reply was not, leaving a dangling user turn — no context on reload and malformed history the model must paper over. Backend: - set/clear_interrupted_turn markers (metadata.py) with race-safe reason precedence: user_stopped (authoritative client signal) always wins over the connection_lost cancellation fallback. clear is an atomic pop (ReturnValues=UPDATED_OLD) returning the settled reason. - stream_coordinator: (CancelledError, GeneratorExit) backstop persists the IN-FLIGHT partial assistant text (accumulator scoped to the current message so already-persisted mid-turn messages aren't duplicated) via asyncio.shield; empty-partial placeholder gated on a user-tail to repair role alternation only where needed. - app-api POST /sessions/{id}/interrupt: authoritative user_stopped carrier (cookie auth; fetch keepalive + X-CSRF-Token, not sendBeacon). Lives on app-api because the AgentCore Runtime data plane only proxies /invocations + /ping. - inference-api: pop the marker at turn start and prepend a reason-specific ephemeral interruption note at the pending_ctx_block seam (invisible to the user via the displayText split; ages out via compaction). Remaining: SPA stop signal in cancelChatRequest + reload chip/Continue. Co-Authored-By: Claude Opus 4.8 * feat(spa): surface interrupted-turn state — stop signal + reload chip Completes the interrupted-turn feature on the SPA side: - cancelChatRequest fires a best-effort keepalive fetch to POST /sessions/{id}/interrupt with {reason:'user_stopped'} + X-CSRF-Token (not sendBeacon — it can't set the CSRF header) before aborting, and reflects the interruption locally so the chip shows without a reload. - lastTurnInterrupted + reason are per-session ChatState signals (mirroring lastTurnContinuable): hydrated from session metadata on reload (set-true only), cleared beside every continuable clear (new send / continuation / new streamed turn). - message-actions renders a chip on the last message: connection_lost → "Response interrupted" + Continue (reuses continueTruncatedTurn against the persisted partial); user_stopped → "You stopped this response", no Continue. Gated on last-message + not-loading; max_tokens Continue wins. Specs cover both chip variants, Continue emit + precedence, the keepalive signal shape, local reflect, failure isolation, and continuation clears. Full app build + affected unit specs green. Co-Authored-By: Claude Opus 4.8 * docs: add assistant KB sync (scheduled re-index) design spec Sweeper-not-scheduler architecture with layered runaway guards; all five open questions resolved against AgentCore Identity docs, Drive API docs, and code inspection. Co-Authored-By: Claude Fable 5 * feat(kb-sync): add SyncPolicy data layer, DueSyncIndex GSI, delete cascades PR-1 of the KB sync feature (docs/specs/assistant-kb-sync.md): - SyncPolicy model + repository in apis.shared.sync_policies (adjacency list SYNCPOL# items on the assistants table) - Sparse DueSyncIndex (GSI4) — keys present only while state=active, so paused policies are physically invisible to the dispatcher sweep - Conditional re-arm for idempotent dispatch; breaker counters (consecutive failures / source-gone) maintained by record_sync_result - Assistant and document delete paths eagerly cascade sync policies so no schedule outlives its source - Document gains contentHash/lastSyncedAt/syncPolicyId; Assistant gains lastUsedAt (inactivity-pause input) Inert until the PR-2 dispatcher exists: nothing reads DueSyncIndex yet. Co-Authored-By: Claude Fable 5 * feat(kb-sync): dispatcher + worker Lambdas, EventBridge sweep, kb-sync image pipeline PR-2 of the KB sync feature (docs/specs/assistant-kb-sync.md): Backend: - kb-sync dispatcher (apis/app_api/kb_sync/dispatcher.py) — sweeps the sparse DueSyncIndex each tick and applies the runaway guards in order: kill switch, liveness (orphan policies hard-deleted), circuit breaker (failure/not-found streaks -> paused_error), 30-day inactivity pause, in-flight skip via syncRunStartedAt, re-arm-with-backoff BEFORE async-invoking the worker - worker stub (records "skipped", clears the run stamp); Drive sync lands in PR-3, web re-crawl in PR-4 - rearm_policy gains mark_run_started so claiming the in-flight slot is atomic with winning the re-arm - dispatcher reads assistant/source records by raw adjacency-list key (keeps the image surface to apis.shared.sync_policies + kb_sync); tests create them through the real services so schema drift breaks Infra: - KbSyncConstruct: two DockerImageFunctions sharing one image with ImageConfig.Command overrides; rate(15 min) EventBridge rule — DISABLED unless CDK_KB_SYNC_ENABLED=true (dark by default), env kill switch mirrors the flag; namespace-conditioned PutMetricData; error alarms; function-name SSM params for the code-deploy step - platform-as-bootstrap: byte-stable bootstrap-assets/kb-sync stub Pipeline: - backend/Dockerfile.kb-sync (lightweight: boto3+pydantic, no ML deps) - build-one.sh kb-sync case, deploy-image-lambda-one.sh kb-sync-dispatcher/worker cases sharing one image tag - deploy-image-lambda-one.sh: first-deploy grace skip when the function-name SSM param doesn't exist yet (backend.yml runs before the platform deploy on the introducing PR) - backend.yml build+deploy jobs; nightly scan-images + supply-chain test lists include the new Dockerfile Co-Authored-By: Claude Fable 5 * feat(kb-sync): Drive-file sync path — vault token, change detection, staged re-ingest PR-3 of the KB sync feature (docs/specs/assistant-kb-sync.md §6.1): Worker (replaces PR-2 stub for drive_file policies): - resolves the policy creator's stored Google token from the AgentCore Identity vault (GetWorkloadAccessTokenForUserId -> GetResourceOauth2Token, no live user session; same customParameters as consent — vault-key rule) - two-gate change detection: Drive files.get version vs stored sourceEtag (continuous with import provenance), then sha256 vs contentHash — identical bytes never reach Docling/Titan - changed bytes staged to the document's EXISTING S3 key -> the untouched ingestion pipeline re-chunks/re-embeds via its S3 event - pause semantics per spec §7: requires_consent / Google 401 -> paused_reauth (not a failure streak); Drive 404 (deleted-or-unshared, indistinguishable) -> not_found strike, dispatcher pauses at 2; trashed=true -> grace skip; provider/adapter/provenance gone -> paused_error; every path records the run so the stamp always clears Shrinkage cleanup: - worker stashes previousChunkCount before staging; ingestion completion atomically pops it (REMOVE + UPDATED_OLD — duplicate S3 events can't double-delete) and deletes stale tail vectors {doc}#{new..prev-1} via new delete_vector_tail; uses post-split len(chunks), the true vector count this run wrote Adapter/image/infra: - GoogleDriveAdapter.get_file_metadata (cheap files.get, trashed + version/md5Checksum/modifiedTime fields) - kb-sync image gains apis.shared.oauth + file_sources (FastAPI-free, verified by import-surface simulation); httpx + bedrock-agentcore pins - worker IAM: vault token read (two Get* actions only — no consent completion, no provider CRUD), oauth-providers table read, documents bucket put; env: workload identity name + callback URL (same values app-api uses) Co-Authored-By: Claude Fable 5 * feat(kb-sync): web re-crawl path — refresh/upsert crawls, miss accounting, TTL fix PR-4 of the KB sync feature (docs/specs/assistant-kb-sync.md §6.2): Crawler refresh mode (opt-in via RefreshState; normal crawls unchanged): - upsert-by-URL: known pages reuse their document records — a re-crawl never duplicates documents - conditional GET with the stored ETag (304 = no body, no re-extract) plus a content-hash gate; identical bytes never re-embed - 'changed' emitted BEFORE the S3 overwrite so the worker stashes the previous chunk count ahead of the ingestion event (shrinkage cleanup) - transient fetch errors never flip an existing indexed doc to failed; its last-good content keeps serving - link-enqueue block deduplicated into a nested helper CrawlJob TTL fix (the silent-kill trap): terminal crawl jobs carry a 30-day TTL — a sync-covered job auto-expiring would trip the dispatcher's liveness check and delete the policy. finalize_crawl gains set_ttl; sync re-crawls finalize with the ttl REMOVED, and reset_crawl_for_refresh rearms the job (running, zeroed counters, no ttl) before each run. Worker web path: - re-runs the policy's crawl with its stored (already-capped) settings, 13-min crawler budget inside the 15-min Lambda so finalize + miss accounting always complete - miss accounting: pages absent from 2 CONSECUTIVE re-crawls are soft-deleted with cleanup run inline; fetch failures count as seen (outage != gone), robots-disallowed pages do not; misses reset on reappearance; missing root doc is recreated route-style Image: web_sources + documents + embeddings trees; beautifulsoup4, trafilatura, lxml pins. Worker Lambda timeout 10 -> 15 min. Co-Authored-By: Claude Fable 5 * feat(kb-sync): sync-policy API + resume hooks — CRUD routes, run-now, reauth/inactivity resume - New /assistants/{id}/sync-policies router (edit-gated like documents): create/list/patch/delete + POST run-now (202, atomic 10-min cooldown on lastManualRunAt). Resume of paused_reauth is 409 — only a fresh OAuth consent resumes it. - Reauth markers: worker pause writes USER#/SYNCREAUTH# marker so complete_consent() resumes exactly the right policies with one query; markers are advisory (resume re-verifies state) and self-clean. - Inactivity resume: throttled lastUsedAt bump on chat use (conditional write, one winner/day) wakes paused_inactive policies due-immediately. - restore_crawl_ttl: deleting a crawl's policy puts the job back on normal 30-day expiry (running jobs untouched). - drive_file policies keep a syncPolicyId back-pointer on the document, cleared on policy delete. Co-Authored-By: Claude Fable 5 * feat(kb-sync): SPA sync-policy controls on assistant knowledge page PR-6 (final) of the KB-sync series. Per-source "Keep in sync" controls on the assistant knowledge editor for imported Drive documents and web crawls: interval select (Manual only/Daily/Weekly/Monthly), status line (state, reason, last/next sync), pause/resume, Sync now (cooldown-aware), and a Reconnect affordance for paused_reauth policies that routes through the existing OAuth consent popup (a fresh consent auto-resumes server-side). Controls are owner/editor-only; device uploads show no control. Backend (additive, same-PR per cross-package contract): - DocumentResponse now exposes sourceConnectorId/sourceAdapterKey/ sourceFileId/syncPolicyId/lastSyncedAt so the SPA can tell imported docs from device uploads and route reconnect - GET /web-sources/crawls without ?active=true returns full history (list_all_crawls) — completed crawls are the syncable web sources Co-Authored-By: Claude Fable 5 * feat(interrupted-turn): persist stopped-turn metadata so cost + message badges survive A Stop aborts the fetch before the stream's terminal `metadata` SSE (usage / cost / context) can arrive, so both the per-message metadata badges and the session cost badge stayed blank for that turn — and blank even after reload, because the cancellation path persisted only the partial text + interrupted marker, never any metadata. The abort is load-bearing (killing the socket is what stops generation and billing), so the backend can't push the metadata to the dead socket. Instead: - Backend: `_persist_interruption` now also calls `_store_message_metadata` (the same call the `done` path uses), which writes the per-message row AND bumps the denormalized session aggregates that hydrate the cost badge on reload. Keyed to the interrupted message's odd-position index. A cut generation often never delivered Bedrock's terminal usage event, so new `_projected_input_usage` falls back to the context-attribution projection for input-side tokens/cost (output unknown → priced at zero). - Frontend: `refreshAggregatesAfterStop` (delayed + one retry, mirroring refreshTitleFromServer, since the write races the abort) re-pulls session metadata and re-seeds the cost badge live. Per-message badges hydrate from the same persisted row on the next reload. Known limitation: the per-message contextBreakdown partition badge is not a persisted field (live-only even for normal turns), so it stays blank on a stopped/reloaded turn — a separate change affecting all turns. Tests: backend test_interrupted_turn_persistence.py (+2) and full streaming dir (175) green; frontend chat-http.service.spec.ts (+2) green. Co-Authored-By: Claude Opus 4.8 * fix(kb-sync): grant worker read on the vault's backing OAuth secrets The KB sync worker resolves the policy creator's Google token from the AgentCore Identity vault via GetResourceOauth2Token, but that call reads the refresh token *through* the Secrets Manager secret the vault auto-creates per provider (bedrock-agentcore-identity!default/oauth2/). The worker role granted only the two bedrock-agentcore:* actions, so the call failed with AccessDenied on secretsmanager:GetSecretValue and every Drive sync returned "failed". Add the read-only AgentCoreIdentityOAuthSecrets grant (GetSecretValue + DescribeSecret on ...!default/oauth2/*) — the same grant app-api and inference-api already carry, minus the write lifecycle a background fetcher never needs. Covered by a new assertion in kb-sync.test.ts. Verified live in dev-ai: with the grant, the worker gets past the vault read (the remaining paused_reauth is a genuine expired-refresh-token, not this IAM gap). Co-Authored-By: Claude Opus 4.8 * refactor(oauth): forward admin customParameters, drop hardcoded vendor baseline `custom_parameters_for` previously injected vendor-specific OAuth params (Google `access_type=offline`, plus `prompt=consent` on the force-auth path) on top of the connector's admin-configured extras, keyed off `provider_type`. That hid a documented requirement inside the code and made the `customParameters` map depend on the call site (grant vs retrieval), which AgentCore factors into its vault key. Simplify to a single rule: every call site forwards the connector's admin-configured `customParameters` verbatim via `custom_parameters_for(admin_extras)`. Admins set `access_type=offline` and `prompt=consent` in the connector's "Custom OAuth Parameters" field (the seeded Google connector already carries both), so the same map is sent on consent and on every retrieval — no baseline merge, no `provider_type`/`force_authentication` branching, no per-call-site drift. Removes `_vendor_baseline_params`, the `provider_type_lookup` plumbing on the consent hook, and the `force_authentication=True` customParameters argument at all read sites (the SDK-level `force_authentication` flag is unchanged). Behaviour for the existing Google connector is identical (same resulting map); the win is that the vault key is now provably consistent and there are no hidden customizations in the token path. Co-Authored-By: Claude Opus 4.8 * docs: add tool-search token-bloat + per-user markdown memory specs Two design specs drafted alongside recent exploration work: - tool-search-token-bloat-strategy: cross-source tool-search plan for MCP token bloat (tiered discovery, AWS Agent Registry tier). - user-markdown-memory: per-user markdown "second brain" memory, re-scoped skills reference-file mechanism, prompt-cache injection. Docs only; no code or behaviour change. Co-Authored-By: Claude Opus 4.8 * feat(kb-sync): log workload identity + userId on a reauth pause A reauth pause was opaque — "credentials need re-consent" gave no hint why. The vault token is keyed by (workload identity, userId), so the overwhelmingly common cause is that the token was vaulted under a DIFFERENT workload identity than the worker queries: e.g. a consent done through local dev (AGENTCORE_RUNTIME_WORKLOAD_NAME=local_dev_inference) can't be read by the deployed worker (platform-workload), and vice versa. That exact mismatch cost hours to diagnose. Surface the workload name, userId, and provider the lookup used in the pause warning so the mismatch is obvious in one log line. Co-Authored-By: Claude Opus 4.8 * chore(kb-sync): plumb CDK_KB_SYNC_ENABLED into the platform deploy KB sync ships dark: the EventBridge rule is created disabled and the KB_SYNC_ENABLED kill switch is false unless CDK synths with CDK_KB_SYNC_ENABLED=true (config.ts). platform.yml never forwarded that var, so a CDK deploy always synthed the feature off — the only way to enable it was an out-of-band CLI flip that reverts on the next deploy. Forward it as an environment-scoped `${{ vars.CDK_KB_SYNC_ENABLED }}` like every other CDK_ var. The development environment variable is set to "true" (enables the rule + kill switch in dev-ai durably); production leaves it unset, so it stays dark until validated there. Co-Authored-By: Claude Opus 4.8 * chore(kb-sync): default the feature ON with a kill switch KB sync shipped opt-in (default off), a holdover from ship-dark incremental development. The feature is complete and guarded, so a deployer/cloner of the public stack should get it working by default — a hidden default-off flag is bad DX. Invert to opt-out: enabled unless CDK_KB_SYNC_ENABLED=false (or a `kbSync.enabled: false` cdk.json context). The workflow forwards `${{ vars.CDK_KB_SYNC_ENABLED }}`, which is an empty string when unset — a plain `?? false` fallback wouldn't flip the default, so config.ts treats empty/unset as "use the default (on)" and only the literal "false" as the kill switch. Adds config-resolution tests covering unset / empty / "true" / "false" / context-disable. Co-Authored-By: Claude Opus 4.8 * feat(kb-sync): clarify the auto-sync control and always show last-synced The knowledge-base sync control was a bare "Manual only" dropdown that never explained what syncing does, and its status line only appeared while a schedule was active — a manual-only file showed nothing. - Lead the control with an "Auto-sync from " label (with a descriptive title tooltip) so every row states what it is and where it pulls from. - Rename the options to self-describing verbs: "Don't auto-sync", "Sync daily/weekly/monthly". - Always surface a "Last synced" line, including manual-only sources, via a new lastSyncedAt input fed from Document.lastSyncedAt. - Pair a relative age with an absolute date/time ("Synced 2h ago · Jul 3, 2:14 PM") for both "how long ago" and "exactly when". Display-only; the timestamps already exist in the data model. Co-Authored-By: Claude Opus 4.8 * fix(kb-sync): tidy the sync row layout and repair blank sync timestamps Aligns the knowledge-base sync control with the approved mockup and fixes a bug that rendered the status line as a bare "Synced · next sync". Layout: - Move the "Auto-sync from " context out of the control's button row (where it forced "Pause" to wrap) and into the file's meta line. - Compact the control to just the schedule select + actions. - Add a status-line dot (green healthy / amber attention / grey idle). - Top-align the status icon and download/trash actions with the filename (items-start) instead of floating against the taller row. Timestamp bug: several generators built ISO strings as `datetime.isoformat() + "Z"`, yielding "…+00:00Z" (offset AND Z). That is invalid ISO 8601 and parses to Invalid Date in strict engines (Safari), so last-sync / next-sync rendered blank. Normalize to a single trailing Z in service, dispatcher, and worker; harden the dispatcher parser to always return a UTC-aware datetime; and harden the SPA to tolerate the legacy "+00:00Z" so already-persisted policies still render. Download and delete controls on documents are preserved. Co-Authored-By: Claude Opus 4.8 * fix(kb-sync): give web sources the same sync treatment as documents The web-source (crawl) row shared the sync control with documents but was missing the parity bindings the document row got: no source-context line and no persistent last-synced timestamp, so a manual web source rendered as a bare dropdown while an identical document showed full context. - Add the meta-line context "Auto-sync from the web" (guarded by isCrawlSyncable), mirroring the document's "Auto-sync from ". - Feed the control lastSyncedAt from crawl.completedAt so a manual web source still shows when it last refreshed (the component already prefers the policy's lastSyncAt when a schedule is active). - Make the crawl meta line wrap-safe (flex-wrap), matching the doc row. Co-Authored-By: Claude Opus 4.8 * feat(kb-sync): unified skeleton loading for the knowledge base lists The Web sources list had no loading flag, so it popped into existence after its network round-trip, while Uploaded Documents showed a lone centered spinner — two async lists with inconsistent, jarring loads. Introduce one initial-load gate for both lists: - Add isLoadingCrawls, set from loadSyncData (cleared on the viewer early-return and in finally), mirroring isLoadingDocuments. - isLoadingKnowledge computed gates both lists so they reveal together; raise both flags synchronously in ngOnInit so the first paint is the skeleton, not an empty flash. - Replace the documents spinner with a skeleton list (pulsing icon + two text bars, varied widths) that mirrors the final row shape — no layout shift, and consistent with the existing connector-button skeleton. Co-Authored-By: Claude Opus 4.8 * feat(kb-sync): show a "Saving…" indicator while a sync change applies Changing the schedule (e.g. Don't auto-sync → Sync weekly) held the busy state for the whole create/update/delete round-trip but only disabled the controls — there was no positive sign anything was happening, and the select eagerly reverts to its old value until the mutation confirms, so it read as "nothing happened". While busy, the status slot now shows a spinner + "Saving…" (role=status), taking precedence over the sync status line — including for a manual-only source being enabled, where no policy or status exists yet. Co-Authored-By: Claude Opus 4.8 * fix(users): normalize sync timestamps to strict ISO 8601 (single Z) UserSyncService built timestamps as `datetime.isoformat() + "Z"`, yielding "…+00:00Z" — both an offset AND a Z. That is invalid ISO 8601 and parses to Invalid Date in strict engines (Safari), so the admin user-list "Last login" and user-detail "Created"/"Last login" dates (rendered via `new Date()`) showed "Never". Same class of bug just fixed across KB-sync (service/dispatcher/worker); this applies the identical normalization to the users domain. - Write path: add `_iso()` helper, normalize `created_at`/`last_login_at`. - Read path: add `_heal_iso()` and apply in `_item_to_profile` / `_item_to_list_item` so legacy "+00:00Z" rows render correctly. Necessary because `created_at` is preserved across logins forever (never rewritten), so pre-fix users would otherwise show "Never" in Safari permanently. - Regression tests for the write-path invariant and read-path heal. `last_login_at` also backs GSI2SK/GSI3SK (sort-by-last-login); the suffix change is lexicographically safe — ordering is dominated by the microsecond datetime prefix and a transient mix of "+00:00Z"/"Z" rows does not corrupt it. Co-Authored-By: Claude Opus 4.8 * feat(assistant-editor): group knowledge base into a contrasted inset panel Wrap the Knowledge base section on the Assistant editor in a rounded, bordered inset with a subtle gray-100/60 fill instead of the flat border-t divider shared by other sections. Against the gray-50 form column this reads as a mildly contrasted group, and the white inner lists (Web sources / Uploaded Documents) now float on the tint to reinforce the grouping. Drops the border-t/pt-8 divider since the card provides its own separation; the form's space-y-8 preserves the top gap. Co-Authored-By: Claude Opus 4.8 * feat(connectors): add Google connector logo SVGs Add Drive, Docs, Gmail, and Calendar icon assets under public/logos. Co-Authored-By: Claude Opus 4.8 * docs(specs): agentic platform primitives plan + scheduled-runs design + spike brief Reframe the proactive-agent effort from a single "Oliver" feature into a primitive-enablement plan, mapped onto the Harness (headless run entrypoint) and Registry (catalog + governance) explorations. - agentic-platform-primitives.md: primitive maturity + gap ledger, the six fundamentals (F1 headless run entrypoint .. F6 registry/governance), phased plan, and Harness/Registry overlap. Oliver demoted to one validation use case among several. - scheduled-agent-runs.md: detailed design for F1+F2+F3 (renamed from the earlier Oliver draft; generalized so any config/prompt/cadence works). - harness-entrypoint-spike-brief.md: tight spike brief for the F1 keystone (unattended-as-user auth + server-side SSE), to run in dev-ai. Resolved decisions: F1 = minimal internal run_agent_headless(), A2A-ready; governance floor (F6a) pulled forward into Phase A, Registry discovery (F6b) deferred. Open: KB decoupling appetite, floor depth, sequencing. Co-Authored-By: Claude Opus 4.8 * feat(harness): spike headless agent-run entrypoint (F1) — proven in dev-ai run_agent_headless in apis/shared/harness: per-owner Cognito bearer mint (workload-token + SigV4 front-door paths proven dead at the runtime gateway), server-side SSE drain pinned to live wire shapes, F6a audit records + guardrails/classification seams, delivery via the runtime's own session materialization + title override. Driver script reproduces the dev-ai proof and the negative auth probes. Findings + Phase A design in docs/specs/harness-entrypoint-spike-findings.md. Co-Authored-By: Claude Opus 4.8 * docs(specs): record F1 spike findings + lock decision-gate calls The headless-run entrypoint spike (Fable 5) is GO — proven end-to-end in dev-ai. Bring its findings doc onto develop as the decision record and lock the four gate decisions into the plan of record. - Add harness-entrypoint-spike-findings.md (design deliverables + Phase A punch list; full spike code lives on branch spike/harness-headless-entrypoint). - agentic-platform-primitives.md §6: resolve act-as-user auth policy (Cognito per-owner token behind an explicit headless-grant record), F6a floor depth (audit fail-closed now; PII checkpoint required before unattended schedules), KB decoupling (defer), sequencing (proactive-spine-first confirmed). - scheduled-agent-runs.md §8: mark unattended-auth resolved with the chosen path. Co-Authored-By: Claude Opus 4.8 * feat(harness): headless-grant record + production hardening of the F1 primitive Replace the spike's BFF-table Scan with an explicit headless-grant record (apis/shared/harness/grants.py): create-on-enable from an attended session, per-owner lookup via the sparse HeadlessGrantUserIndex GSI, revocation that deletes the stored credential, and a documented "must have logged in within 30 days" policy (TTL anchored to the login that issued the pinned refresh token, matching the Cognito refresh-token validity). CognitoRefreshBearerAuth now mints from the grant (rotation-aware: a rotated refresh token is persisted back before the mint returns — punch-list #5). Dedupe build_invocations_url into the harness as the single canonical resolver; the chat proxy imports it (punch-list #4). Document the enabled_tools=None = all-RBAC-allowed semantic and the schedule-snapshot rule (punch-list #7). Governance docstrings updated to the locked F6a decision: audit-only fail-closed + wired no-op guardrail/classification seams, implementations gated to the scheduled phase. Add an _build_http_client seam and tests covering the grant lifecycle, grant-backed minting, audit fail-closed ordering, and stream outcomes against a MockTransport runtime. Co-Authored-By: Claude Opus 4.8 * feat(runs): cookie-authed "Run now" surface behind flag + RBAC capability POST /runs/now executes one agent turn through the exact unattended path a scheduled run will use (create-on-enable grant -> per-owner Cognito mint -> runtime /invocations -> server-side SSE drain -> governance floor -> session materialization) — the PR-1 validation surface from docs/specs/scheduled-agent-runs.md §7. GET/DELETE /runs/grant expose grant status and total revocation. Gating is two independent controls (spec §6): the SCHEDULED_RUNS_ENABLED kill switch (default ON; only the literal "false" disables — empty workflow vars can't dark-stop prod) and a new `scheduled-runs` RBAC capability resolved through the mature tools grant axis (apis/shared/rbac/capabilities.py) — GA = grant the id to the default role. Auth is the standard SPA cookie dependency per the CLAUDE.md app-api rule; mint failures surface as 409, never 401, so the SPA is not bounced through the login redirect. Co-Authored-By: Claude Opus 4.8 * feat(infra): SCHEDULED_RUNS_ENABLED flag + HeadlessGrantUserIndex GSI Thread the scheduled-runs kill switch through CDK: scheduledRuns.enabled in config.ts (the CDK_KB_SYNC_ENABLED empty-string-safe ternary, copied exactly) -> SCHEDULED_RUNS_ENABLED on the app-api container -> CDK_SCHEDULED_RUNS_ENABLED forwarded by platform.yml. Default ON with a kill switch; nightly inherits the default. Add the sparse HeadlessGrantUserIndex GSI (grant_user_id / created_at) to the BFF sessions table backing apis/shared/harness/grants.py — only HEADLESS-GRANT# items carry the partition attribute, so session rows never project into it. App-api's existing table grant already covers index/*, so no IAM change. Tests mirror the kbSync flag matrix and pin the GSI shape. Co-Authored-By: Claude Opus 4.8 * docs(specs): Phase B scoping brief — scheduler + PII-ordering prerequisite Records the work breakdown, model tiering, and the one design fork gating scheduled delivery: the F6a classify_output checkpoint must run in-loop (or as a post-hoc scrub), not pre-delivery, because the runtime turn persists the session during the turn. B1 (inert schedule CRUD) is safe to start now. Co-Authored-By: Claude Opus 4.8 * docs(specs): governance for scheduled runs is role/auth-based; drop B0 PII gate A headless run executes as the owner with the owner's RBAC and delivers only to the owner's own session list, so it crosses no new access boundary and introduces no new recipient. Governance = RBAC (run-as-user) + grant lifecycle + quota + fail-closed audit — all already built. The content classification pass adds nothing for deliver-to-self, so B0 collapses and B2 delivery is unblocked. Revises primitives §6-4 and the Phase B brief §1. Co-Authored-By: Claude Opus 4.8 * feat(schedules): B1 — schedule data model + CRUD (inert) Add ScheduledPrompt model + ScheduledPromptService (apis/shared, mirroring sync_policies) and app-api CRUD under /schedules — create/list/get/update/ pause/resume/delete, gated by SCHEDULED_RUNS_ENABLED + the scheduled-runs RBAC capability. Cadence (daily/weekday/weekly) -> next_run_at is computed timezone-aware in the service so the future dispatcher stays a dumb "who's due" query. enabled_tools is snapshotted at creation (Phase A punch #7). Storage rides the existing sessions-metadata table (PK=USER#{user_id}, SK=SCHEDPROMPT#{schedule_id}), with a new sparse DueScheduleIndex GSI (GSI3_PK/GSI3_SK, distinct from SessionLookupIndex's GSI_PK/GSI_SK to avoid attribute collision) projected only while state=="active". app-api already holds CRUD+index/* IAM grants on this table, so no IAM changes were needed. Deliberately inert: nothing fires yet. The dispatcher/worker that reads DueScheduleIndex and calls run_agent_headless is B2. Co-Authored-By: Claude Opus 4.8 * feat(schedules): B3 — schedule management SPA + enablement Adds the user-facing schedules feature: a signal-based list/create/edit page under frontend/ai.client/src/app/schedules/ (bounded cadence UI — daily/weekday/weekly + hour + IANA timezone, optional assistant + tool snapshot, pause/resume/delete with confirm), the headless-grant enablement UX (status banner, "Enable scheduled runs", paused_error reauth_required / oauth_required affordances), and capability-gated nav visibility that rides the /schedules list call's 403/404 rather than a dedicated client signal. Backend: adds POST /runs/grant to apis/app_api/runs/routes.py, sharing the existing _resolve_grant create-on-enable logic with /runs/now so a user can turn on scheduled runs without running a prompt first. Same kill-switch + scheduled-runs capability gating. Co-Authored-By: Claude Opus 4.8 * feat(schedules): B2 — scheduler dispatcher + worker EventBridge rate(5m) -> dispatcher (sweeps the sparse DueScheduleIndex, runaway guard, conditional re-arm before fire-and-forget) -> worker (mints a per-owner Cognito bearer, run_agent_headless(trigger="schedule"), records the outcome, pauses on reauth_required / oauth_required / repeated_failures). Two Docker Lambdas share one image (Dockerfile.scheduled-runs) via ImageConfig.Command; bootstrap-stub + out-of-band image deploy mirroring kb-sync. IAM scoped to sessions-metadata RW, BFF-grant table RW, app-client secret read, and AgentCore vault token + oauth-secret read; no SigV4/InvokeAgentRuntime (the minted bearer is the front door, matching the Run-now path). Delivery flows straight through — governance is role/auth-based. Fixes the failure breaker: added a persistent consecutive_failures counter (model + record_run_result, mirroring sync_policies) so repeated_failures pauses at the production default of 3. The original last-status proxy capped the streak at 2 and could never trip at the default; unified the worker's two error paths on the returned streak and added regression tests at the default threshold. Co-Authored-By: Claude Opus 4.8 * fix(schedules): register nav routes in specs to fix CI NG04002 The schedule-form and schedules-list specs used provideRouter([]), but the components navigate programmatically (router.navigate(['/schedules']) etc.) on save/cancel/create/edit. With no matching route the navigation rejects (NG04002) AFTER the test body, surfacing as unhandled rejections that fail the whole vitest process even though all 1378 tests pass. Register the target routes so the real router resolves them. Scoped ng test: 46 passed, 0 errors. Co-Authored-By: Claude Opus 4.8 * fix(schedules): make scheduled-runs worker image importable The worker Lambda crashed at INIT with ImportModuleError: the lean scheduled-runs image (Dockerfile.scheduled-runs) never installed cryptography or cachetools, which the worker pulls in transitively via apis.shared.harness -> apis.shared.sessions_bff (cookie.py AESGCM, cache.py TTLCache). Even past that, apis/shared/sessions/metadata.py imported agents.main_agent...is_preview_session at module top level, dragging the agents+strands packages (deliberately absent from the lean image) into the worker's runtime delivery path. - Defer the preview-session import in sessions/metadata.py to call time. The two functions the headless delivery path uses (ensure_session_metadata_exists / update_session_title) never call it, so the agents import is never triggered in a headless run. Public name and behavior unchanged for live app_api/inference_api callers. - Add cryptography==48.0.1 + cachetools==6.2.4 to the shared image requirements (dispatcher requirements.txt is the file the Dockerfile installs; worker kept in sync). Verified: worker + dispatcher import cleanly in an isolated lean-image simulation (image pins + bundled modules only, no agents/strands). Surfaced by dogfooding a real scheduled run in dev-ai. Co-Authored-By: Claude Opus 4.8 * fix(schedules): make preview-session check importable in lean worker image Follow-up to the worker-image import fix (#566). Dogfooding the deployed worker surfaced a second, deferred failure: the headless delivery path (runner -> ensure_session_metadata_exists, metadata.py:773) DOES call is_preview_session, so the previous lazy-import wrapper only moved the ModuleNotFoundError: No module named 'agents' from INIT to run time. The run still 'completed' (the runtime materializes the session during the turn), but the idempotent session-row ensure + title override were skipped ('result delivery failed' in the worker log). is_preview_session is a trivial startswith('preview-') check; its weight came only from agents/strands imports in agents.main_agent.session.preview_session_manager. Move a dependency-free copy into apis.shared.sessions.preview (mirroring what apis.inference_api.chat.routes already does with its own local copy) and import it in metadata.py. A drift-guard test keeps the literal in lockstep with agents...Prefixes.PREVIEW_SESSION. Verified: lean-image simulation now imports metadata + runs is_preview_session with no agents/strands; 77 targeted tests pass. Co-Authored-By: Claude Opus 4.8 * fix(scheduled-runs): intersect client enabled_tools with RBAC on headless paths Client-supplied `enabled_tools` was trusted end to end on the headless surfaces: schedule create/update and "Run now" froze/forwarded the request body verbatim, and inference-api's tool filter performs no RBAC check of its own (registry membership is its only gate). A user holding the scheduled-runs capability could therefore craft a request enabling a tool outside their AppRole, and — for schedules — persist it into an unattended, repeating run. Add `AppRoleService.filter_requested_tools`, a narrow-never-grant intersection of a requested tool list against the caller's resolved RBAC grant (honors the `*` wildcard; admits a scoped `base::tool` id when its base server is granted), mirroring the existing `_apply_enabled_skills_filter` contract on the skills axis. Apply it at the three app-api write/entry points where a full User (with roles) is in hand: schedule create, schedule update (PATCH must not bypass the create-time check), and run-now. `None` is preserved as "resolve to defaults". Note: fire-time re-intersection against *current* RBAC (so a later role revocation disarms a sleeping schedule) is deferred — the worker/dispatcher only carry a bare user_id, not the owner's live roles. Tracked as follow-up. Co-Authored-By: Claude Opus 4.8 * test(schedules): guard the scheduled-runs lean image against agents/strands imports Add an AST-based test asserting the modules bundled into backend/Dockerfile.scheduled-runs (harness/, scheduled_prompts/, sessions_bff/, sessions/ + the two lambda handlers) never import agents/ or strands — top level OR lazy, since a deferred import still crashes at call time in the lean image. This is the guard that would have caught the ModuleNotFoundError shipped in the first cut of the worker. Co-Authored-By: Claude Opus 4.8 * docs(kaizen): scope managed-Harness build-vs-adopt spike for the headless lane Surfaced while dogfooding scheduled runs: we use AgentCore Runtime (BYO container), not the newly-GA managed Harness. Brief evaluates adopting the managed Harness as the backing for the headless/scheduled/proactive lane only (interactive chat stays build — it needs hooks/custom-loop/MCP-Apps the managed Harness forbids). Captures pros (managed memory fixing the write-only-in-cloud gap, versioned endpoints, Step Functions, export escape hatch), cons, and three gating spike questions. Queued for kaizen review. Co-Authored-By: Claude Opus 4.8 * feat(schedules): let schedule edits clear assistantId/enabledTools B1's update_scheduled_prompt skipped None, so a PATCH could never detach a schedule's assistant or reset its tool restriction — the SPA's clear checkboxes were inert (a bare null reads as 'leave unchanged'). Add an explicit clear contract: an UNSET sentinel in the service distinguishes 'omitted' (leave) from None (clear -> REMOVE the attribute). UpdateScheduleRequest gains clearAssistant/clearTools booleans (rejected if combined with a value). clearAssistant reverts to the default agent; clearTools re-snapshots the caller's current RBAC-allowed tools, mirroring creation so a schedule never stores an unresolved None. SPA sends the flags. Co-Authored-By: Claude Opus 4.8 * fix(app-api): grant bedrock:ListFoundationModels to task role The admin GET /admin/bedrock/models endpoint calls the Bedrock control plane's ListFoundationModels, but the App API Fargate task role only had bedrock:InvokeModel. In deployed environments the call raised AccessDeniedException, which the app-wide AWS-error handler maps to a generic 502 ("Upstream service error."). It only worked locally because local dev runs with the developer's broader AWS credentials. Add a BedrockListFoundationModels statement granting bedrock:ListFoundationModels and bedrock:GetFoundationModel. These are account-level list/read actions that do not support resource-level permissions, so they are granted on `*`. Requires a platform.yml (CDK) deploy to take effect. Co-Authored-By: Claude Opus 4.8 * feat(sessions): unread indicator for scheduled-run deliveries A scheduled (unattended) run delivers a session the user wasn't watching. Surface it: the sidebar shows an unread dot until they open it. - sessions/metadata.py: set_session_unread / mark_session_read — a targeted single-attribute UpdateExpression on the row's current SK (GSI-resolved), concurrent-safe with the title/activity writes; preview-session guarded; best-effort (never raises). - harness/runner.py: mark the delivered session unread only on a *completed* run with trigger == "schedule" — attended "Run now" and failed/consent- blocked runs never set the dot (the user is present / there's nothing to read). - app_api POST /sessions/{id}/read: clears the durable flag when the user opens the session; idempotent, ownership-enforced via the GSI lookup. - SPA: durable server-persisted unread on SessionMetadata (survives reload, reaches other devices) ORed with ChatStateService's ephemeral in-tab unread for interactive background completions; session-list renders the dot. Backend 108 + frontend 25 tests pass. Co-Authored-By: Claude Opus 4.8 * docs(kaizen): queue Bedrock Mantle endpoint watchlist item Phil-initiated kaizen focus: scope a future bedrock-runtime (Converse) -> bedrock-mantle endpoint migration. Watchlist/Defer — strategic alignment with where Bedrock capability lands first, not near-term need; Claude-on- Mantle is Messages-API-only today and lacks cross-region + native CountTokens + Guardrails, so the primary chat path stays on bedrock-runtime. Interim low-risk value = finishing the already-scaffolded non-Claude OpenAI-compatible Mantle lane. Co-Authored-By: Claude Opus 4.8 * docs(kaizen): managed-Harness spike findings — 3 gating questions answered Complete the #570 build-vs-adopt spike for the headless/scheduled lane. Answered from the now-GA AWS managed Harness docs cross-checked against our code and the proven F1 entrypoint spike: - Q1 RBAC -> allowedTools: qualified yes (per-invoke globs; we already snapshot the RBAC-narrowed set statically at the app-api boundary). Non-membership gates relocate (quota/cost -> dispatcher; approval -> exclude on headless; consent -> Identity outbound). - Q2 per-user tokens: yes on mechanism (OAuth-inbound customJWTAuthorizer == our authorizer; Gateway outbound == our USER_FEDERATION exchange). SigV4 cannot do per-user identity. One residual: customParameters vault-key pinning through the Gateway-managed exchange -> live probe. - Q3 lose MCP Apps + SSE on headless: yes (interactive-only affordances; SPA loads the delivered session, not the harness stream). Recommendation: green-light a narrow InvokeHarness probe to close the Q2 residual; interactive inference-api untouched. Co-Authored-By: Claude Opus 4.8 * feat(sidenav): gate Scheduled Runs nav entry to system_admin Hide the "Scheduled Runs" menu option from non-admins while the feature is still maturing. Adds an isAdmin() check to the existing capability gate, mirroring the admin-dashboard nav pattern. isAdmin is already wired into the sidenav component from UserService; showSchedules keeps its accessibility-probe behavior. Co-Authored-By: Claude Opus 4.8 * docs(kaizen): managed-Harness Q2 live-probe result — GO-with-boundary Records the live dev-ai probe (2026-07-06) that closes the Q2 customParameters residual from the spike findings. Confirmed live (real CreateHarness/InvokeHarness via boto3): our exact customJWTAuthorizer is accepted (harness READY); outboundAuth.oauth customParameters is a first-class field persisted verbatim on GetHarness (so we CAN pin the same params the consent flow uses); OAuth-inbound runs as the owner (HTTP 200); the exchange calls the same GetResourceOauth2Token our get_token_for_user uses; a failed exchange surfaces legibly as a typed runtimeClientError stream event (maps to paused_reauth). Boundary found: the managed Gateway 3LO (AUTHORIZATION_CODE) exchange fails with "must provide a ResourceOauth2ReturnUrl" and does not source that URL from defaultReturnUrl / OAuth2CallbackUrl header / workload AllowedResourceOauth2ReturnUrl — a GA wiring gap. Cross-workload token visibility (platform vs harness-own workload identity) unreached past it. Decision: GO to adopt Harness on the headless lane, but keep customParameters-sensitive / all 3LO connectors on our own get_token_for_user until the return-URL wiring is resolved with AWS. Aside: managed memory is on by default per harness (relevant to F5). Co-Authored-By: Claude Opus 4.8 * feat(sessions): add mark-as-read/unread toggle with sidebar dot fixes Adds a "Mark as read / Mark as unread" toggle to the session options menu in both the top nav and the sidebar session list, backed by a new POST /sessions/{id}/unread endpoint (mark_session_unread) that mirrors the existing /read verb. The client surfaces the dot instantly via the ChatStateService unread signal while the durable server flag lands async. Fixes two sidebar-only bugs where the in-row options trigger lives inside the row's group, so the CDK menu restoring focus to it on close tripped group-focus-within: - Bug 1: the ellipsis trigger stayed visible after a menu action. Switch its reveal from :focus / group-focus-within to :focus-visible, so mouse-restored focus no longer reveals it while keyboard nav still does. - Bug 2: a freshly marked-unread dot didn't appear until the next browser interaction. The dot was being hidden by group-focus-within (restored trigger focus); scope that to group-has-[button:focus-visible] instead, and kick a synchronous refreshSessions() in the mark-unread branch (mirroring mark-read) so the OnPush row re-renders immediately. The top-nav toggle was unaffected because its trigger sits outside the row. Co-Authored-By: Claude Opus 4.8 * feat(schedules): interval cadence + "Run now" with background-task toasts Two additions to the Scheduled Runs feature: - Interval cadence: schedules can now run every N minutes/hours in addition to daily/weekly. Adds interval_value/interval_unit to the scheduled-prompt model and API, a MIN_INTERVAL_MINUTES floor enforced on create/update/ resume, and interval_to_minutes() feeding compute_next_run_at. The schedule form gains the interval option with matching validation. - "Run now": run a schedule's prompt headlessly on demand from the schedule form. RunNowService fires POST /runs/now (existing app-api endpoint) as fire-and-forget and reports progress through a new app-wide toast system — BackgroundTaskService (signal-backed task list) rendered by the BackgroundTaskToastsComponent mounted in app.html. On completion the run materializes as a real session; the list is refreshed and the toast offers a "View" affordance. Tests: backend test_schedules_routes / test_scheduled_prompts / test_harness_runner (100 passed); frontend schedule-form, run-api, run-now, background-task, and background-task-toasts specs (30 passed). Co-Authored-By: Claude Opus 4.8 * docs(memory): reframe spec as Memory Spaces — bindable, templated, shareable Reframes the per-user markdown memory spec into the Memory Space primitive: a named, first-class, bindable, templated, and shareable markdown wiki. Oliver becomes a Chief-of-Staff template + a bound agent rather than a special-cased feature. - Adds the three-layer abstraction (Agent / Memory Space / declarative binding) and the structural-config vs. semantic-MEMORY.md split. - Space-keyed storage (SPACE#{id} + membership records + UserSpacesIndex GSI) so sharing is expressible from day one. - Entry types (entity / episodic / fact), Space Templates, and manifest- indexed fields for relational/temporal queries ("who owes what"). - Sharing via owner/editor/viewer grants mirroring assistant collab-edit, with run-as-user write attribution. - Data governance is proportionate: same data class as sessions/artifacts behind the same Entra JWT + RBAC — identity-based, no content-inspection gate. Deletion-purge + inherited encryption are the only real items. - 8-PR phasing; PR-1 (data layer) is the next buildout phase. Co-Authored-By: Claude Opus 4.8 * docs(kaizen): recover resourceOauth2ReturnUrl shape section into harness findings PR #576 merged from a state prior to commit 908f7e18, orphaning the resourceOauth2ReturnUrl parameter-shape + harness-security cross-check subsection. This re-applies it onto develop. Co-Authored-By: Claude Opus 4.8 * docs(memory): bake in full-ownership zip export of a Memory Space Add a first-class "download the entire space as a .zip of raw markdown" capability (index + all entries, structure preserved, metadata.json), framed as the user-ownership / zero-lock-in property. Promotes the stubbed export endpoint into §9 with contents, access, streaming mechanics, and an import-friendly round-trip note; folds the zip export into PR-5. Co-Authored-By: Claude Opus 4.8 * feat(memory): Memory Spaces data layer (PR-1) Adds the F5 Memory Space primitive's data layer — no runtime wiring, gated by MEMORY_SPACES_ENABLED (default off). Backend (apis/shared/memory/): - store.py: S3 content-addressed byte store (sibling of the skills store) - models.py: MemorySpace / MemoryIndex / MemoryEntryRef / SpaceMember - templates.py: Blank / Chief-of-Staff / Research-Notebook presets - repository.py: dedicated memory-spaces table CRUD (META/INDEX/MEMBER rows, OwnerIndex + MemberIndex GSIs, Decimal handling) - service.py: permission-gated lifecycle + sharing + entry/index I/O, resolve_permission chokepoint (viewer reads, editor writes, owner shares/deletes), content-addressed writes with GC-on-replace - feature_flags.py: memory_spaces_enabled() (default off) - 47 moto-backed tests; import boundaries clean Infrastructure: - MemorySpacesConstruct: S3 bucket + dedicated memory-spaces DynamoDB table (OwnerIndex/MemberIndex GSIs), a per-domain table matching the project's actual pattern - Threaded via PlatformComputeRefs to both compute roles (readwrite S3 + DynamoDB); env vars S3_MEMORY_SPACES_BUCKET_NAME / DYNAMODB_MEMORY_SPACES_TABLE_NAME / MEMORY_SPACES_ENABLED - config.ts flag default-off; resource-count assertions updated - tsc + 429 jest tests pass Spec updated to reflect the dedicated-table + two-GSI decisions. Co-Authored-By: Claude Opus 4.8 * docs(memory): re-slice phasing into primitive vs. agent-consumption workstreams Splits the plan into two workstreams to keep the Memory Space a clean bindable primitive: Workstream A (this epic) delivers the primitive + the user-facing "own your data" surface (data layer, app-api CRUD, export, sharing, SPA panel, consolidation); Workstream B (Agent/Harness layer) delivers agent-consumption — the memory_* tools, declarative binding, and system-prompt index injection — so any run surface can bind the same primitive rather than welding it to inference-api. Co-Authored-By: Claude Opus 4.8 * feat(memory): Memory Spaces user surface — /memory/spaces CRUD (A2) Workstream A2 of the re-sliced memory epic: the user-facing "own your data" surface over the Memory Space primitive. No agent-consumption (tools / binding / prompt injection) — that's the Agent/Harness workstream. app-api (apis/app_api/memory_spaces/): - routes.py: /memory/spaces CRUD over MemorySpaceService — list (with templates + accurate per-space role), create-from-template, get (index + entry manifest), delete-or-leave, entry read/list/upsert/delete, index read/update. Sync handlers (FastAPI threadpools the sync boto3 service). - Gated by require_memory_spaces_user: 404 while MEMORY_SPACES_ENABLED off (surface behaves as unmounted); cookie auth via get_current_user_from_session. - Service errors translated NotFound->404, Permission->403, Error->400. - models.py: camelCase request/response models. - Mounted before the existing /memory (AgentCore Memory) router; paths are non-overlapping (/memory/spaces vs /memory/{record_id}). shared service: - leave_space(): a member drops their own grant (the shared-in forget-me case); owner cannot leave. - list_spaces_for_user() now returns (space, role) so shared-in spaces carry the member's real viewer/editor grant (only consumer is the new route). Tests: 12 route tests (moto-backed real service, flag gate, CRUD, 403/404, member-leaves-via-delete) + leave_space service tests. Full memory suite + import boundaries green (69 passing). Co-Authored-By: Claude Opus 4.8 * feat(memory): Memory Space zip export — /memory/spaces/{id}/export (A3) The "own your data" leg of Workstream A: a loss-free `.zip` download of a space's raw markdown (§9). `MemorySpaceService.export_space` gathers the corpus once (index + every entry's bytes) behind the viewer+ permission gate, including the member grant list only for editor+ callers (mirrors `list_members`). The app-api route builds the archive in a `SpooledTemporaryFile` — spilling to disk beyond 8 MiB so a large space never pins memory — and streams it back. Zip mirrors the S3 layout (`{name}/MEMORY.md`, `entries//.md`, `metadata.json`) so it is self-contained and re-importable later. Archive path components are sanitized against zip-slip. Route tests cover layout, verbatim frontmatter, owner-vs-viewer member disclosure, 403/404/flag-off, and the hostile-slug case. Co-Authored-By: Claude Opus 4.8 * feat(memory): Memory Space sharing + optimistic manifest concurrency (A4) The sharing leg of Workstream A, plus the concurrency guarantee that makes multi-editor spaces safe. Sharing surface (app-api, over the existing service grant methods): - GET /memory/spaces/{id}/shares list grants (editor+) - POST /memory/spaces/{id}/shares grant viewer|editor (owner) - PATCH /memory/spaces/{id}/shares/{email} change a grant's role (owner) - DELETE /memory/spaces/{id}/shares/{email} revoke (owner, idempotent) New `MemorySpaceService.update_share` gives PATCH proper not-found semantics and preserves the grant's original createdAt (distinct from share's upsert). Optimistic manifest concurrency (the real design content): - `MemorySpaceRepository.put_index(expected_version=…)` does a conditional DynamoDB write on the manifest `version`, raising the repository-local `OptimisticLockError` on a mismatch. - `write_entry`/`delete_entry` route through a new `_mutate_index` helper: a bounded read-modify-conditional-write retry loop. Because an entry write touches a single slug, re-reading the fresh manifest and re-applying is safe; it converges on transient races and raises `MemorySpaceConcurrencyError` (→ 409) only on a sustained one. Behavior is unchanged for single-writer spaces. Tests: 6 route tests (share CRUD, member gains access, non-owner 403, viewer can't list, PATCH-unknown 404, owner-role 422) + 7 service tests (update_share role/origin/owner-gate + version-increments, stale-write rejected, retry converges, gives-up-after-max). 76 memory + import-boundary tests green. Co-Authored-By: Claude Opus 4.8 * feat(memory): Memory Spaces SPA panel — list/detail/create/share/export (A5) The user-facing "Memory" surface for the Memory Space primitive, under frontend/ai.client/src/app/memory-spaces/. Makes A2–A4 visible to users. - List page: owned + shared-in spaces as cards with role/template badges; per-card open, share (owner), download .zip, delete/leave. Empty, loading, error, and feature-unavailable states. - Detail page: view/edit the MEMORY.md index (editor+) and the entry list; entries open in a dialog to view (viewer) or edit/create (editor+); delete per entry. Header carries share/download/delete-or-leave. - Create-from-template dialog and a share dialog (add-by-email + per-row role + delta-on-save over the A4 /shares endpoints). Viewer access is read-only throughout; the share dialog fails soft for non-owners. - Signal facade (MemorySpaceService) + thin API service mirror the assistants/schedules pattern. The nav entry rides a live accessible$ probe: a 404 (MEMORY_SPACES_ENABLED off) hides it, matching showSchedules. - Routes memory-spaces + memory-spaces/:id (authGuard); redesign-tokens and @angular/cdk/dialog conventions throughout. Facade spec (7 tests) green; dev build + tsc clean; sidenav specs still pass. Co-Authored-By: Claude Opus 4.8 * test(memory): mock MemorySpaceService in sidenav spec (fix unhandled rejection) The A5 sidenav now injects MemorySpaceService and probes `loadSpaces()` in the auth effect. The sidenav spec mocked ScheduleService but not the new service, so the authenticated-probe test constructed the real MemorySpaceService, which fired a real XHR to /memory/spaces (status 0, no backend); `loadSpaces` then re-threw, surfacing as a Vitest "unhandled rejection" at suite level. Mock MemorySpaceService exactly as ScheduleService is mocked, and add matching coverage: probe fires once authenticated (not while unauthenticated) and the `showMemorySpaces` nav gate resolves null→false, false→false, true→true. Full suite: 1422 passed, 0 errors. Co-Authored-By: Claude Opus 4.8 * fix(memory): wire memory-spaces table/bucket names onto app-api app-api owns the Memory Spaces CRUD surface (`/memory/spaces/*`) but its container environment only set MEMORY_SPACES_ENABLED — never the table or bucket names the service reads (DYNAMODB_MEMORY_SPACES_TABLE_NAME / S3_MEMORY_SPACES_BUCKET_NAME). Without them the repository falls back to the default "memory-spaces" table name, which doesn't exist, so every read throws a boto3 ResourceNotFoundException that the centralized handler maps to a 502 "Upstream service error." (inference-api already sets the identical trio, but per the service-boundary rule it isn't the one serving these routes.) Thread `refs.memorySpacesTable`/`.memorySpacesBucket` through AppApiSsmParams and emit both names next to the flag in buildAppApiEnvironment. Names are always wired (read lazily); only MEMORY_SPACES_ENABLED gates route mounting, so flipping the switch on later needs no env change. New unit test guards the wiring; tsc + 431 infra jest tests green. Co-Authored-By: Claude Opus 4.8 * docs(cdk): capture the "wire resource name to every compute" rule Fold the lesson from the app-api env-wiring fix into the cdk-infrastructure skill so the next construct-author sees it while wiring, not after a 502. Adds a "Cross-Construct References" subsection: set a resource's name env var on every compute that reads it (one doesn't imply the other), the silent-502 failure mode (default-name fallback → ResourceNotFoundException → generic 502, invisible to synth/CI), and the env-map test guard + service-boundary caveat. Co-Authored-By: Claude Opus 4.8 * feat(memory): deterministic consolidation health pass (A6) The safe, non-LLM slice of Workstream A6. `MemorySpaceService.consolidate` (editor+) + `POST /memory/spaces/{id}/consolidate` → a `ConsolidationReport`. Auto-fixes only storage hygiene: orphaned content-addressed objects — keys under a space's prefix that no manifest entry or the index pointer references (leaks from crashed/raced writes) — are GC'd (new `MemorySpaceStore.list_keys` drives it). Everything that needs a judgment call is *reported, not mutated*: - duplicate content across slugs (same content hash) — which slug survives is semantic, so it's flagged, never auto-merged; - dead `[[slug]]` wikilinks in MEMORY.md — reported; opt-in `stripDeadLinks` unlinks them (they point nowhere) while preserving the surrounding prose; - over-cap entry counts (`MEMORY_SPACE_INDEX_CAP`, default 200) — flagged, never auto-evicted. This deliberately does not merge/evict/rewrite durable memory — that's deferred to the LLM consolidation pass (Workstream B era), which extends this exact `consolidate()` seam once agentic writes create real duplication/staleness to act on. On-demand only for now; scheduler/threshold auto-run and SPA surfacing are follow-ups. Tests: 8 service (healthy report, orphan GC + skip, dup-report-no-merge, dead-link report + strip-keeps-prose, over-cap flag, editor gate) + 4 route (report shape, no-body, viewer 403, flag-off 404) + 3 store (list_keys prefix scoping / empty / disabled). 104 memory + boundary tests green. Co-Authored-By: Claude Opus 4.8 * docs(agent): Agent Designer spec — unified primitive-binding surface Captures the strategy for the "Agent Designer" (Agent Harness Editor): a new authoring surface that composes an Agent from RBAC-governed primitives (instructions, model, KBs, tools, skills, Memory Spaces, + future), replacing the term/feature "Assistant." Locks the load-bearing decisions: own a primitive-agnostic Agent contract and federate AgentCore Registry later rather than build on it (adopt-with-boundary precedent); evolve the assistant store in place (no parallel table); a uniform bindings[] model with the model as a governed single-select; RBAC = compose the five existing per-primitive access checks (incl. ModelAccessService), not a new system; design-time filter + run-time re-resolution per invoker with block-on- missing v1; ship memory-consumption as a thin vertical slice before the full Designer. Phasing 0–5 + later AWS federation; supersedes the memory spec's "extend the Assistant" §B1 framing. Co-Authored-By: Claude Opus 4.8 * feat(agents): Agent contract + compat mapping in shared assistants models Phase 1 (PR-1) of the Agent Designer. Pure library, zero behavior change: legacy Assistants read unchanged and no caller passes the new fields yet. - AgentModelConfig (D3 governed single-select; field is model_settings/ alias modelConfig to dodge pydantic's reserved model_config — R3) - AgentBinding (open kind on read, KNOWN_BINDING_KINDS for request validation) - optional model_settings + bindings on Assistant (additive) - compat.effective_bindings/to_agent_view (D2): absent bindings synthesize a knowledge_base binding reffing the assistant id (KB's only stable identity, F4 deferred — R4); absent model maps to None, never fabricated (R1) - Decimal-safe serialization for modelConfig.params floats Co-Authored-By: Claude Opus 4.8 * feat(agents): persist bindings + modelConfig with design-time validation Phase 1 (PR-2). The Agent fields now round-trip through the rag-assistants store and are validated at write time by composing existing RBAC checks (D4). Legacy clients are unaffected: the SPA sends none of the new fields and the AssistantResponse surface is unchanged. - service.create_assistant/update_assistant thread bindings + model_settings; to_ddb_safe on write / from_ddb on read so modelConfig.params floats survive DynamoDB (Decimal); explicit [] replaces bindings, absent leaves them untouched - app_api/agents/services/binding_validation.py composes model access (ModelAccessService), memory resolve_permission (viewer+/editor+), the implicit-KB rejection, and inert shape-only checks for tool/skill (D4/D5) - assistants POST/PUT validate then pass through; validation raises 4xx outside the create handler's generic except so it isn't masked as 500 - tests: validation matrix (incl. inert no-RBAC guarantee), persistence round trip, legacy no-field read Co-Authored-By: Claude Opus 4.8 * feat(agents): /agents alias router behind AGENTS_API_ENABLED (dark) Phase 1 (PR-3). A governed Agent read/write surface over the evolved assistant store: same shared service functions and identity-based access gates as /assistants, but returning the Agent shape (compat.to_agent_view -> AgentResponse) so callers see modelConfig + bindings. Legacy ids valid unchanged. - feature_flags.agents_enabled(): AGENTS_API_ENABLED, default OFF (memory-spaces pattern) — surface 404s while off, ships incrementally, /assistants unaffected - app_api/agents/routes.py: require_agents_enabled 404-gate; draft/create/list/ get/update/delete + 4 shares endpoints, delegating to apis.shared.assistants service and reprojecting via to_agent_view; create/update run binding_validation - AgentResponse/AgentsListResponse/AgentSharesResponse (agentId == assistantId) - main.py mounts the router - test-chat + document sub-routes deliberately excluded (would force a 2nd architecture import-boundary exception); list is owner+shared (public/pagination parity deferred to the Phase-4 Designer) - tests: 404-gate, agentId/bindings projection, CRUD permission gating, shares Co-Authored-By: Claude Opus 4.8 * feat(agents): wire AGENTS_API_ENABLED through CDK + Phase 1 docs Phase 1 (PR-4). Completes Phase 1 deployability: the /agents surface can now be turned on per environment. No new AWS resources — the flag only gates whether the routes 404 (the assistant store it reads is always present). - config.ts: AgentsConfig { enabled }; CDK_AGENTS_API_ENABLED (default off, empty-string-safe) or an `agents.enabled` cdk.json context, mirroring memorySpaces exactly - app-api-environment.ts: AGENTS_API_ENABLED env on app-api - infra tests: default-off / opt-on assertion; mock-config default - docs: agent-designer.md Phase-1 status (+ the two refinements and the Oliver-dogfood-gated-on-Phase-3 note); CHANGELOG [Unreleased] The live Oliver dogfood (D6) is deliberately NOT included: it needs Phase 3 harness resolution + Memory Spaces deployed to the target env before a memory_space binding resolves at invocation. Tracked as the Phase 3 payoff. Co-Authored-By: Claude Opus 4.8 * feat(agents): thread AGENTS_API_ENABLED to the inference runtime (Phase 3 PR-0) Phase 3 harness resolution runs inside inference-api, so the runtime needs the same flag the app-api surface got in #593. Default off, mirrors the app-api wiring; without it the harness ignores Agent bindings entirely (today's behavior). Co-Authored-By: Claude Opus 4.8 * feat(agents): resolve Agent modelConfig at invocation, per invoker (Phase 3 PR-A) The Harness now re-resolves an Agent's governed modelConfig against the INVOKING user (D5) and applies it to model selection. Absent modelConfig ⇒ the model resolves exactly as today; gated on AGENTS_API_ENABLED (off in all envs still). - agent_binding_resolver.py: resolve_agent_invocation() checks the pinned model against AppRoleService.can_access_model for the invoker (R2 — same gate the harness uses elsewhere), returns a model_override or raises AgentBindingBlockedError. inference-api imports apis.shared only (boundary-safe) - routes.py /invocations: resolve after assistant load, before the KB search; on block, stream a conversational stream_error via stream_conversational_message (D5 block-with-message, no silent downgrade). Override wins at model resolution; agent params sit beneath request params, still flowing through admin bounds/locks - tests: allowed→override, denied→block (checked vs invoker), no-modelConfig→ empty plan (no RBAC call); 57 existing inference/chat tests unchanged Co-Authored-By: Claude Opus 4.8 * feat(agents): Memory-Space hydration helper for prompt injection (Phase 3 PR-B) Shared, sync helper that resolves a memory_space binding's alwaysLoad specs into injectable text fragments — the read side of Workstream B. - resolve_always_load(): MEMORY.md → index; latest:/ → most-recent matching manifest entry (defines that scheme, which had no resolver); bare slug → entry. Missing entries skipped (never fails a turn). Byte-budgeted with a truncation marker pointing at memory_read (MEMORY_INJECTION_MAX_BYTES, ~24KB) - render_memory_block(): delimited system-prompt block; empty for a fresh space - reads go through MemorySpaceService (re-checks viewer+ internally) — no leak - 11 unit tests against a fake service Co-Authored-By: Claude Opus 4.8 * feat(agents): inject bound Memory Space into the prompt, per invoker (Phase 3 PR-C) The Harness now resolves an Agent's memory_space binding against the invoking user and injects the space's alwaysLoad content (read-only) into the system prompt — the first half of the Workstream B / Oliver payoff. - agent_binding_resolver: _resolve_memory() checks the invoker's grant via MemorySpaceService.resolve_permission (D4); blocks (D5) when the flag is off, the space is gone, or a readwrite binding meets a below-editor invoker (no silent read-only downgrade). Returns ResolvedMemoryBinding (v1: first binding) - routes.py: after prompt assembly, hydrate via resolve_always_load (asyncio.to_ thread; MemorySpaceService re-checks viewer+) and append render_memory_block; best-effort — a memory-read hiccup never fails the turn - tests: memory grant matrix (none/flag-off/missing/read-viewer/readwrite-viewer- block/readwrite-editor/invoker-identity); 78 inference+compat tests green Co-Authored-By: Claude Opus 4.8 * fix(agents): rename app_api.agents package to avoid shadowing top-level agents run-app-api.sh launches app-api with `cd src/apis/app_api && python main.py`, putting that directory on sys.path[0]. The new apis/app_api/agents/ package (Agent Designer surface, #591/#592) then shadowed the top-level `agents` package, so `admin/quota/routes.py`'s `from agents.main_agent...` resolved into it and crashed startup with `ModuleNotFoundError: No module named 'agents.main_agent'`. Tests never caught it — pytest runs from backend/ where `agents` resolves correctly. Production is unaffected (the container runs `uvicorn apis.app_api.main:app` from WORKDIR /app, so sys.path[0] is /app). Rename the package apis/app_api/agents → apis/app_api/agent_designer (and its test dir) so its name can't collide with the top-level `agents` package. Pure rename: the /agents URL surface, router, and behavior are unchanged. Verified: `import agents.main_agent.quota.repository` and the full app-api module now load from src/apis/app_api; 34 agent/boundary tests pass. Co-Authored-By: Claude Opus 4.8 * feat(agents): memory_* tools scoped to an Agent's bound Memory Space (Phase 3) Completes the Workstream B write side: an Agent with a memory_space binding now gets memory_list / memory_read (always) and memory_write (readwrite bindings only) at invocation — Oliver can read AND write his space. - agents/builtin_tools/memory_spaces/: closure-scoped factories capturing the binding's space id + invoker identity, MemorySpaceService via asyncio.to_thread (artifact-tools pattern). Every call re-checks the grant inside the service (viewer+ read / editor+ write), so a revoked grant becomes an error tool-result mid-session, never a leak - routes.py _build_memory_tools(): appended to the extra_tools seam only when a memory binding resolved; write tool gated on access==readwrite. extra_tools agents are never cached → tools closed over user A can't be served to user B - not gated on enabled_tools: the governing capability is the Agent's binding, not the user's tool picker (same reasoning as artifact tools) - tests: tool success/permission-error/not-found matrix + seam counts (none=0, read=2, readwrite=3); 84 inference+tool tests green Co-Authored-By: Claude Opus 4.8 * chore(memory): default Memory Spaces ON with a kill switch Memory Spaces is a complete feature (CRUD + SPA panel + agent binding), so it should ship enabled for every deployer/forker — opt-out, not opt-in — matching the kbSync / scheduledRuns convention. The table + bucket are already provisioned unconditionally in PlatformStack, so this only flips the runtime MEMORY_SPACES_ENABLED env var; no new infra footprint. - config.ts: memorySpaces.enabled default true (empty/unset workflow var = on, only literal "false" disables); interface + block docs updated - platform.yml: forward CDK_MEMORY_SPACES_ENABLED (kill switch) and CDK_AGENTS_API_ENABLED (per-env enable for the still-default-off /agents surface, so dev can turn it on for dogfooding without a code change) - app-api-environment.ts: comment reflects default-on - config.test.ts: 5 tests locking default-on + kill-switch + context override Agent Designer (AGENTS_API_ENABLED) stays default OFF until the Phase-4 Designer UI ships — a headless /agents surface helps no forker. Co-Authored-By: Claude Opus 4.8 * feat(agent-designer): Phase 4 — Agent Designer UI + bindable catalog API Ships the Agent Designer authoring surface (Phase 4) and its Phase-2 precursor, the bindable-primitives catalog. The pickers can't exist without the catalog, so both land together. Backend (Phase 2 catalog): - GET /agents/bindable?kind=model|tool|skill|knowledge_base|memory_space returns an RBAC-filtered palette, composing the 5 existing per-primitive access services (D4); no new RBAC invented. Uniform BindableItem shape so every picker consumes one contract. Route declared before /{agent_id} so the literal path isn't captured. knowledge_base → empty (welded/synthesized); skill/memory_space → empty when their feature flag is off. Behind AGENTS_API_ENABLED. - Fix binding_validation._validate_model: it resolved models via get_managed_model() — a primary-key lookup on the internal UUID — but modelConfig.modelId is the Bedrock model_id that the runtime resolver, RBAC (permissions.models) and invocation all key on. A valid model would have been rejected 400 on save the moment the picker set one. Now matches by model_id, consistent with the whole chain. Frontend (Phase 4 UI): - New agents/ feature dir (separate from the Assistants editor): Agent + Binding + BindableItem TS contracts, a thin AgentApiService, and an AgentService signal facade with the accessible$ 404-probe idiom + a per-kind bindable cache. - Agents list page (model + binding-count badges) and an agent-form page: persona/emoji/tags/starters, a required single-select model picker (D3), tool/skill multi-select chips, and a memory-space picker with access (read/read+write — write disabled unless editor+ on the space, per D5) and an alwaysLoad MEMORY.md toggle. KB shown read-only. Sharing reuses the assistants share dialog (agentId == assistantId). - Routes agents / agents/new / agents/:id/edit, plus a sidenav "Agents" entry gated on the accessible$ probe. Tests: backend 1552 pass (9 new catalog + 4 new route + 3 updated model-validation); SPA build + tsc clean, ng test 7 AgentService + 11 sidenav specs pass. Co-Authored-By: Claude Opus 4.8 * test(sidenav): stub AgentService probe to fix unhandled HTTP rejection The sidenav constructor now probes agent accessibility (void agentService.loadAgents()), but sidenav.spec.ts didn't provide a mock AgentService, so the real service fired an unstubbed HTTP GET /agents that rejected with status 0 — vitest fails the run on unhandled errors even though all assertions passed. Provide a mock AgentService (accessible$ signal + no-op loadAgents) mirroring the schedule/memory stubs, plus parity tests for the probe + showAgents gate. Co-Authored-By: Claude Opus 4.8 * fix(agent-designer): align model write-check with the bindable catalog The catalog lists models via ModelAccessService.filter_accessible_models, but design-time write validation used can_access_model — and the two disagree. filter_accessible_models grants access whenever the model id is in the user's AppRole permissions.models; can_access_model only honors that membership when the model record ALSO carries a non-empty allowed_app_roles. So a model granted purely via the user's AppRole (empty allowed_app_roles) was listed by the picker but rejected on save with a 403 — and the runtime resolver (membership-based) would actually have allowed it. Validate the model with the same filter_accessible_models predicate the catalog uses, so 'if the palette offers it, the write accepts it' holds by construction. Adds a regression test for the empty-allowed_app_roles grant. Co-Authored-By: Claude Opus 4.8 * feat(sidenav): gate Memory Spaces + Agents to system-admin, add Preview badges Match the Scheduled Runs treatment for the two other preview surfaces: Memory Spaces and Agents now also require the system_admin AppRole (showX() && isAdmin()) in addition to their accessibility probe. Adds a small amber 'Preview' badge to all three nav entries (Agents, Memory Spaces, Scheduled Runs) so their preview status is visible. Co-Authored-By: Claude Opus 4.8 * feat(agent-designer): resolve tool bindings at invocation (replace + per-invoker RBAC) An Agent's `tool` bindings were stored by the Designer but inert at run time — the free-select tool picker fully drove the toolset regardless of what the Agent bound. This resolves them, mirroring the shipped `modelConfig` override: - Run-time (inference-api): `resolve_agent_invocation` now returns `plan.tools` (`ResolvedTools`). When an Agent binds tools they *replace* the request's `enabled_tools` for the turn; each bound tool is re-checked against the INVOKING user via `AppRoleService.can_access_tool` (the same AppRole gate the harness uses for model, R2) and a missing tool blocks the turn with a message (D5). No tool binding ⇒ `plan.tools is None` ⇒ the request drives the toolset exactly as today. Wired at the existing `extra_tools`/`get_agent` seam via `effective_enabled_tools` (also feeds the spreadsheet/artifact tool gates + attachment guidance/inventory). - Design-time (app-api): `tool` dropped from `_INERT_KINDS`; a bound tool must be in the author's palette (`ToolCatalogService.get_user_accessible_tools`, the same source the picker fetches — "if the palette offers it, the write accepts it", cf. the model check). The palette is resolved once per write. `skill` bindings stay inert here (their run-time fold interacts with agent_type/skill resolution — a follow-up slice). Tests: 6 resolver cases (override, dedupe, block-on-missing, per-invoker, none→passthrough) + 5 validation cases (accessible/inaccessible/empty-ref/fetch-once/lazy). Full backend suite green (4621 passed). Co-Authored-By: Claude Opus 4.8 * feat(topnav): surface active assistant in the top nav Move the assistant/agent indicator out of the chat-input footer and into the top nav, beside the session title, so an attached assistant is visible throughout the conversation. - Add a compact 'variant' to app-assistant-indicator: a subtle name-only pill (emoji + name) that opens the same actions menu (New session / Edit / Share) on click. The full card style is preserved behind variant="card". - Add a menuPlacement input so the actions dropdown opens downward in the top nav instead of clipping off-screen. - Thread the assistant/owner/loading state and action outputs from the chat container into app-topnav; render the pill (with a loading shimmer) to the right of the title. - Remove the now-orphaned footer indicator and loading skeletons from the full-page chat container (embedded preview footer left intact). - Assistant card: move conversation starters into a collapsible accordion (expanded by default) to keep the card compact. Co-Authored-By: Claude Opus 4.8 * feat(agent-designer): resolve skill bindings at invocation (replace + force skill-mode) Completes the tool/skill runtime-resolution gap (tools landed in #601). An Agent's `skill` bindings were stored by the Designer but inert at run time. This resolves them, mirroring the tool/model overrides: - Run-time (inference-api): `resolve_agent_invocation` now returns `plan.skills` (`ResolvedSkills`). When an Agent binds skills they *replace* the request's skills for the turn AND the route forces `agent_type="skill"` so the SkillAgent discloses exactly the bound set. Each bound skill is re-checked against the INVOKING user via `AppRoleService.can_access_skill`; a missing skill — or the Skills feature being disabled in this environment — blocks the turn with a message (D5). No skill binding ⇒ `plan.skills is None` ⇒ the request's agent_type/enabled_skills drive the turn as today. Wired by reassigning `effective_agent_type`/`effective_skill_ids` before the main-turn get_agent, so the values flow into the construction snapshot and a bound-skill agent resumes on the same skills_hash (resume-safe, same mechanism the tool slice relies on). - Design-time (app-api): `skill` dropped from inert (no inert kinds remain). A bound skill is flag-gated (`skills_enabled()`) and must be in the author's palette (`resolve_accessible_skill_ids`, the same source the picker fetches — cf. the tool check); the palette is resolved once per write and only when skills are enabled. Tests: +6 resolver (override, dedupe, flag-off block, block-on-missing, per-invoker, none →passthrough) + 6 validation (accessible/inaccessible/empty-ref/flag-off/fetch-once/lazy). Full backend suite green (4631 passed). Co-Authored-By: Claude Opus 4.8 * feat(agent-designer): reflect governed agent bindings in the chat-input (lock pickers) The backend governs an Agent's model/tool/skill bindings at invocation (#601, #602) — the agent's set wins regardless of what the client sends. The chat-input still showed the model/tool/skill pickers as free-select, which was dishonest (a change the backend ignores). This locks each picker to the active Agent's bindings, per primitive. - Session page (`session.page.ts`): inject AgentService/ToolService/SkillService; fetch the governed Agent alongside the assistant (agentId == assistantId) in `loadAssistant`; apply per-primitive locks from `modelConfig`/`bindings`, and release them when navigating to plain chat. Best-effort: the /agents surface may be disabled (404) or the assistant may be a legacy assistant with no bindings — every failure leaves the pickers free-select. - ModelService/ToolService/SkillService: add a small agent-lock API (`lockToAgent*` / `clearAgentLock` + `agentLocked`/`agentModelLocked`). While locked, `enabledToolIds`/ `enabledSkillIds` return the bound set (replace semantics, matching the backend), toggles no-op, and `isToolShownEnabled`/`isSkillShownEnabled` render the bound set honestly. - UI: model-dropdown shows a locked read-only chip ("set by this agent"); model-settings shows a "Set by agent" model row and a "This agent uses a fixed set of tools/skills" banner, with tool/skill/sub-tool toggles disabled + greyed while locked. This is UI honesty, not enforcement — the backend remains the authority. Per-primitive: an agent that binds a model but no tools locks only the model; the rest stay free-select. Tests: +5 tool-lock, +5 skill-lock, +4 model-lock service specs (ng test, 51 pass); `tsc` clean; production build (AOT template check) clean. Known limitations (documented for follow-up): a model race if the pinned model isn't in the user's loaded set yet (dropdown disables but may show the fallback name until models load); the skill-lock banner only shows in skills chat-mode. Co-Authored-By: Claude Opus 4.8 * fix(agent-designer): release chat-input picker locks on new conversation The agent-binding picker locks live in root singleton services (Model/Tool/Skill Service) that outlive the session component. Clicking "New chat" navigates to `/`, which recreates the session component with fresh assistant()/agent() signals (both null). The lock-release lived inside the `if (loadedAssistant || … || agent())` guard, which is false on that fresh component — so the stale locks from the previous agent conversation were never released, leaving the model + tools pickers stuck. Move `clearAgentBindingLocks()` out of the guard so it always runs when there is no assistant in the URL. Idempotent — a no-op when nothing is locked. Co-Authored-By: Claude Opus 4.8 * feat(agent-designer): show only the bound tools/skills when an agent locks the settings When an Agent dictates a fixed toolset/skillset, the settings panel listed every accessible tool/skill with the bound ones toggled on and the rest greyed off — a long, noisy list. Filter to show ONLY the bound (enabled) tools/skills so the panel reflects exactly what the agent uses. - ToolService.visibleTools / SkillService.visibleSkills: agent-locked → filter to the bound ids; otherwise the full accessible list. - model-settings template iterates the visible* lists. Tests: +1 tool-lock, +1 skill-lock spec (ng test green); tsc + AOT build clean. Co-Authored-By: Claude Opus 4.8 * chore(agent-designer): default AGENTS_API_ENABLED on with a kill switch The Agent Designer is complete (contract → surface → resolution → Designer UI → binding reflection), so flip the feature flag from opt-in to default-on, matching the house style for shipped features (scheduled_runs / memorySpaces). - backend `agents_enabled()`: empty-string-safe default-on — unset/empty ⇒ enabled, only the literal "false" disables (was `== "true"`, default off). - CDK `config.agents.enabled`: mirror the memorySpaces/scheduledRuns ternary (`!== 'false'` + context fallback `?? true`), so an unset/empty GitHub Actions var can't silently disable it. - Tests: add the Agents API default-on/empty/kill-switch/context suite to config.test.ts (mirrors Memory Spaces); rename the app-api-environment threading test (no longer "default off"). The `/agents/*` API now ships everywhere; the SPA nav stays preview-gated (system-admin + "Preview" badge) until Assistants are deprecated, so this doesn't broaden user-facing exposure — it just stops the API 404ing per-environment. Co-Authored-By: Claude Opus 4.8 * feat(agent-designer): manage an agent's knowledge base from the Agent Designer Extract the assistant editor's inline "Knowledge base" section into a standalone, reusable KnowledgeBaseSectionComponent and use it in both the assistant form and the Agent Designer — replacing the agent form's read-only "managed automatically" card with the live document/web-crawl/connector flow. This closes the last Agent migration blocker. The gap was frontend-only: the document upload/ingestion/retrieval pipeline already keys on the record id and agentId == assistantId, so /assistants/{id}/documents backs an agent unchanged. No backend or data-model changes (Option 1, not the deferred F4 first-class KB primitive). The component owns record identity via a createDraft callback so the first content-adding action can mint a draft in create mode; a permissionResolved input gates the edit-only sync-policy calls so a viewer never 403s on the default owner guess. The assistant form keeps createDraftAssistant as its callback (shedding ~1000 lines); the agent form adds createDraftAgent and drops the read-only kbBinding path. Verified: ng build clean, ng test 1449 specs green (incl. assistant-form spec). Co-Authored-By: Claude Opus 4.8 * test(scheduled-runs): freeze dispatcher clock to de-flake cadence rearm test test_next_run_at_uses_schedule_cadence asserted the daily-9am re-arm delta fell in (1h, 48h), which fails when CI runs in the hour before 9am Boise (the next daily run is legitimately <1h away). Freeze dispatcher._now to a fixed instant and assert next_run_at equals compute_next_run_at recomputed from the same instant, making the test time-of-day independent. Co-Authored-By: Claude Opus 4.8 * feat(agent-designer): govern model params + live editor preview Model-params governance: - binding_validation._validate_model_params rejects params that are unsupported / locked / out-of-[min,max] / out-of-allowed against the model's admin supported_params (belt-and-suspenders to the runtime merge; author-facing 400 instead of a silent clamp). +9 tests. - Data-driven Parameters subsection under the model picker reading meta.supportedParams (numeric inputs, enum selects, locked read-only); empty params omit `params` (today's exact resolution). Live side-by-side preview in the agent editor: - New AgentPreviewComponent reuses PreviewChatService and streams the SAVED agent through the real /chat/stream invocation path, so all bindings (model/params/tools/skills/memory) resolve server-side. Capability strip + dirty banner make the resolved context and the save-to-apply semantics explicit. - Agents send a minimal request body (message/session_id/agent id) and opt out of the assistant preview's system_prompt + owner-tools injection, which fought the bindings and blew the 8KB system_prompt cap for long personas (422). PreviewChatService gains a backward- compatible opts flag; assistant preview behavior unchanged. - Two-column editor shell mirroring the assistant editor. Verified: backend 59 pass (9 new), ng build clean, 16 SPA specs (7 agents + 9 preview-chat). Co-Authored-By: Claude Opus 4.8 * feat(agent-designer): lock preview model picker; trim preview nav The Agent Designer preview reused the main chat-input, whose model dropdown reads the root ModelService — so it showed the user's global model (e.g. Sonnet 5) and let them switch it, even though the harness resolves the model from the agent's binding server-side. Wire the preview to lock that picker to the agent's model via the same lockToAgentModel mechanism the session page uses for a real agent conversation, released on destroy (and idempotently on the next plain chat via the session page's self-heal effect). Also hide the Memory Spaces and Scheduled Runs side-nav entries for now (routes/pages and their capability probes are unchanged, so re-enabling is just re-adding the template blocks). Agents stays system-admin only. Co-Authored-By: Claude Opus 4.8 * fix(memory-spaces): route namespaced entry slugs via :path converter Entry slugs are namespaced with a slash (e.g. `people/brian-bolt`), but the app-api entry routes declared a plain `{slug}` param whose converter stops at `/`. Uvicorn percent-decodes `%2F`→`/` before routing, so `/entries/people/ brian-bolt` never matched `/entries/{slug}` and returned 404 on view/edit/delete. Switch the GET/PUT/DELETE entry routes to the `{slug:path}` converter so the embedded slash is captured and the slug arrives matching the manifest. Adds a route test exercising upsert→read→delete with a slashed slug. Co-Authored-By: Claude Opus 4.8 * feat(memory-spaces): let the agent read/write MEMORY.md via reserved slug MEMORY.md is the space's human-readable index — a standalone S3 object outside the entries manifest, injected into the agent's context each session via hydration. It never appears in `memory_list`, and the agent had no tool to read it back or keep it in sync with the entries it writes, so the machine-readable manifest and the human-readable index could silently drift. Route the reserved `"MEMORY.md"` slug (case-insensitive) through the existing service methods: `memory_read("MEMORY.md")` → `read_index` (viewer+), `memory_write("MEMORY.md", body)` → `update_index` (editor+, body only). No new tool surface; matches the literal hydration already uses. The slug is reserved — the agent cannot create an ordinary entry named MEMORY.md. Write stays gated identically to entry writes (only bound when the binding grants readwrite; service re-checks editor+). Docstrings + spec §4/§5 updated. Co-Authored-By: Claude Opus 4.8 * feat(schedules): target Agents instead of Assistants on scheduled runs The scheduled-run form's target selector now lists Agents (the Agent Designer primitive that supersedes the Assistant) instead of Assistants. Same underlying record — agentId == assistantId — so the wire field stays `assistantId` and no backend change is needed for the swap. Because an Agent's `tool` bindings replace the run's `enabled_tools` at invocation (agent_binding_resolver / routes.py effective_enabled_tools), the manual tool picker is now hidden whenever an Agent is selected — showing it would let the user pick tools that get silently discarded. The picker (and its snapshot semantics) remains only for the "Default agent" case. Submit drops any stale snapshot when an Agent is targeted. Also fixes "Run now" to target the selected Agent via ragAssistantId (the /runs/now backend already accepts it) — previously it ignored the target, so the attended test surface didn't match what the schedule would actually run. Co-Authored-By: Claude Opus 4.8 * chore(deps): upgrade Strands to 1.47.0 and add aws-bedrock-token-generator Bumps strands-agents 1.40.0 -> 1.47.0 (and the [bidi] extra to match) and adds aws-bedrock-token-generator==1.1.0 (bounded >=1.1.0,<2.0.0 by strands' openai extra). strands-agents-tools stays at 0.5.2 (resolver-confirmed compatible). Unblocks Bedrock Mantle work that needs the newer SDK: - OpenAIResponsesModel (Responses API) for models that don't support Chat Completions (e.g. openai.gpt-5.x on Mantle). - bedrock_mantle_config, which mints the Mantle bearer token via aws-bedrock-token-generator and derives the base URL + model-family base path (openai.gpt-5.* -> /openai/v1, else -> /v1). Full backend suite green on 1.47.0 (2306 passed). Co-Authored-By: Claude Opus 4.8 * fix(scheduled-runs): remove RBAC gate causing prod 403 "Access Denied" Regular users hit a 403 "You do not have access to scheduled runs" toast on page load. The `/schedules` and `/runs/*` surfaces were gated by the `scheduled-runs` RBAC capability, granted only to a beta cohort's AppRole (admins passed via the `*` wildcard). The sidenav ran a background `loadSchedules()` probe on every load, and the global errorInterceptor popped the toast on the 403 before the schedule service's graceful catch ran. The feature doesn't need admin/beta gating — keep it low-key and reachable only by direct URL for now: - Drop the capability check from both `require_scheduled_runs_user` gates; only the `SCHEDULED_RUNS_ENABLED` kill switch remains (404 when off). Runs still execute with the caller's own RBAC-allowed tools, so this widens who can reach the surface, not what any one caller can do. - Remove the vestigial sidenav schedules probe and dead showSchedules/navigateToSchedules wiring (the template never rendered a "Scheduled runs" link). - Update route + sidenav tests accordingly. `apis/shared/rbac/capabilities.py` is now unreferenced; left in place as generic RBAC infra so re-gating is a two-line revert. Co-Authored-By: Claude Opus 4.8 * docs: consolidate release workflow into one auto-invoked steering doc + skill Fold the versioning and release-notes guidance into a single 'cutting a release' guide covering the branch workflow, SemVer bump + version sync, change identification across the divergent main/develop histories, writing both release docs, the squash-merge PR into main, and the required backmerge into develop. - Add .kiro/steering/cutting-a-release.md (inclusion: auto — name + description, intent-triggered) - Add .claude/skills/cutting-a-release/ (SKILL.md auto-invoked via description) with references/{release-notes-format,changelog-format}.md for progressive disclosure - Remove superseded .kiro/steering/{versioning,release-notes}.md and .claude/skills/{versioning,release-notes}/ - Repoint .github/copilot-instructions.md at the consolidated skill/steering * feat(models): Mantle Responses API + per-model region; drop endpoint-path knob Refactors the admin "mantle" provider onto Strands' bedrock_mantle_config so the SDK owns the base URL, model-family base path, and bearer-token minting — removing hand-rolled inference plumbing. Adds the two things the library can't infer as declarative per-model fields: - apiMode (chat | responses): selects OpenAIModel vs OpenAIResponsesModel. Some Mantle models (e.g. openai.gpt-5.x) only serve the Responses API and reject Chat Completions, which the endpoint-path knob could never satisfy. - region: optional override into bedrock_mantle_config["region"], driving both the Mantle endpoint host and the SigV4 region the token is signed for — so a model can pin inference to its host region (e.g. gpt-5.x in us-east-1) independent of where the app runs. mantleEndpointPath is kept as an accept-but-ignore deprecated schema field (no stored record breaks) and removed from the UI + runtime. The Responses API uses different native param names, so to_mantle_config selects a Responses map (max_output_tokens, nested reasoning.effort) by mode. Runtime fields (mantle_api_mode/mantle_region) thread through model_config, the agent factory, base_agent, the paused-turn snapshot, stream_coordinator, and the chat service/routes. get_mantle_base_url/generate_bedrock_bearer_token are retained for the admin model-browse list (not inference). Gemma 4 (google.gemma-4-31b) is temporarily un-curated: it needs the /openai/v1 base path but the SDK only routes openai.gpt-5.* there, and bedrock_mantle_config forbids a base_url override. Re-add once the "google.gemma-" family prefix lands upstream in strands-agents/sdk-python. Backend suite green (2342). Frontend typecheck + manage-models specs green. Co-Authored-By: Claude Opus 4.8 * fix(api-converse): serve /chat/api-converse from app-api, not via inference proxy The API-key converse endpoint was broken in cloud. app-api proxied POST /chat/api-converse to `{INFERENCE_API_URL}/chat/api-converse`, but inference-api now runs inside an AgentCore Runtime whose data plane only serves POST /invocations and GET /ping — any other path returns UnknownOperationException (404) before reaching the container. It worked locally only because localhost:8001 bypasses the runtime gateway. Relocate the handler onto app-api as a self-contained route (validate key -> RBAC -> bedrock-runtime.converse -> cost accounting), reusing the shared services it already depends on. app-api reaches Bedrock directly via its task role, so there is no inference-api hop and no INFERENCE_API_URL dependency. Delete the proxy, the now-dead inference-api route, and its DTOs (moved to app_api/chat/models.py). Repoint the converse tests at the app-api module. Verified: 263 backend tests pass, import-boundary test clean, and a real un-mocked smoke against a Bedrock model returns 200 (stream + non-stream). Co-Authored-By: Claude Opus 4.8 * fix(app-api): grant Bedrock streaming + inference-profile invoke The relocated /chat/api-converse handler calls Bedrock Converse from app-api, so the task role's invoke grant must cover what the catalog's model IDs need. Expand the BedrockInvokeModel statement to add bedrock:InvokeModelWithResponseStream (the stream=true path) and broaden resources to all-region foundation models plus the account-level inference-profile ARN, since the catalog uses `us.*` cross-region inference profiles. Mirrors inference-api's BedrockModelInvocation grant. Verified: infra tsc clean, 442 infra jest tests pass. Co-Authored-By: Claude Opus 4.8 * fix(settings): point API-key snippets at /api/chat/api-converse After the BFF refactor, CloudFront only routes /api/* to the backend; other paths hit the SPA origin, which rejects POST with a CloudFront 403. The generated curl/Python/JS examples emitted the bare origin, producing `/chat/api-converse`. Resolve a relative/empty appApiUrl against the current origin so snippets target `/api/chat/api-converse`; leave an already-absolute value (local dev's http://localhost:8000) untouched. Co-Authored-By: Claude Opus 4.8 * feat(api-converse): route Bedrock Mantle models via a shared builder The API-key /chat/api-converse handler was Bedrock-only; provider="mantle" models (e.g. openai.gpt-5.4) 400'd because it always called bedrock-runtime.converse. Add a Mantle path so the full model catalog works. Extract the Mantle model construction (class-pick + bedrock_mantle_config) and its param maps + MantleApiMode enum out of agents/main_agent/core into a new apis/shared/models/mantle.py, so the agent factory and the API-key handler share ONE implementation (app-api can't import agents/). The factory now delegates to build_mantle_model. The handler resolves the requested model's provider from the catalog and branches: bedrock -> boto3 converse (unchanged); mantle -> the shared builder + the bare Strands model's .stream(), which yields the same Converse-shaped events the Bedrock path already emits — so SSE translation and usage/cost accounting are shared (cost is now tagged with the real provider). Unknown / lookup-failure ids fail safe to the Bedrock path. Verified: shared builder + factory-delegation + handler mantle-path unit tests; and real dev-ai smokes — chat-mode Mantle and Responses-API Mantle (openai.gpt-5.4) both return 200 (stream + non-stream) against the live endpoint. Co-Authored-By: Claude Opus 4.8 * feat(app-api): grant bedrock-mantle:CreateInference for api-converse The api-converse Mantle path invokes a Mantle model directly from app-api, so the task role needs bedrock-mantle:CreateInference (Mantle's own IAM namespace) — without it, mantle requests AccessDeny. Fold it into the existing project-scoped Mantle statement (was browse-only Get*/List*), renamed BedrockMantleInference to mirror the runtime role's grant. Verified: infra tsc clean, integration jest green (24 passed). Co-Authored-By: Claude Opus 4.8 * Release/1.3.0 Bedrock Mantle expansion + API-key endpoint repair. Mantle models gain per-model apiMode (chat|responses) and region fields on Strands 1.47's bedrock_mantle_config, unlocking Responses-only models like openai.gpt-5.x; the API-key POST /chat/api-converse endpoint is relocated onto app-api (the inference-api proxy was unreachable behind the AgentCore Runtime data plane) and now serves the full model catalog including Mantle via a shared build_mantle_model builder. Plus Memory Spaces and Scheduled Runs follow-ups. - feat(models): Mantle Responses API + per-model region; drop endpoint-path knob (#620) - feat(api-converse): serve /chat/api-converse from app-api with Mantle routing + IAM grants (#621) - feat(memory-spaces): agent read/write MEMORY.md via reserved slug (#614) - feat(schedules): target Agents instead of Assistants on scheduled runs (#615) - fix(scheduled-runs): remove RBAC gate causing prod 403 (#617) - fix(memory-spaces): route namespaced entry slugs via :path converter (#614) - chore(deps): strands-agents 1.40.0 -> 1.47.0 + aws-bedrock-token-generator 1.1.0 (#619) - docs: consolidate release workflow into one skill (#618) Co-Authored-By: Claude Fable 5 --------- Signed-off-by: Colin Smith <7762103+colinmxs@users.noreply.github.com> Signed-off-by: Phil Merrell Signed-off-by: ofilson Signed-off-by: dependabot[bot] Signed-off-by: colinmxs Co-authored-by: Colin Smith <7762103+colinmxs@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 Co-authored-by: Colin Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> Co-authored-by: colinmxs Co-authored-by: derrickfink Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: ofilson Co-authored-by: Oscar Filson Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Derrick Fink Co-authored-by: Colin Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> --- .claude/skills/cutting-a-release/SKILL.md | 249 +++++++ .../references/changelog-format.md | 74 ++ .../references/release-notes-format.md | 89 +++ .claude/skills/release-notes/SKILL.md | 257 ------- .claude/skills/versioning/SKILL.md | 30 - .github/copilot-instructions.md | 3 +- .kiro/steering/cutting-a-release.md | 296 ++++++++ .kiro/steering/release-notes.md | 277 ------- .kiro/steering/versioning.md | 30 - CHANGELOG.md | 35 + README.md | 4 +- RELEASE_NOTES.md | 81 +++ VERSION | 2 +- backend/pyproject.toml | 9 +- .../builtin_tools/memory_spaces/tools.py | 54 +- backend/src/agents/main_agent/base_agent.py | 9 +- .../agents/main_agent/core/agent_factory.py | 58 +- .../agents/main_agent/core/model_config.py | 79 +- .../streaming/stream_coordinator.py | 3 +- .../src/apis/app_api/chat/converse_routes.py | 678 +++++++++++++++--- backend/src/apis/app_api/chat/models.py | 43 ++ backend/src/apis/app_api/main.py | 2 +- .../src/apis/app_api/memory_spaces/routes.py | 6 +- backend/src/apis/app_api/runs/routes.py | 30 +- backend/src/apis/app_api/schedules/routes.py | 27 +- .../inference_api/chat/converse_routes.py | 467 ------------ backend/src/apis/inference_api/chat/models.py | 38 - backend/src/apis/inference_api/chat/routes.py | 46 +- .../src/apis/inference_api/chat/service.py | 6 +- backend/src/apis/inference_api/main.py | 2 - .../src/apis/shared/bedrock/bearer_token.py | 5 +- .../src/apis/shared/models/managed_models.py | 41 +- backend/src/apis/shared/models/mantle.py | 105 +++ backend/src/apis/shared/models/models.py | 55 +- backend/src/apis/shared/sessions/models.py | 17 +- .../memory_spaces/test_memory_tools.py | 33 + .../main_agent/core/test_agent_factory.py | 91 ++- .../main_agent/core/test_model_config.py | 36 +- .../apis/app_api/test_memory_spaces_routes.py | 21 + .../tests/apis/app_api/test_runs_routes.py | 27 +- .../tests/routes/test_api_converse_mantle.py | 193 +++++ .../routes/test_converse_cost_accounting.py | 26 +- .../routes/test_model_access_enforcement.py | 44 +- backend/tests/routes/test_pbt_model_access.py | 10 +- .../test_pbt_model_access_preservation.py | 32 +- backend/tests/routes/test_schedules_routes.py | 22 +- backend/tests/shared/test_managed_models.py | 33 +- backend/tests/shared/test_mantle.py | 81 +++ backend/uv.lock | 29 +- docs/specs/user-markdown-memory.md | 14 + frontend/ai.client/package-lock.json | 4 +- frontend/ai.client/package.json | 2 +- .../manage-models/model-catalog.page.spec.ts | 10 +- .../admin/manage-models/model-form.page.html | 38 +- .../admin/manage-models/model-form.page.ts | 41 +- .../manage-models/models/curated-models.ts | 43 +- .../models/managed-model.model.ts | 39 +- .../app/components/sidenav/sidenav.spec.ts | 41 +- .../src/app/components/sidenav/sidenav.ts | 27 +- .../app/schedules/models/schedule.model.ts | 6 + .../schedule-form/schedule-form.page.html | 93 +-- .../schedule-form/schedule-form.page.spec.ts | 47 +- .../schedule-form/schedule-form.page.ts | 79 +- .../settings/pages/api-keys/api-keys.page.ts | 16 +- .../constructs/app-api/app-api-iam-grants.ts | 40 +- infrastructure/package-lock.json | 4 +- infrastructure/package.json | 2 +- 67 files changed, 2708 insertions(+), 1723 deletions(-) create mode 100644 .claude/skills/cutting-a-release/SKILL.md create mode 100644 .claude/skills/cutting-a-release/references/changelog-format.md create mode 100644 .claude/skills/cutting-a-release/references/release-notes-format.md delete mode 100644 .claude/skills/release-notes/SKILL.md delete mode 100644 .claude/skills/versioning/SKILL.md create mode 100644 .kiro/steering/cutting-a-release.md delete mode 100644 .kiro/steering/release-notes.md delete mode 100644 .kiro/steering/versioning.md create mode 100644 backend/src/apis/app_api/chat/models.py delete mode 100644 backend/src/apis/inference_api/chat/converse_routes.py create mode 100644 backend/src/apis/shared/models/mantle.py create mode 100644 backend/tests/routes/test_api_converse_mantle.py create mode 100644 backend/tests/shared/test_mantle.py diff --git a/.claude/skills/cutting-a-release/SKILL.md b/.claude/skills/cutting-a-release/SKILL.md new file mode 100644 index 000000000..df4764a82 --- /dev/null +++ b/.claude/skills/cutting-a-release/SKILL.md @@ -0,0 +1,249 @@ +--- +name: cutting-a-release +description: >- + Cut a new release of this monorepo. Use whenever the user wants to make, cut, + ship, or prep a release, bump the version, or update RELEASE_NOTES.md / + CHANGELOG.md — e.g. "let's cut a release", "make a new release", "bump the + version and update the release notes", "ship the next version". Covers the + release-branch workflow, the SemVer bump + version sync, identifying what + changed across the divergent main/develop histories, writing the changelog and + release notes sized to the release, the squash-merge PR into main, and the + required backmerge into develop. +--- + +# Cutting a Release + +Single source of truth for cutting a release. Three concerns move together: the +**branch/PR workflow**, the **version bump**, and the **two release documents** +(`RELEASE_NOTES.md` + `CHANGELOG.md`). Do the steps in order, all on one +`release/x.y.z` branch cut from `develop`. + +Detailed format templates live in reference files — load them when you reach §5: + +- Release-notes structure, section order, and spotlight template → + [references/release-notes-format.md](references/release-notes-format.md) +- Changelog structure and per-release entry skeleton → + [references/changelog-format.md](references/changelog-format.md) + +--- + +## 0. Shape of a release + +``` +develop (PR-only) main (PR-only, squash) + │ 1. pull latest develop │ + │ 2. cut release/x.y.z ────────────┐ + │ bump VERSION + sync │ + │ write CHANGELOG + NOTES │ + │ commit + push │ + │ 3. PR release/x.y.z ──▶ main (squash-merge after review) + │ 4. backmerge ◀────────────────────┤ (branch off develop, merge main in, + │ (PR to develop, MERGE COMMIT)│ PR into develop, MERGE COMMIT) + ▼ ▼ +``` + +Two hard rules of this repo's branch model: + +1. **Never commit directly to `develop` or `main`.** Both are PR-only — the + release bump and the backmerge each land through a branch + PR. +2. **After the squash-merge into `main`, you MUST backmerge `main` → `develop`** + (§6). Skipping it leaves the branches divergent and every future release + conflicts on the release-artifact files. + +--- + +## 1. Branch workflow + +```bash +git switch develop +git pull --ff-only origin develop # release must contain all of develop +git switch -c release/x.y.z # branch name == version being shipped (no v prefix) +``` + +The branch name **corresponds to the version being pushed** (`release/1.2.0` ships +`1.2.0`) and carries **all commits currently on `develop`**. + +Then, on the release branch: create a task list, determine the bump (§3), bump +`VERSION` + sync (§2), write both docs (§4–§5). Keep every edit on the release +branch — `develop` stays untouched. Commit and push: + +```bash +git add VERSION backend/pyproject.toml backend/uv.lock \ + frontend/ai.client/package.json frontend/ai.client/package-lock.json \ + infrastructure/package.json infrastructure/package-lock.json \ + CHANGELOG.md RELEASE_NOTES.md README.md +git commit -m "Release/x.y.z" -m "" +git push -u origin release/x.y.z +``` + +Open a PR `release/x.y.z` → `main`. After prerequisites + code review pass, it is +**squash-merged** into `main` (the version-check gate passes because `VERSION` +changed). Then do the backmerge (§6). + +--- + +## 2. Version bump + +Single `VERSION` file at the repo root is the source of truth. Format: +`MAJOR.MINOR.PATCH[-PRERELEASE]` (SemVer 2.0), no `v` prefix. + +1. Edit `VERSION`. +2. `bash scripts/common/sync-version.sh` +3. `bash scripts/common/sync-version.sh --check` → must print `[PASS]`. +4. Commit `VERSION` **and every file the sync touched, including lockfiles**. + +The sync script propagates the version into `backend/pyproject.toml`, +`frontend/ai.client/package.json`, `infrastructure/package.json`, the `README.md` +badge + "Current release" line, and **regenerates** `backend/uv.lock` and both +`package-lock.json`. Run it inside the dev container (needs `uv` + `npm` at pinned +versions). + +- **PR gate:** PRs to `main` fail CI if `VERSION` is unchanged vs `main` or the + manifests are out of sync. +- **CI handles the rest:** Docker image tags, AWS resource tags, health-endpoint + versions, frontend version display, and the `vX.Y.Z` git tag. Just bump + sync. + +--- + +## 3. Decide the bump — SemVer + release sizing + +Pick the tier from what shipped, then size the write-up to match. + +| Bump | When | Notes depth | +|---|---|---| +| **Patch** `x.y.Z` | Bug fixes, security/dep bumps, CI/CD, docs, internal refactors — no new user-facing capability | **Brief.** 2–4 sentence Highlights + compact per-category bullets + one deployment note. No spotlights, no per-layer subsections. | +| **Minor** `x.Y.0` | New features, endpoints, pages, capabilities (even if flag-gated/preview) | **Deep.** Highlights + one spotlight per major feature (backend/frontend/infra/tests) + per-category bullets. | +| **Major** `X.0.0` | Breaking changes, architecture shifts, migrations | **Deepest.** Above + prominent migration/upgrade section and breaking-change callouts. | + +Rules of thumb: don't pad a patch; don't starve a feature; size to the largest +single change; turning a dark feature **on by default** or completing a preview +capability counts as user-facing → **minor**. + +--- + +## 4. Identify what changed (the divergent-history trap) + +`develop` is squash-merged → `main`, so **`main` and `develop` have divergent +histories**; a squash collapses a whole release into one commit on `main` whose +SHA matches nothing on `develop`. + +> **Do NOT use `git log main..develop`.** Even after a backmerge makes `main` an +> ancestor of `develop`, main's *squashed* history means develop's granular +> commits are never reachable from main — so `main..develop` returns the entire +> project history, not the release delta. + +Use the previous release's cut point (or tag) as the boundary: + +```bash +git tag --list 'v*' --sort=-creatordate | head +git log ..develop --no-merges --reverse --format='%h %s' +git log ..develop --merges --format='%s' # PR numbers +``` + +Exclude commits a prior **backmerge** dragged in (the previous `Release/x.y.z` +squash commit, direct-to-main hotfixes) — they aren't new work. For every +non-trivial commit **read the diff, not the message** (`git show --stat `). +Bucket each change: + +| Category | Emoji | Use for | +|---|---|---| +| New | 🚀 | New features, endpoints, pages, capabilities | +| Improved | ✨ | Enhancements to existing features | +| Fixed | 🐛 | Bug fixes | +| Changed | ⚠️ | Breaking changes, removals, deprecations, migrations | +| Security | 🔒 | CVE patches, CodeQL fixes, auth hardening | +| Performance | ⚡ | Latency/throughput/cost wins users notice | +| Infrastructure | 🏗️ | CDK/IaC changes, new AWS resources, deploy-order changes | +| Dependencies | 📦 | Package upgrades (grouped in a table) | +| CI/CD | 🔧 | Workflow/pipeline/tooling changes | +| Docs | 📚 | Documentation worth calling out | + +**Include** user/operator-facing changes, bug fixes people hit, security updates, +breaking changes, dependency upgrades. **Changelog-only:** minor dep bumps with no +behavior change, internal test additions. **Exclude from both:** pure internal +refactors, typo/comment/formatter churn. + +--- + +## 5. Write the two documents + +Same pass, same categorized list. Write `CHANGELOG.md` first (factual log), then +`RELEASE_NOTES.md` (narrative). Cross-check every changelog line maps to something +in the notes (except the changelog-only items above). + +- **`CHANGELOG.md`** — [Keep a Changelog], terse, one line per change, PR-linked + `(#NNN)` when known, grouped by category emoji. New version at top; omit empty + categories; never invent PR numbers. Full skeleton → + [references/changelog-format.md](references/changelog-format.md). +- **`RELEASE_NOTES.md`** — narrative, benefit-first with technical depth. New + release at top; **never modify previous entries.** Lead each item with the + user/operator outcome, then the mechanism (files, endpoints, classes). Section + order + spotlight template → [references/release-notes-format.md](references/release-notes-format.md). + +**Translate technical → benefit.** "Implemented tool-catalog caching" → "Tool +admin changes now propagate to chat on the next turn (was: required a restart). +Backed by a TTL-cached DynamoDB snapshot." + +**Link hygiene:** GitHub renders `{#custom-anchor}` heading suffixes literally — +don't use them; prefer plain text over fragile intra-doc anchors. + +**Before finalizing:** `sync-version.sh --check` passes, and both docs lead with +the new version above the untouched previous entries. + +--- + +## 6. Backmerge `main` → `develop` (required) + +After the release PR squash-merges into `main`, reconcile the branches — PR-based, +you cannot push directly to `develop`. + +```bash +git fetch origin main develop +git switch develop && git merge --ff-only origin/develop +git switch -c backmerge/main-into-develop-x.y.z +git merge --no-commit --no-ff origin/main # preview conflicts +``` + +If there are conflicts they're only the release-artifact files (`VERSION`, +`CHANGELOG.md`, `RELEASE_NOTES.md`, `README.md`, manifests, lockfiles) — resolve +in **main's favor** (main holds the canonical post-release state; develop's copy +is pre-release plus any stale `[Unreleased]`-style content that has now shipped): + +```bash +git checkout --theirs -- VERSION CHANGELOG.md RELEASE_NOTES.md README.md \ + backend/pyproject.toml backend/uv.lock \ + frontend/ai.client/package.json frontend/ai.client/package-lock.json \ + infrastructure/package.json infrastructure/package-lock.json +git add -A +bash scripts/common/sync-version.sh --check # must PASS +git commit --no-edit +``` + +Verify `git merge-base --is-ancestor origin/main HEAD` succeeds, push, and open a +PR into `develop`. + +> **Merge the backmerge PR with a MERGE COMMIT, not a squash** — the point is to +> make `main` a genuine ancestor of `develop`. Squashing recreates the divergence +> and the conflicts return next release. (Feature branches into `develop` squash +> as usual; the backmerge is the deliberate exception.) + +A clean auto-merge happens when `develop` never touched the release artifacts +since the last reconciliation and `main` only *added* on top. Verify the result +anyway (VERSION + both docs correct, feature code intact) before committing. + +--- + +## 7. Common pitfalls + +- **`git log main..develop`** returns all history, not the delta (§4) — use the + prior release cut point. +- **Trusting commit messages** — read the diff for non-trivial commits. +- **Forgetting lockfiles** — `sync-version.sh` regenerates `uv.lock` + both + `package-lock.json`; commit them or CI drifts. +- **Squashing the backmerge** — recreates divergence; use a merge commit. +- **Committing straight to `develop`/`main`** — both are PR-only. +- **Padding a patch / starving a feature** — size the notes to the release (§3). +- **Inventing PR numbers or fragile `{#anchor}` links** — omit / use plain text. +- **Skipping the backmerge** — the #1 cause of next-release conflict pain. + +[Keep a Changelog]: https://keepachangelog.com/en/1.1.0/ diff --git a/.claude/skills/cutting-a-release/references/changelog-format.md b/.claude/skills/cutting-a-release/references/changelog-format.md new file mode 100644 index 000000000..2a0e96bda --- /dev/null +++ b/.claude/skills/cutting-a-release/references/changelog-format.md @@ -0,0 +1,74 @@ +# `CHANGELOG.md` — structure & entry skeleton + +Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) with the category +emojis from the change-identification step. Terse, factual, PR-linked. No +narrative — that lives in `RELEASE_NOTES.md`. New version goes at the **top**. + +## Full file header (first time only) + +```markdown +# Changelog + +All notable changes to this project are documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +For narrative release notes written for operators and product owners, see [RELEASE_NOTES.md](RELEASE_NOTES.md). +``` + +## Per-release entry + +A short one-paragraph lead is enough, then categorized one-line bullets. Omit +categories with no entries. + +```markdown +## [X.Y.Z] - YYYY-MM-DD + + + +### 🚀 Added +- Voice mode with Nova Sonic bidirectional audio streaming (`/voice/stream` WebSocket endpoint) (#234) +- `create_agent()` factory supporting `chat`, `skill`, and `voice` agent types (#235) + +### ✨ Improved +- Tool admin changes now propagate on the next chat turn via TTL-cached DynamoDB snapshot (#240) + +### ⚠️ Changed +- **Breaking:** Removed `/oauth/*` routes and the in-house token vault. External MCP tools now use AgentCore Identity (#241) + +### 🐛 Fixed +- Duplicate sidebar entries caused by `ensure_session_metadata_exists` conditional collision (#248) + +### 🔒 Security +- Markdown-rendered links now carry `rel="noopener noreferrer"` to prevent reverse-tabnabbing (#252) + +### 📦 Dependencies +- Backend: `fastapi` 0.135.3 → 0.136.1, `strands-agents` 1.34.1 → 1.37.0 +- CI: `github/codeql-action` 4.35.1 → 4.35.2 + +### 🏗️ Infrastructure +- New `CfnWorkloadIdentity` (`-platform-workload`) shared between app-api and inference-api (#241) + +### 🔧 CI/CD +- E2E pipeline added with dynamic CloudFront URL discovery and Cognito user provisioning (#255) +``` + +## Style rules + +- **One line per change.** If it needs more, it belongs as a spotlight in + `RELEASE_NOTES.md` with only a pointer here. +- **Reference PRs with `(#NNN)`** when known. If the PR number isn't available, + omit it — don't invent. +- **Breaking changes stay prominent:** prefix with `**Breaking:**` and include + migration steps inline or link to the release-notes section. +- **Dependency sections** can collapse minor bumps into one line per component; + the full From/To table lives in `RELEASE_NOTES.md`. +- **Omit empty categories** — don't render headings with no entries. + +## Keeping the two documents in sync + +1. Build one master bullet list of every change; categorize and filter it. +2. Write `CHANGELOG.md` first (the factual log). +3. Write `RELEASE_NOTES.md` next, promoting the largest items into narrative + spotlights and leaving the rest as per-category bullets. +4. Cross-check: every `CHANGELOG.md` line maps to something in `RELEASE_NOTES.md` + (spotlight, bullet, or table row) — except the changelog-only exceptions + (minor dep bumps, internal test additions). diff --git a/.claude/skills/cutting-a-release/references/release-notes-format.md b/.claude/skills/cutting-a-release/references/release-notes-format.md new file mode 100644 index 000000000..eb47318a9 --- /dev/null +++ b/.claude/skills/cutting-a-release/references/release-notes-format.md @@ -0,0 +1,89 @@ +# `RELEASE_NOTES.md` — structure & spotlight template + +Narrative, benefit-first release notes for product owners, operators, and the +developers who deploy this system. Detailed and technical, but always lead with +the outcome. The new release goes at the **top**; never modify previous entries. + +> **Patch releases use the short form.** The full section order and feature- +> spotlight template below describe a **feature/minor or major** release. For a +> patch, keep only the **Header**, a short **Highlights** paragraph, the relevant +> **per-category bullets**, and **Deployment notes** — omit feature spotlights, +> per-layer subsections, and the test-coverage section. + +## Header + +```markdown +# Release Notes — vX.Y.Z + +**Release Date:** +**Previous Release:** vX.Y.(Z-1) () + +--- +``` + +## Section order (feature / minor / major) + +1. **Highlights** — 3–5 sentence standalone summary. Someone reading only this + paragraph should grasp the release theme, the 2–3 biggest features, and whether + any action is required. +2. **Feature spotlights** — one `##` per major feature, with backend / frontend / + infrastructure / test-coverage subsections. Narrative depth belongs here. +3. **🐛 Bug fixes** — lead with the user-visible symptom, then the root cause. +4. **🔒 Security** — CVEs, CodeQL findings, auth hardening. +5. **⚡ Performance** — measurable improvements only. +6. **⚠️ Breaking changes** — migration steps required. Omit if none. +7. **🏗️ Infrastructure** — new resources, SSM params, IAM changes operators must know. +8. **🔧 CI/CD** — workflow/pipeline changes, GitHub Actions upgrade table. +9. **📦 Dependencies** — markdown table with From/To columns, grouped by component. +10. **🧪 Test coverage** — line counts + scope for notable additions (optional). +11. **🚀 Deployment notes** — what operators must do differently. Always include, + even if the answer is "no special steps." + +## Feature spotlight template + +```markdown +## + +<1–2 sentence user-facing summary: what changed and why it matters.> + +### Backend +- + +### Frontend +- + +### Infrastructure +- + +### Test Coverage ++ lines of new tests covering . +``` + +## Writing style + +- Match the tone and depth of the existing entries — written for developers who + deploy and maintain this system. +- Every feature section explains **what** changed, **why** it matters, and **how** + it works at a technical level. +- Use specific file names, endpoint paths, and class names. +- Include line counts for large test additions (e.g. "4,200+ lines of new tests"). +- Dependency upgrades use a markdown table with From/To columns. +- Lead with the user/operator outcome; follow with the mechanism. + +## Translating technical → user-benefit + +| Engineering commit | Release-note framing | +|---|---| +| "Implemented caching layer on tool catalog" | "Tool admin changes now propagate to chat on the next turn (previously required a restart). Backed by a TTL-cached DynamoDB snapshot." | +| "Fixed null pointer in session metadata write" | "Resolved an issue where sessions could accumulate duplicate sidebar entries." | +| "Added OAuth2 USER_FEDERATION flow" | "Users can now connect external MCP tools (Google, Microsoft, GitHub, Canvas) with one-click consent directly from chat." | +| "Refactored OAuth extractor" | *(exclude — no user impact)* | + +## Pitfalls + +- **Don't modify previous entries** — only prepend the new one. +- **Don't use `{#custom-anchor}` heading suffixes** — GitHub renders them as + literal text. Prefer plain text over fragile intra-doc anchors. +- **Don't invent PR numbers** — omit if unconfirmed. +- **Don't duplicate narrative across categories** — a feature spanning + backend+frontend+infra stays in one spotlight with subsections. diff --git a/.claude/skills/release-notes/SKILL.md b/.claude/skills/release-notes/SKILL.md deleted file mode 100644 index 3b38efc22..000000000 --- a/.claude/skills/release-notes/SKILL.md +++ /dev/null @@ -1,257 +0,0 @@ ---- -name: release-notes -description: Write and update RELEASE_NOTES.md and CHANGELOG.md for this monorepo. Use when preparing a release, updating an existing release entry, or landing a notable change on develop. Covers the squash-merge branch model, how to identify changes across divergent main/develop histories, the two-document split (narrative release notes vs. technical changelog), standardized section structure, writing style, and common pitfalls. ---- - -# Writing Release Notes & Changelog - -This repo maintains **two** release artifacts that describe the same set of changes for different audiences: - -| File | Audience | Tone | Length | -|---|---|---|---| -| `RELEASE_NOTES.md` | Product owners, operators, developers shipping the system | Narrative, benefit-first with technical depth | Detailed | -| `CHANGELOG.md` | Developers integrating against the APIs, auditors, release engineers | Terse, factual, PR-linked (Keep a Changelog format) | Compact | - -Both files are updated in the **same release pass** from the same source of commits. The narrative in `RELEASE_NOTES.md` may group and re-order changes thematically; `CHANGELOG.md` preserves a flat, categorized list. - ---- - -## Branch Model & Why This Is Hard - -This repo uses a squash-merge workflow: `develop` accumulates feature branches via merge commits, and when a release is cut, `develop` is squash-merged into `main`. This means `main` and `develop` have **divergent git histories** — you cannot do a simple `git log main..develop` to get a clean diff. Commit SHAs on `main` don't correspond to anything on `develop`. - -## How to Identify What Changed - -### Step 1: Find the boundary - -Look at the last squash-merge commit on `main` to determine when the previous release was cut: - -```bash -git log main --oneline -5 -``` - -Then find the corresponding release tag or date. Use that date as your boundary. - -### Step 2: List commits on develop since the boundary - -```bash -git log develop --oneline --no-merges --since="" -``` - -This gives you the raw commit list, but **do not rely solely on commit messages**. Dependabot commits are usually accurate, but human commits often have vague or incomplete messages. - -### Step 3: Inspect the actual code changes - -For every non-trivial commit, read the diff or at minimum the `--stat` output: - -```bash -git show --stat -git show --no-patch # full commit message -``` - -For feature commits, read the changed files to understand what was actually built — not just what the message claims. Look for: - -- New API endpoints (routes files) -- New or modified models/schemas -- New frontend pages or components -- Infrastructure changes (CDK stacks, config) -- New test files (indicates new functionality) -- Dependency changes (`pyproject.toml`, `package.json`) - -### Step 4: Categorize every change - -Bucket each change into one of the standardized categories. Use the same bucket in both documents so they stay in sync. - -| Category | Emoji | When to use | -|---|---|---| -| New | 🚀 | New features, endpoints, pages, or capabilities | -| Improved | ✨ | Enhancements to existing features (UX, readability, ergonomics) | -| Fixed | 🐛 | Bug fixes | -| Changed | ⚠️ | Breaking changes, removals, deprecations, migration-required updates | -| Security | 🔒 | CVE patches, CodeQL fixes, auth hardening | -| Performance | ⚡ | Latency, throughput, or cost reductions users will notice | -| Infrastructure | 🏗️ | CDK/IaC changes, new AWS resources, deploy order changes | -| Dependencies | 📦 | Package upgrades (grouped in a table) | -| CI/CD | 🔧 | Workflow, pipeline, or tooling changes | -| Docs | 📚 | Documentation additions worth calling out | - -### Step 5: Decide what to include vs. exclude - -**Include in both documents:** -- User-facing changes (features, UX, workflows) -- Operator-facing changes (deploy steps, env vars, infra) -- Bug fixes users or operators would have hit -- Security updates -- Breaking API changes and migrations -- Dependency upgrades (grouped table) - -**Include in `CHANGELOG.md` only (not prominent in release notes):** -- Minor dependency bumps with no behavior change -- Internal test additions (unless they signal a new feature) - -**Exclude from both:** -- Pure internal refactors with no user or operator impact -- Typo fixes, comment-only changes -- Formatter/linter churn - ---- - -## Translating Technical → User-Benefit - -When drafting `RELEASE_NOTES.md`, lead with the outcome, then explain the mechanism. Keep the technical detail — this repo's audience expects it — but don't bury the benefit. - -| Engineering commit | Release note framing | -|---|---| -| "Implemented caching layer on tool catalog" | "Tool admin changes now propagate to chat on the next turn (previously required a restart). Backed by a TTL-cached DynamoDB snapshot." | -| "Fixed null pointer in session metadata write" | "Resolved an issue where sessions could accumulate duplicate sidebar entries." | -| "Added OAuth2 USER_FEDERATION flow" | "Users can now connect external MCP tools (Google, Microsoft, GitHub, Canvas) with one-click consent directly from the chat." | -| "Refactored OAuth extractor" | *(exclude — no user impact)* | - ---- - -## `RELEASE_NOTES.md` Format - -The new release goes at the **top** of the file. Do not modify previous release sections. - -### Header - -```markdown -# Release Notes — v1.0.0-beta.XX - -**Release Date:** -**Previous Release:** v1.0.0-beta.XX-1 () - ---- -``` - -### Section order - -1. **Highlights** — 3-5 sentence standalone summary. Someone reading only this paragraph should understand the release's theme, the 2-3 biggest features, and whether any action is required. -2. **Feature spotlights** — one H2 per major feature. Use subsections for backend / frontend / infrastructure / test coverage. This is where narrative depth belongs. -3. **🐛 Bug fixes** — concise bullet list. Lead with the user-visible symptom, follow with the root cause. -4. **🔒 Security** — CVEs, CodeQL findings, auth hardening. -5. **⚡ Performance** — measurable improvements only. -6. **⚠️ Breaking changes** — migration steps required. If there are none, omit the section. -7. **🏗️ Infrastructure** — new resources, SSM parameters, IAM changes operators must know about. -8. **🔧 CI/CD improvements** — workflow and pipeline changes, plus a GitHub Actions upgrade table. -9. **📦 Dependency upgrades** — markdown table, From/To columns, grouped by component (backend / frontend / infra). -10. **🧪 Test coverage** — line counts and scope for notable test additions (optional — include when test work is substantial). -11. **🚀 Deployment notes** — what operators must do differently. Always include, even if the answer is "no special steps." - -### Feature spotlight template - -```markdown -## - -<1-2 sentence user-facing summary: what changed and why it matters.> - -### Backend - -- -- - -### Frontend - -- - -### Infrastructure - -- - -### Test Coverage - -+ lines of new tests covering . -``` - -### Writing style - -- Match the tone and depth of the existing release notes in the file. They are detailed and technical — written for developers who deploy and maintain this system. -- Every feature section should explain **what** changed, **why** it matters, and **how** it works at a technical level. -- Use specific file names, endpoint paths, and class names when relevant. -- Include line counts for large test additions (e.g., "4,200+ lines of new tests"). -- For dependency upgrades, use a markdown table with From/To columns. -- Lead with the user or operator outcome; follow with the mechanism. - ---- - -## `CHANGELOG.md` Format - -Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) with the category emojis from Step 4. Terse. PR-linked where possible. No narrative. - -### Full file header (first time only) - -```markdown -# Changelog - -All notable changes to this project are documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -For narrative release notes written for operators and product owners, see [RELEASE_NOTES.md](RELEASE_NOTES.md). -``` - -### Per-release entry - -```markdown -## [1.0.0-beta.XX] - YYYY-MM-DD - -### 🚀 Added -- Voice mode with Nova Sonic bidirectional audio streaming (`/voice/stream` WebSocket endpoint) (#234) -- `create_agent()` factory supporting `chat`, `skill`, and `voice` agent types (#235) - -### ✨ Improved -- Tool admin changes now propagate on the next chat turn via TTL-cached DynamoDB snapshot (#240) - -### ⚠️ Changed -- **Breaking:** Removed `/oauth/*` routes, `OAuthService`, and the in-house token vault. External MCP tools now use AgentCore Identity (#241) -- Settings/connections page removed; connector consent is inline during chat (#241) - -### 🐛 Fixed -- Duplicate sidebar entries caused by `ensure_session_metadata_exists` conditional collision (#248) -- `OAuth2CallbackUrl` header stripped by middleware when `provider_id` query param was appended (#249) - -### 🔒 Security -- Markdown-rendered links now carry `rel="noopener noreferrer"` to prevent reverse-tabnabbing (#252) - -### 📦 Dependencies -- Backend: `fastapi` 0.135.3 → 0.136.1, `strands-agents` 1.34.1 → 1.37.0, `bedrock-agentcore` 1.6.0 → 1.6.4 -- Frontend: (see RELEASE_NOTES.md for full table) -- CI: `github/codeql-action` 4.35.1 → 4.35.2 - -### 🏗️ Infrastructure -- New `CfnWorkloadIdentity` (`-platform-workload`) shared between app-api and inference-api (#241) -- SSM parameters added under `//oauth/platform-workload-identity-{name,arn}` - -### 🔧 CI/CD -- E2E pipeline added with dynamic CloudFront URL discovery and Cognito user provisioning (#255) -``` - -### Changelog style rules - -- One line per change. If you need more than one line, the change belongs as a spotlight in `RELEASE_NOTES.md` with only a pointer here. -- Reference PRs with `(#NNN)` when known. If the PR number isn't available at authoring time, omit — don't invent. -- Keep breaking changes prominent: prefix with `**Breaking:**` and include migration steps inline or link to the release notes section. -- Dependency sections can collapse minor bumps into a single line per component; the full table lives in `RELEASE_NOTES.md`. -- Omit categories that have no entries for the release — don't render empty headings. - ---- - -## Keeping the Two Documents in Sync - -Both files describe the same set of changes. The easiest workflow: - -1. Do Steps 1-3 once and build a master bullet list of every change. -2. Categorize (Step 4) and filter (Step 5). -3. Write `CHANGELOG.md` first — it's the factual log. -4. Write `RELEASE_NOTES.md` next, promoting the largest categorized items into narrative spotlights and leaving everything else as per-category bullets. -5. Cross-check: every `CHANGELOG.md` line should map to something in `RELEASE_NOTES.md` (spotlight, bullet, or table row). Exceptions are the minor-dependency and internal-test-addition lines that legitimately live only in the changelog. - ---- - -## Common Pitfalls - -- **Don't trust commit messages blindly.** A commit titled "fix: update models" might contain a new feature with 800 lines of code. Always check the diff. -- **Don't miss Dependabot PRs.** They often bump 10+ packages in a single grouped PR. Check `pyproject.toml`, `package.json`, and workflow files for version changes. -- **Don't forget CI/CD changes.** Workflow file modifications (`.github/workflows/`) are easy to overlook but important for operators. -- **Don't duplicate narrative across categories.** If a feature spans backend + frontend + infra, keep it in one spotlight with subsections — don't scatter it across the document. -- **Don't let the changelog drift from the release notes.** Every entry in one should be traceable to the other (allowing for the minor-dependency exception). -- **Check the VERSION file and README badge.** These should already be updated via `sync-version.sh` before the release notes are finalized. -- **Don't invent PR numbers.** If you can't confirm a PR link, leave it out. diff --git a/.claude/skills/versioning/SKILL.md b/.claude/skills/versioning/SKILL.md deleted file mode 100644 index 591937147..000000000 --- a/.claude/skills/versioning/SKILL.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -name: version -description: Bump the monorepo version using SemVer. Use when creating releases, updating the VERSION file, or syncing version across package manifests. ---- -# Version Bumping - -This monorepo uses a single `VERSION` file at the repo root as the source of truth. - -## Format - -`MAJOR.MINOR.PATCH[-PRERELEASE]` (SemVer 2.0). No `v` prefix. -Example: `1.0.0-beta.1`, `1.1.0` - -## How to Bump - -1. Edit the `VERSION` file with the new version -2. Run `bash scripts/common/sync-version.sh` -3. Commit both the `VERSION` file and the updated manifests - -The sync script updates `backend/pyproject.toml`, `frontend/ai.client/package.json`, `infrastructure/package.json`, and the `README.md` version badge and "Current release" text. - -## PR Gate - -PRs to `main` will fail CI if: -- The `VERSION` file hasn't changed compared to `main` -- Package manifests are out of sync (run the sync script to fix) - -## What CI Handles - -Docker image tags, AWS resource tags, health endpoint versions, frontend version display, and git tags are all handled automatically by CI. Just bump `VERSION` and sync. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 5db9b9aee..9ac3443c4 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -81,5 +81,4 @@ Before non-trivial work in these areas, consult the matching skill/steering doc: - Angular components/signals → `.claude/skills/angualar-best-practices/` and `.kiro/steering/angular-*.md` - Tailwind v4 / a11y → `.claude/skills/tailwind-ui/` and `.kiro/steering/tailwind-*.md` - CORS across stacks → `.claude/skills/cors-deployment/SKILL.md` -- Release notes / CHANGELOG → `.claude/skills/release-notes/SKILL.md` -- Version bumps → `.claude/skills/versioning/SKILL.md` +- Cutting a release (version bump + release notes + changelog + branch/backmerge) → `.claude/skills/cutting-a-release/SKILL.md` and `.kiro/steering/cutting-a-release.md` diff --git a/.kiro/steering/cutting-a-release.md b/.kiro/steering/cutting-a-release.md new file mode 100644 index 000000000..4459f7ce6 --- /dev/null +++ b/.kiro/steering/cutting-a-release.md @@ -0,0 +1,296 @@ +--- +inclusion: auto +name: cutting-a-release +description: >- + Cut a new release of this monorepo. Use whenever the user wants to make, cut, + ship, or prep a release, bump the version, or update RELEASE_NOTES.md / + CHANGELOG.md — e.g. "let's cut a release", "make a new release", "bump the + version and update the release notes", "ship the next version". Covers the + release-branch workflow, the SemVer bump + version sync, identifying what + changed across the divergent main/develop histories, writing the changelog + and release notes (sized to the release), the squash-merge PR into main, and + the required backmerge into develop. +--- + +# Cutting a Release + +This is the single source of truth for cutting a release. It folds together three +concerns that always move together: the **branch/PR workflow**, the **version +bump**, and the **two release documents** (`RELEASE_NOTES.md` + `CHANGELOG.md`). + +Do the steps in order. The whole thing is one pass on a dedicated `release/x.y.z` +branch, cut from `develop`. + +--- + +## 0. The shape of a release (read first) + +``` +develop (PR-only) main (PR-only, squash) + │ │ + │ 1. pull latest develop │ + │ 2. cut release/x.y.z ──────────────┐ + │ bump VERSION + sync │ + │ write CHANGELOG + NOTES │ + │ commit + push │ + │ │ + │ 3. PR release/x.y.z ──▶ main (squash-merge after review) + │ │ + │ 4. backmerge ◀────────────────────┤ (branch off develop, merge main in, + │ (PR into develop, │ PR into develop, MERGE COMMIT) + │ merge commit — NOT squash) │ + ▼ ▼ +``` + +Two hard rules that this repo's branch model forces: + +1. **Never commit directly to `develop` or `main`.** Both are PR-only. Every + change — including the release bump and the backmerge — lands through a branch + and a PR. +2. **After the squash-merge into `main`, you MUST backmerge `main` into `develop`.** + Skipping this leaves the branches divergent and every future release conflicts + on the release-artifact files. See §6. + +--- + +## 1. Branch workflow + +### 1.1 Cut the release branch + +```bash +git switch develop +git pull --ff-only origin develop # release must contain all of develop +git switch -c release/x.y.z # branch name == the version being shipped +``` + +- The release branch **corresponds to the version being pushed** (`release/1.2.0` + ships `1.2.0`). No `v` prefix on the branch. +- It carries **all commits currently on `develop`** — that's the release payload. + +### 1.2 Do the work (the rest of this doc) + +Create a task list, then on the release branch: determine the bump (§3), bump +`VERSION` + sync (§2), write both release docs (§4–§5). Keep every edit on the +release branch — `develop` stays untouched. + +### 1.3 Commit and push + +Commit the release artifacts together with a `Release/x.y.z` subject: + +```bash +git add VERSION backend/pyproject.toml backend/uv.lock \ + frontend/ai.client/package.json frontend/ai.client/package-lock.json \ + infrastructure/package.json infrastructure/package-lock.json \ + CHANGELOG.md RELEASE_NOTES.md README.md +git commit -m "Release/x.y.z" -m "" +git push -u origin release/x.y.z +``` + +### 1.4 PR into `main` + +Open a PR from `release/x.y.z` → `main`. Once prerequisites and code review pass, +it is **squash-merged** into `main`. The version-check CI gate (see §2) passes +because `VERSION` changed. + +### 1.5 Backmerge into `develop` + +Non-negotiable follow-up. See §6. + +--- + +## 2. Version bump + +The monorepo uses a single `VERSION` file at the repo root as the source of truth. + +- **Format:** `MAJOR.MINOR.PATCH[-PRERELEASE]` (SemVer 2.0), no `v` prefix. + Examples: `1.0.0-beta.1`, `1.1.0`, `1.2.0`. + +**How to bump:** + +1. Edit `VERSION` with the new version. +2. Run `bash scripts/common/sync-version.sh`. +3. Verify with `bash scripts/common/sync-version.sh --check` (must print `[PASS]`). +4. Commit `VERSION` **and** every file the sync script touched (including lockfiles). + +The sync script propagates `VERSION` into `backend/pyproject.toml`, +`frontend/ai.client/package.json`, `infrastructure/package.json`, the `README.md` +version badge and "Current release" text, and **regenerates the lockfiles** +(`backend/uv.lock`, both `package-lock.json`). All of those must be committed. + +> Run the sync inside the dev container (it needs `uv` + `npm` at the project's +> pinned versions). See the dev-environment steering doc for how to exec into it. + +**PR gate.** PRs to `main` fail CI if `VERSION` is unchanged vs `main`, or if the +manifests are out of sync (run the sync script to fix). + +**CI handles the rest** — Docker image tags, AWS resource tags, health-endpoint +versions, frontend version display, and the `vX.Y.Z` git tag are all automatic. +Just bump `VERSION` and sync. + +--- + +## 3. Decide the bump — SemVer + release sizing + +Pick the SemVer tier from what actually shipped, then size the write-up to match. + +| Bump | When | Notes depth | +|---|---|---| +| **Patch** `x.y.Z` | Bug fixes, security/dep bumps, CI/CD, docs, internal refactors — no new user-facing capability | **Brief.** 2–4 sentence Highlights + compact per-category bullets + one deployment-note paragraph. No feature spotlights, no per-layer subsections. | +| **Minor** `x.Y.0` | New features, endpoints, pages, or capabilities (even if flag-gated/preview) | **Deep.** Full treatment: Highlights + one spotlight per major feature (backend/frontend/infra/tests subsections) + per-category bullets. | +| **Major** `X.0.0` | Breaking changes, architecture shifts, migrations | **Deepest.** Everything above + a prominent migration/upgrade section and breaking-change callouts. | + +Rules of thumb: + +- **Don't pad a patch.** Three CI commits and a dep bump = a screen or less. +- **Don't starve a feature.** A real new capability earns a spotlight with the + what/why/how and file/endpoint/class detail this audience expects. +- **Size to the largest single change.** One real feature among ten chores makes + it a feature (minor) release for write-up purposes. +- Turning a previously-dark feature **on by default**, or completing a + previously-preview capability, counts as user-facing → **minor**. + +--- + +## 4. Identify what changed (the divergent-history trap) + +This repo squash-merges `develop` → `main`, so **`main` and `develop` have +divergent histories**. A squash collapses a whole release into one commit on +`main` whose SHA matches nothing on `develop`. + +> **Do NOT use `git log main..develop`.** Even after a backmerge makes `main` an +> ancestor of `develop`, main's *squashed* history means develop's granular +> commits are never reachable from main — so `main..develop` returns the entire +> project history (thousands of commits), not the release delta. This has bitten +> us every release. + +**Use the previous release's cut point as the boundary instead:** + +```bash +# The commit develop was at when the LAST release branch was cut, +# or the previous release tag: +git tag --list 'v*' --sort=-creatordate | head +git log ..develop --no-merges --reverse --format='%h %s' +git log ..develop --merges --format='%s' # PR numbers +``` + +If the boundary range still pulls in commits carried by a prior **backmerge** +(e.g. the previous `Release/x.y.z` squash commit and any direct-to-main hotfixes), +exclude those — they are not new work for this release. + +For every non-trivial commit, **read the diff, not just the message** (`git show +--stat `, `git show --no-patch `). Messages lie; a "fix: update models" +can hide 800 lines of feature. Bucket each change into the standard categories: + +| Category | Emoji | Use for | +|---|---|---| +| New | 🚀 | New features, endpoints, pages, capabilities | +| Improved | ✨ | Enhancements to existing features | +| Fixed | 🐛 | Bug fixes | +| Changed | ⚠️ | Breaking changes, removals, deprecations, migration-required updates | +| Security | 🔒 | CVE patches, CodeQL fixes, auth hardening | +| Performance | ⚡ | Latency/throughput/cost wins users notice | +| Infrastructure | 🏗️ | CDK/IaC changes, new AWS resources, deploy-order changes | +| Dependencies | 📦 | Package upgrades (grouped in a table) | +| CI/CD | 🔧 | Workflow/pipeline/tooling changes | +| Docs | 📚 | Documentation worth calling out | + +**Include** user- and operator-facing changes, bug fixes people hit, security +updates, breaking changes/migrations, and dependency upgrades. **Changelog-only:** +minor dep bumps with no behavior change, internal test additions. **Exclude from +both:** pure internal refactors, typo/comment/formatter churn. + +--- + +## 5. Write the two documents + +Both files are updated in the **same pass** from the same categorized list. +Write `CHANGELOG.md` first (the factual log), then `RELEASE_NOTES.md` (the +narrative), and cross-check that every changelog line maps to something in the +notes (allowing the changelog-only exceptions above). + +- **`CHANGELOG.md`** — [Keep a Changelog] format, terse, one line per change, + PR-linked `(#NNN)` when known, grouped by the category emojis. New version goes + at the top; omit empty categories; never invent PR numbers. +- **`RELEASE_NOTES.md`** — narrative, benefit-first with technical depth. New + release at the top; **do not modify previous entries.** Lead each item with the + user/operator outcome, then the mechanism (file names, endpoints, class names). + +The exact section order, the feature-spotlight template, the header format, and +the changelog per-release skeleton live in the reference files so this doc stays +scannable: + +- Release-notes structure & spotlight template → #[[file:.claude/skills/cutting-a-release/references/release-notes-format.md]] +- Changelog structure & entry skeleton → #[[file:.claude/skills/cutting-a-release/references/changelog-format.md]] + +**Translate technical → benefit.** "Implemented tool-catalog caching" → +"Tool admin changes now propagate to chat on the next turn (was: required a +restart). Backed by a TTL-cached DynamoDB snapshot." + +**Anchor/link hygiene:** GitHub renders `{#custom-anchor}` heading suffixes as +literal text — don't use them. Prefer plain text over fragile intra-doc anchors. + +**Before finalizing:** confirm `VERSION`, the README badge, and both docs all +show the new version (`sync-version.sh --check`), and that the new entries sit +above the untouched previous ones. + +--- + +## 6. Backmerge `main` → `develop` (required) + +After the release PR is squash-merged into `main`, reconcile the branches. This +is PR-based like everything else — **you cannot push the merge directly to +`develop`.** + +```bash +git fetch origin main develop +git switch develop && git merge --ff-only origin/develop # get current +git switch -c backmerge/main-into-develop-x.y.z +git merge --no-commit --no-ff origin/main # preview conflicts +``` + +**Resolve conflicts in `main`'s favor for the release-artifact files.** The only +conflicts are `VERSION`, `CHANGELOG.md`, `RELEASE_NOTES.md`, `README.md`, the +manifests, and the lockfiles — because both branches touched them. `main` holds +the canonical post-release state (and develop's copy is the pre-release state, +plus any stale `[Unreleased]`-style content that has now shipped): + +```bash +git checkout --theirs -- VERSION CHANGELOG.md RELEASE_NOTES.md README.md \ + backend/pyproject.toml backend/uv.lock \ + frontend/ai.client/package.json frontend/ai.client/package-lock.json \ + infrastructure/package.json infrastructure/package-lock.json +git add -A +bash scripts/common/sync-version.sh --check # must PASS +git commit --no-edit +``` + +Then verify `git merge-base --is-ancestor origin/main HEAD` prints success (main +is now an ancestor), push the branch, and open a PR into `develop`. + +> **Merge the backmerge PR with a MERGE COMMIT, not a squash.** The entire point +> is to make `main` a genuine ancestor of `develop`. Squashing it recreates the +> divergence and the conflicts return next release. (Feature branches into +> `develop` squash as usual; the backmerge is the deliberate exception.) + +Some backmerges are conflict-free — if `develop` never touched the release +artifacts since the last reconciliation and `main` only *added* on top, git +auto-merges. Verify the result anyway (VERSION + both docs correct, feature code +intact) before committing. + +--- + +## 7. Common pitfalls + +- **`git log main..develop` for the changelog** — returns all history, not the + delta (see §4). Use the prior release cut point. +- **Trusting commit messages** — always read the diff for non-trivial commits. +- **Forgetting the lockfiles** — `sync-version.sh` regenerates `uv.lock` and both + `package-lock.json`; they must be committed or CI drifts. +- **Squashing the backmerge** — recreates divergence; use a merge commit. +- **Committing straight to `develop`/`main`** — both are PR-only. +- **Padding a patch / starving a feature** — size the notes to the release (§3). +- **Inventing PR numbers or fragile `{#anchor}` links** — omit if unknown; use + plain text. +- **Skipping the backmerge** — the #1 cause of next-release conflict pain. + +[Keep a Changelog]: https://keepachangelog.com/en/1.1.0/ diff --git a/.kiro/steering/release-notes.md b/.kiro/steering/release-notes.md deleted file mode 100644 index c2cd86091..000000000 --- a/.kiro/steering/release-notes.md +++ /dev/null @@ -1,277 +0,0 @@ ---- -inclusion: fileMatch -fileMatchPattern: 'RELEASE_NOTES.md,CHANGELOG.md' ---- - -# Writing Release Notes & Changelog - -This repo maintains **two** release artifacts that describe the same set of changes for different audiences: - -| File | Audience | Tone | Length | -|---|---|---|---| -| `RELEASE_NOTES.md` | Product owners, operators, developers shipping the system | Narrative, benefit-first with technical depth | Detailed | -| `CHANGELOG.md` | Developers integrating against the APIs, auditors, release engineers | Terse, factual, PR-linked (Keep a Changelog format) | Compact | - -Both files are updated in the **same release pass** from the same source of commits. - ---- - -## Match Depth to Release Size - -Release depth scales with what actually shipped — not every release earns a long writeup. We expect to cut patches frequently (potentially several per day), so a small patch must stay **short and digestible**, while a feature or minor release earns the full narrative treatment described later in this doc. - -Decide the tier first, then write to it: - -| Release type | SemVer | What's in it | `RELEASE_NOTES.md` depth | `CHANGELOG.md` depth | -|---|---|---|---|---| -| **Patch** | `x.y.Z` | Bug fixes, security/dep bumps, CI/CD, docs, internal refactors — no new user-facing capability | **Brief.** A 2-4 sentence Highlights paragraph + compact per-category bullets + a one-paragraph deployment note. **No** feature spotlights, **no** per-layer (backend/frontend/infra) subsections, **no** test-coverage section. Aim for a screen or less. | One-line bullets per change, grouped by category. A one-sentence lead paragraph is enough. | -| **Minor / feature** | `x.Y.0` | New features, endpoints, pages, or capabilities | **Deep.** Full treatment: Highlights, one feature spotlight per major feature with backend/frontend/infrastructure/test-coverage subsections, then per-category bullets. | Spotlight-worthy items get a richer bullet; everything else one line. Narrative stays in `RELEASE_NOTES.md`. | -| **Major** | `X.0.0` | Breaking changes, architecture shifts, migrations | **Deepest.** Everything above plus a prominent migration/upgrade section and breaking-change callouts. | Breaking changes prefixed `**Breaking:**` with migration pointers. | - -Rules of thumb: - -- **Don't pad a patch.** If the release is three CI/CD commits and a dependency bump, the notes should be a screen or less. Resist inventing spotlights or test-coverage line counts that don't matter. -- **Don't starve a feature.** A new capability still gets a spotlight with the what/why/how and the file/endpoint/class detail this audience expects — brevity-for-patches is not license to thin out feature notes. -- **The category bullets (🔒 / 📦 / 🔧 / 🐛 / …) are the same in both tiers.** The difference is whether they're preceded by narrative spotlights (feature) or stand alone (patch). -- **When in doubt, size the notes to the largest single change in the release.** One real feature among ten chores makes it a feature release for write-up purposes. - ---- - -## Branch Model & Why This Is Hard - -This repo uses a squash-merge workflow: `develop` accumulates feature branches via merge commits, and when a release is cut, `develop` is squash-merged into `main`. This means `main` and `develop` have **divergent git histories** — you cannot do a simple `git log main..develop` to get a clean diff. Commit SHAs on `main` don't correspond to anything on `develop`. - -## How to Identify What Changed - -### Step 1: Find the boundary - -Look at the last squash-merge commit on `main` to determine when the previous release was cut: - -```bash -git log main --oneline -5 -``` - -Then find the corresponding release tag or date. Use that date as your boundary. - -### Step 2: List commits on develop since the boundary - -```bash -git log develop --oneline --no-merges --since="" -``` - -This gives you the raw commit list, but **do not rely solely on commit messages**. Dependabot commits are usually accurate, but human commits often have vague or incomplete messages. - -### Step 3: Inspect the actual code changes - -For every non-trivial commit, read the diff or at minimum the `--stat` output: - -```bash -git show --stat -git show --no-patch # full commit message -``` - -For feature commits, read the changed files to understand what was actually built — not just what the message claims. Look for: - -- New API endpoints (routes files) -- New or modified models/schemas -- New frontend pages or components -- Infrastructure changes (CDK stacks, config) -- New test files (indicates new functionality) -- Dependency changes (`pyproject.toml`, `package.json`) - -### Step 4: Categorize every change - -Bucket each change into one of the standardized categories. Use the same bucket in both documents so they stay in sync. - -| Category | Emoji | When to use | -|---|---|---| -| New | 🚀 | New features, endpoints, pages, or capabilities | -| Improved | ✨ | Enhancements to existing features (UX, readability, ergonomics) | -| Fixed | 🐛 | Bug fixes | -| Changed | ⚠️ | Breaking changes, removals, deprecations, migration-required updates | -| Security | 🔒 | CVE patches, CodeQL fixes, auth hardening | -| Performance | ⚡ | Latency, throughput, or cost reductions users will notice | -| Infrastructure | 🏗️ | CDK/IaC changes, new AWS resources, deploy order changes | -| Dependencies | 📦 | Package upgrades (grouped in a table) | -| CI/CD | 🔧 | Workflow, pipeline, or tooling changes | -| Docs | 📚 | Documentation additions worth calling out | - -### Step 5: Decide what to include vs. exclude - -**Include in both documents:** -- User-facing changes (features, UX, workflows) -- Operator-facing changes (deploy steps, env vars, infra) -- Bug fixes users or operators would have hit -- Security updates -- Breaking API changes and migrations -- Dependency upgrades (grouped table) - -**Include in `CHANGELOG.md` only (not prominent in release notes):** -- Minor dependency bumps with no behavior change -- Internal test additions (unless they signal a new feature) - -**Exclude from both:** -- Pure internal refactors with no user or operator impact -- Typo fixes, comment-only changes -- Formatter/linter churn - ---- - -## Translating Technical → User-Benefit - -When drafting `RELEASE_NOTES.md`, lead with the outcome, then explain the mechanism. Keep the technical detail — this repo's audience expects it — but don't bury the benefit. - -| Engineering commit | Release note framing | -|---|---| -| "Implemented caching layer on tool catalog" | "Tool admin changes now propagate to chat on the next turn (previously required a restart). Backed by a TTL-cached DynamoDB snapshot." | -| "Fixed null pointer in session metadata write" | "Resolved an issue where sessions could accumulate duplicate sidebar entries." | -| "Added OAuth2 USER_FEDERATION flow" | "Users can now connect external MCP tools (Google, Microsoft, GitHub, Canvas) with one-click consent directly from the chat." | -| "Refactored OAuth extractor" | *(exclude — no user impact)* | - ---- - -## `RELEASE_NOTES.md` Format - -The new release goes at the **top** of the file. Do not modify previous release sections. - -> **Patch releases use the short form.** The full section order and feature-spotlight template below describe a **feature / minor or major** release. For a patch (see *Match Depth to Release Size*), keep only the **Header**, a short **Highlights** paragraph, the relevant **per-category bullets**, and **Deployment notes** — omit feature spotlights, per-layer subsections, and the test-coverage section. - -### Header - -```markdown -# Release Notes — v1.0.0-beta.XX - -**Release Date:** -**Previous Release:** v1.0.0-beta.XX-1 () - ---- -``` - -### Section order - -1. **Highlights** — 3-5 sentence standalone summary. Someone reading only this paragraph should understand the release's theme, the 2-3 biggest features, and whether any action is required. -2. **Feature spotlights** — one H2 per major feature. Use subsections for backend / frontend / infrastructure / test coverage. This is where narrative depth belongs. -3. **🐛 Bug fixes** — concise bullet list. Lead with the user-visible symptom, follow with the root cause. -4. **🔒 Security** — CVEs, CodeQL findings, auth hardening. -5. **⚡ Performance** — measurable improvements only. -6. **⚠️ Breaking changes** — migration steps required. Omit if none. -7. **🏗️ Infrastructure** — new resources, SSM parameters, IAM changes operators must know about. -8. **🔧 CI/CD improvements** — workflow and pipeline changes, plus a GitHub Actions upgrade table. -9. **📦 Dependency upgrades** — markdown table with From/To columns, grouped by component (backend / frontend / infra). -10. **🧪 Test coverage** — line counts and scope for notable test additions (optional). -11. **🚀 Deployment notes** — what operators must do differently. Always include, even if the answer is "no special steps." - -### Feature spotlight template - -```markdown -## - -<1-2 sentence user-facing summary: what changed and why it matters.> - -### Backend - -- - -### Frontend - -- - -### Infrastructure - -- - -### Test Coverage - -+ lines of new tests covering . -``` - -### Writing style - -- Match the tone and depth of the existing release notes in the file. They are detailed and technical — written for developers who deploy and maintain this system. -- Every feature section should explain **what** changed, **why** it matters, and **how** it works at a technical level. -- Use specific file names, endpoint paths, and class names when relevant. -- Include line counts for large test additions (e.g., "4,200+ lines of new tests"). -- For dependency upgrades, use a markdown table with From/To columns. -- Lead with the user or operator outcome; follow with the mechanism. - ---- - -## `CHANGELOG.md` Format - -Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) with the category emojis from Step 4. Terse. PR-linked where possible. No narrative. - -### Full file header (first time only) - -```markdown -# Changelog - -All notable changes to this project are documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -For narrative release notes written for operators and product owners, see [RELEASE_NOTES.md](RELEASE_NOTES.md). -``` - -### Per-release entry - -```markdown -## [1.0.0-beta.XX] - YYYY-MM-DD - -### 🚀 Added -- Voice mode with Nova Sonic bidirectional audio streaming (`/voice/stream` WebSocket endpoint) (#234) -- `create_agent()` factory supporting `chat`, `skill`, and `voice` agent types (#235) - -### ✨ Improved -- Tool admin changes now propagate on the next chat turn via TTL-cached DynamoDB snapshot (#240) - -### ⚠️ Changed -- **Breaking:** Removed `/oauth/*` routes, `OAuthService`, and the in-house token vault. External MCP tools now use AgentCore Identity (#241) -- Settings/connections page removed; connector consent is inline during chat (#241) - -### 🐛 Fixed -- Duplicate sidebar entries caused by `ensure_session_metadata_exists` conditional collision (#248) -- `OAuth2CallbackUrl` header stripped by middleware when `provider_id` query param was appended (#249) - -### 🔒 Security -- Markdown-rendered links now carry `rel="noopener noreferrer"` to prevent reverse-tabnabbing (#252) - -### 📦 Dependencies -- Backend: `fastapi` 0.135.3 → 0.136.1, `strands-agents` 1.34.1 → 1.37.0, `bedrock-agentcore` 1.6.0 → 1.6.4 -- Frontend: (see RELEASE_NOTES.md for full table) -- CI: `github/codeql-action` 4.35.1 → 4.35.2 - -### 🏗️ Infrastructure -- New `CfnWorkloadIdentity` (`-platform-workload`) shared between app-api and inference-api (#241) -- SSM parameters added under `//oauth/platform-workload-identity-{name,arn}` - -### 🔧 CI/CD -- E2E pipeline added with dynamic CloudFront URL discovery and Cognito user provisioning (#255) -``` - -### Changelog style rules - -- One line per change. If you need more than one line, the change belongs as a spotlight in `RELEASE_NOTES.md` with only a pointer here. -- Reference PRs with `(#NNN)` when known. If the PR number isn't available at authoring time, omit — don't invent. -- Keep breaking changes prominent: prefix with `**Breaking:**` and include migration steps inline or link to the release notes section. -- Dependency sections can collapse minor bumps into a single line per component; the full table lives in `RELEASE_NOTES.md`. -- Omit categories that have no entries for the release — don't render empty headings. - ---- - -## Keeping the Two Documents in Sync - -1. Do Steps 1-3 once and build a master bullet list of every change. -2. Categorize (Step 4) and filter (Step 5). -3. Write `CHANGELOG.md` first — it's the factual log. -4. Write `RELEASE_NOTES.md` next, promoting the largest categorized items into narrative spotlights and leaving everything else as per-category bullets. -5. Cross-check: every `CHANGELOG.md` line should map to something in `RELEASE_NOTES.md` (spotlight, bullet, or table row). Exceptions are the minor-dependency and internal-test-addition lines that legitimately live only in the changelog. - ---- - -## Common Pitfalls - -- **Don't trust commit messages blindly.** A commit titled "fix: update models" might contain a new feature with 800 lines of code. Always check the diff. -- **Don't miss Dependabot PRs.** They often bump 10+ packages in a single grouped PR. Check `pyproject.toml`, `package.json`, and workflow files for version changes. -- **Don't forget CI/CD changes.** Workflow file modifications (`.github/workflows/`) are easy to overlook but important for operators. -- **Don't duplicate narrative across categories.** If a feature spans backend + frontend + infra, keep it in one spotlight with subsections. -- **Don't let the changelog drift from the release notes.** Every entry in one should be traceable to the other (allowing for the minor-dependency exception). -- **Check the VERSION file and README badge.** These should already be updated via `sync-version.sh` before the release notes are finalized. -- **Don't invent PR numbers.** If you can't confirm a PR link, leave it out. diff --git a/.kiro/steering/versioning.md b/.kiro/steering/versioning.md deleted file mode 100644 index 69fe0dc00..000000000 --- a/.kiro/steering/versioning.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -inclusion: manual ---- - -# Version Bumping - -This monorepo uses a single `VERSION` file at the repo root as the source of truth. - -## Format - -`MAJOR.MINOR.PATCH[-PRERELEASE]` (SemVer 2.0). No `v` prefix. -Example: `1.0.0-beta.1`, `1.1.0` - -## How to Bump - -1. Edit the `VERSION` file with the new version -2. Run `bash scripts/common/sync-version.sh` -3. Commit both the `VERSION` file and the updated manifests - -The sync script updates `backend/pyproject.toml`, `frontend/ai.client/package.json`, `infrastructure/package.json`, the `README.md` version badge and "Current release" text, and regenerates `backend/uv.lock` and `package-lock.json` for both npm projects. All updated files (including lockfiles) must be committed. - -## PR Gate - -PRs to `main` will fail CI if: -- The `VERSION` file hasn't changed compared to `main` -- Package manifests are out of sync (run the sync script to fix) - -## What CI Handles - -Docker image tags, AWS resource tags, health endpoint versions, frontend version display, and git tags are all handled automatically by CI. Just bump `VERSION` and sync. diff --git a/CHANGELOG.md b/CHANGELOG.md index f58901475..d8adfde24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,41 @@ All notable changes to this project are documented in this file. Format follows For narrative release notes written for operators and product owners, see [RELEASE_NOTES.md](RELEASE_NOTES.md). +## [1.3.0] - 2026-07-10 + +Bedrock Mantle expansion and an API-key endpoint repair. Mantle models gain declarative per-model `apiMode` (Chat Completions vs Responses API) and `region` fields on Strands' `bedrock_mantle_config`, unlocking Responses-only models like `openai.gpt-5.x`; the API-key `POST /chat/api-converse` endpoint — broken in cloud since the BFF/runtime migrations — is relocated onto app-api and now serves the full model catalog including Mantle. Plus Memory Spaces and Scheduled Runs follow-ups. No breaking changes; a platform (CDK) deploy is required for two app-api IAM grants. + +### 🚀 Added + +- Per-model Mantle API mode + region: admin model records gain `apiMode` (`chat` | `responses`) selecting `OpenAIModel` vs `OpenAIResponsesModel`, and an optional `region` override driving both the Mantle endpoint host and SigV4 token region — Responses-only models (e.g. `openai.gpt-5.x`) now work, and a model can pin inference to its host region independent of where the app runs (#620) +- API-key `/chat/api-converse` now serves the full model catalog: `provider="mantle"` models route through a shared `build_mantle_model` builder (`apis/shared/models/mantle.py`, also used by the agent factory), whose Strands `.stream()` yields the same Converse-shaped events as the Bedrock path so SSE translation and cost accounting are shared (#621) +- The agent can read and keep a Memory Space's `MEMORY.md` index in sync via the reserved `MEMORY.md` slug on `memory_read` / `memory_write` (viewer+ read, editor+ write; the slug is reserved and can't become an ordinary entry) (#614) +- The scheduled-run form targets Agents (the Agent Designer primitive) instead of Assistants; the manual tool picker hides when an Agent is selected (its tool bindings replace `enabled_tools` at invocation), and "Run now" honors the selected Agent (#615) + +### ⚠️ Changed + +- Scheduled Runs are no longer gated by the `scheduled-runs` RBAC capability — only the `SCHEDULED_RUNS_ENABLED` kill switch remains (404 when off). The surface stays low-key (no nav entry, reachable by direct URL); runs still execute with the caller's own RBAC-allowed tools (#617) +- Mantle inference plumbing moved onto Strands' `bedrock_mantle_config` (the SDK owns the base URL, model-family base path, and bearer-token minting); `mantleEndpointPath` is deprecated (accepted-but-ignored, no stored record breaks) and removed from the admin UI. Gemma 4 is temporarily un-curated pending upstream `google.gemma-` family-prefix routing (#620) + +### 🐛 Fixed + +- API-key `POST /chat/api-converse` was broken in cloud: app-api proxied it to inference-api, whose AgentCore Runtime data plane only serves `/invocations` + `/ping` (`UnknownOperationException`). The handler is now a self-contained app-api route (validate key → RBAC → Bedrock converse → cost accounting) with no inference-api hop (#621) +- Settings API-key code snippets now resolve a relative `appApiUrl` against the current origin, targeting `/api/chat/api-converse` — the bare-origin URL missed CloudFront's `/api/*` backend routing and returned a 403 (#621) +- Regular users no longer hit a 403 "Access Denied" toast on page load — the sidenav's background schedules probe tripped the beta-cohort RBAC gate through the global error interceptor; the vestigial probe is also removed (#617) +- Memory Space entries with namespaced slugs (e.g. `people/brian-bolt`) no longer 404 on view/edit/delete — the entry routes use a `{slug:path}` converter so the embedded slash survives routing (#614) + +### 🏗️ Infrastructure + +- app-api task role: the Bedrock invoke statement gains `bedrock:InvokeModelWithResponseStream` plus all-region foundation-model and account-level inference-profile ARNs, and the project-scoped Mantle statement gains `bedrock-mantle:CreateInference` — both required by the relocated api-converse handler (#621) + +### 📦 Dependencies + +- Backend: `strands-agents` 1.40.0 → 1.47.0 (and the `[bidi]` extra), added `aws-bedrock-token-generator` 1.1.0 (#619) + +### 📚 Docs + +- Release workflow consolidated into a single auto-invoked "cutting a release" steering doc + skill, replacing the separate versioning and release-notes guides (#618) + ## [1.2.0] - 2026-07-09 Agent Designer completion. Finishes the run-time binding trio (skills join model + tools), makes the chat input honor an active agent's governed bindings, adds a live side-by-side editor preview and model-parameter governance, brings full knowledge-base management into the Designer, and flips the `/agents` API on by default. The Agent Designer nav stays admin-gated Preview; no breaking changes and no migration. diff --git a/README.md b/README.md index 2e9547680..ff4ffab3d 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ **An open-source, production-ready Generative AI platform for institutions** *Built by Boise State University, designed for everyone.* -[![Release](https://img.shields.io/badge/Release-v1.2.0-6366f1?style=flat&logo=github&logoColor=white)](RELEASE_NOTES.md) +[![Release](https://img.shields.io/badge/Release-v1.3.0-6366f1?style=flat&logo=github&logoColor=white)](RELEASE_NOTES.md) [![Nightly](https://github.com/Boise-State-Development/agentcore-public-stack/actions/workflows/nightly.yml/badge.svg)](https://github.com/Boise-State-Development/agentcore-public-stack/actions/workflows/nightly.yml) ![Python](https://img.shields.io/badge/Python-3.13+-3776AB?style=flat&logo=python&logoColor=white) @@ -296,7 +296,7 @@ agentcore-public-stack/ See [RELEASE_NOTES.md](RELEASE_NOTES.md) for the full changelog, including new features, bug fixes, platform upgrades, and deployment notes for each release. -**Current release:** v1.2.0 +**Current release:** v1.3.0 --- diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 5835e5308..a3acebd1f 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,84 @@ +# Release Notes — v1.3.0 + +**Release Date:** July 10, 2026 +**Previous Release:** v1.2.0 (July 9, 2026) + +--- + +> 🏗️ **This release requires a platform (CDK) deploy** — two IAM grants on the app-api task role back the relocated API-key endpoint. No new AWS resources beyond IAM, no data migration, no breaking changes. Operators with Mantle Responses-only models (e.g. `openai.gpt-5.x`) should set `apiMode=responses` on those model records after deploy — see the Deployment notes. + +--- + +## Highlights + +v1.3.0 expands **Bedrock Mantle** support and repairs the **API-key chat endpoint** in cloud. Mantle model records gain two declarative per-model fields — `apiMode` (Chat Completions vs the Responses API) and an optional `region` override — built on Strands 1.47's `bedrock_mantle_config`, which unlocks Responses-only models like `openai.gpt-5.x` and lets a model pin inference to its host region. The API-key `POST /chat/api-converse` endpoint, broken in cloud since the BFF and AgentCore-Runtime migrations, now lives on app-api as a self-contained route and serves the **full model catalog** — Bedrock *and* Mantle — through one shared model builder. Smaller follow-ups: the agent can now read and maintain a Memory Space's `MEMORY.md` index, scheduled runs target Agents (and no longer 403 for regular users), and namespaced Memory Space entry slugs resolve correctly. + +## Bedrock Mantle — Responses API and per-model regions + +Some Mantle-hosted models only serve OpenAI's Responses API and reject Chat Completions — no endpoint-path knob could ever satisfy them. Admins can now declare, per model, which API a model speaks and which region hosts it. + +### Backend + +- **Strands-owned Mantle plumbing (#620).** The admin `mantle` provider now rides Strands' `bedrock_mantle_config`: the SDK owns the base URL, the model-family base path, and bearer-token minting (via the new `aws-bedrock-token-generator` dependency), replacing the hand-rolled inference plumbing. Two declarative per-model fields cover what the library can't infer: + - `apiMode` (`chat` | `responses`) — selects `OpenAIModel` vs `OpenAIResponsesModel`. The Responses API uses different native param names, so `to_mantle_config` selects a Responses-specific param map (`max_output_tokens`, nested `reasoning.effort`) by mode. + - `region` — optional override driving both the Mantle endpoint host and the SigV4 region the bearer token is signed for, so a model can pin inference to its host region (e.g. `gpt-5.x` in `us-east-1`) independent of where the app runs. +- The runtime fields (`mantle_api_mode` / `mantle_region`) thread through `model_config`, the agent factory, `base_agent`, the paused-turn snapshot, `stream_coordinator`, and the chat service/routes, so bound agents and resumed turns honor them end to end (#620). +- **`mantleEndpointPath` is deprecated** — accepted-but-ignored in the schema so no stored record breaks, and removed from the admin UI and runtime. Gemma 4 (`google.gemma-4-31b`) is temporarily un-curated: it needs the `/openai/v1` base path but the SDK only routes `openai.gpt-5.*` there; it returns once the `google.gemma-` family prefix lands upstream (#620). +- **Dependency step (#619):** `strands-agents` 1.40.0 → 1.47.0 (with the `[bidi]` extra) plus `aws-bedrock-token-generator` 1.1.0, resolver-confirmed against `strands-agents-tools` 0.5.2. Full backend suite green on the new pin. + +### Test Coverage + +Backend suite green at 2,342 tests on the refactor; frontend typecheck + manage-models specs green. + +## API keys work in cloud again — `/chat/api-converse` on app-api, full catalog + +The programmatic API-key endpoint was broken in every deployed environment: app-api proxied `POST /chat/api-converse` to inference-api, but inference-api now runs inside an AgentCore Runtime whose data plane only serves `POST /invocations` and `GET /ping` — every other path returns `UnknownOperationException` before reaching the container. It worked locally only because `localhost:8001` bypasses the runtime gateway. + +### Backend + +- **Self-contained app-api route (#621).** The handler moves onto app-api (validate key → RBAC → Bedrock converse → cost accounting), reaching Bedrock directly via the task role — no inference-api hop, no `INFERENCE_API_URL` dependency. The proxy, the dead inference-api route, and its DTOs are deleted; verified with an un-mocked smoke returning 200 for stream and non-stream. +- **Mantle models now work over API keys (#621).** The handler was Bedrock-only — `provider="mantle"` models 400'd. Mantle model construction (class-pick + `bedrock_mantle_config` + param maps) is extracted to `apis/shared/models/mantle.py` as `build_mantle_model`, shared by the agent factory and the API-key handler (app-api can't import `agents/`). The handler resolves the requested model's provider from the catalog and branches: Bedrock → boto3 converse (unchanged); Mantle → the shared builder's bare Strands `.stream()`, which yields the same Converse-shaped events — so SSE translation and usage/cost accounting are one code path, and cost records now carry the real provider. Unknown ids fail safe to the Bedrock path. Verified with live dev smokes: chat-mode Mantle and Responses-API Mantle (`openai.gpt-5.4`) both 200 (stream + non-stream). + +### Frontend + +- **API-key snippets point at the right URL (#621).** After the BFF refactor, CloudFront only routes `/api/*` to the backend; the generated curl/Python/JS examples emitted the bare origin, producing a CloudFront 403. The settings page now resolves a relative/empty `appApiUrl` against the current origin (`/api/chat/api-converse`), leaving local dev's absolute `http://localhost:8000` untouched. + +### Infrastructure + +- **app-api IAM (#621):** the Bedrock invoke statement gains `bedrock:InvokeModelWithResponseStream` and broadens to all-region foundation models plus the account-level inference-profile ARN (the catalog uses `us.*` cross-region profiles), mirroring inference-api's grant; and the project-scoped Mantle statement (previously browse-only `Get*`/`List*`) gains `bedrock-mantle:CreateInference`. + +## ✨ Improved + +- **Scheduled runs target Agents (#615).** The schedule form's target selector lists Agents (the Designer primitive superseding Assistants — same record, `agentId == assistantId`, so the wire field is unchanged). Because an Agent's tool bindings *replace* the run's `enabled_tools` at invocation, the manual tool picker now hides when an Agent is selected — it previously offered tools that would be silently discarded — and "Run now" actually targets the selected Agent (it previously ignored it). +- **The agent can maintain a Memory Space's index (#614).** `MEMORY.md` — the human-readable index hydrated into the agent's context each session — was write-only from the agent's perspective, so it could silently drift from the entries the agent writes. The reserved `MEMORY.md` slug (case-insensitive) now routes `memory_read` → `read_index` (viewer+) and `memory_write` → `update_index` (editor+), with the same readwrite-binding gate as entry writes. The slug is reserved; it can't become an ordinary entry. + +## ⚠️ Changed + +- **Scheduled Runs are un-gated from RBAC (#617).** The `scheduled-runs` capability check is dropped from the `/schedules` and `/runs/*` gates — only the `SCHEDULED_RUNS_ENABLED` kill switch remains (404 when off). This widens who can *reach* the surface, not what any caller can do: runs still execute with the caller's own RBAC-allowed tools. The feature stays deliberately low-key — no nav entry, reachable by direct URL. `apis/shared/rbac/capabilities.py` remains in place so re-gating is a two-line revert. + +## 🐛 Bug fixes + +- **Regular users saw a 403 "Access Denied" toast on page load (#617).** The sidenav ran a background `loadSchedules()` probe on every load; the beta-cohort RBAC gate 403'd it and the global error interceptor popped the toast before the service's graceful catch ran. Fixed by the un-gating above plus removing the vestigial probe (the template never rendered a schedules link). +- **Namespaced Memory Space entries 404'd (#614).** Entry slugs are namespaced with a slash (e.g. `people/brian-bolt`), but the entry routes declared a plain `{slug}` param whose converter stops at `/` — and Uvicorn percent-decodes `%2F` before routing, so view/edit/delete never matched. The GET/PUT/DELETE routes now use the `{slug:path}` converter. + +## 📦 Dependencies + +| Component | Package | From | To | +|---|---|---|---| +| Backend | `strands-agents` (+ `[bidi]`) | 1.40.0 | 1.47.0 | +| Backend | `aws-bedrock-token-generator` | — | 1.1.0 (new) | + +## 🚀 Deployment notes + +Deploy order: **platform (CDK) → backend → frontend.** + +- **Platform deploy is required** for the two app-api IAM grants (#621). Without them, the relocated `/chat/api-converse` AccessDenies on streaming/inference-profile Bedrock models and on all Mantle models. +- **Mantle model records:** after deploy, set `apiMode=responses` on any Responses-only Mantle model (e.g. `openai.gpt-5.4`) in the admin model manager — records default to `chat`. `mantleEndpointPath` is now ignored; no cleanup needed. Optionally set `region` on models whose host region differs from the app's. +- **Scheduled Runs** become reachable (by direct URL) to all users where `SCHEDULED_RUNS_ENABLED` is on; set it to `false` to turn the surface off entirely. +- No data migration and no breaking API changes. + +--- + # Release Notes — v1.2.0 **Release Date:** July 9, 2026 diff --git a/VERSION b/VERSION index 26aaba0e8..f0bb29e76 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.0 +1.3.0 diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 5ad5d9e64..c4f49c4bb 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "agentcore-stack" -version = "1.2.0" +version = "1.3.0" requires-python = ">=3.10" description = "Multi-agent conversational AI system with AWS Bedrock AgentCore" readme = "README.md" @@ -54,7 +54,7 @@ dependencies = [ [project.optional-dependencies] # AgentCore-specific dependencies (for inference_api) agentcore = [ - "strands-agents==1.40.0", + "strands-agents==1.47.0", "strands-agents-tools==0.5.2", "aws-opentelemetry-distro==0.17.0", @@ -62,11 +62,14 @@ agentcore = [ # Multi-provider LLM support "openai==2.32.0", # For OpenAI models "google-genai==1.73.1", # For Google Gemini models + # Mints Bedrock Mantle bearer tokens for Strands' bedrock_mantle_config + # (OpenAIModel / OpenAIResponsesModel). Bounded >=1.1.0,<2.0.0 by strands. + "aws-bedrock-token-generator==1.1.0", ] # Voice/BidiAgent dependencies (Nova Sonic speech-to-speech) bidi = [ - "strands-agents[bidi]==1.40.0", + "strands-agents[bidi]==1.47.0", ] # Document ingestion pipeline dependencies (for Lambda deployment) diff --git a/backend/src/agents/builtin_tools/memory_spaces/tools.py b/backend/src/agents/builtin_tools/memory_spaces/tools.py index c6317da6a..145a814ad 100644 --- a/backend/src/agents/builtin_tools/memory_spaces/tools.py +++ b/backend/src/agents/builtin_tools/memory_spaces/tools.py @@ -30,6 +30,20 @@ logger = logging.getLogger(__name__) +# The space's human-readable index (MEMORY.md) is not a manifest entry — it's a +# standalone S3 object addressed by ``space.index_s3_key`` and auto-injected into +# the agent's context each session via hydration (``always_load`` starts with +# "MEMORY.md"). It never appears in ``memory_list``. To let the agent keep that +# index in sync with the entries it writes, ``memory_read``/``memory_write`` treat +# this reserved slug specially, routing to ``read_index``/``update_index`` instead +# of the entry manifest. The slug is reserved: an agent cannot create an ordinary +# entry named "MEMORY.md". +_INDEX_SLUG = "MEMORY.md" + + +def _is_index_slug(slug: str) -> bool: + return slug.strip().lower() == _INDEX_SLUG.lower() + def _error(text: str) -> dict[str, Any]: return {"content": [{"text": f"❌ {text}"}], "status": "error"} @@ -44,6 +58,10 @@ async def memory_list(entry_type: Optional[str] = None) -> dict[str, Any]: specific entry. Returns each entry's slug, type, description, and last-updated time. Optionally filter by `entry_type` ("entity", "episodic", or "fact"). + The `MEMORY.md` index is not an entry and never appears here — it's injected + into your context each session and is read/written via `memory_read("MEMORY.md")` + / `memory_write("MEMORY.md", ...)`. + Args: entry_type: Optional filter — one of "entity", "episodic", "fact". """ @@ -72,14 +90,23 @@ async def memory_read(slug: str) -> dict[str, Any]: """Read the full content of one entry in your bound memory space. Pass a `slug` from `memory_list` (or referenced in your injected memory index). + The special slug `MEMORY.md` reads the space's human-readable index (the same + text injected into your context each session) — use it to see the current index + before updating it with `memory_write`. Args: - slug: The entry's stable id within the space (e.g. "jane-doe"). + slug: The entry's stable id within the space (e.g. "jane-doe"), or the + reserved slug "MEMORY.md" for the space index. """ try: - body = await asyncio.to_thread( - MemorySpaceService().read_entry, space_id, user_id, user_email, slug - ) + if _is_index_slug(slug): + body = await asyncio.to_thread( + MemorySpaceService().read_index, space_id, user_id, user_email + ) + else: + body = await asyncio.to_thread( + MemorySpaceService().read_entry, space_id, user_id, user_email, slug + ) except MemorySpaceNotFoundError: return _error(f"No memory entry '{slug}' exists in '{space_name}'.") except MemorySpacePermissionError as exc: @@ -105,13 +132,28 @@ async def memory_write( will want recalled later. Writing an existing `slug` replaces that entry. Only available when the agent's memory binding grants write access. + The special slug `MEMORY.md` replaces the space's human-readable index instead of + creating an entry — keep it in sync as you add entries (e.g. a one-line pointer or + `[[slug]]` wikilink per entry). Read the current index first with + `memory_read("MEMORY.md")`; `entry_type` and `description` are ignored for it. + Args: - slug: Stable id for the entry (e.g. "jane-doe", "daily-2026-07-07"). - body: The entry's markdown content. + slug: Stable id for the entry (e.g. "jane-doe", "daily-2026-07-07"), or the + reserved slug "MEMORY.md" to replace the space index. + body: The entry's (or index's) markdown content. entry_type: "entity", "episodic", or "fact" (default "fact"). description: Short one-line summary shown in listings. """ try: + if _is_index_slug(slug): + await asyncio.to_thread( + MemorySpaceService().update_index, + space_id, user_id, user_email, body, + ) + return { + "content": [{"text": f'Updated the MEMORY.md index of "{space_name}".'}], + "status": "success", + } ref = await asyncio.to_thread( lambda: MemorySpaceService().write_entry( space_id, user_id, user_email, slug, body, diff --git a/backend/src/agents/main_agent/base_agent.py b/backend/src/agents/main_agent/base_agent.py index ddb863df4..55a3fb04e 100644 --- a/backend/src/agents/main_agent/base_agent.py +++ b/backend/src/agents/main_agent/base_agent.py @@ -60,7 +60,8 @@ def __init__( provider: Optional[str] = None, max_tokens: Optional[int] = None, inference_params: Optional[Dict[str, Any]] = None, - mantle_endpoint_path: Optional[str] = None, + mantle_api_mode: Optional[str] = None, + mantle_region: Optional[str] = None, skip_persistence: bool = False, extra_tools: Optional[List[Any]] = None, ): @@ -105,7 +106,8 @@ def __init__( caching_enabled=caching_enabled, provider=provider, inference_params=resolved_params, - mantle_endpoint_path=mantle_endpoint_path, + mantle_api_mode=mantle_api_mode, + mantle_region=mantle_region, ) # Frozen snapshot of agent-construction params, used when the turn @@ -118,7 +120,8 @@ def __init__( "provider": provider, "caching_enabled": caching_enabled, "inference_params": dict(resolved_params), - "mantle_endpoint_path": mantle_endpoint_path, + "mantle_api_mode": mantle_api_mode, + "mantle_region": mantle_region, } # Load retry configuration from environment variables diff --git a/backend/src/agents/main_agent/core/agent_factory.py b/backend/src/agents/main_agent/core/agent_factory.py index 856a38d75..1c5a9900e 100644 --- a/backend/src/agents/main_agent/core/agent_factory.py +++ b/backend/src/agents/main_agent/core/agent_factory.py @@ -12,6 +12,7 @@ from agents.main_agent.core.bedrock_count_tokens import CountTokensBedrockModel from agents.main_agent.core.model_config import ModelConfig, ModelProvider from agents.main_agent.config.constants import EnvVars +from apis.shared.models.mantle import build_mantle_model logger = logging.getLogger(__name__) @@ -62,44 +63,49 @@ def _create_openai_model(model_config: ModelConfig) -> OpenAIModel: return OpenAIModel(client_args=client_args, **openai_config) @staticmethod - def _create_mantle_model(model_config: ModelConfig) -> OpenAIModel: + def _create_mantle_model(model_config: ModelConfig): """ - Create an OpenAIModel pointed at Bedrock Mantle + Create a Strands OpenAI-compatible model pointed at Bedrock Mantle Mantle is AWS's OpenAI-compatible inference surface for Bedrock-hosted - models, so the Strands OpenAIModel does the protocol work — only the - client differs: the regional Mantle base URL plus a short-term bearer - token minted from the runtime role's credentials (requires - `bedrock:CallWithBearerToken`). The token is minted per agent build; - it lives 12h and an agent is built per turn, so expiry is a non-issue. + models. Strands' ``bedrock_mantle_config`` owns the wire details: it + mints the short-term bearer token (via aws-bedrock-token-generator, + requires `bedrock-mantle:CallWithBearerToken`) on demand and derives the + regional base URL plus the model-family base path + (`openai.gpt-5.*` -> /openai/v1, everything else -> /v1). + + The model's declared API surface picks the class: Chat Completions + (``OpenAIModel``) or the Responses API (``OpenAIResponsesModel``) — some + Mantle models (e.g. `openai.gpt-5.x`) only serve Responses and reject + Chat Completions. + + ``mantle_region`` optionally pins inference to the region hosting the + model (e.g. us-east-1) independent of the app's region; it drives both + the Mantle endpoint and the region the bearer token is signed for. Args: model_config: Model configuration Returns: - OpenAIModel: Configured model targeting Bedrock Mantle - - Raises: - ValueError: If no AWS credentials are available to mint the token + OpenAIModel | OpenAIResponsesModel: Configured model targeting Mantle """ - from apis.shared.bedrock import ( - generate_bedrock_bearer_token, - get_mantle_base_url, - ) - - region = os.getenv(EnvVars.AWS_REGION) - base_url = get_mantle_base_url(region, model_config.mantle_endpoint_path) - client_args = { - "api_key": generate_bedrock_bearer_token(region), - "base_url": base_url, - } - + # region resolves from (in order) the model override, then AWS_REGION; + # None lets Strands fall back to the boto session / standard chain. + region = model_config.mantle_region or os.getenv(EnvVars.AWS_REGION) mantle_config = model_config.to_mantle_config() logger.info( - f"Creating Bedrock Mantle model with model_id={model_config.model_id} " - f"base_url={base_url}" + f"Creating Bedrock Mantle model (api={model_config.mantle_api_mode.value}) " + f"with model_id={model_config.model_id} " + f"region={region or ''}" + ) + # Shared builder — also used by the API-key /chat/api-converse handler + # (apis/app_api) so the Mantle model construction is never forked. + return build_mantle_model( + model_id=mantle_config["model_id"], + api_mode=model_config.mantle_api_mode, + region=region, + params=mantle_config.get("params"), ) - return OpenAIModel(client_args=client_args, **mantle_config) @staticmethod def _create_gemini_model(model_config: ModelConfig) -> GeminiModel: diff --git a/backend/src/agents/main_agent/core/model_config.py b/backend/src/agents/main_agent/core/model_config.py index 1d4547230..761435d36 100644 --- a/backend/src/agents/main_agent/core/model_config.py +++ b/backend/src/agents/main_agent/core/model_config.py @@ -8,6 +8,14 @@ from enum import Enum from agents.main_agent.config.constants import EnvVars, Defaults +# MantleApiMode and the Mantle param maps live in apis.shared so the API-key +# converse handler (apis/app_api, which can't import agents/) shares one +# implementation of Mantle model construction with the agent factory. +from apis.shared.models.mantle import ( + MantleApiMode, + MANTLE_CHAT_PARAM_MAP as _MANTLE_CHAT_PARAM_MAP, + MANTLE_RESPONSES_PARAM_MAP as _MANTLE_RESPONSES_PARAM_MAP, +) logger = logging.getLogger(__name__) @@ -57,15 +65,8 @@ class ModelProvider(str, Enum): "reasoning_effort": "reasoning_effort", } -# Mantle speaks the OpenAI chat-completions protocol, so the canonical->native -# mapping mirrors OpenAI's. Kept separate so Mantle-specific divergence (e.g. -# params some open-weight models reject) has a home without touching OpenAI. -_MANTLE_PARAM_MAP: Dict[str, str] = { - "temperature": "temperature", - "top_p": "top_p", - "max_tokens": "max_tokens", - "reasoning_effort": "reasoning_effort", -} +# Mantle Chat Completions / Responses param maps (_MANTLE_CHAT_PARAM_MAP, +# _MANTLE_RESPONSES_PARAM_MAP) are imported from apis.shared.models.mantle above. _GEMINI_PARAM_MAP: Dict[str, str] = { "temperature": "temperature", @@ -96,7 +97,8 @@ class ModelProvider(str, Enum): set(_BEDROCK_PARAM_MAP) | set(_OPENAI_PARAM_MAP) | set(_GEMINI_PARAM_MAP) - | set(_MANTLE_PARAM_MAP) + | set(_MANTLE_CHAT_PARAM_MAP) + | set(_MANTLE_RESPONSES_PARAM_MAP) ) @@ -272,10 +274,16 @@ class ModelConfig: provider: ModelProvider = ModelProvider.BEDROCK inference_params: Dict[str, Any] = field(default_factory=dict) retry_config: Optional[RetryConfig] = None - # Bedrock Mantle endpoint path (``/v1`` or ``/openai/v1``). Only consulted - # on the MANTLE provider path, where it selects the base URL the OpenAI - # client targets. ``None`` falls back to ``/v1`` in the agent factory. - mantle_endpoint_path: Optional[str] = None + # Bedrock Mantle: which OpenAI-compatible API the model speaks (Chat + # Completions vs Responses). Only consulted on the MANTLE provider path, + # where the factory uses it to pick OpenAIModel vs OpenAIResponsesModel. + mantle_api_mode: MantleApiMode = MantleApiMode.CHAT_COMPLETIONS + # Bedrock Mantle: optional AWS region override for the inference endpoint. + # ``None`` -> the agent's AWS_REGION. Lets a model pin inference to the + # region where it's hosted (e.g. openai.gpt-5.x in us-east-1) independent + # of where the app runs. Drives both the Mantle base URL and the region + # the bearer token is signed for (via bedrock_mantle_config). + mantle_region: Optional[str] = None def get_provider(self) -> ModelProvider: """ @@ -388,15 +396,23 @@ def to_openai_config(self) -> Dict[str, Any]: return config def to_mantle_config(self) -> Dict[str, Any]: - """Convert to OpenAIModel kwargs for Bedrock Mantle. - - Mantle is OpenAI-wire-compatible, so the output feeds the same - Strands ``OpenAIModel`` — only the client (base_url + bearer token) - differs, and that's the factory's job. + """Convert to OpenAI-compatible kwargs for Bedrock Mantle. + + Mantle is OpenAI-wire-compatible, so the output feeds either Strands' + ``OpenAIModel`` (Chat Completions) or ``OpenAIResponsesModel`` (Responses + API) — the factory picks the class from ``mantle_api_mode`` and supplies + the client (base_url + bearer token) via ``bedrock_mantle_config``. The + two APIs use different native param names, so the param map is selected + by mode here. """ + param_map = ( + _MANTLE_RESPONSES_PARAM_MAP + if self.mantle_api_mode == MantleApiMode.RESPONSES + else _MANTLE_CHAT_PARAM_MAP + ) params: Dict[str, Any] = {} _apply_canonical_params( - params, self.inference_params, _MANTLE_PARAM_MAP, "mantle", self.model_id + params, self.inference_params, param_map, "mantle", self.model_id ) config: Dict[str, Any] = {"model_id": self.model_id} if params: @@ -421,7 +437,8 @@ def to_dict(self) -> Dict[str, Any]: "caching_enabled": self.caching_enabled, "provider": self.get_provider().value, "inference_params": dict(self.inference_params), - "mantle_endpoint_path": self.mantle_endpoint_path, + "mantle_api_mode": self.mantle_api_mode.value, + "mantle_region": self.mantle_region, } @classmethod @@ -431,7 +448,8 @@ def from_params( caching_enabled: Optional[bool] = None, provider: Optional[str] = None, inference_params: Optional[Dict[str, Any]] = None, - mantle_endpoint_path: Optional[str] = None, + mantle_api_mode: Optional[str] = None, + mantle_region: Optional[str] = None, ) -> "ModelConfig": """Create ModelConfig from optional parameters. @@ -442,8 +460,11 @@ def from_params( inference_params: Canonical-name -> value map (temperature, top_p, max_tokens, thinking, ...). Each provider's translation table drops unsupported keys silently. - mantle_endpoint_path: Bedrock Mantle endpoint path ("/v1" or - "/openai/v1"). Only consulted on the MANTLE provider path. + mantle_api_mode: Bedrock Mantle API surface ("chat" or "responses"). + Only consulted on the MANTLE provider path; unknown/empty falls + back to Chat Completions. + mantle_region: Bedrock Mantle region override. Only consulted on the + MANTLE provider path; ``None`` falls back to the agent's region. """ provider_enum = ModelProvider.BEDROCK if provider: @@ -452,10 +473,18 @@ def from_params( except ValueError: pass # Invalid provider, fall back to default + auto-detect via model_id + api_mode = MantleApiMode.CHAT_COMPLETIONS + if mantle_api_mode: + try: + api_mode = MantleApiMode(mantle_api_mode.lower()) + except ValueError: + pass # Unknown mode -> chat completions (the Mantle default) + return cls( model_id=model_id or cls.model_id, caching_enabled=caching_enabled if caching_enabled is not None else cls.caching_enabled, provider=provider_enum, inference_params=dict(inference_params) if inference_params else {}, - mantle_endpoint_path=mantle_endpoint_path, + mantle_api_mode=api_mode, + mantle_region=mantle_region, ) diff --git a/backend/src/agents/main_agent/streaming/stream_coordinator.py b/backend/src/agents/main_agent/streaming/stream_coordinator.py index 55d3d745f..a79bd5a70 100644 --- a/backend/src/agents/main_agent/streaming/stream_coordinator.py +++ b/backend/src/agents/main_agent/streaming/stream_coordinator.py @@ -1241,7 +1241,8 @@ async def _persist_paused_turn_snapshot( agent_type=snapshot_source.get("agent_type"), enabled_skills=snapshot_source.get("enabled_skills"), inference_params=dict(inference_params) if inference_params else None, - mantle_endpoint_path=snapshot_source.get("mantle_endpoint_path"), + mantle_api_mode=snapshot_source.get("mantle_api_mode"), + mantle_region=snapshot_source.get("mantle_region"), captured_at=now.isoformat(), expires_at=(now + timedelta(hours=1)).isoformat(), ) diff --git a/backend/src/apis/app_api/chat/converse_routes.py b/backend/src/apis/app_api/chat/converse_routes.py index d96ec534f..849fd02d1 100644 --- a/backend/src/apis/app_api/chat/converse_routes.py +++ b/backend/src/apis/app_api/chat/converse_routes.py @@ -1,122 +1,578 @@ -"""Proxy for the API-key authenticated Bedrock Converse endpoint. +"""API-key authenticated converse endpoint. -Forwards /chat/api-converse requests to the Inference API, which handles -cost accounting, quota enforcement, and the actual Bedrock call. This -ensures a single code path for all API-key traffic regardless of which -URL external consumers use. +Provides a direct Bedrock Converse API wrapper authenticated via API keys +(X-API-Key header). Supports: +- Single-shot and multi-turn conversations +- Streaming (SSE) and non-streaming responses +- Reasoning models (extended thinking / reasoning content blocks) +- Multiple Bedrock model IDs -In production the Inference API lives on a separate Fargate service -(AgentCore Runtime) reachable via INFERENCE_API_URL. Locally it defaults -to http://localhost:8001. +This handler lives on **app-api** (not inference-api) on purpose: it is a +user-facing, API-key-authenticated endpoint, and inference-api now runs +inside an AgentCore Runtime whose data plane only serves ``POST /invocations`` +and ``GET /ping`` — any other path (like ``/chat/api-converse``) is +unreachable in cloud. app-api reaches Bedrock directly via its task role, so +there is no inference-api hop and no dependency on ``INFERENCE_API_URL``. + +RBAC model access is enforced via ``AppRoleService.can_access_model()`` +before any Bedrock invocation occurs. Requests for models the caller's +role does not permit are rejected with HTTP 403. Only Bedrock-provider +models are supported — non-Bedrock catalog models (e.g. provider ``mantle``) +are rejected by Bedrock with HTTP 400. """ +import json import logging import os +from datetime import datetime, timezone +from typing import AsyncGenerator, Optional -import httpx -from fastapi import APIRouter, Header, HTTPException, Request +import boto3 +from botocore.exceptions import ClientError as BotoClientError +from fastapi import APIRouter, Header, HTTPException, status from fastapi.responses import StreamingResponse +from apis.shared.auth.models import User +from apis.shared import quota as shared_quota +from apis.shared.rbac.service import get_app_role_service +from apis.shared.costs.calculator import CostCalculator +from apis.shared.costs.pricing_config import create_pricing_snapshot +from apis.shared.sessions.metadata import store_message_metadata +from apis.shared.sessions.models import ( + MessageMetadata, + TokenUsage, + ModelInfo, + Attribution, +) + +from apis.shared.models.mantle import ( + MantleApiMode, + build_mantle_model, + param_map_for, +) + +from .models import ConverseRequest, ConverseResponse + logger = logging.getLogger(__name__) router = APIRouter(prefix="/chat", tags=["api-converse"]) -_INFERENCE_API_URL = os.environ.get("INFERENCE_API_URL", "http://localhost:8001") -_PROXY_TIMEOUT_SECONDS = 300.0 +# --------------------------------------------------------------------------- +# API key validation dependency +# --------------------------------------------------------------------------- +async def _validate_api_key(api_key: str): + """Validate the raw API key and return the ValidatedApiKey, or raise 401.""" + from apis.shared.auth.api_keys.service import get_api_key_service -def _build_upstream_client() -> httpx.AsyncClient: - """Single seam where the proxy's upstream client is constructed. + service = get_api_key_service() + validated = await service.validate_key(api_key) + if validated is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired API key", + ) + return validated - Tests substitute a MockTransport-backed client here without having to - monkey-patch the global `httpx.AsyncClient` symbol — which would also - intercept any test-side httpx clients running in the same process. + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _build_user_from_api_key(validated_key) -> User: + """Construct a minimal User from a ValidatedApiKey for quota checking.""" + return User( + email=f"{validated_key.user_id}@api-key", + user_id=validated_key.user_id, + name=validated_key.name, + roles=["user"], + ) + +async def _record_cost( + user_id: str, model_id: str, usage: dict, key_id: str, provider: str = "bedrock" +) -> None: + """Calculate and store cost metadata for an api-converse request. + + Fail-open: any error is logged but never re-raised so the caller's + response is not blocked. """ - return httpx.AsyncClient(timeout=httpx.Timeout(_PROXY_TIMEOUT_SECONDS)) + try: + pricing = await create_pricing_snapshot(model_id) + if pricing is None: + logger.warning( + "No pricing snapshot for model; skipping cost recording" + ) + return + + total_cost, breakdown = CostCalculator.calculate_message_cost(usage, pricing) + + token_usage = TokenUsage( + inputTokens=usage.get("inputTokens", 0), + outputTokens=usage.get("outputTokens", 0), + totalTokens=usage.get("inputTokens", 0) + usage.get("outputTokens", 0), + cacheReadInputTokens=usage.get("cacheReadInputTokens"), + cacheWriteInputTokens=usage.get("cacheWriteInputTokens"), + ) + + model_info = ModelInfo( + modelId=model_id, + modelName=model_id, + provider=provider, + ) + + attribution = Attribution( + userId=user_id, + sessionId=f"api-converse-{key_id}", + timestamp=datetime.now(timezone.utc).isoformat(), + ) + + metadata = MessageMetadata( + token_usage=token_usage, + model_info=model_info, + attribution=attribution, + cost=total_cost, + ) + + await store_message_metadata( + session_id=f"api-converse-{key_id}", + user_id=user_id, + message_id=0, + message_metadata=metadata, + ) + except Exception as exc: + logger.error( + "Failed to record cost", + exc_info=True, + ) + + +def _get_bedrock_client(): + """Return a boto3 bedrock-runtime client.""" + region = os.environ.get("AWS_REGION", "us-east-1") + return boto3.client("bedrock-runtime", region_name=region) +def _build_converse_params(request: ConverseRequest) -> dict: + """Build the kwargs dict for bedrock.converse / converse_stream.""" + # Convert messages to Bedrock Converse format + messages = [ + {"role": m.role, "content": [{"text": m.content}]} + for m in request.messages + ] + + inference_config: dict = {} + if request.temperature is not None: + inference_config["temperature"] = request.temperature + if request.max_tokens is not None: + inference_config["maxTokens"] = request.max_tokens + if request.top_p is not None: + inference_config["topP"] = request.top_p + + params: dict = { + "modelId": request.model_id, + "messages": messages, + } + + if request.system_prompt: + params["system"] = [{"text": request.system_prompt}] + + if inference_config: + params["inferenceConfig"] = inference_config + + return params + + +def _extract_reasoning_and_text(content_blocks: list) -> tuple[str, str | None]: + """Extract text and optional reasoning from Bedrock response content blocks. + + Reasoning models (e.g. Claude with extended thinking) return a + ``reasoningContent`` block alongside the normal ``text`` block. + + Returns: + (text, reasoning) – reasoning is None for non-reasoning models. + """ + text_parts: list[str] = [] + reasoning_parts: list[str] = [] + + for block in content_blocks: + if "text" in block: + text_parts.append(block["text"]) + elif "reasoningContent" in block: + # Extended thinking / reasoning block + rc = block["reasoningContent"] + if "reasoningText" in rc: + reasoning_parts.append(rc["reasoningText"].get("text", "")) + + text = "".join(text_parts) + reasoning = "".join(reasoning_parts) if reasoning_parts else None + return text, reasoning + + +# --------------------------------------------------------------------------- +# Streaming helpers +# --------------------------------------------------------------------------- + +def _converse_event_to_sse(event: dict, state: dict) -> list[str]: + """Map one Bedrock-Converse stream event to SSE frame(s). + + Shared by the Bedrock path (boto3 ``converse_stream``) and the Mantle path + (Strands ``model.stream``): both emit the same Converse event shape, since + Strands normalizes every provider onto Converse events. ``state`` carries + cross-event flags — ``in_reasoning`` (bool) and the latest ``usage`` dict + (set when a metadata event arrives). + """ + out: list[str] = [] + if "messageStart" in event: + out.append(_sse("message_start", {"role": event["messageStart"].get("role", "assistant")})) + elif "contentBlockStart" in event: + cbs = event["contentBlockStart"] + idx = cbs.get("contentBlockIndex", 0) + start_data = cbs.get("start", {}) + if "toolUse" in start_data: + out.append(_sse("content_block_start", { + "contentBlockIndex": idx, "type": "tool_use", "toolUse": start_data["toolUse"], + })) + else: + out.append(_sse("content_block_start", {"contentBlockIndex": idx, "type": "text"})) + elif "contentBlockDelta" in event: + cbd = event["contentBlockDelta"] + idx = cbd.get("contentBlockIndex", 0) + delta = cbd.get("delta", {}) + if "text" in delta: + out.append(_sse("content_block_delta", { + "contentBlockIndex": idx, "type": "text", "text": delta["text"], + })) + elif "reasoningContent" in delta: + rc = delta["reasoningContent"] + if "text" in rc: + if not state.get("in_reasoning"): + state["in_reasoning"] = True + out.append(_sse("reasoning_start", {"contentBlockIndex": idx})) + out.append(_sse("reasoning_delta", {"contentBlockIndex": idx, "text": rc["text"]})) + elif "contentBlockStop" in event: + idx = event["contentBlockStop"].get("contentBlockIndex", 0) + if state.get("in_reasoning"): + out.append(_sse("reasoning_stop", {"contentBlockIndex": idx})) + state["in_reasoning"] = False + out.append(_sse("content_block_stop", {"contentBlockIndex": idx})) + elif "messageStop" in event: + out.append(_sse("message_stop", {"stopReason": event["messageStop"].get("stopReason", "end_turn")})) + elif "metadata" in event: + meta = event["metadata"] + usage = meta.get("usage", {}) + state["usage"] = usage + out.append(_sse("metadata", {"usage": usage, "metrics": meta.get("metrics", {})})) + return out + + +async def _stream_converse(request: ConverseRequest, user_id: str, key_id: str) -> AsyncGenerator[str, None]: + """Call Bedrock converse_stream and yield SSE events.""" + client = _get_bedrock_client() + params = _build_converse_params(request) + + try: + response = client.converse_stream(**params) + except BotoClientError as exc: + error_code = exc.response["Error"]["Code"] + logger.error(f"Bedrock converse_stream ClientError ({error_code})", exc_info=True) + yield _sse("error", {"error": "Model invocation failed due to a service error."}) + yield _sse("done", {}) + return + except Exception: + logger.error("Bedrock converse_stream error", exc_info=True) + yield _sse("error", {"error": "Model invocation failed due to an internal error."}) + yield _sse("done", {}) + return + + stream = response.get("stream") + if not stream: + yield _sse("error", {"error": "No stream returned from Bedrock"}) + yield _sse("done", {}) + return + + state: dict = {"in_reasoning": False, "usage": {}} + for event in stream: + for frame in _converse_event_to_sse(event, state): + yield frame + + yield _sse("done", {}) + + if state["usage"]: + await _record_cost( + user_id=user_id, model_id=request.model_id, usage=state["usage"], key_id=key_id, + ) + + +# --------------------------------------------------------------------------- +# Bedrock Mantle path (OpenAI-compatible surface; provider="mantle") +# +# Mantle models don't speak Bedrock Converse — they ride the OpenAI wire +# protocol. We reuse the SHARED Strands builder (apis.shared.models.mantle, +# same one the agent factory uses) and invoke the bare model's `.stream()`, +# which yields the same Converse-shaped events the Bedrock path emits — so the +# SSE translation and usage/cost accounting are identical. +# --------------------------------------------------------------------------- + +def _build_mantle_params(request: ConverseRequest, api_mode: MantleApiMode) -> dict: + """Translate the request's canonical inference params to Mantle-native names.""" + pmap = param_map_for(api_mode) + canonical = { + "temperature": request.temperature, + "top_p": request.top_p, + "max_tokens": request.max_tokens, + } + params: dict = {} + for key, value in canonical.items(): + if value is not None and key in pmap: + # The api-converse param set maps only to flat native names + # (temperature / top_p / max_tokens|max_output_tokens) — no nesting. + params[pmap[key]] = value + return params + + +def _build_request_mantle_model(request: ConverseRequest, api_mode: MantleApiMode, region: Optional[str]): + """Construct the shared Strands Mantle model for this request.""" + return build_mantle_model( + model_id=request.model_id, + api_mode=api_mode, + region=region or None, + params=_build_mantle_params(request, api_mode) or None, + ) + + +def _mantle_messages(request: ConverseRequest) -> list[dict]: + """Convert request messages to the Converse content-block format.""" + return [{"role": m.role, "content": [{"text": m.content}]} for m in request.messages] + + +async def _stream_mantle( + request: ConverseRequest, + user_id: str, + key_id: str, + api_mode: MantleApiMode, + region: Optional[str], +) -> AsyncGenerator[str, None]: + """Invoke a Mantle model via Strands and yield the same SSE shape as Bedrock.""" + try: + model = _build_request_mantle_model(request, api_mode, region) + messages = _mantle_messages(request) + system_prompt = request.system_prompt or None + except Exception: + logger.error("Failed to build Mantle model", exc_info=True) + yield _sse("error", {"error": "Model invocation failed due to an internal error."}) + yield _sse("done", {}) + return + + state: dict = {"in_reasoning": False, "usage": {}} + try: + async for event in model.stream(messages, system_prompt=system_prompt): + for frame in _converse_event_to_sse(event, state): + yield frame + except Exception: + logger.error("Bedrock Mantle stream error", exc_info=True) + yield _sse("error", {"error": "Model invocation failed due to a service error."}) + yield _sse("done", {}) + return + + yield _sse("done", {}) + + if state["usage"]: + await _record_cost( + user_id=user_id, model_id=request.model_id, usage=state["usage"], + key_id=key_id, provider="mantle", + ) + + +async def _mantle_converse( + request: ConverseRequest, + user_id: str, + key_id: str, + api_mode: MantleApiMode, + region: Optional[str], +) -> ConverseResponse: + """Non-streaming Mantle converse: consume the model stream and aggregate.""" + try: + model = _build_request_mantle_model(request, api_mode, region) + messages = _mantle_messages(request) + system_prompt = request.system_prompt or None + except Exception: + logger.error("Failed to build Mantle model", exc_info=True) + raise HTTPException(status_code=502, detail="Model invocation failed due to an internal error.") + + text_parts: list[str] = [] + reasoning_parts: list[str] = [] + usage: dict = {} + stop_reason: Optional[str] = None + try: + async for event in model.stream(messages, system_prompt=system_prompt): + if "contentBlockDelta" in event: + delta = event["contentBlockDelta"].get("delta", {}) + if "text" in delta: + text_parts.append(delta["text"]) + elif "reasoningContent" in delta: + rc = delta["reasoningContent"] + if "text" in rc: + reasoning_parts.append(rc["text"]) + elif "messageStop" in event: + stop_reason = event["messageStop"].get("stopReason", "end_turn") + elif "metadata" in event: + usage = event["metadata"].get("usage", {}) + except Exception: + logger.error("Bedrock Mantle converse error", exc_info=True) + raise HTTPException(status_code=502, detail="Model invocation failed due to a service error.") + + if usage: + await _record_cost( + user_id=user_id, model_id=request.model_id, usage=usage, + key_id=key_id, provider="mantle", + ) + + return ConverseResponse( + content="".join(text_parts), + model_id=request.model_id, + usage=usage or None, + stop_reason=stop_reason, + reasoning="".join(reasoning_parts) if reasoning_parts else None, + ) + + +async def _resolve_model_routing(model_id: str) -> tuple[str, Optional[str], Optional[str]]: + """Resolve (provider, mantle_api_mode, mantle_region) for an external model id. + + Looks the model up in the managed-models catalog by its external id. Falls + back to the Bedrock Converse path when the model isn't found or the lookup + fails (fail-safe: unknown ids behave exactly as before this change). + """ + try: + from apis.shared.models.managed_models import list_managed_models + + for model in await list_managed_models(): + if model.model_id == model_id: + return (model.provider or "bedrock", model.mantle_api_mode, model.mantle_region) + except Exception: + logger.warning("Model routing lookup failed; defaulting to bedrock", exc_info=True) + return ("bedrock", None, None) + + + +def _sse(event_type: str, data: dict) -> str: + """Format a single SSE event.""" + return f"event: {event_type}\ndata: {json.dumps(data)}\n\n" + + +# --------------------------------------------------------------------------- +# Endpoint +# --------------------------------------------------------------------------- + @router.post( "/api-converse", - summary="Converse with a Bedrock model via API key (proxied to Inference API)", + response_model=ConverseResponse, responses={ + 200: {"description": "Non-streaming response (or SSE stream when stream=true)"}, 401: {"description": "Invalid or expired API key"}, - 502: {"description": "Inference API unreachable"}, + 400: {"description": "Bad request (invalid model, empty messages, etc.)"}, }, + summary="Converse with a Bedrock model via API key", ) -async def api_converse_proxy( - request: Request, +async def api_converse( + request: ConverseRequest, x_api_key: str = Header(..., alias="X-API-Key"), ): - """Thin proxy that forwards the request to the Inference API. + """Direct Bedrock Converse API wrapper authenticated via API key. - The Inference API handles API-key validation, quota checks, Bedrock - invocation, and cost recording. This proxy simply relays the request - and response (including SSE streams) so that external consumers can - use the App API URL for everything. + Supports streaming (SSE) and non-streaming responses, multi-turn + conversations, and reasoning models that return extended thinking blocks. """ - target_url = f"{_INFERENCE_API_URL}/chat/api-converse" - body = await request.body() - - headers = { - "Content-Type": "application/json", - "X-API-Key": x_api_key, - } + # 1. Validate API key + validated_key = await _validate_api_key(x_api_key) + logger.info("api-converse request received") - logger.info(f"Proxying api-converse to {target_url}") + # 1.5 Per-key rate limit (fail-open) + from apis.shared.rate_limit import get_rate_limiter - # The client lifecycle must outlive this handler — closing it via - # `async with` while a stream is in flight makes httpx drain the upstream - # response during `__aexit__`, buffering the entire SSE stream before - # headers reach the browser. Open the client manually and tie its - # cleanup to the streaming generator's `finally` (or to the early-exit - # paths below) so headers can flush as soon as the upstream's first - # response message arrives. - client = _build_upstream_client() try: - response = await client.send( - client.build_request("POST", target_url, headers=headers, content=body), - stream=True, - ) - except httpx.ConnectError: - await client.aclose() - logger.error(f"Cannot reach Inference API at {target_url}") - raise HTTPException(status_code=502, detail="Inference API is unreachable") - except httpx.TimeoutException: - await client.aclose() - logger.error(f"Inference API request timed out: {target_url}") - raise HTTPException(status_code=504, detail="Inference API request timed out") + limiter = get_rate_limiter() + if not await limiter.check_rate_limit(validated_key.key_id): + logger.warning( + f"Rate limit exceeded for key {validated_key.key_id} " + f"(user={validated_key.user_id})" + ) + raise HTTPException( + status_code=429, + detail="Rate limit exceeded. Max 60 requests per minute.", + headers={"Retry-After": "60"}, + ) + except HTTPException: + raise except Exception as exc: - await client.aclose() - logger.error(f"Proxy error: {exc}", exc_info=True) - raise HTTPException( - status_code=502, - detail="An unexpected error occurred while proxying to the Inference API", - ) + logger.error(f"Rate limit check error: {exc}", exc_info=True) - if response.status_code >= 400: + # 2. Basic validation + if not request.messages: + raise HTTPException(status_code=400, detail="messages array must not be empty") + + # 2.5 Build User and synthetic session_id for quota / cost accounting + user = _build_user_from_api_key(validated_key) + session_id = f"api-converse-{validated_key.key_id}" + + # 2.6 Quota check (fail-open: errors are logged but don't block the request) + if shared_quota.is_quota_enforcement_enabled(): try: - error_body = await response.aread() - finally: - await response.aclose() - await client.aclose() + quota_checker = shared_quota.get_quota_checker() + quota_result = await quota_checker.check_quota(user=user, session_id=session_id) + if not quota_result.allowed: + if quota_result.quota_limit is None: + # No quota tier configured for this API-key user — fail-open + # per Requirement 3.6 (don't block on internal/config issues) + logger.warning( + f"No quota tier for user {validated_key.user_id}; " + f"proceeding (fail-open)" + ) + else: + logger.warning( + f"Quota exceeded for user {validated_key.user_id}: {quota_result.message}" + ) + raise HTTPException(status_code=429, detail=quota_result.message) + except HTTPException: + raise + except Exception as exc: + logger.error( + f"Error checking quota for user {validated_key.user_id}: {exc}", + exc_info=True, + ) + + # 2.7 Model access check (RBAC) + app_role_service = get_app_role_service() + if not await app_role_service.can_access_model(user, request.model_id): raise HTTPException( - status_code=response.status_code, - detail=error_body.decode("utf-8", errors="replace"), + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Access denied to model: {request.model_id}", ) - content_type = response.headers.get("content-type", "") - if "text/event-stream" in content_type: - async def stream_relay(): - try: - async for chunk in response.aiter_bytes(): - yield chunk - finally: - await response.aclose() - await client.aclose() + # 2.8 Provider routing — Bedrock Converse vs Bedrock Mantle (OpenAI wire). + provider, mantle_api_mode, mantle_region = await _resolve_model_routing(request.model_id) + is_mantle = (provider or "").lower() == "mantle" + try: + api_mode = ( + MantleApiMode(mantle_api_mode) if mantle_api_mode else MantleApiMode.CHAT_COMPLETIONS + ) + except ValueError: + api_mode = MantleApiMode.CHAT_COMPLETIONS + # 3. Streaming path + if request.stream: + if is_mantle: + generator = _stream_mantle( + request, user_id=validated_key.user_id, key_id=validated_key.key_id, + api_mode=api_mode, region=mantle_region, + ) + else: + generator = _stream_converse( + request, user_id=validated_key.user_id, key_id=validated_key.key_id, + ) return StreamingResponse( - stream_relay(), + generator, media_type="text/event-stream", headers={ "Cache-Control": "no-cache", @@ -124,13 +580,63 @@ async def stream_relay(): }, ) + # 4. Non-streaming path — Mantle (Strands) vs Bedrock Converse (boto3). + if is_mantle: + return await _mantle_converse( + request, user_id=validated_key.user_id, key_id=validated_key.key_id, + api_mode=api_mode, region=mantle_region, + ) + + client = _get_bedrock_client() + params = _build_converse_params(request) + try: - response_body = await response.aread() - finally: - await response.aclose() - await client.aclose() - return StreamingResponse( - iter([response_body]), - media_type=content_type or "application/json", - status_code=response.status_code, + response = client.converse(**params) + except BotoClientError as exc: + error_code = exc.response["Error"]["Code"] + if error_code == "ThrottlingException": + logger.warning("Bedrock throttling on converse call", exc_info=True) + raise HTTPException( + status_code=429, + detail="Model is temporarily overloaded. Please retry shortly.", + headers={"Retry-After": "5"}, + ) + logger.error(f"Bedrock ClientError ({error_code}) on converse call", exc_info=True) + if error_code in ("ValidationException", "ModelErrorException"): + raise HTTPException( + status_code=400, + detail="Invalid request — check model ID, message format, and content policy.", + ) + if error_code == "AccessDeniedException": + raise HTTPException(status_code=403, detail="Model access is not available.") + raise HTTPException(status_code=502, detail="Model invocation failed due to a service error.") + except Exception: + logger.error("Unexpected error during Bedrock converse call", exc_info=True) + raise HTTPException(status_code=502, detail="Model invocation failed due to an internal error.") + + # Parse response + output = response.get("output", {}) + message = output.get("message", {}) + content_blocks = message.get("content", []) + + text, reasoning = _extract_reasoning_and_text(content_blocks) + + usage = response.get("usage") + stop_reason = response.get("stopReason") + + # Record cost for non-streaming response + if usage is not None: + await _record_cost( + user_id=validated_key.user_id, + model_id=request.model_id, + usage=usage, + key_id=validated_key.key_id, + ) + + return ConverseResponse( + content=text, + model_id=request.model_id, + usage=usage, + stop_reason=stop_reason, + reasoning=reasoning, ) diff --git a/backend/src/apis/app_api/chat/models.py b/backend/src/apis/app_api/chat/models.py new file mode 100644 index 000000000..b8b7c0be5 --- /dev/null +++ b/backend/src/apis/app_api/chat/models.py @@ -0,0 +1,43 @@ +"""Request/response models for the API-key authenticated converse endpoint. + +These DTOs are owned by app-api because the ``/chat/api-converse`` handler +lives here (see ``converse_routes.py``). External API-key consumers post to +this shape and receive ``ConverseResponse`` for non-streaming requests. +""" + +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel + + +class ConverseMessage(BaseModel): + """A single message in the conversation.""" + + role: str # "user" or "assistant" + content: str + + +class ConverseRequest(BaseModel): + """Request model for /chat/api-converse endpoint. + + Supports both single-shot and multi-turn conversations. + """ + + model_id: str # Bedrock model ID (e.g. "us.anthropic.claude-haiku-4-5-20251001-v1:0") + messages: List[ConverseMessage] + system_prompt: Optional[str] = None + temperature: Optional[float] = None + max_tokens: Optional[int] = 4096 + stream: bool = False # Whether to stream the response via SSE + top_p: Optional[float] = None + + +class ConverseResponse(BaseModel): + """Non-streaming response from /chat/api-converse.""" + + role: str = "assistant" + content: str + model_id: str + usage: Optional[Dict[str, Any]] = None + stop_reason: Optional[str] = None + reasoning: Optional[str] = None # Populated for reasoning models diff --git a/backend/src/apis/app_api/main.py b/backend/src/apis/app_api/main.py index b02fc7eaf..28701d26d 100644 --- a/backend/src/apis/app_api/main.py +++ b/backend/src/apis/app_api/main.py @@ -220,7 +220,7 @@ async def lifespan(app: FastAPI): app.include_router(models_router) app.include_router(costs_router) app.include_router(chat_router) # Application-specific chat endpoints -app.include_router(converse_router) # Proxies to Inference API for cost accounting +app.include_router(converse_router) # API-key authenticated /chat/api-converse (Bedrock Converse) app.include_router(bff_chat_proxy_router) # Cookie-authenticated SSE proxy (Phase 4, dormant until SPA cutover) app.include_router(mcp_apps_router) # MCP Apps app-initiated tools/call proxy (PR #5; inert until host flag on) app.include_router(memory_spaces_router) # Memory Spaces user surface (A2); 404s while flag off diff --git a/backend/src/apis/app_api/memory_spaces/routes.py b/backend/src/apis/app_api/memory_spaces/routes.py index 108fe661e..ef86ff2b3 100644 --- a/backend/src/apis/app_api/memory_spaces/routes.py +++ b/backend/src/apis/app_api/memory_spaces/routes.py @@ -428,7 +428,7 @@ def list_entries( return EntriesListResponse(entries=[EntryRefResponse.from_ref(r) for r in entries]) -@router.get("/{space_id}/entries/{slug}", response_model=EntryContentResponse) +@router.get("/{space_id}/entries/{slug:path}", response_model=EntryContentResponse) def read_entry( space_id: str, slug: str, user: User = Depends(require_memory_spaces_user) ) -> EntryContentResponse: @@ -439,7 +439,7 @@ def read_entry( return EntryContentResponse(slug=slug, content=content) -@router.put("/{space_id}/entries/{slug}", response_model=EntryRefResponse) +@router.put("/{space_id}/entries/{slug:path}", response_model=EntryRefResponse) def upsert_entry( space_id: str, slug: str, @@ -462,7 +462,7 @@ def upsert_entry( return EntryRefResponse.from_ref(ref) -@router.delete("/{space_id}/entries/{slug}", status_code=status.HTTP_204_NO_CONTENT) +@router.delete("/{space_id}/entries/{slug:path}", status_code=status.HTTP_204_NO_CONTENT) def delete_entry( space_id: str, slug: str, user: User = Depends(require_memory_spaces_user) ) -> None: diff --git a/backend/src/apis/app_api/runs/routes.py b/backend/src/apis/app_api/runs/routes.py index 5eb542657..b92c5c4b3 100644 --- a/backend/src/apis/app_api/runs/routes.py +++ b/backend/src/apis/app_api/runs/routes.py @@ -8,12 +8,17 @@ surface is to validate the unattended path end-to-end (scheduled-runs PR-1, docs/specs/scheduled-agent-runs.md §7). -Gating — two independent controls (spec §6): +Gating — a single per-environment control (spec §6): -* ``SCHEDULED_RUNS_ENABLED`` — per-environment kill switch (default on). - Off → every route here 404s, as if unmounted. -* ``scheduled-runs`` RBAC capability — *who* may use the surface. Granted - to the beta cohort's AppRole; missing → 403. GA = grant to ``default``. +* ``SCHEDULED_RUNS_ENABLED`` — kill switch (default on). Off → every route + here 404s, as if unmounted. + +The surface is otherwise open to any authenticated user — deliberately *not* +behind the ``scheduled-runs`` RBAC capability, which turned every non-beta +caller into a 403 the SPA surfaced as an "Access Denied" toast. Each run +still executes with the caller's own RBAC-allowed tools (see +``run_agent_headless``), so this widens *who* can reach the surface, not +*what* any one caller can do. Auth is the standard SPA cookie dependency (``get_current_user_from_session``) per the CLAUDE.md app-api rule. The headless grant is **created-on-enable**: @@ -41,10 +46,6 @@ RunResult, run_agent_headless, ) -from apis.shared.rbac.capabilities import ( - SCHEDULED_RUNS_CAPABILITY, - user_has_capability, -) from apis.shared.rbac.service import get_app_role_service logger = logging.getLogger(__name__) @@ -67,20 +68,15 @@ def get_headless_grant_service() -> HeadlessGrantService: async def require_scheduled_runs_user( user: User = Depends(get_current_user_from_session), ) -> User: - """Cookie auth + kill switch + cohort capability, in that order. + """Cookie auth + kill switch, in that order. 404 when the environment kill switch is off (the surface behaves as if unmounted — runtime-checked so tests and env flips need no module - reload), 403 when the authenticated caller lacks the ``scheduled-runs`` - capability. + reload). Otherwise any authenticated user may use the surface — it is + intentionally not behind an RBAC capability (see module docstring). """ if not scheduled_runs_enabled(): raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found") - if not await user_has_capability(user, SCHEDULED_RUNS_CAPABILITY): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="You do not have access to scheduled runs.", - ) return user diff --git a/backend/src/apis/app_api/schedules/routes.py b/backend/src/apis/app_api/schedules/routes.py index b403b8b53..f21d0a579 100644 --- a/backend/src/apis/app_api/schedules/routes.py +++ b/backend/src/apis/app_api/schedules/routes.py @@ -5,13 +5,15 @@ dispatcher/worker that reads ``DueScheduleIndex`` and calls ``run_agent_headless`` is B2 (docs/specs/scheduled-agent-runs.md §7). -Gating mirrors the Phase A "Run now" surface exactly (two independent -controls, spec §6): +Gating is a single per-environment control (spec §6): -* ``SCHEDULED_RUNS_ENABLED`` — per-environment kill switch (default on). - Off -> every route here 404s, as if unmounted. -* ``scheduled-runs`` RBAC capability -- *who* may use the surface. Granted - to the beta cohort's AppRole; missing -> 403. GA = grant to ``default``. +* ``SCHEDULED_RUNS_ENABLED`` — kill switch (default on). Off -> every route + here 404s, as if unmounted. + +The surface is otherwise open to any authenticated user. It carries no nav +entry and isn't linked from the SPA, so it stays low-key / reachable only by +direct URL — deliberately *not* behind the ``scheduled-runs`` RBAC capability +(which turned every non-beta caller into a 403 the SPA surfaced as a toast). Auth is the standard SPA cookie dependency (``get_current_user_from_session``) per the CLAUDE.md app-api rule. @@ -27,7 +29,6 @@ from apis.shared.auth.dependencies import get_current_user_from_session from apis.shared.auth.models import User from apis.shared.feature_flags import scheduled_runs_enabled -from apis.shared.rbac.capabilities import SCHEDULED_RUNS_CAPABILITY, user_has_capability from apis.shared.rbac.service import get_app_role_service from apis.shared.scheduled_prompts.service import ( MIN_INTERVAL_MINUTES, @@ -58,19 +59,15 @@ async def require_scheduled_runs_user( user: User = Depends(get_current_user_from_session), ) -> User: - """Cookie auth + kill switch + cohort capability, in that order. + """Cookie auth + kill switch, in that order. 404 when the environment kill switch is off (the surface behaves as if - unmounted), 403 when the authenticated caller lacks the - ``scheduled-runs`` capability. Mirrors ``apis.app_api.runs.routes``. + unmounted). Otherwise any authenticated user may use the surface — it is + intentionally not behind an RBAC capability (see module docstring). + Mirrors ``apis.app_api.runs.routes``. """ if not scheduled_runs_enabled(): raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found") - if not await user_has_capability(user, SCHEDULED_RUNS_CAPABILITY): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="You do not have access to scheduled runs.", - ) return user diff --git a/backend/src/apis/inference_api/chat/converse_routes.py b/backend/src/apis/inference_api/chat/converse_routes.py deleted file mode 100644 index 063846e0b..000000000 --- a/backend/src/apis/inference_api/chat/converse_routes.py +++ /dev/null @@ -1,467 +0,0 @@ -"""API-key authenticated converse endpoint. - -Provides a direct Bedrock Converse API wrapper authenticated via API keys -(X-API-Key header). Supports: -- Single-shot and multi-turn conversations -- Streaming (SSE) and non-streaming responses -- Reasoning models (extended thinking / reasoning content blocks) -- Multiple Bedrock model IDs - -RBAC model access is enforced via ``AppRoleService.can_access_model()`` -before any Bedrock invocation occurs. Requests for models the caller's -role does not permit are rejected with HTTP 403. -""" - -import json -import logging -import os -from datetime import datetime, timezone -from typing import AsyncGenerator - -import boto3 -from botocore.exceptions import ClientError as BotoClientError -from fastapi import APIRouter, Header, HTTPException, status -from fastapi.responses import StreamingResponse - -from apis.shared.auth.models import User -from apis.shared import quota as shared_quota -from apis.shared.rbac.service import get_app_role_service -from apis.shared.costs.calculator import CostCalculator -from apis.shared.costs.pricing_config import create_pricing_snapshot -from apis.shared.sessions.metadata import store_message_metadata -from apis.shared.sessions.models import ( - MessageMetadata, - TokenUsage, - ModelInfo, - Attribution, -) - -from .models import ConverseRequest, ConverseResponse - -logger = logging.getLogger(__name__) - -router = APIRouter(prefix="/chat", tags=["api-converse"]) - - -# --------------------------------------------------------------------------- -# API key validation dependency -# --------------------------------------------------------------------------- - -async def _validate_api_key(api_key: str): - """Validate the raw API key and return the ValidatedApiKey, or raise 401.""" - from apis.shared.auth.api_keys.service import get_api_key_service - - service = get_api_key_service() - validated = await service.validate_key(api_key) - if validated is None: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid or expired API key", - ) - return validated - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -def _build_user_from_api_key(validated_key) -> User: - """Construct a minimal User from a ValidatedApiKey for quota checking.""" - return User( - email=f"{validated_key.user_id}@api-key", - user_id=validated_key.user_id, - name=validated_key.name, - roles=["user"], - ) - -async def _record_cost(user_id: str, model_id: str, usage: dict, key_id: str) -> None: - """Calculate and store cost metadata for an api-converse request. - - Fail-open: any error is logged but never re-raised so the caller's - response is not blocked. - """ - try: - pricing = await create_pricing_snapshot(model_id) - if pricing is None: - logger.warning( - "No pricing snapshot for model; skipping cost recording" - ) - return - - total_cost, breakdown = CostCalculator.calculate_message_cost(usage, pricing) - - token_usage = TokenUsage( - inputTokens=usage.get("inputTokens", 0), - outputTokens=usage.get("outputTokens", 0), - totalTokens=usage.get("inputTokens", 0) + usage.get("outputTokens", 0), - cacheReadInputTokens=usage.get("cacheReadInputTokens"), - cacheWriteInputTokens=usage.get("cacheWriteInputTokens"), - ) - - model_info = ModelInfo( - modelId=model_id, - modelName=model_id, - provider="bedrock", - ) - - attribution = Attribution( - userId=user_id, - sessionId=f"api-converse-{key_id}", - timestamp=datetime.now(timezone.utc).isoformat(), - ) - - metadata = MessageMetadata( - token_usage=token_usage, - model_info=model_info, - attribution=attribution, - cost=total_cost, - ) - - await store_message_metadata( - session_id=f"api-converse-{key_id}", - user_id=user_id, - message_id=0, - message_metadata=metadata, - ) - except Exception as exc: - logger.error( - "Failed to record cost", - exc_info=True, - ) - - -def _get_bedrock_client(): - """Return a boto3 bedrock-runtime client.""" - region = os.environ.get("AWS_REGION", "us-east-1") - return boto3.client("bedrock-runtime", region_name=region) - - -def _build_converse_params(request: ConverseRequest) -> dict: - """Build the kwargs dict for bedrock.converse / converse_stream.""" - # Convert messages to Bedrock Converse format - messages = [ - {"role": m.role, "content": [{"text": m.content}]} - for m in request.messages - ] - - inference_config: dict = {} - if request.temperature is not None: - inference_config["temperature"] = request.temperature - if request.max_tokens is not None: - inference_config["maxTokens"] = request.max_tokens - if request.top_p is not None: - inference_config["topP"] = request.top_p - - params: dict = { - "modelId": request.model_id, - "messages": messages, - } - - if request.system_prompt: - params["system"] = [{"text": request.system_prompt}] - - if inference_config: - params["inferenceConfig"] = inference_config - - return params - - -def _extract_reasoning_and_text(content_blocks: list) -> tuple[str, str | None]: - """Extract text and optional reasoning from Bedrock response content blocks. - - Reasoning models (e.g. Claude with extended thinking) return a - ``reasoningContent`` block alongside the normal ``text`` block. - - Returns: - (text, reasoning) – reasoning is None for non-reasoning models. - """ - text_parts: list[str] = [] - reasoning_parts: list[str] = [] - - for block in content_blocks: - if "text" in block: - text_parts.append(block["text"]) - elif "reasoningContent" in block: - # Extended thinking / reasoning block - rc = block["reasoningContent"] - if "reasoningText" in rc: - reasoning_parts.append(rc["reasoningText"].get("text", "")) - - text = "".join(text_parts) - reasoning = "".join(reasoning_parts) if reasoning_parts else None - return text, reasoning - - -# --------------------------------------------------------------------------- -# Streaming helpers -# --------------------------------------------------------------------------- - -async def _stream_converse(request: ConverseRequest, user_id: str, key_id: str) -> AsyncGenerator[str, None]: - """Call Bedrock converse_stream and yield SSE events.""" - client = _get_bedrock_client() - params = _build_converse_params(request) - - try: - response = client.converse_stream(**params) - except BotoClientError as exc: - error_code = exc.response["Error"]["Code"] - logger.error(f"Bedrock converse_stream ClientError ({error_code})", exc_info=True) - yield _sse("error", {"error": "Model invocation failed due to a service error."}) - yield _sse("done", {}) - return - except Exception: - logger.error("Bedrock converse_stream error", exc_info=True) - yield _sse("error", {"error": "Model invocation failed due to an internal error."}) - yield _sse("done", {}) - return - - stream = response.get("stream") - if not stream: - yield _sse("error", {"error": "No stream returned from Bedrock"}) - yield _sse("done", {}) - return - - # Track state for SSE lifecycle events - in_reasoning = False - accumulated_usage: dict = {} - - for event in stream: - # --- message start --- - if "messageStart" in event: - role = event["messageStart"].get("role", "assistant") - yield _sse("message_start", {"role": role}) - - # --- content block start --- - elif "contentBlockStart" in event: - cbs = event["contentBlockStart"] - idx = cbs.get("contentBlockIndex", 0) - start_data = cbs.get("start", {}) - - if "toolUse" in start_data: - yield _sse("content_block_start", { - "contentBlockIndex": idx, - "type": "tool_use", - "toolUse": start_data["toolUse"], - }) - else: - yield _sse("content_block_start", { - "contentBlockIndex": idx, - "type": "text", - }) - - # --- content block delta --- - elif "contentBlockDelta" in event: - cbd = event["contentBlockDelta"] - idx = cbd.get("contentBlockIndex", 0) - delta = cbd.get("delta", {}) - - if "text" in delta: - yield _sse("content_block_delta", { - "contentBlockIndex": idx, - "type": "text", - "text": delta["text"], - }) - elif "reasoningContent" in delta: - rc = delta["reasoningContent"] - if "text" in rc: - if not in_reasoning: - in_reasoning = True - yield _sse("reasoning_start", {"contentBlockIndex": idx}) - yield _sse("reasoning_delta", { - "contentBlockIndex": idx, - "text": rc["text"], - }) - - # --- content block stop --- - elif "contentBlockStop" in event: - idx = event["contentBlockStop"].get("contentBlockIndex", 0) - if in_reasoning: - yield _sse("reasoning_stop", {"contentBlockIndex": idx}) - in_reasoning = False - yield _sse("content_block_stop", {"contentBlockIndex": idx}) - - # --- message stop --- - elif "messageStop" in event: - stop_reason = event["messageStop"].get("stopReason", "end_turn") - yield _sse("message_stop", {"stopReason": stop_reason}) - - # --- metadata --- - elif "metadata" in event: - meta = event["metadata"] - usage = meta.get("usage", {}) - accumulated_usage = usage - metrics = meta.get("metrics", {}) - yield _sse("metadata", {"usage": usage, "metrics": metrics}) - - yield _sse("done", {}) - - # Record cost after stream completes - if accumulated_usage: - await _record_cost( - user_id=user_id, - model_id=request.model_id, - usage=accumulated_usage, - key_id=key_id, - ) - - - -def _sse(event_type: str, data: dict) -> str: - """Format a single SSE event.""" - return f"event: {event_type}\ndata: {json.dumps(data)}\n\n" - - -# --------------------------------------------------------------------------- -# Endpoint -# --------------------------------------------------------------------------- - -@router.post( - "/api-converse", - response_model=ConverseResponse, - responses={ - 200: {"description": "Non-streaming response (or SSE stream when stream=true)"}, - 401: {"description": "Invalid or expired API key"}, - 400: {"description": "Bad request (invalid model, empty messages, etc.)"}, - }, - summary="Converse with a Bedrock model via API key", -) -async def api_converse( - request: ConverseRequest, - x_api_key: str = Header(..., alias="X-API-Key"), -): - """Direct Bedrock Converse API wrapper authenticated via API key. - - Supports streaming (SSE) and non-streaming responses, multi-turn - conversations, and reasoning models that return extended thinking blocks. - """ - # 1. Validate API key - validated_key = await _validate_api_key(x_api_key) - logger.info("api-converse request received") - - # 1.5 Per-key rate limit (fail-open) - from apis.shared.rate_limit import get_rate_limiter - - try: - limiter = get_rate_limiter() - if not await limiter.check_rate_limit(validated_key.key_id): - logger.warning( - f"Rate limit exceeded for key {validated_key.key_id} " - f"(user={validated_key.user_id})" - ) - raise HTTPException( - status_code=429, - detail="Rate limit exceeded. Max 60 requests per minute.", - headers={"Retry-After": "60"}, - ) - except HTTPException: - raise - except Exception as exc: - logger.error(f"Rate limit check error: {exc}", exc_info=True) - - # 2. Basic validation - if not request.messages: - raise HTTPException(status_code=400, detail="messages array must not be empty") - - # 2.5 Build User and synthetic session_id for quota / cost accounting - user = _build_user_from_api_key(validated_key) - session_id = f"api-converse-{validated_key.key_id}" - - # 2.6 Quota check (fail-open: errors are logged but don't block the request) - if shared_quota.is_quota_enforcement_enabled(): - try: - quota_checker = shared_quota.get_quota_checker() - quota_result = await quota_checker.check_quota(user=user, session_id=session_id) - if not quota_result.allowed: - if quota_result.quota_limit is None: - # No quota tier configured for this API-key user — fail-open - # per Requirement 3.6 (don't block on internal/config issues) - logger.warning( - f"No quota tier for user {validated_key.user_id}; " - f"proceeding (fail-open)" - ) - else: - logger.warning( - f"Quota exceeded for user {validated_key.user_id}: {quota_result.message}" - ) - raise HTTPException(status_code=429, detail=quota_result.message) - except HTTPException: - raise - except Exception as exc: - logger.error( - f"Error checking quota for user {validated_key.user_id}: {exc}", - exc_info=True, - ) - - # 2.7 Model access check (RBAC) - app_role_service = get_app_role_service() - if not await app_role_service.can_access_model(user, request.model_id): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=f"Access denied to model: {request.model_id}", - ) - - # 3. Streaming path - if request.stream: - return StreamingResponse( - _stream_converse(request, user_id=validated_key.user_id, key_id=validated_key.key_id), - media_type="text/event-stream", - headers={ - "Cache-Control": "no-cache", - "X-Accel-Buffering": "no", - }, - ) - - # 4. Non-streaming path - client = _get_bedrock_client() - params = _build_converse_params(request) - - try: - response = client.converse(**params) - except BotoClientError as exc: - error_code = exc.response["Error"]["Code"] - if error_code == "ThrottlingException": - logger.warning("Bedrock throttling on converse call", exc_info=True) - raise HTTPException( - status_code=429, - detail="Model is temporarily overloaded. Please retry shortly.", - headers={"Retry-After": "5"}, - ) - logger.error(f"Bedrock ClientError ({error_code}) on converse call", exc_info=True) - if error_code in ("ValidationException", "ModelErrorException"): - raise HTTPException( - status_code=400, - detail="Invalid request — check model ID, message format, and content policy.", - ) - if error_code == "AccessDeniedException": - raise HTTPException(status_code=403, detail="Model access is not available.") - raise HTTPException(status_code=502, detail="Model invocation failed due to a service error.") - except Exception: - logger.error("Unexpected error during Bedrock converse call", exc_info=True) - raise HTTPException(status_code=502, detail="Model invocation failed due to an internal error.") - - # Parse response - output = response.get("output", {}) - message = output.get("message", {}) - content_blocks = message.get("content", []) - - text, reasoning = _extract_reasoning_and_text(content_blocks) - - usage = response.get("usage") - stop_reason = response.get("stopReason") - - # Record cost for non-streaming response - if usage is not None: - await _record_cost( - user_id=validated_key.user_id, - model_id=request.model_id, - usage=usage, - key_id=validated_key.key_id, - ) - - return ConverseResponse( - content=text, - model_id=request.model_id, - usage=usage, - stop_reason=stop_reason, - reasoning=reasoning, - ) diff --git a/backend/src/apis/inference_api/chat/models.py b/backend/src/apis/inference_api/chat/models.py index 046094eb1..495eab24b 100644 --- a/backend/src/apis/inference_api/chat/models.py +++ b/backend/src/apis/inference_api/chat/models.py @@ -208,41 +208,3 @@ class GenerateTitleResponse(BaseModel): title: str session_id: str - - -# --------------------------------------------------------------------------- -# API Converse models (direct Bedrock Converse API via API key auth) -# --------------------------------------------------------------------------- - - -class ConverseMessage(BaseModel): - """A single message in the conversation.""" - - role: str # "user" or "assistant" - content: str - - -class ConverseRequest(BaseModel): - """Request model for /chat/api-converse endpoint. - - Supports both single-shot and multi-turn conversations. - """ - - model_id: str # Bedrock model ID (e.g. "us.anthropic.claude-haiku-4-5-20251001-v1:0") - messages: List[ConverseMessage] - system_prompt: Optional[str] = None - temperature: Optional[float] = None - max_tokens: Optional[int] = 4096 - stream: bool = False # Whether to stream the response via SSE - top_p: Optional[float] = None - - -class ConverseResponse(BaseModel): - """Non-streaming response from /chat/api-converse.""" - - role: str = "assistant" - content: str - model_id: str - usage: Optional[Dict[str, Any]] = None - stop_reason: Optional[str] = None - reasoning: Optional[str] = None # Populated for reasoning models diff --git a/backend/src/apis/inference_api/chat/routes.py b/backend/src/apis/inference_api/chat/routes.py index 610a7801b..7e5281954 100644 --- a/backend/src/apis/inference_api/chat/routes.py +++ b/backend/src/apis/inference_api/chat/routes.py @@ -297,19 +297,20 @@ async def _resolve_model_settings( model_id: str | None, explicit_caching_enabled: bool | None, request_inference_params: dict | None, -) -> tuple[bool | None, dict, str | None]: +) -> tuple[bool | None, dict, str | None, str | None]: """Resolve runtime model knobs from the managed-model registry. - Returns ``(caching_enabled, inference_params, mantle_endpoint_path)``. A - single registry lookup drives all three. ``mantle_endpoint_path`` is the - server-authoritative Bedrock Mantle path recorded on the model (``/v1`` or - ``/openai/v1``); ``None`` for non-Mantle models. Resolving it here keeps - the path off the client request — the SPA can't override it. + Returns ``(caching_enabled, inference_params, mantle_api_mode, + mantle_region)``. A single registry lookup drives all of them. The Mantle + fields are server-authoritative (recorded on the model): ``mantle_api_mode`` + selects Chat Completions vs the Responses API and ``mantle_region`` optionally + pins inference to a specific region; both ``None`` for non-Mantle models. + Resolving them here keeps them off the client request — the SPA can't override. """ request_params = dict(request_inference_params or {}) if not model_id: - return explicit_caching_enabled, request_params, None + return explicit_caching_enabled, request_params, None, None managed_model = await _find_managed_model(model_id) @@ -320,19 +321,24 @@ async def _resolve_model_settings( else: caching = None - mantle_endpoint_path = ( - getattr(managed_model, "mantle_endpoint_path", None) + mantle_api_mode = ( + getattr(managed_model, "mantle_api_mode", None) + if managed_model is not None + else None + ) + mantle_region = ( + getattr(managed_model, "mantle_region", None) if managed_model is not None else None ) inference_params = _merge_inference_params(managed_model, request_params) - return caching, inference_params, mantle_endpoint_path + return caching, inference_params, mantle_api_mode, mantle_region async def _resolve_caching_enabled(model_id: str | None, explicit_caching_enabled: bool | None) -> bool | None: """Backward-compat wrapper around :func:`_resolve_model_settings`.""" - caching, _, _ = await _resolve_model_settings(model_id, explicit_caching_enabled, None) + caching, _, _, _ = await _resolve_model_settings(model_id, explicit_caching_enabled, None) return caching @@ -888,7 +894,7 @@ async def invocations(request: InvocationRequest, current_user: User = Depends(g atc = input_data.app_tool_call try: request_inference_params = dict(input_data.inference_params or {}) - caching_enabled, inference_params, mantle_endpoint_path = await _resolve_model_settings( + caching_enabled, inference_params, mantle_api_mode, mantle_region = await _resolve_model_settings( model_id=input_data.model_id, explicit_caching_enabled=input_data.caching_enabled, request_inference_params=request_inference_params, @@ -903,7 +909,8 @@ async def invocations(request: InvocationRequest, current_user: User = Depends(g caching_enabled=caching_enabled, provider=input_data.provider, inference_params=inference_params, - mantle_endpoint_path=mantle_endpoint_path, + mantle_api_mode=mantle_api_mode, + mantle_region=mantle_region, agent_type=effective_agent_type, is_resume=False, accessible_skill_ids=effective_skill_ids, @@ -935,7 +942,7 @@ async def invocations(request: InvocationRequest, current_user: User = Depends(g acu = input_data.app_context_update try: request_inference_params = dict(input_data.inference_params or {}) - caching_enabled, inference_params, mantle_endpoint_path = await _resolve_model_settings( + caching_enabled, inference_params, mantle_api_mode, mantle_region = await _resolve_model_settings( model_id=input_data.model_id, explicit_caching_enabled=input_data.caching_enabled, request_inference_params=request_inference_params, @@ -950,7 +957,8 @@ async def invocations(request: InvocationRequest, current_user: User = Depends(g caching_enabled=caching_enabled, provider=input_data.provider, inference_params=inference_params, - mantle_endpoint_path=mantle_endpoint_path, + mantle_api_mode=mantle_api_mode, + mantle_region=mantle_region, agent_type=effective_agent_type, is_resume=False, accessible_skill_ids=effective_skill_ids, @@ -1546,7 +1554,8 @@ async def invocations(request: InvocationRequest, current_user: User = Depends(g caching_enabled=snapshot.caching_enabled, provider=snapshot.provider, inference_params=resume_inference_params, - mantle_endpoint_path=snapshot.mantle_endpoint_path, + mantle_api_mode=snapshot.mantle_api_mode, + mantle_region=snapshot.mantle_region, agent_type=snapshot.agent_type, is_resume=True, # Resume must rebuild the SAME cache key the original turn used, @@ -1618,7 +1627,7 @@ async def invocations(request: InvocationRequest, current_user: User = Depends(g # Single registry lookup resolves caching + inference params + # the Mantle endpoint path, merging admin defaults with request # overrides. - caching_enabled, inference_params, mantle_endpoint_path = await _resolve_model_settings( + caching_enabled, inference_params, mantle_api_mode, mantle_region = await _resolve_model_settings( model_id=effective_model_id, explicit_caching_enabled=input_data.caching_enabled, request_inference_params=request_inference_params, @@ -1682,7 +1691,8 @@ async def invocations(request: InvocationRequest, current_user: User = Depends(g caching_enabled=caching_enabled, provider=effective_provider, inference_params=inference_params, - mantle_endpoint_path=mantle_endpoint_path, + mantle_api_mode=mantle_api_mode, + mantle_region=mantle_region, agent_type=effective_agent_type, extra_tools=extra_tools, is_resume=False, diff --git a/backend/src/apis/inference_api/chat/service.py b/backend/src/apis/inference_api/chat/service.py index 28258dde2..57f707346 100644 --- a/backend/src/apis/inference_api/chat/service.py +++ b/backend/src/apis/inference_api/chat/service.py @@ -132,7 +132,8 @@ async def get_agent( agent_type: Optional[str] = None, extra_tools: Optional[list] = None, inference_params: Optional[Dict[str, Any]] = None, - mantle_endpoint_path: Optional[str] = None, + mantle_api_mode: Optional[str] = None, + mantle_region: Optional[str] = None, is_resume: bool = False, accessible_skill_ids: Optional[List[str]] = None, ) -> BaseAgent: @@ -240,7 +241,8 @@ async def get_agent( max_tokens=max_tokens, extra_tools=extra_tools, inference_params=merged_params, - mantle_endpoint_path=mantle_endpoint_path, + mantle_api_mode=mantle_api_mode, + mantle_region=mantle_region, ) # Only the SkillAgent accepts accessible_skill_ids; ChatAgent's constructor # would reject the unknown kwarg, so gate it on the skill type. diff --git a/backend/src/apis/inference_api/main.py b/backend/src/apis/inference_api/main.py index 25e11f7b5..08efba76f 100644 --- a/backend/src/apis/inference_api/main.py +++ b/backend/src/apis/inference_api/main.py @@ -145,12 +145,10 @@ async def lifespan(app: FastAPI): # Import routers #from health.health import router as health_router from apis.inference_api.chat.routes import router as agentcore_router -from apis.inference_api.chat.converse_routes import router as converse_router from apis.inference_api.chat.voice_routes import router as voice_router # Include routers #app.include_router(health_router) app.include_router(agentcore_router) # AgentCore Runtime endpoints: /ping, /invocations -app.include_router(converse_router) # API-key authenticated converse endpoint app.include_router(voice_router) # WebSocket voice streaming endpoint # Connector consent flows live on app-api now: the AgentCore Runtime data plane # only proxies /invocations and /ping, so user-facing /connectors/* paths can't diff --git a/backend/src/apis/shared/bedrock/bearer_token.py b/backend/src/apis/shared/bedrock/bearer_token.py index 97d913e80..76a1145ad 100644 --- a/backend/src/apis/shared/bedrock/bearer_token.py +++ b/backend/src/apis/shared/bedrock/bearer_token.py @@ -38,8 +38,9 @@ # Mantle is a regional endpoint on the api.aws TLD (not amazonaws.com). _MANTLE_HOST_TEMPLATE = "https://bedrock-mantle.{region}.api.aws" -# Default OpenAI-compatible path. Some models (e.g. Gemma 4) are served on -# `/openai/v1` instead — see `ManagedModel.mantle_endpoint_path`. +# Default OpenAI-compatible path used for the admin model-browse list call. +# (Inference no longer uses this helper — the Strands SDK's bedrock_mantle_config +# derives the per-model base path from the model id.) _MANTLE_DEFAULT_PATH = "/v1" diff --git a/backend/src/apis/shared/models/managed_models.py b/backend/src/apis/shared/models/managed_models.py index 1ff7d125b..1a5aed664 100644 --- a/backend/src/apis/shared/models/managed_models.py +++ b/backend/src/apis/shared/models/managed_models.py @@ -38,19 +38,30 @@ def _resolve_supports_caching(supports_caching: Optional[bool], provider: str) - return provider.lower() == 'bedrock' -def _resolve_mantle_endpoint_path(endpoint_path: Optional[str], provider: str) -> Optional[str]: - """Resolve the Bedrock Mantle endpoint path for a model. +def _resolve_mantle_api_mode(api_mode: Optional[str], provider: str) -> Optional[str]: + """Resolve the Bedrock Mantle API surface for a model. - Only meaningful for ``provider == 'mantle'`` — Mantle serves different - models on different OpenAI-compatible paths (``/v1`` vs ``/openai/v1``) - and exposes no API to discover which, so the value is recorded per model - (from the model card / curated catalog). Defaults to ``/v1`` for Mantle - models when unset; ``None`` for every other provider (the field is inert - there). + Only meaningful for ``provider == 'mantle'`` — it selects Chat Completions + vs the Responses API, a per-model fact Mantle exposes no API to discover. + Defaults to ``'chat'`` for Mantle models when unset; ``None`` for every + other provider (the field is inert there). """ if provider.lower() != 'mantle': return None - return endpoint_path or '/v1' + mode = (api_mode or '').lower() + return mode if mode in ('chat', 'responses') else 'chat' + + +def _resolve_mantle_region(region: Optional[str], provider: str) -> Optional[str]: + """Resolve the Bedrock Mantle region override for a model. + + Only meaningful for ``provider == 'mantle'`` — pins inference to the region + hosting the model, independent of the app's region. ``None`` (fall back to + the app's region at agent-build time) when unset or for other providers. + """ + if provider.lower() != 'mantle': + return None + return region or None # Initialize DynamoDB client dynamodb = boto3.resource('dynamodb') @@ -233,7 +244,8 @@ async def _create_managed_model_cloud(model_data: ManagedModelCreate, table_name knowledge_cutoff_date=model_data.knowledge_cutoff_date, supports_caching=_resolve_supports_caching(model_data.supports_caching, model_data.provider), is_default=model_data.is_default, - mantle_endpoint_path=_resolve_mantle_endpoint_path(model_data.mantle_endpoint_path, model_data.provider), + mantle_api_mode=_resolve_mantle_api_mode(model_data.mantle_api_mode, model_data.provider), + mantle_region=_resolve_mantle_region(model_data.mantle_region, model_data.provider), supported_params=model_data.supported_params, created_at=now, updated_at=now, @@ -272,9 +284,12 @@ async def _create_managed_model_cloud(model_data: ManagedModelCreate, table_name item['cacheReadPricePerMillionTokens'] = model_data.cache_read_price_per_million_tokens if model_data.knowledge_cutoff_date is not None: item['knowledgeCutoffDate'] = model_data.knowledge_cutoff_date - resolved_mantle_path = _resolve_mantle_endpoint_path(model_data.mantle_endpoint_path, model_data.provider) - if resolved_mantle_path is not None: - item['mantleEndpointPath'] = resolved_mantle_path + resolved_api_mode = _resolve_mantle_api_mode(model_data.mantle_api_mode, model_data.provider) + if resolved_api_mode is not None: + item['apiMode'] = resolved_api_mode + resolved_region = _resolve_mantle_region(model_data.mantle_region, model_data.provider) + if resolved_region is not None: + item['region'] = resolved_region if model_data.supported_params is not None: item['supportedParams'] = model_data.supported_params.model_dump(by_alias=True, exclude_none=True) diff --git a/backend/src/apis/shared/models/mantle.py b/backend/src/apis/shared/models/mantle.py new file mode 100644 index 000000000..445750958 --- /dev/null +++ b/backend/src/apis/shared/models/mantle.py @@ -0,0 +1,105 @@ +"""Shared Bedrock Mantle model construction. + +Single home for building a Strands OpenAI-compatible model pointed at Bedrock +Mantle, plus the per-API-mode canonical->native param maps. Both consumers use +this so the mantle build logic is never forked: + +- the agent loop's ``AgentFactory._create_mantle_model`` (agents/), and +- the API-key ``/chat/api-converse`` handler (apis/app_api/), which cannot + import ``agents/`` per the import-boundary rule. + +Strands' ``bedrock_mantle_config`` owns the wire details — it mints the +short-term bearer token (via aws-bedrock-token-generator, requires +``bedrock-mantle:CallWithBearerToken``) and derives the regional base URL plus +the model-family base path. All this module does is pick the model class from +the declared API mode and forward the region + already-native params. +""" + +from enum import Enum +from typing import Any, Dict, Optional + + +class MantleApiMode(str, Enum): + """OpenAI-compatible API surface a Bedrock Mantle model speaks. + + Selects which Strands model class gets built: Chat Completions + (``OpenAIModel``) or the Responses API (``OpenAIResponsesModel``). Some + Mantle-hosted models (e.g. ``openai.gpt-5.x``) only serve Responses and + reject Chat Completions outright, so this is a per-model fact the admin + records — Mantle exposes no API to discover it. + """ + CHAT_COMPLETIONS = "chat" + RESPONSES = "responses" + + +# Canonical param name -> provider-native key path (dot-separated for nested SDK +# fields). A canonical param without an entry here is silently dropped. + +# Mantle Chat Completions mirrors OpenAI's chat-completions protocol. +MANTLE_CHAT_PARAM_MAP: Dict[str, str] = { + "temperature": "temperature", + "top_p": "top_p", + "max_tokens": "max_tokens", + "reasoning_effort": "reasoning_effort", +} + +# Mantle Responses API (OpenAIResponsesModel) uses different native names: +# `max_output_tokens` for the output cap and a nested `reasoning.effort` object. +# The SDK spreads `params` straight into `responses.create(**params)` with no +# translation, so the canonical names must be pre-mapped here. +MANTLE_RESPONSES_PARAM_MAP: Dict[str, str] = { + "temperature": "temperature", + "top_p": "top_p", + "max_tokens": "max_output_tokens", + "reasoning_effort": "reasoning.effort", +} + + +def param_map_for(api_mode: MantleApiMode) -> Dict[str, str]: + """Return the canonical->native param map for a Mantle API mode.""" + return ( + MANTLE_RESPONSES_PARAM_MAP + if api_mode == MantleApiMode.RESPONSES + else MANTLE_CHAT_PARAM_MAP + ) + + +def build_mantle_model( + model_id: str, + api_mode: MantleApiMode, + region: Optional[str] = None, + params: Optional[Dict[str, Any]] = None, +): + """Build a Strands OpenAI-compatible model targeting Bedrock Mantle. + + Args: + model_id: The Mantle model id (e.g. ``openai.gpt-5.4``). + api_mode: Chat Completions vs Responses — picks the model class. + region: Optional Mantle region override. ``None`` -> Strands resolves + from the ambient boto/region chain. Drives both the endpoint host + and the region the bearer token is signed for. + params: Already-native inference params (canonical names must be + pre-translated via :func:`param_map_for`). Spread into the client. + + Returns: + ``OpenAIModel`` | ``OpenAIResponsesModel`` configured for Mantle. + """ + # Lazy import: strands is heavy, and apis.shared is imported broadly. + from strands.models import OpenAIResponsesModel + from strands.models.openai import OpenAIModel + + bedrock_mantle_config: Dict[str, Any] = {} + if region: + bedrock_mantle_config["region"] = region + + model_cls = ( + OpenAIResponsesModel + if api_mode == MantleApiMode.RESPONSES + else OpenAIModel + ) + + config: Dict[str, Any] = {"model_id": model_id} + if params: + config["params"] = params + + return model_cls(bedrock_mantle_config=bedrock_mantle_config, **config) diff --git a/backend/src/apis/shared/models/models.py b/backend/src/apis/shared/models/models.py index ee5b60697..d1b5f5bd3 100644 --- a/backend/src/apis/shared/models/models.py +++ b/backend/src/apis/shared/models/models.py @@ -195,13 +195,28 @@ class ManagedModelCreate(BaseModel): alias="isDefault", description="Whether this is the default model for new sessions. Only one model can be default." ) + mantle_api_mode: Optional[str] = Field( + None, + alias="apiMode", + description="Bedrock Mantle API surface (provider='mantle' only): 'chat' " + "(OpenAI Chat Completions, the default) or 'responses' (OpenAI " + "Responses API — required by models that don't serve Chat " + "Completions, e.g. openai.gpt-5.x). Ignored for other providers." + ) + mantle_region: Optional[str] = Field( + None, + alias="region", + description="Bedrock Mantle region override (provider='mantle' only): pins " + "inference to the region hosting the model (e.g. 'us-east-1'), " + "independent of where the app runs. Empty -> the app's region. " + "Ignored for other providers." + ) mantle_endpoint_path: Optional[str] = Field( None, alias="mantleEndpointPath", - description="Bedrock Mantle endpoint path segment (provider='mantle' only): " - "'/v1' (OpenAI Chat Completions, the default) or '/openai/v1' " - "(e.g. Gemma 4). The per-model value comes from the model card; " - "there is no API that exposes it. Ignored for other providers." + description="[DEPRECATED] Bedrock Mantle endpoint path segment. The base " + "path is now derived by the SDK from the model id; accepted for " + "backward compatibility but ignored." ) supported_params: Optional[SupportedParams] = Field( None, @@ -265,11 +280,23 @@ class ManagedModelUpdate(BaseModel): alias="isDefault", description="Whether this is the default model for new sessions." ) + mantle_api_mode: Optional[str] = Field( + None, + alias="apiMode", + description="Bedrock Mantle API surface (provider='mantle' only): 'chat' " + "or 'responses'. Ignored for other providers." + ) + mantle_region: Optional[str] = Field( + None, + alias="region", + description="Bedrock Mantle region override (provider='mantle' only). " + "Empty -> the app's region. Ignored for other providers." + ) mantle_endpoint_path: Optional[str] = Field( None, alias="mantleEndpointPath", - description="Bedrock Mantle endpoint path segment (provider='mantle' only): " - "'/v1' or '/openai/v1'. Ignored for other providers." + description="[DEPRECATED] Bedrock Mantle endpoint path segment; accepted " + "for backward compatibility but ignored (SDK derives the path)." ) supported_params: Optional[SupportedParams] = Field( None, @@ -331,11 +358,23 @@ class ManagedModel(BaseModel): alias="isDefault", description="Whether this is the default model for new sessions. Only one model can be default." ) + mantle_api_mode: Optional[str] = Field( + None, + alias="apiMode", + description="Bedrock Mantle API surface (provider='mantle' only): 'chat' " + "(default) or 'responses'. Ignored for other providers." + ) + mantle_region: Optional[str] = Field( + None, + alias="region", + description="Bedrock Mantle region override (provider='mantle' only). " + "Empty -> the app's region. Ignored for other providers." + ) mantle_endpoint_path: Optional[str] = Field( None, alias="mantleEndpointPath", - description="Bedrock Mantle endpoint path segment (provider='mantle' only): " - "'/v1' or '/openai/v1'. Ignored for other providers." + description="[DEPRECATED] Bedrock Mantle endpoint path segment; retained for " + "backward compatibility with older records but no longer used." ) supported_params: Optional[SupportedParams] = Field( None, diff --git a/backend/src/apis/shared/sessions/models.py b/backend/src/apis/shared/sessions/models.py index 0dad8339a..105ccb0cc 100644 --- a/backend/src/apis/shared/sessions/models.py +++ b/backend/src/apis/shared/sessions/models.py @@ -122,12 +122,19 @@ class PausedTurnSnapshot(BaseModel): description="Canonical inference param dict captured at pause. When present, " "supersedes the legacy temperature/max_tokens fields on resume." ) - mantle_endpoint_path: Optional[str] = Field( + mantle_api_mode: Optional[str] = Field( default=None, - alias="mantleEndpointPath", - description="Bedrock Mantle endpoint path ('/v1' or '/openai/v1') captured at " - "pause so a resumed Mantle turn rebuilds the same base URL. None for " - "non-Mantle turns and snapshots written before the field existed.", + alias="mantleApiMode", + description="Bedrock Mantle API surface ('chat' or 'responses') captured at " + "pause so a resumed Mantle turn rebuilds the same model class. None " + "for non-Mantle turns and snapshots written before the field existed.", + ) + mantle_region: Optional[str] = Field( + default=None, + alias="mantleRegion", + description="Bedrock Mantle region override captured at pause so a resumed " + "Mantle turn targets the same region. None for non-Mantle turns and " + "snapshots written before the field existed.", ) captured_at: str = Field(..., alias="capturedAt", description="ISO 8601 timestamp when the turn paused") expires_at: str = Field(..., alias="expiresAt", description="ISO 8601 timestamp after which the snapshot is no longer valid for resume") diff --git a/backend/tests/agents/builtin_tools/memory_spaces/test_memory_tools.py b/backend/tests/agents/builtin_tools/memory_spaces/test_memory_tools.py index 55745c57f..960394104 100644 --- a/backend/tests/agents/builtin_tools/memory_spaces/test_memory_tools.py +++ b/backend/tests/agents/builtin_tools/memory_spaces/test_memory_tools.py @@ -76,6 +76,19 @@ async def test_missing_entry_is_error_result(self, monkeypatch): result = await _call(tool, slug="ghost") assert result["status"] == "error" and "No memory entry 'ghost'" in result["content"][0]["text"] + @pytest.mark.asyncio + @pytest.mark.parametrize("slug", ["MEMORY.md", "memory.md", " MEMORY.MD "]) + async def test_memory_md_slug_reads_index(self, monkeypatch, slug): + svc = _patch_service(monkeypatch) + svc.read_index.return_value = "# Index\n- [[jane]]" + tool = make_memory_read_tool("spc_1", "Brain", "u1", "u1@x.edu") + result = await _call(tool, slug=slug) + assert result["status"] == "success" + assert result["content"][0]["text"] == "# Index\n- [[jane]]" + # routed to the index, never the entry manifest + svc.read_index.assert_called_once_with("spc_1", "u1", "u1@x.edu") + svc.read_entry.assert_not_called() + class TestMemoryWrite: @pytest.mark.asyncio @@ -98,3 +111,23 @@ async def test_write_permission_error_is_error_result(self, monkeypatch): tool = make_memory_write_tool("spc_1", "Brain", "u1", "u1@x.edu") result = await _call(tool, slug="jane", body="x") assert result["status"] == "error" and "don't have write access" in result["content"][0]["text"] + + @pytest.mark.asyncio + @pytest.mark.parametrize("slug", ["MEMORY.md", "memory.md"]) + async def test_memory_md_slug_updates_index(self, monkeypatch, slug): + svc = _patch_service(monkeypatch) + tool = make_memory_write_tool("spc_1", "Brain", "u1", "u1@x.edu") + result = await _call(tool, slug=slug, body="# Index\n- [[jane]]", entry_type="entity") + assert result["status"] == "success" + assert "Updated the MEMORY.md index" in result["content"][0]["text"] + # routed to update_index (body only); never creates an entry + svc.update_index.assert_called_once_with("spc_1", "u1", "u1@x.edu", "# Index\n- [[jane]]") + svc.write_entry.assert_not_called() + + @pytest.mark.asyncio + async def test_memory_md_write_permission_error_is_error_result(self, monkeypatch): + svc = _patch_service(monkeypatch) + svc.update_index.side_effect = MemorySpacePermissionError("read-only") + tool = make_memory_write_tool("spc_1", "Brain", "u1", "u1@x.edu") + result = await _call(tool, slug="MEMORY.md", body="x") + assert result["status"] == "error" and "don't have write access" in result["content"][0]["text"] diff --git a/backend/tests/agents/main_agent/core/test_agent_factory.py b/backend/tests/agents/main_agent/core/test_agent_factory.py index 3cb229c9d..186093777 100644 --- a/backend/tests/agents/main_agent/core/test_agent_factory.py +++ b/backend/tests/agents/main_agent/core/test_agent_factory.py @@ -85,85 +85,84 @@ def test_openai_provider_creates_openai_model(self, mock_openai_cls, mock_agent_ # --------------------------------------------------------------------------- -# Bedrock Mantle provider creates Agent with OpenAIModel against the -# regional Mantle endpoint, authenticated with a minted bearer token. +# Bedrock Mantle provider creates an Agent with an OpenAI-compatible Strands +# model (OpenAIModel or OpenAIResponsesModel) routed through the SDK's +# bedrock_mantle_config, which owns base URL, base path, and bearer token. # --------------------------------------------------------------------------- class TestCreateAgentMantle: + """The factory delegates Mantle model construction to the shared + ``build_mantle_model`` (apis.shared.models.mantle) so the API-key converse + handler and the agent loop share one implementation. These tests assert the + delegation contract; the class-pick / region behavior is covered directly + on the builder in ``tests/shared/test_mantle.py``. + """ + @patch("agents.main_agent.core.agent_factory.Agent") - @patch("agents.main_agent.core.agent_factory.OpenAIModel") - def test_mantle_provider_creates_openai_model_with_mantle_client( - self, mock_openai_cls, mock_agent_cls, monkeypatch + @patch("agents.main_agent.core.agent_factory.build_mantle_model") + def test_mantle_chat_mode_delegates_with_default_mode_and_region( + self, mock_build, mock_agent_cls, monkeypatch ): from agents.main_agent.core.agent_factory import AgentFactory + from agents.main_agent.core.model_config import MantleApiMode monkeypatch.setenv("AWS_REGION", "us-west-2") mock_model_instance = MagicMock() - mock_openai_cls.return_value = mock_model_instance + mock_build.return_value = mock_model_instance mantle_config = ModelConfig( model_id="openai.gpt-oss-120b", provider=ModelProvider.MANTLE ) - with patch( - "apis.shared.bedrock.generate_bedrock_bearer_token", - return_value="bedrock-api-key-token", - ) as mock_token: - AgentFactory.create_agent(model_config=mantle_config, **_COMMON_KWARGS) + AgentFactory.create_agent(model_config=mantle_config, **_COMMON_KWARGS) - mock_token.assert_called_once_with("us-west-2") - mock_openai_cls.assert_called_once() - call_kwargs = mock_openai_cls.call_args.kwargs - assert call_kwargs["client_args"]["api_key"] == "bedrock-api-key-token" - assert ( - call_kwargs["client_args"]["base_url"] - == "https://bedrock-mantle.us-west-2.api.aws/v1" - ) + mock_build.assert_called_once() + call_kwargs = mock_build.call_args.kwargs assert call_kwargs["model_id"] == "openai.gpt-oss-120b" + assert call_kwargs["api_mode"] == MantleApiMode.CHAT_COMPLETIONS + assert call_kwargs["region"] == "us-west-2" mock_agent_cls.assert_called_once() assert mock_agent_cls.call_args.kwargs["model"] is mock_model_instance @patch("agents.main_agent.core.agent_factory.Agent") - @patch("agents.main_agent.core.agent_factory.OpenAIModel") - def test_mantle_endpoint_path_selects_base_url( - self, mock_openai_cls, mock_agent_cls, monkeypatch + @patch("agents.main_agent.core.agent_factory.build_mantle_model") + def test_mantle_responses_mode_delegates_with_responses_mode( + self, mock_build, mock_agent_cls, monkeypatch ): - """A model carrying mantle_endpoint_path='/openai/v1' (e.g. Gemma 4) - must build the base URL on that path, not the default /v1.""" + """api_mode='responses' must be forwarded to the shared builder.""" from agents.main_agent.core.agent_factory import AgentFactory + from agents.main_agent.core.model_config import MantleApiMode monkeypatch.setenv("AWS_REGION", "us-west-2") mantle_config = ModelConfig( - model_id="google.gemma-4-31b", + model_id="openai.gpt-5.4", provider=ModelProvider.MANTLE, - mantle_endpoint_path="/openai/v1", + mantle_api_mode=MantleApiMode.RESPONSES, ) - with patch( - "apis.shared.bedrock.generate_bedrock_bearer_token", - return_value="bedrock-api-key-token", - ): - AgentFactory.create_agent(model_config=mantle_config, **_COMMON_KWARGS) + AgentFactory.create_agent(model_config=mantle_config, **_COMMON_KWARGS) - call_kwargs = mock_openai_cls.call_args.kwargs - assert ( - call_kwargs["client_args"]["base_url"] - == "https://bedrock-mantle.us-west-2.api.aws/openai/v1" - ) + call_kwargs = mock_build.call_args.kwargs + assert call_kwargs["model_id"] == "openai.gpt-5.4" + assert call_kwargs["api_mode"] == MantleApiMode.RESPONSES + assert call_kwargs["region"] == "us-west-2" @patch("agents.main_agent.core.agent_factory.Agent") - @patch("agents.main_agent.core.agent_factory.OpenAIModel") - def test_mantle_without_credentials_raises( - self, mock_openai_cls, mock_agent_cls, monkeypatch + @patch("agents.main_agent.core.agent_factory.build_mantle_model") + def test_mantle_region_override_pins_inference_region( + self, mock_build, mock_agent_cls, monkeypatch ): + """A per-model region override targets that region regardless of AWS_REGION.""" from agents.main_agent.core.agent_factory import AgentFactory + from agents.main_agent.core.model_config import MantleApiMode + monkeypatch.setenv("AWS_REGION", "us-west-2") mantle_config = ModelConfig( - model_id="openai.gpt-oss-120b", provider=ModelProvider.MANTLE + model_id="openai.gpt-5.4", + provider=ModelProvider.MANTLE, + mantle_api_mode=MantleApiMode.RESPONSES, + mantle_region="us-east-1", ) - with patch( - "apis.shared.bedrock.generate_bedrock_bearer_token", - side_effect=ValueError("No AWS credentials available"), - ): - with pytest.raises(ValueError, match="No AWS credentials"): - AgentFactory.create_agent(model_config=mantle_config, **_COMMON_KWARGS) + AgentFactory.create_agent(model_config=mantle_config, **_COMMON_KWARGS) + + assert mock_build.call_args.kwargs["region"] == "us-east-1" # --------------------------------------------------------------------------- diff --git a/backend/tests/agents/main_agent/core/test_model_config.py b/backend/tests/agents/main_agent/core/test_model_config.py index acc4328fd..4a49b81e5 100644 --- a/backend/tests/agents/main_agent/core/test_model_config.py +++ b/backend/tests/agents/main_agent/core/test_model_config.py @@ -444,6 +444,39 @@ def test_mantle_config_drops_top_k(self): assert "params" not in result + def test_mantle_responses_mode_maps_responses_param_names(self): + """Responses API uses max_output_tokens and nested reasoning.effort, + NOT the chat-completions max_tokens/reasoning_effort.""" + from agents.main_agent.core.model_config import MantleApiMode + + cfg = ModelConfig( + model_id="openai.gpt-5.4", + provider=ModelProvider.MANTLE, + mantle_api_mode=MantleApiMode.RESPONSES, + inference_params={ + "max_tokens": 2048, + "reasoning_effort": "high", + "temperature": 0.3, + }, + ) + result = cfg.to_mantle_config() + + assert result["params"]["max_output_tokens"] == 2048 + assert "max_tokens" not in result["params"] + assert result["params"]["reasoning"] == {"effort": "high"} + assert result["params"]["temperature"] == 0.3 + + def test_mantle_chat_mode_keeps_chat_param_names(self): + cfg = ModelConfig( + model_id="openai.gpt-oss-120b", + provider=ModelProvider.MANTLE, + inference_params={"max_tokens": 2048}, + ) + result = cfg.to_mantle_config() + + assert result["params"]["max_tokens"] == 2048 + assert "max_output_tokens" not in result["params"] + def test_explicit_mantle_provider_wins_over_auto_detect(self): """Mantle is never auto-detected; explicit provider must stick even for ids that look like other providers' (e.g. `openai.` prefix).""" @@ -503,7 +536,8 @@ def test_to_dict_keys(self, model_config: ModelConfig): "caching_enabled", "provider", "inference_params", - "mantle_endpoint_path", + "mantle_api_mode", + "mantle_region", } diff --git a/backend/tests/apis/app_api/test_memory_spaces_routes.py b/backend/tests/apis/app_api/test_memory_spaces_routes.py index 5010d1228..83872bddb 100644 --- a/backend/tests/apis/app_api/test_memory_spaces_routes.py +++ b/backend/tests/apis/app_api/test_memory_spaces_routes.py @@ -198,6 +198,27 @@ def test_entry_upsert_read_list_delete(self, service, monkeypatch): assert client.delete(f"/memory/spaces/{sid}/entries/jane-doe").status_code == 204 assert client.get(f"/memory/spaces/{sid}/entries/jane-doe").status_code == 404 + def test_namespaced_slug_with_slash(self, service, monkeypatch): + """Slugs are namespaced (``people/brian-bolt``); the `{slug:path}` converter + must route the embedded slash instead of 404-ing on it.""" + client, sid = self._make_space(service, monkeypatch) + slug = "people/brian-bolt" + + up = client.put( + f"/memory/spaces/{sid}/entries/{slug}", + json={"body": "# Brian\nDirector", "type": "entity"}, + ) + assert up.status_code == 200 + assert up.json()["slug"] == slug + + got = client.get(f"/memory/spaces/{sid}/entries/{slug}") + assert got.status_code == 200 + assert got.json()["slug"] == slug + assert "Director" in got.json()["content"] + + assert client.delete(f"/memory/spaces/{sid}/entries/{slug}").status_code == 204 + assert client.get(f"/memory/spaces/{sid}/entries/{slug}").status_code == 404 + class TestAccessControl: def test_stranger_cannot_read_space(self, service, monkeypatch): diff --git a/backend/tests/apis/app_api/test_runs_routes.py b/backend/tests/apis/app_api/test_runs_routes.py index 0bba6bc43..44bd054eb 100644 --- a/backend/tests/apis/app_api/test_runs_routes.py +++ b/backend/tests/apis/app_api/test_runs_routes.py @@ -1,8 +1,8 @@ """Route tests for the headless "Run now" surface (`/runs/*`). -Pins the three-layer gate (cookie auth → SCHEDULED_RUNS_ENABLED kill switch -→ `scheduled-runs` RBAC capability), the create-on-enable grant flow, and -the RunResult → camelCase response mapping. +Pins the two-layer gate (cookie auth → SCHEDULED_RUNS_ENABLED kill switch; +no RBAC capability gate), the create-on-enable grant flow, and the +RunResult → camelCase response mapping. """ from __future__ import annotations @@ -127,7 +127,6 @@ def _make_client( monkeypatch: pytest.MonkeyPatch, *, authed: bool = True, - capability: bool = True, flag: Optional[str] = None, grants: Optional[FakeGrantService] = None, with_session_record: bool = False, @@ -141,12 +140,6 @@ def _make_client( else: monkeypatch.setenv("SCHEDULED_RUNS_ENABLED", flag) - async def fake_capability(user, capability_id): - assert capability_id == "scheduled-runs" - return capability - - monkeypatch.setattr(runs_routes, "user_has_capability", fake_capability) - grants = grants or FakeGrantService() monkeypatch.setattr(runs_routes, "get_headless_grant_service", lambda: grants) monkeypatch.setattr(runs_routes, "get_app_role_service", lambda: FakeRoleService(role_tools)) @@ -228,11 +221,11 @@ def test_empty_flag_value_stays_on(self, monkeypatch): client, _, _ = _make_client(monkeypatch, flag="", with_session_record=True) assert client.post("/runs/now", json={"prompt": "hi"}).status_code == 200 - def test_missing_capability_is_403(self, monkeypatch): - client, _, _ = _make_client(monkeypatch, capability=False) - response = client.post("/runs/now", json={"prompt": "hi"}) - assert response.status_code == 403 - assert client.get("/runs/grant").status_code == 403 + def test_any_authenticated_user_is_allowed(self, monkeypatch): + # No RBAC capability gate: an ordinary authenticated user gets 200, + # not the old 403. (Regression for the prod "Access Denied" toast.) + client, _, _ = _make_client(monkeypatch, with_session_record=True) + assert client.post("/runs/now", json={"prompt": "hi"}).status_code == 200 # --------------------------------------------------------------------------- @@ -409,10 +402,6 @@ def test_kill_switch_off_hides_the_surface_as_404(self, monkeypatch): client, _, _ = _make_client(monkeypatch, flag="false") assert client.post("/runs/grant").status_code == 404 - def test_missing_capability_is_403(self, monkeypatch): - client, _, _ = _make_client(monkeypatch, capability=False) - assert client.post("/runs/grant").status_code == 403 - def test_unauthenticated_request_is_401(self, monkeypatch): client, _, _ = _make_client(monkeypatch, authed=False) assert client.post("/runs/grant").status_code == 401 diff --git a/backend/tests/routes/test_api_converse_mantle.py b/backend/tests/routes/test_api_converse_mantle.py new file mode 100644 index 000000000..ce10f3389 --- /dev/null +++ b/backend/tests/routes/test_api_converse_mantle.py @@ -0,0 +1,193 @@ +"""Tests for the Bedrock Mantle path of app-api /chat/api-converse. + +Mantle models (provider="mantle") don't speak Bedrock Converse — the handler +routes them through the shared Strands builder and invokes the bare model's +`.stream()`, which yields Converse-shaped events. These tests mock the shared +builder + routing so no network/AWS is touched. +""" + +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from apis.app_api.chat.converse_routes import router +from apis.shared.auth.api_keys.models import ValidatedApiKey +from apis.shared.models.mantle import MantleApiMode + + +VALID_KEY = "test-key" +MOCK_KEY = ValidatedApiKey(key_id="k1", user_id="u1", name="Test Key") + +# Converse-shaped events, exactly what Strands' model.stream yields. +MANTLE_EVENTS = [ + {"messageStart": {"role": "assistant"}}, + {"contentBlockDelta": {"contentBlockIndex": 0, "delta": {"text": "hello "}}}, + {"contentBlockDelta": {"contentBlockIndex": 0, "delta": {"text": "mantle"}}}, + {"contentBlockStop": {"contentBlockIndex": 0}}, + {"messageStop": {"stopReason": "end_turn"}}, + {"metadata": {"usage": {"inputTokens": 10, "outputTokens": 3}, "metrics": {}}}, +] + + +class _FakeMantleModel: + """Stand-in for a Strands OpenAIModel/OpenAIResponsesModel.""" + + def __init__(self, events): + self._events = events + self.stream_calls = [] + + def stream(self, messages, system_prompt=None, **kwargs): + self.stream_calls.append({"messages": messages, "system_prompt": system_prompt}) + + async def _gen(): + for event in self._events: + yield event + + return _gen() + + +def _client() -> TestClient: + app = FastAPI() + app.include_router(router) + return TestClient(app) + + +def _role_service(can_access=True): + svc = MagicMock() + svc.can_access_model = AsyncMock(return_value=can_access) + return svc + + +def _mantle_patches(routing, fake_model, record_cost): + """Common patch stack for the mantle path.""" + return [ + patch("apis.app_api.chat.converse_routes._validate_api_key", AsyncMock(return_value=MOCK_KEY)), + patch("apis.app_api.chat.converse_routes.shared_quota.is_quota_enforcement_enabled", return_value=False), + patch("apis.app_api.chat.converse_routes.get_app_role_service", return_value=_role_service()), + patch("apis.app_api.chat.converse_routes._resolve_model_routing", AsyncMock(return_value=routing)), + patch("apis.app_api.chat.converse_routes.build_mantle_model", return_value=fake_model), + patch("apis.app_api.chat.converse_routes._record_cost", record_cost), + ] + + +class TestMantleNonStreaming: + def test_returns_aggregated_text_and_records_mantle_cost(self): + fake_model = _FakeMantleModel(MANTLE_EVENTS) + record_cost = AsyncMock() + patches = _mantle_patches(("mantle", "chat", "us-east-1"), fake_model, record_cost) + for p in patches: + p.start() + try: + resp = _client().post( + "/chat/api-converse", + headers={"X-API-Key": VALID_KEY}, + json={"model_id": "openai.gpt-oss-120b", "messages": [{"role": "user", "content": "hi"}]}, + ) + finally: + for p in patches: + p.stop() + + assert resp.status_code == 200 + body = resp.json() + assert body["content"] == "hello mantle" + assert body["stop_reason"] == "end_turn" + assert body["usage"] == {"inputTokens": 10, "outputTokens": 3} + # Cost recorded against the mantle provider, not bedrock. + record_cost.assert_awaited_once() + assert record_cost.await_args.kwargs["provider"] == "mantle" + + def test_responses_mode_forwarded_to_builder(self): + fake_model = _FakeMantleModel(MANTLE_EVENTS) + build = MagicMock(return_value=fake_model) + patches = [ + patch("apis.app_api.chat.converse_routes._validate_api_key", AsyncMock(return_value=MOCK_KEY)), + patch("apis.app_api.chat.converse_routes.shared_quota.is_quota_enforcement_enabled", return_value=False), + patch("apis.app_api.chat.converse_routes.get_app_role_service", return_value=_role_service()), + patch("apis.app_api.chat.converse_routes._resolve_model_routing", + AsyncMock(return_value=("mantle", "responses", "us-east-1"))), + patch("apis.app_api.chat.converse_routes.build_mantle_model", build), + patch("apis.app_api.chat.converse_routes._record_cost", AsyncMock()), + ] + for p in patches: + p.start() + try: + resp = _client().post( + "/chat/api-converse", + headers={"X-API-Key": VALID_KEY}, + json={"model_id": "openai.gpt-5.4", "max_tokens": 256, + "messages": [{"role": "user", "content": "hi"}]}, + ) + finally: + for p in patches: + p.stop() + + assert resp.status_code == 200 + kwargs = build.call_args.kwargs + assert kwargs["api_mode"] == MantleApiMode.RESPONSES + assert kwargs["region"] == "us-east-1" + # Responses API renames the output cap. + assert kwargs["params"] == {"max_output_tokens": 256} + + +class TestMantleStreaming: + def test_streams_sse_and_records_cost(self): + fake_model = _FakeMantleModel(MANTLE_EVENTS) + record_cost = AsyncMock() + patches = _mantle_patches(("mantle", "chat", None), fake_model, record_cost) + for p in patches: + p.start() + try: + resp = _client().post( + "/chat/api-converse", + headers={"X-API-Key": VALID_KEY}, + json={"model_id": "openai.gpt-oss-120b", "stream": True, + "messages": [{"role": "user", "content": "hi"}]}, + ) + body = resp.text + finally: + for p in patches: + p.stop() + + assert resp.status_code == 200 + assert "text/event-stream" in resp.headers["content-type"] + assert "event: message_start" in body + assert "event: content_block_delta" in body + assert "event: metadata" in body + assert "event: done" in body + record_cost.assert_awaited_once() + assert record_cost.await_args.kwargs["provider"] == "mantle" + + +class TestResolveModelRouting: + @pytest.mark.asyncio + async def test_returns_mantle_fields_for_mantle_model(self): + from apis.app_api.chat.converse_routes import _resolve_model_routing + + m = MagicMock() + m.model_id = "openai.gpt-5.4" + m.provider = "mantle" + m.mantle_api_mode = "responses" + m.mantle_region = "us-east-1" + with patch("apis.shared.models.managed_models.list_managed_models", + AsyncMock(return_value=[m])): + provider, api_mode, region = await _resolve_model_routing("openai.gpt-5.4") + assert (provider, api_mode, region) == ("mantle", "responses", "us-east-1") + + @pytest.mark.asyncio + async def test_defaults_to_bedrock_when_not_found(self): + from apis.app_api.chat.converse_routes import _resolve_model_routing + + with patch("apis.shared.models.managed_models.list_managed_models", + AsyncMock(return_value=[])): + assert await _resolve_model_routing("unknown.model") == ("bedrock", None, None) + + @pytest.mark.asyncio + async def test_defaults_to_bedrock_on_lookup_error(self): + from apis.app_api.chat.converse_routes import _resolve_model_routing + + with patch("apis.shared.models.managed_models.list_managed_models", + AsyncMock(side_effect=RuntimeError("boom"))): + assert await _resolve_model_routing("openai.gpt-5.4") == ("bedrock", None, None) diff --git a/backend/tests/routes/test_converse_cost_accounting.py b/backend/tests/routes/test_converse_cost_accounting.py index d3271e6c7..297d74ccd 100644 --- a/backend/tests/routes/test_converse_cost_accounting.py +++ b/backend/tests/routes/test_converse_cost_accounting.py @@ -18,7 +18,7 @@ from fastapi import FastAPI, HTTPException from fastapi.testclient import TestClient -from apis.inference_api.chat.converse_routes import router +from apis.app_api.chat.converse_routes import router from apis.shared.auth.api_keys.models import ValidatedApiKey @@ -90,7 +90,7 @@ def client(app): def mock_validate_api_key(): """Patch _validate_api_key to return a canned ValidatedApiKey.""" with patch( - "apis.inference_api.chat.converse_routes._validate_api_key", + "apis.app_api.chat.converse_routes._validate_api_key", new_callable=AsyncMock, return_value=MOCK_VALIDATED_KEY, ) as m: @@ -109,7 +109,7 @@ def mock_bedrock_client(): } with patch( - "apis.inference_api.chat.converse_routes._get_bedrock_client", + "apis.app_api.chat.converse_routes._get_bedrock_client", return_value=fake_client, ) as m: yield fake_client @@ -123,7 +123,7 @@ def mock_store_metadata(): we must patch at the converse_routes module level for the mock to intercept. """ with patch( - "apis.inference_api.chat.converse_routes.store_message_metadata", + "apis.app_api.chat.converse_routes.store_message_metadata", new_callable=AsyncMock, ) as m: yield m @@ -143,7 +143,7 @@ def mock_pricing(): "snapshotAt": "2025-01-15T00:00:00Z", } with patch( - "apis.inference_api.chat.converse_routes.create_pricing_snapshot", + "apis.app_api.chat.converse_routes.create_pricing_snapshot", new_callable=AsyncMock, return_value=pricing, ) as m: @@ -157,7 +157,7 @@ def mock_quota_disabled(): Patches at the converse_routes module level where shared_quota is used. """ with patch( - "apis.inference_api.chat.converse_routes.shared_quota.is_quota_enforcement_enabled", + "apis.app_api.chat.converse_routes.shared_quota.is_quota_enforcement_enabled", return_value=False, ) as m: yield m @@ -169,7 +169,7 @@ def mock_app_role_service(): svc = MagicMock() svc.can_access_model = AsyncMock(return_value=True) with patch( - "apis.inference_api.chat.converse_routes.get_app_role_service", + "apis.app_api.chat.converse_routes.get_app_role_service", return_value=svc, ): yield svc @@ -481,7 +481,7 @@ def test_reasoning_model_response_shape( fake_client.converse.return_value = _make_reasoning_converse_response() with patch( - "apis.inference_api.chat.converse_routes._get_bedrock_client", + "apis.app_api.chat.converse_routes._get_bedrock_client", return_value=fake_client, ): resp = client.post( @@ -528,7 +528,7 @@ def test_response_format_consistent_across_inputs( fake_client.converse.return_value = _make_converse_response(reply_text) with patch( - "apis.inference_api.chat.converse_routes._get_bedrock_client", + "apis.app_api.chat.converse_routes._get_bedrock_client", return_value=fake_client, ): payload = { @@ -661,7 +661,7 @@ def test_sse_always_ends_with_done( fake_client.converse_stream.return_value = {"stream": iter(stream_events)} with patch( - "apis.inference_api.chat.converse_routes._get_bedrock_client", + "apis.app_api.chat.converse_routes._get_bedrock_client", return_value=fake_client, ): resp = client.post( @@ -706,7 +706,7 @@ class TestAuthPreservation: def test_invalid_api_key_returns_401(self, client): """Invalid API key must return 401 with correct error detail.""" with patch( - "apis.inference_api.chat.converse_routes._validate_api_key", + "apis.app_api.chat.converse_routes._validate_api_key", new_callable=AsyncMock, side_effect=HTTPException( status_code=401, detail="Invalid or expired API key" @@ -791,7 +791,7 @@ def test_bedrock_error_returns_502( fake_client.converse.side_effect = Exception("Bedrock is down") with patch( - "apis.inference_api.chat.converse_routes._get_bedrock_client", + "apis.app_api.chat.converse_routes._get_bedrock_client", return_value=fake_client, ): resp = client.post( @@ -820,7 +820,7 @@ def test_streaming_bedrock_error_yields_error_event( fake_client.converse_stream.side_effect = fake_client.exceptions.ClientError("Stream failed") with patch( - "apis.inference_api.chat.converse_routes._get_bedrock_client", + "apis.app_api.chat.converse_routes._get_bedrock_client", return_value=fake_client, ): resp = client.post( diff --git a/backend/tests/routes/test_model_access_enforcement.py b/backend/tests/routes/test_model_access_enforcement.py index 78db85fb9..4de4e91d5 100644 --- a/backend/tests/routes/test_model_access_enforcement.py +++ b/backend/tests/routes/test_model_access_enforcement.py @@ -365,7 +365,7 @@ class TestApiConverseAccessDenied: """ def test_returns_403_for_unauthorized_model(self): - from apis.inference_api.chat.converse_routes import router + from apis.app_api.chat.converse_routes import router app = FastAPI() app.include_router(router) @@ -375,11 +375,11 @@ def test_returns_403_for_unauthorized_model(self): mock_limiter = _make_rate_limiter(allowed=True) with patch( - "apis.inference_api.chat.converse_routes._validate_api_key", + "apis.app_api.chat.converse_routes._validate_api_key", new_callable=AsyncMock, return_value=mock_key, ), patch( - "apis.inference_api.chat.converse_routes.get_app_role_service", + "apis.app_api.chat.converse_routes.get_app_role_service", return_value=mock_svc, ), patch( "apis.shared.rate_limit.get_rate_limiter", @@ -407,7 +407,7 @@ def test_403_response_is_json_not_sse(self): Requirements: 2.2 """ - from apis.inference_api.chat.converse_routes import router + from apis.app_api.chat.converse_routes import router app = FastAPI() app.include_router(router) @@ -417,11 +417,11 @@ def test_403_response_is_json_not_sse(self): mock_limiter = _make_rate_limiter(allowed=True) with patch( - "apis.inference_api.chat.converse_routes._validate_api_key", + "apis.app_api.chat.converse_routes._validate_api_key", new_callable=AsyncMock, return_value=mock_key, ), patch( - "apis.inference_api.chat.converse_routes.get_app_role_service", + "apis.app_api.chat.converse_routes.get_app_role_service", return_value=mock_svc, ), patch( "apis.shared.rate_limit.get_rate_limiter", @@ -454,7 +454,7 @@ class TestApiConverseAccessAllowed: """ def test_returns_200_for_authorized_model(self): - from apis.inference_api.chat.converse_routes import router + from apis.app_api.chat.converse_routes import router app = FastAPI() app.include_router(router) @@ -465,11 +465,11 @@ def test_returns_200_for_authorized_model(self): mock_bedrock = _make_bedrock_client() with patch( - "apis.inference_api.chat.converse_routes._validate_api_key", + "apis.app_api.chat.converse_routes._validate_api_key", new_callable=AsyncMock, return_value=mock_key, ), patch( - "apis.inference_api.chat.converse_routes.get_app_role_service", + "apis.app_api.chat.converse_routes.get_app_role_service", return_value=mock_svc, ), patch( "apis.shared.rate_limit.get_rate_limiter", @@ -478,10 +478,10 @@ def test_returns_200_for_authorized_model(self): "apis.shared.quota.is_quota_enforcement_enabled", return_value=False, ), patch( - "apis.inference_api.chat.converse_routes._get_bedrock_client", + "apis.app_api.chat.converse_routes._get_bedrock_client", return_value=mock_bedrock, ), patch( - "apis.inference_api.chat.converse_routes._record_cost", + "apis.app_api.chat.converse_routes._record_cost", new_callable=AsyncMock, ): client = TestClient(app, raise_server_exceptions=False) @@ -504,7 +504,7 @@ class TestApiConverseWildcardAccess: """ def test_wildcard_access_allows_any_model(self): - from apis.inference_api.chat.converse_routes import router + from apis.app_api.chat.converse_routes import router app = FastAPI() app.include_router(router) @@ -515,11 +515,11 @@ def test_wildcard_access_allows_any_model(self): mock_bedrock = _make_bedrock_client() with patch( - "apis.inference_api.chat.converse_routes._validate_api_key", + "apis.app_api.chat.converse_routes._validate_api_key", new_callable=AsyncMock, return_value=mock_key, ), patch( - "apis.inference_api.chat.converse_routes.get_app_role_service", + "apis.app_api.chat.converse_routes.get_app_role_service", return_value=mock_svc, ), patch( "apis.shared.rate_limit.get_rate_limiter", @@ -528,10 +528,10 @@ def test_wildcard_access_allows_any_model(self): "apis.shared.quota.is_quota_enforcement_enabled", return_value=False, ), patch( - "apis.inference_api.chat.converse_routes._get_bedrock_client", + "apis.app_api.chat.converse_routes._get_bedrock_client", return_value=mock_bedrock, ), patch( - "apis.inference_api.chat.converse_routes._record_cost", + "apis.app_api.chat.converse_routes._record_cost", new_callable=AsyncMock, ): client = TestClient(app, raise_server_exceptions=False) @@ -555,7 +555,7 @@ class TestApiConverseRateLimitPrecedence: """ def test_rate_limit_returns_429_not_403(self): - from apis.inference_api.chat.converse_routes import router + from apis.app_api.chat.converse_routes import router app = FastAPI() app.include_router(router) @@ -565,11 +565,11 @@ def test_rate_limit_returns_429_not_403(self): mock_limiter = _make_rate_limiter(allowed=False) with patch( - "apis.inference_api.chat.converse_routes._validate_api_key", + "apis.app_api.chat.converse_routes._validate_api_key", new_callable=AsyncMock, return_value=mock_key, ), patch( - "apis.inference_api.chat.converse_routes.get_app_role_service", + "apis.app_api.chat.converse_routes.get_app_role_service", return_value=mock_svc, ), patch( "apis.shared.rate_limit.get_rate_limiter", @@ -598,7 +598,7 @@ class TestApiConverseQuotaPrecedence: """ def test_quota_exceeded_returns_429_not_403(self): - from apis.inference_api.chat.converse_routes import router + from apis.app_api.chat.converse_routes import router app = FastAPI() app.include_router(router) @@ -616,11 +616,11 @@ def test_quota_exceeded_returns_429_not_403(self): mock_quota_checker.check_quota = AsyncMock(return_value=mock_quota_result) with patch( - "apis.inference_api.chat.converse_routes._validate_api_key", + "apis.app_api.chat.converse_routes._validate_api_key", new_callable=AsyncMock, return_value=mock_key, ), patch( - "apis.inference_api.chat.converse_routes.get_app_role_service", + "apis.app_api.chat.converse_routes.get_app_role_service", return_value=mock_svc, ), patch( "apis.shared.rate_limit.get_rate_limiter", diff --git a/backend/tests/routes/test_pbt_model_access.py b/backend/tests/routes/test_pbt_model_access.py index e1a2fd268..ab9e58a1c 100644 --- a/backend/tests/routes/test_pbt_model_access.py +++ b/backend/tests/routes/test_pbt_model_access.py @@ -156,7 +156,7 @@ def test_api_converse_returns_403_for_unauthorized_model(self, model_id: str): **Validates: Requirements 1.2, 2.2** """ - from apis.inference_api.chat.converse_routes import router + from apis.app_api.chat.converse_routes import router app = FastAPI() app.include_router(router) @@ -182,11 +182,11 @@ def test_api_converse_returns_403_for_unauthorized_model(self, model_id: str): } with patch( - "apis.inference_api.chat.converse_routes._validate_api_key", + "apis.app_api.chat.converse_routes._validate_api_key", new_callable=AsyncMock, return_value=mock_validated_key, ), patch( - "apis.inference_api.chat.converse_routes.get_app_role_service", + "apis.app_api.chat.converse_routes.get_app_role_service", return_value=mock_svc, ), patch( "apis.shared.rate_limit.get_rate_limiter", @@ -195,10 +195,10 @@ def test_api_converse_returns_403_for_unauthorized_model(self, model_id: str): "apis.shared.quota.is_quota_enforcement_enabled", return_value=False, ), patch( - "apis.inference_api.chat.converse_routes._get_bedrock_client", + "apis.app_api.chat.converse_routes._get_bedrock_client", return_value=mock_bedrock, ), patch( - "apis.inference_api.chat.converse_routes._record_cost", + "apis.app_api.chat.converse_routes._record_cost", new_callable=AsyncMock, ): client = TestClient(app, raise_server_exceptions=False) diff --git a/backend/tests/routes/test_pbt_model_access_preservation.py b/backend/tests/routes/test_pbt_model_access_preservation.py index f82a3caf5..99f601ce0 100644 --- a/backend/tests/routes/test_pbt_model_access_preservation.py +++ b/backend/tests/routes/test_pbt_model_access_preservation.py @@ -187,7 +187,7 @@ def test_authorized_model_proceeds_api_converse(self, model_id: str): **Validates: Requirements 3.2** """ - from apis.inference_api.chat.converse_routes import router + from apis.app_api.chat.converse_routes import router app = FastAPI() app.include_router(router) @@ -198,11 +198,11 @@ def test_authorized_model_proceeds_api_converse(self, model_id: str): mock_bedrock = _make_mock_bedrock_client() with patch( - "apis.inference_api.chat.converse_routes._validate_api_key", + "apis.app_api.chat.converse_routes._validate_api_key", new_callable=AsyncMock, return_value=mock_validated_key, ), patch( - "apis.inference_api.chat.converse_routes.get_app_role_service", + "apis.app_api.chat.converse_routes.get_app_role_service", return_value=mock_svc, ), patch( "apis.shared.rate_limit.get_rate_limiter", @@ -211,10 +211,10 @@ def test_authorized_model_proceeds_api_converse(self, model_id: str): "apis.shared.quota.is_quota_enforcement_enabled", return_value=False, ), patch( - "apis.inference_api.chat.converse_routes._get_bedrock_client", + "apis.app_api.chat.converse_routes._get_bedrock_client", return_value=mock_bedrock, ), patch( - "apis.inference_api.chat.converse_routes._record_cost", + "apis.app_api.chat.converse_routes._record_cost", new_callable=AsyncMock, ): client = TestClient(app, raise_server_exceptions=False) @@ -306,7 +306,7 @@ def test_wildcard_access_api_converse(self, model_id: str): **Validates: Requirements 3.3** """ - from apis.inference_api.chat.converse_routes import router + from apis.app_api.chat.converse_routes import router app = FastAPI() app.include_router(router) @@ -317,11 +317,11 @@ def test_wildcard_access_api_converse(self, model_id: str): mock_bedrock = _make_mock_bedrock_client() with patch( - "apis.inference_api.chat.converse_routes._validate_api_key", + "apis.app_api.chat.converse_routes._validate_api_key", new_callable=AsyncMock, return_value=mock_validated_key, ), patch( - "apis.inference_api.chat.converse_routes.get_app_role_service", + "apis.app_api.chat.converse_routes.get_app_role_service", return_value=mock_svc, ), patch( "apis.shared.rate_limit.get_rate_limiter", @@ -330,10 +330,10 @@ def test_wildcard_access_api_converse(self, model_id: str): "apis.shared.quota.is_quota_enforcement_enabled", return_value=False, ), patch( - "apis.inference_api.chat.converse_routes._get_bedrock_client", + "apis.app_api.chat.converse_routes._get_bedrock_client", return_value=mock_bedrock, ), patch( - "apis.inference_api.chat.converse_routes._record_cost", + "apis.app_api.chat.converse_routes._record_cost", new_callable=AsyncMock, ): client = TestClient(app, raise_server_exceptions=False) @@ -549,7 +549,7 @@ def test_quota_exceeded_takes_precedence_api_converse(self, model_authorized: bo **Validates: Requirements 3.5** """ - from apis.inference_api.chat.converse_routes import router + from apis.app_api.chat.converse_routes import router app = FastAPI() app.include_router(router) @@ -568,11 +568,11 @@ def test_quota_exceeded_takes_precedence_api_converse(self, model_authorized: bo mock_quota_checker.check_quota = AsyncMock(return_value=mock_quota_result) with patch( - "apis.inference_api.chat.converse_routes._validate_api_key", + "apis.app_api.chat.converse_routes._validate_api_key", new_callable=AsyncMock, return_value=mock_validated_key, ), patch( - "apis.inference_api.chat.converse_routes.get_app_role_service", + "apis.app_api.chat.converse_routes.get_app_role_service", return_value=mock_svc, ), patch( "apis.shared.rate_limit.get_rate_limiter", @@ -621,7 +621,7 @@ def test_rate_limit_takes_precedence_api_converse(self, model_authorized: bool): **Validates: Requirements 3.6** """ - from apis.inference_api.chat.converse_routes import router + from apis.app_api.chat.converse_routes import router app = FastAPI() app.include_router(router) @@ -632,11 +632,11 @@ def test_rate_limit_takes_precedence_api_converse(self, model_authorized: bool): mock_limiter = _make_mock_rate_limiter(allowed=False) with patch( - "apis.inference_api.chat.converse_routes._validate_api_key", + "apis.app_api.chat.converse_routes._validate_api_key", new_callable=AsyncMock, return_value=mock_validated_key, ), patch( - "apis.inference_api.chat.converse_routes.get_app_role_service", + "apis.app_api.chat.converse_routes.get_app_role_service", return_value=mock_svc, ), patch( "apis.shared.rate_limit.get_rate_limiter", diff --git a/backend/tests/routes/test_schedules_routes.py b/backend/tests/routes/test_schedules_routes.py index 5344c3e90..212f21763 100644 --- a/backend/tests/routes/test_schedules_routes.py +++ b/backend/tests/routes/test_schedules_routes.py @@ -9,9 +9,9 @@ - POST /schedules/{id}/resume -> resume (200; 404) - DELETE /schedules/{id} -> delete (204; 404) -Every route shares the three-layer gate (cookie auth -> SCHEDULED_RUNS_ENABLED -kill switch -> `scheduled-runs` RBAC capability), pinned once via `TestGating` -and assumed enabled/authorized everywhere else. The service layer is mocked +Every route shares the two-layer gate (cookie auth -> SCHEDULED_RUNS_ENABLED +kill switch), pinned once via `TestGating` and assumed enabled everywhere +else. The surface carries no RBAC capability gate. The service layer is mocked (DynamoDB semantics are covered by tests/shared/test_scheduled_prompts.py); these tests pin the HTTP contract and ownership isolation. """ @@ -91,7 +91,6 @@ def _make_client( monkeypatch: pytest.MonkeyPatch, *, authed: bool = True, - capability: bool = True, flag: Optional[str] = None, user_id: str = USER_ID, ) -> TestClient: @@ -101,11 +100,6 @@ def _make_client( else: monkeypatch.setenv("SCHEDULED_RUNS_ENABLED", flag) - async def fake_capability(user, capability_id): - assert capability_id == "scheduled-runs" - return capability - - monkeypatch.setattr(schedules_routes, "user_has_capability", fake_capability) monkeypatch.setattr(schedules_routes, "get_app_role_service", lambda: FakeRoleService()) app = FastAPI() @@ -143,10 +137,12 @@ def test_empty_flag_value_stays_on(self, monkeypatch): monkeypatch.setattr(schedules_routes, "list_scheduled_prompts", AsyncMock(return_value=[])) assert client.get("/schedules").status_code == 200 - def test_missing_capability_is_403(self, monkeypatch): - client = _make_client(monkeypatch, capability=False) - assert client.get("/schedules").status_code == 403 - assert client.post("/schedules", json={}).status_code == 403 + def test_any_authenticated_user_is_allowed(self, monkeypatch): + # No RBAC capability gate: an ordinary authenticated user gets 200, + # not the old 403. (Regression for the prod "Access Denied" toast.) + client = _make_client(monkeypatch) + monkeypatch.setattr(schedules_routes, "list_scheduled_prompts", AsyncMock(return_value=[])) + assert client.get("/schedules").status_code == 200 # --------------------------------------------------------------------------- diff --git a/backend/tests/shared/test_managed_models.py b/backend/tests/shared/test_managed_models.py index c6c958197..9fafdd380 100644 --- a/backend/tests/shared/test_managed_models.py +++ b/backend/tests/shared/test_managed_models.py @@ -94,39 +94,46 @@ async def test_supports_caching_default_openai(self): assert model.supports_caching is False @pytest.mark.asyncio - async def test_mantle_endpoint_path_defaults_to_v1(self): + async def test_mantle_api_mode_defaults_to_chat(self): from apis.shared.models.managed_models import create_managed_model, get_managed_model model = await create_managed_model( _make_model_data("qwen.qwen3-32b", provider="mantle", providerName="Qwen") ) - assert model.mantle_endpoint_path == "/v1" + assert model.mantle_api_mode == "chat" + assert model.mantle_region is None # Persists and reloads. reloaded = await get_managed_model(model.id) - assert reloaded.mantle_endpoint_path == "/v1" + assert reloaded.mantle_api_mode == "chat" @pytest.mark.asyncio - async def test_mantle_endpoint_path_explicit_openai_v1(self): + async def test_mantle_api_mode_explicit_responses_with_region(self): from apis.shared.models.managed_models import create_managed_model, get_managed_model model = await create_managed_model( _make_model_data( - "google.gemma-4-31b", + "openai.gpt-5.4", provider="mantle", - providerName="Google", - mantleEndpointPath="/openai/v1", + providerName="OpenAI", + apiMode="responses", + region="us-east-1", ) ) - assert model.mantle_endpoint_path == "/openai/v1" + assert model.mantle_api_mode == "responses" + assert model.mantle_region == "us-east-1" reloaded = await get_managed_model(model.id) - assert reloaded.mantle_endpoint_path == "/openai/v1" + assert reloaded.mantle_api_mode == "responses" + assert reloaded.mantle_region == "us-east-1" @pytest.mark.asyncio - async def test_mantle_endpoint_path_none_for_non_mantle(self): + async def test_mantle_fields_none_for_non_mantle(self): from apis.shared.models.managed_models import create_managed_model - # Even if a path is supplied, it's inert for non-Mantle providers. + # Even if supplied, Mantle fields are inert for non-Mantle providers. model = await create_managed_model( - _make_model_data("claude-3", provider="bedrock", mantleEndpointPath="/openai/v1") + _make_model_data( + "claude-3", provider="bedrock", apiMode="responses", region="us-east-1" + ) ) - assert model.mantle_endpoint_path is None + assert model.mantle_api_mode is None + assert model.mantle_region is None class TestMaxTokensCeiling: diff --git a/backend/tests/shared/test_mantle.py b/backend/tests/shared/test_mantle.py new file mode 100644 index 000000000..90c4749fe --- /dev/null +++ b/backend/tests/shared/test_mantle.py @@ -0,0 +1,81 @@ +"""Unit tests for the shared Bedrock Mantle model builder. + +These cover the class-pick / region / params construction contract that both +the agent factory and the API-key converse handler depend on. The builder +imports the Strands model classes lazily, so patches target their source +modules (`strands.models.openai` / `strands.models`). +""" + +from unittest.mock import MagicMock, patch + +from apis.shared.models.mantle import ( + MantleApiMode, + MANTLE_CHAT_PARAM_MAP, + MANTLE_RESPONSES_PARAM_MAP, + build_mantle_model, + param_map_for, +) + + +class TestBuildMantleModel: + @patch("strands.models.openai.OpenAIModel") + def test_chat_mode_builds_openai_model_with_region(self, mock_openai_cls): + instance = MagicMock() + mock_openai_cls.return_value = instance + + result = build_mantle_model( + model_id="openai.gpt-oss-120b", + api_mode=MantleApiMode.CHAT_COMPLETIONS, + region="us-west-2", + ) + + assert result is instance + mock_openai_cls.assert_called_once() + kwargs = mock_openai_cls.call_args.kwargs + assert kwargs["model_id"] == "openai.gpt-oss-120b" + assert kwargs["bedrock_mantle_config"] == {"region": "us-west-2"} + # The SDK owns base_url + token; we hand it region only. + assert "client_args" not in kwargs + assert "params" not in kwargs + + @patch("strands.models.OpenAIResponsesModel") + @patch("strands.models.openai.OpenAIModel") + def test_responses_mode_builds_responses_model(self, mock_openai_cls, mock_responses_cls): + build_mantle_model( + model_id="openai.gpt-5.4", + api_mode=MantleApiMode.RESPONSES, + region="us-east-1", + ) + + mock_responses_cls.assert_called_once() + mock_openai_cls.assert_not_called() + kwargs = mock_responses_cls.call_args.kwargs + assert kwargs["model_id"] == "openai.gpt-5.4" + assert kwargs["bedrock_mantle_config"] == {"region": "us-east-1"} + + @patch("strands.models.openai.OpenAIModel") + def test_no_region_leaves_bedrock_mantle_config_empty(self, mock_openai_cls): + build_mantle_model(model_id="openai.gpt-oss-120b", api_mode=MantleApiMode.CHAT_COMPLETIONS) + assert mock_openai_cls.call_args.kwargs["bedrock_mantle_config"] == {} + + @patch("strands.models.openai.OpenAIModel") + def test_params_forwarded(self, mock_openai_cls): + build_mantle_model( + model_id="openai.gpt-oss-120b", + api_mode=MantleApiMode.CHAT_COMPLETIONS, + region="us-west-2", + params={"temperature": 0.5, "max_tokens": 128}, + ) + assert mock_openai_cls.call_args.kwargs["params"] == {"temperature": 0.5, "max_tokens": 128} + + +class TestParamMapFor: + def test_chat_mode_map(self): + assert param_map_for(MantleApiMode.CHAT_COMPLETIONS) is MANTLE_CHAT_PARAM_MAP + assert MANTLE_CHAT_PARAM_MAP["max_tokens"] == "max_tokens" + + def test_responses_mode_map(self): + assert param_map_for(MantleApiMode.RESPONSES) is MANTLE_RESPONSES_PARAM_MAP + # Responses API renames the output cap and nests reasoning effort. + assert MANTLE_RESPONSES_PARAM_MAP["max_tokens"] == "max_output_tokens" + assert MANTLE_RESPONSES_PARAM_MAP["reasoning_effort"] == "reasoning.effort" diff --git a/backend/uv.lock b/backend/uv.lock index 14d94f20b..98e74f01b 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.15'", @@ -12,7 +12,7 @@ resolution-markers = [ [[package]] name = "agentcore-stack" -version = "1.2.0" +version = "1.3.0" source = { editable = "." } dependencies = [ { name = "aiofiles" }, @@ -38,6 +38,7 @@ dependencies = [ [package.optional-dependencies] agentcore = [ + { name = "aws-bedrock-token-generator" }, { name = "aws-opentelemetry-distro" }, { name = "bedrock-agentcore" }, { name = "google-genai" }, @@ -46,6 +47,7 @@ agentcore = [ { name = "strands-agents-tools" }, ] all = [ + { name = "aws-bedrock-token-generator" }, { name = "aws-opentelemetry-distro" }, { name = "bedrock-agentcore" }, { name = "black" }, @@ -87,6 +89,7 @@ requires-dist = [ { name = "aiofiles", specifier = "==25.1.0" }, { name = "aiohttp", specifier = "==3.14.1" }, { name = "authlib", specifier = "==1.7.1" }, + { name = "aws-bedrock-token-generator", marker = "extra == 'agentcore'", specifier = "==1.1.0" }, { name = "aws-opentelemetry-distro", marker = "extra == 'agentcore'", specifier = "==0.17.0" }, { name = "beautifulsoup4", specifier = "==4.13.5" }, { name = "bedrock-agentcore", marker = "extra == 'agentcore'", specifier = "==1.9.1" }, @@ -113,8 +116,8 @@ requires-dist = [ { name = "python-multipart", specifier = "==0.0.31" }, { name = "ruff", marker = "extra == 'dev'", specifier = "==0.15.12" }, { name = "starlette", specifier = "==1.3.1" }, - { name = "strands-agents", marker = "extra == 'agentcore'", specifier = "==1.40.0" }, - { name = "strands-agents", extras = ["bidi"], marker = "extra == 'bidi'", specifier = "==1.40.0" }, + { name = "strands-agents", marker = "extra == 'agentcore'", specifier = "==1.47.0" }, + { name = "strands-agents", extras = ["bidi"], marker = "extra == 'bidi'", specifier = "==1.47.0" }, { name = "strands-agents-tools", marker = "extra == 'agentcore'", specifier = "==0.5.2" }, { name = "tiktoken", marker = "extra == 'dev'", specifier = "==0.12.0" }, { name = "trafilatura", specifier = "==2.0.0" }, @@ -367,6 +370,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/82/730650ee5e5b598b7bfdc291b784bc2f6fe02a5671695485403365101088/authlib-1.7.1-py2.py3-none-any.whl", hash = "sha256:8470f4aa6b5590ac41bd81d6e6ee12448ce36a0da0af19bbed69fb53fb4e8ad9", size = 258826, upload-time = "2026-05-04T08:11:23.208Z" }, ] +[[package]] +name = "aws-bedrock-token-generator" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/39/cf1c2e12bc5a84af0f96a546481213f81fa6e7927d2bbabd81758c6558ca/aws_bedrock_token_generator-1.1.0.tar.gz", hash = "sha256:95ccb07f63a91ac486561f6df05cc4e04784c8ff5086dc687ed9c5fd3ab1b5ba", size = 19123, upload-time = "2025-07-29T19:53:19.511Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/fd/745ece98870c3824d294bcdce5dc5e15381188a41bc80832c246b205e40e/aws_bedrock_token_generator-1.1.0-py3-none-any.whl", hash = "sha256:bd12854f7c7e52dde5d980d369379f12d0cc5f0855099d87f38688b0f9de5cd4", size = 10291, upload-time = "2025-07-29T19:53:18.704Z" }, +] + [[package]] name = "aws-opentelemetry-distro" version = "0.17.0" @@ -4619,7 +4634,7 @@ wheels = [ [[package]] name = "strands-agents" -version = "1.40.0" +version = "1.47.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "boto3" }, @@ -4635,9 +4650,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "watchdog" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/07/fa/b5fdfa099b122fea98fc64b9923237077ed6b7c2a90f2c3a65cba00d7202/strands_agents-1.40.0.tar.gz", hash = "sha256:5d867c1255f8449f0030a9a9c085106c15b1704e871d0fea56d3c20b2309a4d3", size = 878176, upload-time = "2026-05-14T13:48:28.812Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/05/5eb8431ff340739b1c21b0da7d19ec9604b9c4953a1c4638df915321573c/strands_agents-1.47.0.tar.gz", hash = "sha256:97770cb6beb6e5fd1a58849f41201eb7edb43fd67ad3de6544ada2f342c2bcf0", size = 1157139, upload-time = "2026-07-10T14:45:05.809Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/ca/ce4c061d0fa007738f0ce4ebdb234969d9343322a089c24d5986620faa66/strands_agents-1.40.0-py3-none-any.whl", hash = "sha256:40c04f411e4082a6eb78b22d5b421757b27aac1f9a42e8198ff3db7fd4fcc13f", size = 432744, upload-time = "2026-05-14T13:48:26.639Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cb/ceae892c5823bea9254160efc02a4553f2ced9d183676110c03c3a9ff0f3/strands_agents-1.47.0-py3-none-any.whl", hash = "sha256:1f6ce17404ff02079244ad7a4a180a2a7150546275e4b91187d71472ba8fe3f2", size = 600611, upload-time = "2026-07-10T14:45:03.942Z" }, ] [package.optional-dependencies] diff --git a/docs/specs/user-markdown-memory.md b/docs/specs/user-markdown-memory.md index 49ccc9823..8887ad937 100644 --- a/docs/specs/user-markdown-memory.md +++ b/docs/specs/user-markdown-memory.md @@ -331,12 +331,26 @@ reconstructed on demand (MRAgent discipline). `{type: entity, "commitments.open": true}`) returning refs, not bodies — this is how "who owes what" and staleness scans avoid a full-corpus load. +> **The `MEMORY.md` index is a reserved slug, not a manifest entry.** It lives outside the +> entries manifest (a standalone `index_s3_key` on the space row), so it never appears in +> `memory_list`/`memory_query`. To let the agent inspect the index it's already given at wake-up, +> `memory_read("MEMORY.md")` routes to `read_index` instead of the manifest (case-insensitive +> match). The slug is reserved — the agent cannot create an ordinary entry named `MEMORY.md`. + ### 5. Write path - **(W1) Synchronous agentic write** — `memory_write(space, slug, body)` / `memory_update(space, slug, patch)` tools the model calls mid-turn, stamping `updated_by` with the invoking user, plus an index-update step. Transparent, matches the coding-agent model. **v1 default.** + - **Index writes reuse the reserved slug.** `memory_write("MEMORY.md", body)` routes to + `update_index` (editor+) rather than creating an entry, so the agent can keep the + human-readable index in sync with the entries it writes — a one-line pointer / `[[slug]]` + wikilink per entry, exactly the coding-agent `MEMORY.md` discipline this feature is modeled + on. `entry_type`/`description` are ignored for it. Gated identically to entry writes: the + tool is only bound when the agent's memory binding grants `readwrite`, and the service + re-checks `editor+`. This closes the drift gap where the agent could add entries but not + update the index describing them. - **(W2) Async post-turn reflection** — a background job reads the completed turn and proposes edits, hung off [`turn_based_session_manager.update_after_turn`](../../backend/src/agents/main_agent/session/turn_based_session_manager.py) (the seam compaction uses). Reliable, no in-loop discipline needed, adds an LLM call/turn. diff --git a/frontend/ai.client/package-lock.json b/frontend/ai.client/package-lock.json index 27023665b..6a60694e6 100644 --- a/frontend/ai.client/package-lock.json +++ b/frontend/ai.client/package-lock.json @@ -1,12 +1,12 @@ { "name": "ai.client", - "version": "1.2.0", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ai.client", - "version": "1.2.0", + "version": "1.3.0", "dependencies": { "@angular/cdk": "21.2.14", "@angular/common": "21.2.17", diff --git a/frontend/ai.client/package.json b/frontend/ai.client/package.json index b0474417b..cebfc0506 100644 --- a/frontend/ai.client/package.json +++ b/frontend/ai.client/package.json @@ -1,6 +1,6 @@ { "name": "ai.client", - "version": "1.2.0", + "version": "1.3.0", "scripts": { "ng": "ng", "start": "ng serve", diff --git a/frontend/ai.client/src/app/admin/manage-models/model-catalog.page.spec.ts b/frontend/ai.client/src/app/admin/manage-models/model-catalog.page.spec.ts index 90318efc3..d450f83b7 100644 --- a/frontend/ai.client/src/app/admin/manage-models/model-catalog.page.spec.ts +++ b/frontend/ai.client/src/app/admin/manage-models/model-catalog.page.spec.ts @@ -195,20 +195,18 @@ describe('ModelCatalogPage', () => { expect(page.addingKey()).toBeNull(); }); - it('renders curated Mantle cards (with vetted endpoint paths) on the Mantle tab', () => { + it('renders curated Mantle cards (with vetted API surface) on the Mantle tab', () => { const page = createComponent(); page.selectTab('mantle'); const keys = page.visibleModels().map(m => m.key); expect(keys).toEqual(CURATED_MANTLE_MODELS.map(m => m.key)); - // Gemma 4 must carry the /openai/v1 path; the Qwen coder the default /v1. - const gemma = page.visibleModels().find(m => m.key === 'gemma-4-31b'); + // The Qwen coder speaks Chat Completions. const qwen = page.visibleModels().find(m => m.key === 'qwen3-coder-30b'); - expect(gemma?.template.mantleEndpointPath).toBe('/openai/v1'); - expect(qwen?.template.mantleEndpointPath).toBe('/v1'); + expect(qwen?.template.apiMode).toBe('chat'); // Mantle models never cache (model-bound to Claude/Nova). - expect(gemma?.template.supportsCaching).toBe(false); + expect(qwen?.template.supportsCaching).toBe(false); }); it('ignores a second addCuratedModel while a create is in flight', async () => { diff --git a/frontend/ai.client/src/app/admin/manage-models/model-form.page.html b/frontend/ai.client/src/app/admin/manage-models/model-form.page.html index f5dcc0654..d27fd2cc9 100644 --- a/frontend/ai.client/src/app/admin/manage-models/model-form.page.html +++ b/frontend/ai.client/src/app/admin/manage-models/model-form.page.html @@ -125,26 +125,42 @@

Basic inform - + @if (isMantle()) {
-
+
+ + +

+ Optional. Pins inference to the region hosting the model (e.g. us-east-1), + independent of where the app runs. Leave blank to use the app's region.

} diff --git a/frontend/ai.client/src/app/admin/manage-models/model-form.page.ts b/frontend/ai.client/src/app/admin/manage-models/model-form.page.ts index e7f79f478..b52deeb5e 100644 --- a/frontend/ai.client/src/app/admin/manage-models/model-form.page.ts +++ b/frontend/ai.client/src/app/admin/manage-models/model-form.page.ts @@ -16,9 +16,10 @@ import { AVAILABLE_PROVIDERS, KNOWN_PARAMS, KnownParamMeta, - MANTLE_ENDPOINT_PATHS, + MANTLE_API_MODES, + MANTLE_API_MODE_LABELS, ManagedModelFormData, - MantleEndpointPath, + MantleApiMode, ModelParamSpec, ModelProvider, SupportedParams, @@ -231,7 +232,8 @@ interface ModelFormGroup { cacheReadPricePerMillionTokens: FormControl; knowledgeCutoffDate: FormControl; supportsCaching: FormControl; - mantleEndpointPath: FormControl; + mantleApiMode: FormControl; + mantleRegion: FormControl; inferenceParams: FormArray>; customInferenceParams: FormArray>; } @@ -255,11 +257,12 @@ export class ModelFormPage implements OnInit { // Available options for multi-select fields readonly availableProviders = AVAILABLE_PROVIDERS; readonly availableModalities = ['TEXT', 'IMAGE', 'VIDEO', 'AUDIO', 'SPEECH', 'EMBEDDING']; - readonly mantleEndpointPaths = MANTLE_ENDPOINT_PATHS; + readonly mantleApiModes = MANTLE_API_MODES; + readonly mantleApiModeLabels = MANTLE_API_MODE_LABELS; /** * Tracks the selected provider as a signal so the template can show/hide - * the Mantle-only endpoint-path field and suppress the caching controls + * the Mantle-only API-mode/region fields and suppress the caching controls * (Mantle open-weight models never cache). Kept in sync with the form * control in ngOnInit + its valueChanges subscription. */ @@ -310,7 +313,8 @@ export class ModelFormPage implements OnInit { cacheReadPricePerMillionTokens: this.fb.control(null, { validators: [Validators.min(0)] }), knowledgeCutoffDate: this.fb.control(null), supportsCaching: this.fb.control(false, { nonNullable: true }), - mantleEndpointPath: this.fb.control('/v1', { nonNullable: true }), + mantleApiMode: this.fb.control('chat', { nonNullable: true }), + mantleRegion: this.fb.control('', { nonNullable: true }), inferenceParams: this.fb.array>([], { validators: [thinkingInvariantsValidator, maxTokensCeilingValidator], }), @@ -819,7 +823,8 @@ export class ModelFormPage implements OnInit { cacheReadPricePerMillionTokens: model.cacheReadPricePerMillionTokens ?? null, knowledgeCutoffDate: model.knowledgeCutoffDate, supportsCaching: model.supportsCaching ?? true, - mantleEndpointPath: this.coerceMantlePath(model.mantleEndpointPath), + mantleApiMode: this.coerceMantleApiMode(model.apiMode), + mantleRegion: model.region ?? '', }); // Repopulate the inference-params rows with any persisted spec. @@ -861,7 +866,8 @@ export class ModelFormPage implements OnInit { cacheReadPricePerMillionTokens: template.cacheReadPricePerMillionTokens ?? null, knowledgeCutoffDate: template.knowledgeCutoffDate ?? null, supportsCaching: template.supportsCaching ?? true, - mantleEndpointPath: this.coerceMantlePath(template.mantleEndpointPath), + mantleApiMode: this.coerceMantleApiMode(template.apiMode), + mantleRegion: template.region ?? '', }); this.rebuildInferenceParamRows(template.provider, template.supportedParams ?? null); @@ -962,8 +968,9 @@ export class ModelFormPage implements OnInit { knowledgeCutoffDate: v.knowledgeCutoffDate, supportsCaching: v.supportsCaching, // Only meaningful for Mantle; null elsewhere so the backend stores - // nothing for other providers. - mantleEndpointPath: v.provider === 'mantle' ? v.mantleEndpointPath : null, + // nothing for other providers. An empty region means "app's region". + apiMode: v.provider === 'mantle' ? v.mantleApiMode : null, + region: v.provider === 'mantle' ? (v.mantleRegion?.trim() || null) : null, supportedParams: this.collectSupportedParams(), }; @@ -989,14 +996,14 @@ export class ModelFormPage implements OnInit { } /** - * Normalize a stored/templated Mantle path onto the known options, falling - * back to the default `/v1` for null/legacy/unknown values so the select - * always has a valid selection. + * Normalize a stored/templated Mantle API mode onto the known options, + * falling back to the default `chat` for null/legacy/unknown values so the + * select always has a valid selection. */ - private coerceMantlePath(value: string | null | undefined): MantleEndpointPath { - return MANTLE_ENDPOINT_PATHS.includes(value as MantleEndpointPath) - ? (value as MantleEndpointPath) - : '/v1'; + private coerceMantleApiMode(value: string | null | undefined): MantleApiMode { + return MANTLE_API_MODES.includes(value as MantleApiMode) + ? (value as MantleApiMode) + : 'chat'; } /** diff --git a/frontend/ai.client/src/app/admin/manage-models/models/curated-models.ts b/frontend/ai.client/src/app/admin/manage-models/models/curated-models.ts index c0678adf1..8c6c94145 100644 --- a/frontend/ai.client/src/app/admin/manage-models/models/curated-models.ts +++ b/frontend/ai.client/src/app/admin/manage-models/models/curated-models.ts @@ -132,8 +132,9 @@ export const CURATED_BEDROCK_MODELS: CuratedModel[] = [ * Caching is intentionally absent: prompt caching on Bedrock is model-bound * to Anthropic Claude + a small set of Amazon Nova models, none of which run * through the Mantle provider, so these never cache and carry no cache - * pricing. `mantleEndpointPath` is the one Mantle-specific field — sourced - * from each model card (there is no API that exposes it). + * pricing. `apiMode` (Chat Completions vs Responses) and an optional `region` + * are the Mantle-specific fields — sourced from each model card (there is no + * API that exposes them). The base path is derived by the SDK from the model id. */ const mantleDefaults = (): Pick< ManagedModelFormData, @@ -173,7 +174,7 @@ export const CURATED_MANTLE_MODELS: CuratedModel[] = [ inputModalities: ['TEXT'], maxInputTokens: 256_000, maxOutputTokens: 8_192, - mantleEndpointPath: '/v1', + apiMode: 'chat', inputPricePerMillionTokens: 0.15, outputPricePerMillionTokens: 0.6, supportedParams: { @@ -185,34 +186,14 @@ export const CURATED_MANTLE_MODELS: CuratedModel[] = [ }, }, }, - { - key: 'gemma-4-31b', - tagline: - "Google Gemma 4 31B — reasoning, vision + tool use, served on Mantle's /openai/v1 path.", - capabilities: ['Reasoning', 'Tool use', 'Vision', '256K context'], - template: { - ...mantleDefaults(), - modelId: 'google.gemma-4-31b', - modelName: 'Gemma 4 31B', - providerName: 'Google', - // Per the AWS model card: text + image + video in, text out. (The - // request payload note explicitly covers images and video.) - inputModalities: ['TEXT', 'IMAGE', 'VIDEO'], - maxInputTokens: 256_000, - maxOutputTokens: 8_192, - // Gemma 4 is served on the /openai/v1 path, NOT the default /v1. - mantleEndpointPath: '/openai/v1', - inputPricePerMillionTokens: 0.14, - outputPricePerMillionTokens: 0.4, - supportedParams: { - params: { - temperature: { supported: true, min: 0, max: 2, default: 0.7 }, - top_p: { supported: true, min: 0, max: 1, default: null }, - max_tokens: { supported: true, min: 1, max: 8_192, default: 4_096 }, - }, - }, - }, - }, + // NOTE: Gemma 4 31B (`google.gemma-4-31b`) is temporarily NOT curated. It is + // served on Mantle's `/openai/v1` base path, but the Strands SDK's + // bedrock_mantle_config only routes `openai.gpt-5.*` to `/openai/v1` (all + // other model ids -> `/v1`), and the config forbids overriding base_url. So a + // one-click Gemma card would build a `/v1` client and fail at chat time. + // Re-add once the `google.gemma-` family prefix lands in the SDK's + // _OPENAI_PATH_MODEL_PREFIXES (upstream strands-agents/sdk-python). Admins + // who need Gemma sooner can add it via the manual form. ]; /** diff --git a/frontend/ai.client/src/app/admin/manage-models/models/managed-model.model.ts b/frontend/ai.client/src/app/admin/manage-models/models/managed-model.model.ts index 3fb9fdd8a..23361599e 100644 --- a/frontend/ai.client/src/app/admin/manage-models/models/managed-model.model.ts +++ b/frontend/ai.client/src/app/admin/manage-models/models/managed-model.model.ts @@ -99,11 +99,19 @@ export interface ManagedModel { /** Whether this is the default model for new sessions */ isDefault: boolean; /** - * Bedrock Mantle endpoint path (`provider === 'mantle'` only): `/v1` - * (OpenAI Chat Completions, the default) or `/openai/v1` (e.g. Gemma 4). - * Sourced from the model card — there is no API that exposes it. Null/absent - * for every other provider. + * Bedrock Mantle API surface (`provider === 'mantle'` only): `chat` (OpenAI + * Chat Completions, the default) or `responses` (OpenAI Responses API — + * required by models that don't serve Chat Completions, e.g. openai.gpt-5.x). + * Null/absent for every other provider. */ + apiMode?: MantleApiMode | null; + /** + * Bedrock Mantle region override (`provider === 'mantle'` only): pins + * inference to the region hosting the model (e.g. `us-east-1`), independent + * of where the app runs. Null/absent -> the app's region and for other providers. + */ + region?: string | null; + /** @deprecated No longer used — the SDK derives the base path from the model id. */ mantleEndpointPath?: string | null; /** Per-model inference parameter capabilities (temperature, top_p, etc.) */ supportedParams?: SupportedParams | null; @@ -158,17 +166,28 @@ export interface ManagedModelFormData { /** Whether this is the default model for new sessions */ isDefault: boolean; /** - * Bedrock Mantle endpoint path (`provider === 'mantle'` only): `/v1` or - * `/openai/v1`. Inert for other providers. + * Bedrock Mantle API surface (`provider === 'mantle'` only): `chat` or + * `responses`. Inert for other providers. */ - mantleEndpointPath?: string | null; + apiMode?: MantleApiMode | null; + /** + * Bedrock Mantle region override (`provider === 'mantle'` only). Empty -> + * the app's region. Inert for other providers. + */ + region?: string | null; /** Per-model inference parameter capabilities */ supportedParams?: SupportedParams | null; } -/** Selectable Bedrock Mantle endpoint paths for the model form. */ -export const MANTLE_ENDPOINT_PATHS = ['/v1', '/openai/v1'] as const; -export type MantleEndpointPath = (typeof MANTLE_ENDPOINT_PATHS)[number]; +/** Selectable Bedrock Mantle API surfaces for the model form. */ +export const MANTLE_API_MODES = ['chat', 'responses'] as const; +export type MantleApiMode = (typeof MANTLE_API_MODES)[number]; + +/** Human-readable labels for the Mantle API surface options. */ +export const MANTLE_API_MODE_LABELS: Record = { + chat: 'Chat Completions', + responses: 'Responses API', +}; /** * Frontend catalog of well-known canonical inference params. diff --git a/frontend/ai.client/src/app/components/sidenav/sidenav.spec.ts b/frontend/ai.client/src/app/components/sidenav/sidenav.spec.ts index 278effd86..77ae3209a 100644 --- a/frontend/ai.client/src/app/components/sidenav/sidenav.spec.ts +++ b/frontend/ai.client/src/app/components/sidenav/sidenav.spec.ts @@ -6,7 +6,6 @@ import { SessionService } from '../../session/services/session/session.service'; import { UserService } from '../../auth/user.service'; import { SessionService as BffSessionService } from '../../auth/session.service'; import { SidenavService } from '../../services/sidenav/sidenav.service'; -import { ScheduleService } from '../../schedules/services/schedule.service'; import { MemorySpaceService } from '../../memory-spaces/services/memory-space.service'; import { AgentService } from '../../agents/services/agent.service'; @@ -16,7 +15,6 @@ describe('Sidenav', () => { let mockBffSession: any; let mockSidenavService: any; let mockUserService: any; - let mockScheduleService: any; let mockMemorySpaceService: any; let mockAgentService: any; @@ -39,10 +37,6 @@ describe('Sidenav', () => { currentUser: signal(null), isAdmin: signal(false), }; - mockScheduleService = { - accessible$: signal(null), - loadSchedules: vi.fn().mockResolvedValue(undefined), - }; mockMemorySpaceService = { accessible$: signal(null), loadSpaces: vi.fn().mockResolvedValue(undefined), @@ -59,7 +53,6 @@ describe('Sidenav', () => { { provide: BffSessionService, useValue: mockBffSession }, { provide: SidenavService, useValue: mockSidenavService }, { provide: UserService, useValue: mockUserService }, - { provide: ScheduleService, useValue: mockScheduleService }, { provide: MemorySpaceService, useValue: mockMemorySpaceService }, { provide: AgentService, useValue: mockAgentService }, ], @@ -73,7 +66,7 @@ describe('Sidenav', () => { async function createComponent() { const { Sidenav } = await import('./sidenav'); const component = TestBed.runInInjectionContext(() => new Sidenav()); - TestBed.tick(); // flush the constructor effect that probes schedule accessibility + TestBed.tick(); // flush the constructor effect that probes feature accessibility return component; } @@ -105,38 +98,6 @@ describe('Sidenav', () => { expect(mockRouter.navigate).toHaveBeenCalledWith(['/auth/login']); }); - it('probes schedule accessibility once a user is authenticated', async () => { - mockUserService.currentUser.set({ user_id: 'u1', email: 'u1@example.com' }); - await createComponent(); - expect(mockScheduleService.loadSchedules).toHaveBeenCalled(); - }); - - it('does not probe schedule accessibility while unauthenticated', async () => { - mockUserService.currentUser.set(null); - await createComponent(); - expect(mockScheduleService.loadSchedules).not.toHaveBeenCalled(); - }); - - it('hides the Scheduled Runs nav entry until accessibility resolves true', async () => { - const component = await createComponent(); - - mockScheduleService.accessible$.set(null); - expect(component.showSchedules()).toBe(false); - - mockScheduleService.accessible$.set(false); - expect(component.showSchedules()).toBe(false); - - mockScheduleService.accessible$.set(true); - expect(component.showSchedules()).toBe(true); - }); - - it('navigates to /schedules and closes the sidenav', async () => { - const component = await createComponent(); - component.navigateToSchedules(); - expect(mockSidenavService.close).toHaveBeenCalled(); - expect(mockRouter.navigate).toHaveBeenCalledWith(['/schedules']); - }); - it('probes memory-space accessibility once a user is authenticated', async () => { mockUserService.currentUser.set({ user_id: 'u1', email: 'u1@example.com' }); await createComponent(); diff --git a/frontend/ai.client/src/app/components/sidenav/sidenav.ts b/frontend/ai.client/src/app/components/sidenav/sidenav.ts index 4ad9e33af..6d6ee1d11 100644 --- a/frontend/ai.client/src/app/components/sidenav/sidenav.ts +++ b/frontend/ai.client/src/app/components/sidenav/sidenav.ts @@ -7,7 +7,6 @@ import { SessionService as BffSessionService } from '../../auth/session.service' import { UserDropdownComponent } from '../topnav/components/user-dropdown.component'; import { SidenavService } from '../../services/sidenav/sidenav.service'; import { TooltipDirective } from '../tooltip/tooltip.directive'; -import { ScheduleService } from '../../schedules/services/schedule.service'; import { MemorySpaceService } from '../../memory-spaces/services/memory-space.service'; import { AgentService } from '../../agents/services/agent.service'; @@ -21,7 +20,6 @@ export class Sidenav { private router = inject(Router); private sessionService = inject(SessionService); private bffSession = inject(BffSessionService); - private scheduleService = inject(ScheduleService); private memorySpaceService = inject(MemorySpaceService); private agentService = inject(AgentService); protected sidenavService = inject(SidenavService); @@ -44,23 +42,12 @@ export class Sidenav { protected isAdmin = this.userService.isAdmin; /** - * Whether to show the "Scheduled runs" nav entry. There is no dedicated - * client-side capability signal for `scheduled-runs` yet, so this rides - * the schedules list call itself: a successful (even empty) list means - * the caller has the capability and the kill switch is on; a 403/404 - * flips `accessible$` to false and the nav entry stays hidden. `null` - * (not yet resolved) also hides it, so the item doesn't flash in before + * Whether to show the "Memory Spaces" nav entry. Rides the spaces list + * call: a successful (even empty) list means the `MEMORY_SPACES_ENABLED` + * kill switch is on; a 404 flips `accessible$` false and the entry stays + * hidden. `null` (unresolved) also hides it so it doesn't flash in before * disappearing. */ - readonly showSchedules = computed(() => this.scheduleService.accessible$() === true); - - /** - * Whether to show the "Memory Spaces" nav entry. Rides the spaces list call - * the same way `showSchedules` rides schedules: a successful (even empty) - * list means the `MEMORY_SPACES_ENABLED` kill switch is on; a 404 flips - * `accessible$` false and the entry stays hidden. `null` (unresolved) also - * hides it so it doesn't flash in before disappearing. - */ readonly showMemorySpaces = computed(() => this.memorySpaceService.accessible$() === true); /** @@ -77,7 +64,6 @@ export class Sidenav { // mirrors UserService's own permissions-fetch gating. effect(() => { if (this.userService.currentUser()) { - void this.scheduleService.loadSchedules(); void this.memorySpaceService.loadSpaces(); void this.agentService.loadAgents(); } @@ -94,11 +80,6 @@ export class Sidenav { this.router.navigate(['/assistants']); } - navigateToSchedules() { - this.sidenavService.close(); - this.router.navigate(['/schedules']); - } - toggleCollapse() { this.sidenavService.toggleCollapsed(); } diff --git a/frontend/ai.client/src/app/schedules/models/schedule.model.ts b/frontend/ai.client/src/app/schedules/models/schedule.model.ts index 9e646cce6..d427630d2 100644 --- a/frontend/ai.client/src/app/schedules/models/schedule.model.ts +++ b/frontend/ai.client/src/app/schedules/models/schedule.model.ts @@ -104,6 +104,12 @@ export interface ScheduledPromptsListResponse { export interface RunNowRequest { prompt: string; title?: string | null; + /** + * Target Agent for the run (`agentId == assistantId`; the backend field is + * `ragAssistantId`). When set, the Agent's bound tools govern the turn and + * `enabledTools` is left null. + */ + ragAssistantId?: string | null; enabledTools?: string[] | null; } diff --git a/frontend/ai.client/src/app/schedules/schedule-form/schedule-form.page.html b/frontend/ai.client/src/app/schedules/schedule-form/schedule-form.page.html index 8ca6b32c9..9c3fa9571 100644 --- a/frontend/ai.client/src/app/schedules/schedule-form/schedule-form.page.html +++ b/frontend/ai.client/src/app/schedules/schedule-form/schedule-form.page.html @@ -199,13 +199,13 @@

When it runs

Target (optional)

- Point this run at a specific assistant, and/or limit which tools it may use. Leave both - unset to use your normal defaults. + Point this run at a specific agent. An agent runs with the tools bound to it. Leave it on + the default agent to limit tools manually below.

@@ -227,47 +227,60 @@

Target (option (change)="onClearAssistantChange($any($event.target).checked)" class="size-3.5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-500 dark:bg-gray-700" /> - Detach assistant (use the default agent) + Detach agent (use the default agent) }

-
-
- Tools - @if (mode() === 'edit') { - - } + @if (agentSelected()) { + +
+

+ This run uses the tools bound to + {{ selectedAgentName() }}. + To change them, edit the agent. +

-

- The tool set is snapshotted when the schedule is created — later changes to your tool - access won't silently expand what a sleeping schedule can do. -

-
- @for (tool of tools(); track tool.toolId) { - - } @empty { -

No tools available.

- } + } @else { +
+
+ Tools + @if (mode() === 'edit') { + + } +
+

+ The tool set is snapshotted when the schedule is created — later changes to your tool + access won't silently expand what a sleeping schedule can do. +

+
+ @for (tool of tools(); track tool.toolId) { + + } @empty { +

No tools available.

+ } +
-
+ } diff --git a/frontend/ai.client/src/app/schedules/schedule-form/schedule-form.page.spec.ts b/frontend/ai.client/src/app/schedules/schedule-form/schedule-form.page.spec.ts index 3d729a3b3..9510f4c08 100644 --- a/frontend/ai.client/src/app/schedules/schedule-form/schedule-form.page.spec.ts +++ b/frontend/ai.client/src/app/schedules/schedule-form/schedule-form.page.spec.ts @@ -6,7 +6,7 @@ import { signal } from '@angular/core'; import { ScheduleFormPage } from './schedule-form.page'; import { ScheduleService } from '../services/schedule.service'; import { RunNowService } from '../services/run-now.service'; -import { AssistantService } from '../../assistants/services/assistant.service'; +import { AgentService } from '../../agents/services/agent.service'; import { ToolService } from '../../services/tool/tool.service'; import { ToastService } from '../../services/toast/toast.service'; import { ScheduledPrompt } from '../models/schedule.model'; @@ -48,9 +48,9 @@ describe('ScheduleFormPage', () => { updateSchedule: vi.fn().mockResolvedValue(stubSchedule()), }; - const mockAssistantService = { - assistants$: signal([]), - loadAssistants: vi.fn().mockResolvedValue(undefined), + const mockAgentService = { + agents$: signal([]), + loadAgents: vi.fn().mockResolvedValue(undefined), }; const mockToolService = { @@ -81,7 +81,7 @@ describe('ScheduleFormPage', () => { provideRouter([{ path: 'schedules', children: [] }]), { provide: ScheduleService, useValue: mockScheduleService }, { provide: RunNowService, useValue: mockRunNow }, - { provide: AssistantService, useValue: mockAssistantService }, + { provide: AgentService, useValue: mockAgentService }, { provide: ToolService, useValue: mockToolService }, { provide: ToastService, useValue: mockToast }, { @@ -147,6 +147,43 @@ describe('ScheduleFormPage', () => { ); }); + it('drops the manual tool snapshot when an agent is selected', async () => { + component.form.patchValue({ + label: 'Test', + promptText: 'Do the thing', + cadence: 'daily', + hourLocal: 8, + timezone: 'UTC', + }); + // Pick some tools first, then target an agent — the agent's bound tools + // govern the run, so the snapshot must not be sent. + component.toggleTool('class_search'); + component.form.controls.assistantId.setValue('ast-9'); + + expect(component.agentSelected()).toBe(true); + await component.onSubmit(); + + expect(mockScheduleService.createSchedule).toHaveBeenCalledWith( + expect.objectContaining({ assistantId: 'ast-9', enabledTools: null }), + ); + }); + + it('run now targets the selected agent and omits the tool snapshot', () => { + component.form.patchValue({ label: 'Test', promptText: 'Do the thing' }); + component.toggleTool('class_search'); + component.form.controls.assistantId.setValue('ast-9'); + + component.runNow(); + + expect(mockRunNow.run).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: 'Do the thing', + ragAssistantId: 'ast-9', + enabledTools: null, + }), + ); + }); + it('requires a weekday when cadence is weekly', async () => { component.form.patchValue({ label: 'Test', diff --git a/frontend/ai.client/src/app/schedules/schedule-form/schedule-form.page.ts b/frontend/ai.client/src/app/schedules/schedule-form/schedule-form.page.ts index 343e7ddbe..754764335 100644 --- a/frontend/ai.client/src/app/schedules/schedule-form/schedule-form.page.ts +++ b/frontend/ai.client/src/app/schedules/schedule-form/schedule-form.page.ts @@ -12,8 +12,8 @@ import { NgIcon, provideIcons } from '@ng-icons/core'; import { heroArrowLeft } from '@ng-icons/heroicons/outline'; import { ScheduleService } from '../services/schedule.service'; import { RunNowService } from '../services/run-now.service'; -import { AssistantService } from '../../assistants/services/assistant.service'; -import { Assistant } from '../../assistants/models/assistant.model'; +import { AgentService } from '../../agents/services/agent.service'; +import { Agent } from '../../agents/models/agent.model'; import { ToolService } from '../../services/tool/tool.service'; import { CreateScheduleRequest, @@ -71,13 +71,22 @@ function timezoneOptions(): string[] { * (daily / weekday / weekly at an hour + IANA timezone) — no cron field, * per docs/specs/scheduled-agent-runs.md §3. * - * Optional fields (assistant, tool selection) use explicit "clear" - * checkboxes rather than relying on sending null: the backend's - * `update_scheduled_prompt` reads a bare `null` as "leave unchanged", so a - * clear is signalled with the explicit `clearAssistant` / `clearTools` - * booleans instead. `clearAssistant` reverts to the default agent; - * `clearTools` re-snapshots the caller's current RBAC-allowed tools. See - * `buildUpdateRequest`. + * The "target" is an Agent (the Agent Designer primitive that supersedes the + * Assistant — same underlying record, `agentId == assistantId`, so the wire + * field stays `assistantId` for backend compatibility). When an Agent is + * selected, its **bound tools govern the run**: the inference path replaces the + * request's `enabled_tools` with the Agent's `tool` bindings at invocation + * (`agent_binding_resolver` / routes.py `effective_enabled_tools`). So the + * manual tool picker is only shown for the "Default agent" case — selecting an + * Agent hides it, and any prior snapshot is dropped so it can't shadow the + * Agent's own toolset. + * + * Optional fields (agent, tool selection) use explicit "clear" checkboxes + * rather than relying on sending null: the backend's `update_scheduled_prompt` + * reads a bare `null` as "leave unchanged", so a clear is signalled with the + * explicit `clearAssistant` / `clearTools` booleans instead. `clearAssistant` + * reverts to the default agent; `clearTools` re-snapshots the caller's current + * RBAC-allowed tools. See `buildUpdateRequest`. */ @Component({ selector: 'app-schedule-form-page', @@ -92,7 +101,7 @@ export class ScheduleFormPage implements OnInit { private readonly fb = inject(FormBuilder); private readonly scheduleService = inject(ScheduleService); private readonly runNowService = inject(RunNowService); - private readonly assistantService = inject(AssistantService); + private readonly agentService = inject(AgentService); private readonly toolService = inject(ToolService); private readonly toast = inject(ToastService); @@ -105,7 +114,7 @@ export class ScheduleFormPage implements OnInit { readonly minIntervalMinutes = MIN_INTERVAL_MINUTES; readonly intervalUnitOptions: IntervalUnit[] = ['minutes', 'hours']; - readonly assistants = this.assistantService.assistants$; + readonly agents = this.agentService.agents$; readonly tools = this.toolService.tools; readonly hourOptions = hourOptions(); @@ -113,15 +122,29 @@ export class ScheduleFormPage implements OnInit { readonly weekdayLabels = WEEKDAY_LABELS; /** - * Whether the assistant/tools sections should send a clear intent on - * submit. Only meaningful in edit mode — create simply omits the field - * when unchecked and nothing has been picked. + * Whether the agent/tools sections should send a clear intent on submit. + * Only meaningful in edit mode — create simply omits the field when + * unchecked and nothing has been picked. */ readonly clearAssistant = signal(false); readonly clearTools = signal(false); readonly selectedToolIds = signal>(new Set()); + /** + * Mirror of the `assistantId` control value (form values aren't signals) so + * the template can reactively hide the manual tool picker when an Agent is + * chosen. A selected Agent's bound tools replace the run's `enabled_tools` + * server-side, so a manual picker there would be silently discarded. + */ + readonly selectedAgentId = signal(null); + readonly agentSelected = computed(() => !!this.selectedAgentId()); + readonly selectedAgentName = computed(() => { + const id = this.selectedAgentId(); + if (!id) return ''; + return (this.agents() as Agent[]).find((a) => a.agentId === id)?.name ?? id; + }); + readonly form = this.fb.group({ label: ['', [Validators.required, Validators.maxLength(200)]], promptText: ['', [Validators.required, Validators.maxLength(20000)]], @@ -142,7 +165,7 @@ export class ScheduleFormPage implements OnInit { const id = this.route.snapshot.paramMap.get('scheduleId'); this.scheduleId.set(id); - void this.assistantService.loadAssistants(false, false); + void this.agentService.loadAgents(false); if (!this.toolService.initialized()) { void this.toolService.loadTools(); } @@ -151,6 +174,13 @@ export class ScheduleFormPage implements OnInit { void this.loadSchedule(id); } + // Keep the selected-agent signal in sync with the control so the tool + // picker hides/shows reactively. + this.selectedAgentId.set(this.form.controls.assistantId.value ?? null); + this.form.controls.assistantId.valueChanges.subscribe((value) => { + this.selectedAgentId.set(value ?? null); + }); + // Keep the cadence-driven signals in sync (form values aren't signals, so // the isWeekly/isInterval computeds read this instead of the control). this.cadenceValue.set(this.form.controls.cadence.value ?? 'daily'); @@ -205,12 +235,6 @@ export class ScheduleFormPage implements OnInit { } } - assistantName(assistantId: string | null): string { - if (!assistantId) return ''; - const match = (this.assistants() as Assistant[]).find((a) => a.assistantId === assistantId); - return match?.name ?? assistantId; - } - toggleTool(toolId: string): void { this.selectedToolIds.update((set) => { const next = new Set(set); @@ -272,7 +296,11 @@ export class ScheduleFormPage implements OnInit { this.saving.set(true); try { - const toolIds = Array.from(this.selectedToolIds()); + // A selected Agent owns its toolset — its `tool` bindings replace the + // run's `enabled_tools` server-side — so never send a manual snapshot + // alongside one; it would be dead data. The picker is hidden in that + // case, but guard here too so a stale selection can't leak through. + const toolIds = value.assistantId ? [] : Array.from(this.selectedToolIds()); if (this.mode() === 'create') { const request: CreateScheduleRequest = { label: value.label!, @@ -361,10 +389,15 @@ export class ScheduleFormPage implements OnInit { return; } - const toolIds = Array.from(this.selectedToolIds()); + const agentId = this.form.controls.assistantId.value ?? null; + // Mirror the schedule's targeting: run against the selected Agent (whose + // bound tools govern the turn), else fall back to the manual tool snapshot + // for the default agent. + const toolIds = agentId ? [] : Array.from(this.selectedToolIds()); const request: RunNowRequest = { prompt: promptText, title: this.form.controls.label.value?.trim() || null, + ragAssistantId: agentId, enabledTools: toolIds.length > 0 ? toolIds : null, }; this.runNowService.run(request); diff --git a/frontend/ai.client/src/app/settings/pages/api-keys/api-keys.page.ts b/frontend/ai.client/src/app/settings/pages/api-keys/api-keys.page.ts index 42d18f957..8e1096dfd 100644 --- a/frontend/ai.client/src/app/settings/pages/api-keys/api-keys.page.ts +++ b/frontend/ai.client/src/app/settings/pages/api-keys/api-keys.page.ts @@ -508,11 +508,19 @@ export class ApiKeysPage { ); readonly apiBaseUrl = computed(() => { - const appApiUrl = this.configService.appApiUrl(); - if (appApiUrl) { - return appApiUrl.replace(/\/+$/, ''); + // The generated snippets are for EXTERNAL callers, so they need an + // absolute, copy-pasteable base URL. In production `appApiUrl` is the + // relative `/api` prefix (same-origin BFF, fronted by CloudFront's + // `/api/*` behavior) — a relative value can't be pasted into curl, and + // dropping it entirely points callers at the SPA origin where POST is + // rejected with CloudFront 403. So resolve a relative/empty value against + // the current origin; leave an already-absolute URL (local dev's + // `http://localhost:8000`) untouched. + const appApiUrl = (this.configService.appApiUrl() ?? '').replace(/\/+$/, ''); + if (/^https?:\/\//i.test(appApiUrl)) { + return appApiUrl; } - return window.location.origin; + return window.location.origin + appApiUrl; }); readonly codeExample = computed(() => { diff --git a/infrastructure/lib/constructs/app-api/app-api-iam-grants.ts b/infrastructure/lib/constructs/app-api/app-api-iam-grants.ts index 20b23aaa5..005cc6b30 100644 --- a/infrastructure/lib/constructs/app-api/app-api-iam-grants.ts +++ b/infrastructure/lib/constructs/app-api/app-api-iam-grants.ts @@ -452,13 +452,25 @@ export function grantAppApiPermissions(props: AppApiIamGrantsProps): void { }), ); - // ── Bedrock (title generation) ── + // ── Bedrock model invocation ── + // Used by both title generation and the API-key `/chat/api-converse` + // handler (apis/app_api/chat/converse_routes.py), which calls Bedrock + // Converse directly on app-api (inference-api is unreachable behind the + // AgentCore Runtime data plane). Mirrors inference-api's grant: + // - InvokeModelWithResponseStream is required for the `stream=true` path. + // - The catalog's model IDs are `us.*` inference profiles, which fan out + // across regions, so foundation-model must be granted on ALL regions + // (`bedrock:*::`), and the inference-profile resource itself is the + // account-level ARN in this region. taskRole.addToPrincipalPolicy( new iam.PolicyStatement({ sid: 'BedrockInvokeModel', effect: iam.Effect.ALLOW, - actions: ['bedrock:InvokeModel'], - resources: [`arn:aws:bedrock:${config.awsRegion}::foundation-model/*`], + actions: ['bedrock:InvokeModel', 'bedrock:InvokeModelWithResponseStream'], + resources: [ + `arn:aws:bedrock:*::foundation-model/*`, + `arn:aws:bedrock:${config.awsRegion}:${config.awsAccount}:*`, + ], }), ); @@ -478,18 +490,22 @@ export function grantAppApiPermissions(props: AppApiIamGrantsProps): void { }), ); - // ── Bedrock Mantle model browsing ── - // GET /admin/mantle/models authenticates against the OpenAI-compatible - // Bedrock Mantle endpoint with a short-term bearer token presigned by this - // role (apis/shared/bedrock/bearer_token.py). Mantle has its OWN IAM - // service namespace — `bedrock-mantle:*`, NOT `bedrock:*`. Listing models - // needs the Get/List actions on the project resource plus the bearer-token - // transport on `*` (read-only subset of AmazonBedrockMantleInferenceAccess). + // ── Bedrock Mantle inference + model browsing ── + // Two app-api paths hit the OpenAI-compatible Bedrock Mantle endpoint, both + // authenticating with a short-term bearer token (bearer-token transport is + // authorized separately on `*` below). Mantle has its OWN IAM service + // namespace — `bedrock-mantle:*`, NOT `bedrock:*`: + // - GET /admin/mantle/models browse — needs Get/List on the project. + // - POST /chat/api-converse for provider="mantle" models — needs + // CreateInference (the handler in apis/app_api/chat/converse_routes.py + // calls a mantle model directly, mirroring the runtime role's grant in + // inference-api-iam-roles.ts). Without it, api-converse mantle requests + // AccessDeny. taskRole.addToPrincipalPolicy( new iam.PolicyStatement({ - sid: 'BedrockMantleList', + sid: 'BedrockMantleInference', effect: iam.Effect.ALLOW, - actions: ['bedrock-mantle:Get*', 'bedrock-mantle:List*'], + actions: ['bedrock-mantle:CreateInference', 'bedrock-mantle:Get*', 'bedrock-mantle:List*'], resources: [`arn:aws:bedrock-mantle:*:${cdk.Stack.of(scope).account}:project/*`], }), ); diff --git a/infrastructure/package-lock.json b/infrastructure/package-lock.json index 9cca548e2..a8446ecc7 100644 --- a/infrastructure/package-lock.json +++ b/infrastructure/package-lock.json @@ -1,12 +1,12 @@ { "name": "infrastructure", - "version": "1.2.0", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "infrastructure", - "version": "1.2.0", + "version": "1.3.0", "dependencies": { "aws-cdk-lib": "2.260.0", "constructs": "10.6.0" diff --git a/infrastructure/package.json b/infrastructure/package.json index 6a1c6a297..bf9ddf053 100644 --- a/infrastructure/package.json +++ b/infrastructure/package.json @@ -1,6 +1,6 @@ { "name": "infrastructure", - "version": "1.2.0", + "version": "1.3.0", "bin": { "infrastructure": "bin/infrastructure.js" },