From f8858632170e01fd556ca8026e2daf588eaf1430 Mon Sep 17 00:00:00 2001 From: Phil Merrell Date: Fri, 17 Jul 2026 14:28:13 -0600 Subject: [PATCH] Release/1.7.0 (#671) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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(kaizen): weekly research scan 2026-07-10 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(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 * feat(identity): MCP user identity forwarding via access-token enrichment Add an opt-in Cognito Pre-Token-Generation v2 Lambda that copies configured user-pool attributes into namespaced claims on the ACCESS token, so personalized MCP tools can identify the caller. The access token is the only token forwarded end-to-end to MCP servers, so enrichment needs no changes to the SPA -> app-api -> inference-api -> MCP forwarding path. Shipped disabled by default (opt-in): a fork that configures nothing gets zero resources and the token is forwarded as before. Enabling requires the Cognito Essentials feature plan (pinned on the pool) plus two GitHub Actions variables (CDK_MCP_TOKEN_ENRICHMENT_ENABLED + CDK_MCP_TOKEN_ENRICHMENT_CLAIMS), keeping the committed cdk.context.json inert. - config: McpIdentityConfig (enabled + accessTokenClaims); claim map settable via JSON env var or context; parseJsonRecordEnv helper. - handler: stdlib-only, fail-open Pre-Token-Gen v2 trigger (returns event unchanged on any error so login is never blocked). - construct: real-code Lambda (fromAsset) attached via addTrigger V2_0; pool featurePlan pinned to ESSENTIALS. - wired conditionally into PlatformStack; platform.yml job-level env. - docs: spec updated (open questions resolved) + implementation summary, incl. the mcp-servers follow-on handoff. Ref: docs/specs/MCP_USER_IDENTITY_FORWARDING_SPEC.md * fix(app-api): grant bedrock-agentcore:CreateTokenVault for OAuth provider create Admin "add OAuth provider" (POST /admin/oauth-providers/) returned a 502 Bad Gateway. dev-ai app-api logs showed the real cause: an AccessDeniedException on bedrock-agentcore:CreateTokenVault against token-vault/default. AgentCore's CreateOauth2CredentialProvider ensures the default token vault exists on the first provider create, which requires CreateTokenVault (+ GetTokenVault) on the caller. The app-api task role had the ...Oauth2CredentialProvider actions but not the TokenVault ones. The shared error handler maps an uncaught AWS ClientError to HTTP 502, so the missing permission surfaced as a 502 rather than a 403. Add CreateTokenVault + GetTokenVault to the AgentCoreWorkloadIdentityAccess statement. The resource scope (token-vault/*) already covered token-vault/default; only the actions were missing. Requires a platform.yml (CDK) redeploy to take effect. Co-Authored-By: Claude Opus 4.8 * fix(scripts): make sync-version.sh portable across GNU and BSD tools The version-sync script only ran inside the dev container / CI (GNU coreutils); on macOS (BSD sed/grep) it errored out and silently left the manifests un-synced, so a release cut locally had to hand-edit every manifest. Replace the three GNU-only constructs with POSIX equivalents: - `grep -oP ... \K` (Perl regex) -> `sed -n 's/.../\1/p'` / awk field split - `sed -i "expr"` (GNU in-place) -> sed_inplace helper (temp file + mv) - `sed "0,/re/s/..."` (GNU-only address) -> awk first-match replace Behavior is unchanged on GNU; the script now runs identically on macOS. Verified both --check and the write path (incl. shields.io `--` hyphen doubling and SemVer->PEP 440 lock conversion) round-trip on BSD tools. Co-Authored-By: Claude Opus 4.8 * feat(admin): make admin sidebar nav sticky on desktop Pin the admin layout aside below the sticky top bar so the section nav stays in view while the content area scrolls. Uses lg:self-start so the aside shrinks to its content (flex items stretch to full height by default, which defeats position:sticky), plus a max-height + overflow so a long nav scrolls internally. Mobile dropdown is untouched. Co-Authored-By: Claude Opus 4.8 * feat(frontend): redesign 404 page to match auth screens Rework the not-found page onto the same design system as the login and first-boot pages: the primary-derived lava-lamp parallax backdrop (six depth-tiered morphing blobs), the masked graph-paper grid overlay, and the frosted-glass card. The oversized 404 sits above the card where the auth pages place the logo, so all three screens read as one system. Preserves existing behavior (sidenav hide/show, Return Home, Go Back) and respects prefers-reduced-motion. Classes are nf-prefixed and component-scoped via view encapsulation. Co-Authored-By: Claude Opus 4.8 * fix(frontend): make shell scroll container real so sticky nav engages The admin aside's lg:sticky never engaged because its nearest scrolling ancestor was the app shell's `flex-1 overflow-y-auto` div, which had no bounded height — it grew to content and the window scrolled instead, so sticky bound to a box that never moved. Pin
to h-dvh so that div becomes a genuine scroll container; the admin aside and top bar now stick. Also apply the admin bar's frosted-glass treatment (bg-*/opacity + backdrop-blur-sm) to the session topnav so the two surfaces match. Co-Authored-By: Claude Opus 4.8 * docs(specs): quota cooldown windows + platform ceiling spec and committee one-pager Replaces the hard monthly quota cutoff with a three-layer model: anchored 5-hour cooldown windows (Claude-style, exact reset times), a hard admin-adjustable platform-wide monthly ceiling as the fiscal guarantee, and the per-user monthly limit demoted to a generous anti-runaway backstop with degrade-to-economy-model as the target behavior. Backstop horizon (monthly vs weekly) is a per-tier choice. Includes an admin pilot tuning playbook with an observe-only phase, a user-facing quota status endpoint, recommended opening numbers, and a 7-PR implementation breakdown. The one-pager is the committee-facing rationale. Co-Authored-By: Claude Fable 5 * fix(frontend): guarantee JIT compiler in vitest runs to stop PlatformLocation flake The unit-test builder keeps Angular packages external, so vitest evaluates raw fesm2022 chunks whose partial declarations (ɵɵngDeclareInjectable/ ɵɵngDeclareFactory) compile eagerly and require @angular/compiler. Its presence was incidental — loaded transitively via @angular/core/testing in the builder's init-testbed setup — so specs with no static Angular imports (app.spec.ts dynamic-imports './app') could evaluate an unlinked @angular/common chunk first and fail with "The injectable 'PlatformLocation' needs to be compiled using the JIT compiler, but '@angular/compiler' is not available" (angular/angular-cli#31993). - add src/test-setup.ts importing @angular/compiler, wired via the test target's setupFiles and included in tsconfig.spec.json - add src/test-setup.spec.ts guarding the invariant deterministically - bump the first shared-view.page spec to 15s: it pays the one-time dynamic page-chunk import, which can exceed 5s under full-suite load Co-Authored-By: Claude Fable 5 * fix(frontend): size chat scroll space to the response, adapt to shell scroll container Replace the fixed viewport-tall bottom spacer in the message list with a min-height on the last turn group (user message + its assistant responses). The response streams into the reserved space instead of pushing a static spacer further down: a short response leaves exactly the room needed to pin the user message at the top, and a response taller than the viewport leaves zero dead scroll below it. Turn groups are keyed by their first message id so a finished turn's DOM (including live MCP App iframes) never remounts when the next turn starts, and the end-of-conversation sections (loader, consent/approval prompts, compaction, orphan artifacts) render inside the reserved space so they stay visible next to the response. Also adapt the session page to the real shell scroll container introduced by #634 (frosted sticky nav): the window no longer scrolls, which had silently broken submit scroll-to-message and scroll save/restore. scrollToMessage now uses scrollIntoView with a scroll-mt-20 header offset, and save/restore reads the shell container's scrollTop via a stable #app-scroll-container hook. Co-Authored-By: Claude Fable 5 * feat(settings): make user settings sidebar nav sticky on desktop Mirror the admin layout change (#632): pin the settings aside below the sticky top bar so the section nav stays in view while the content area scrolls. Uses lg:self-start so the aside shrinks to its content (grid items stretch to full row height by default, which defeats position:sticky), plus a max-height + overflow so a long nav scrolls internally. Mobile dropdown is untouched. Co-Authored-By: Claude Opus 4.8 * feat(admin-tools): discover OAuth-gated MCP servers with the admin's vaulted token The admin tool "Discover" flow refused OAuth-gated MCP servers outright, so servers like the GitHub remote MCP server (api.githubcopilot.com/mcp/) could not be discovered — discovery either 400'd on auth_type=oauth2 or connected unauthenticated and got a 401 from the server (wrapped to a 400). Discovery now accepts the OAuth provider id and connects using the admin's own vaulted 3LO token for that provider, fetched via AgentCore Identity (get_token_for_user) and injected as a bearer — mirroring how the agent loop attaches the end-user's provider token at runtime, and reusing the exact path connector_status already uses. This validates the admin's own connection and lists the tools their token can see (providers such as GitHub scope-filter the tool list to the token's grants). It fetches the admin's token only; it cannot mint an arbitrary end-user's token. Backend: - Add requires_oauth_provider (alias requiresOauthProvider) to MCPDiscoverRequest. - Handler loads the provider, fetches the admin's vaulted token, injects it as oauth_token into create_external_mcp_client. requires_consent -> 409, unknown provider / conflict with forward_auth / oauth2-without-provider -> 400. Frontend: - Send requiresOauthProvider in the discover payload (the form control already existed) and the OAuth2CallbackUrl header (bare /oauth-complete, no query string) so the backend can resolve the admin's token. Tests: 5 backend tests for the OAuth-provider discovery path; 2 SPA specs for the discover payload. Co-Authored-By: Claude Opus 4.8 * feat(manage-models): add Sonnet 5 + GPT-5.4 curated cards, order by capability Add two curated model catalog cards: - Claude Sonnet 5 (bedrock, global.anthropic.claude-sonnet-5) — 1M context, effort-based reasoning, caching on. - GPT-5.4 (mantle, openai.gpt-5.4) — Responses API surface; the openai.gpt-5.* model id matches the SDK's /openai/v1 routing prefixes, so one-click create routes correctly (unlike the commented-out Gemma card). Order the Bedrock Claude cards most-capable-first (Opus 4.7, Sonnet 5, Sonnet 4.6, Haiku 4.5) and place GPT-5.4 ahead of Qwen in the Mantle list. Move the "Bedrock Mantle" provider tab next to "Bedrock" in the catalog selector. Co-Authored-By: Claude Opus 4.8 * fix(mantle): route google.gemma-4-* to /openai/v1 base path Gemma 4 is served ONLY on Mantle's /openai/v1 path (per its AWS model card), but the Strands SDK's _OPENAI_PATH_MODEL_PREFIXES ships only "openai.gpt-5.", so google.gemma-4-* fell through to /v1 and inference 401'd with access_denied ("... is not enabled for this account"). Append "google.gemma-4-" to the SDK's prefix table at build time (_ensure_gemma4_openai_v1_routing: lazy, idempotent, guarded) until it lands upstream. Scoped to the 4.x family — Gemma 3 stays on /v1. - Guard tests: prefix registers on build, all three Gemma 4 variants resolve to /openai/v1, Gemma 3 stays on /v1, registration idempotent. - Correct the stale curated-models.ts note (the "would fail at chat time" claim is obsolete; its "google.gemma-" re-add hint would have misrouted Gemma 3). - Add design note proposing mantleEndpointPath as a live admin setting as the durable alternative to chasing the SDK's hardcoded table. Co-Authored-By: Claude Opus 4.8 * feat(manage-models): make max output tokens optional Newer reasoning / Responses-API models (GPT-5.x, Claude with adaptive thinking) don't publish a discrete max-output-tokens value — output shares the context budget with reasoning tokens, so there's no fixed cap to enter. Our own GPT-5.4 curated card already carries a decorative value with no backing max_tokens spec. maxOutputTokens is only a ceiling for the admin-configured max_tokens inference param and is never sent to the provider, so leaving it unset is safe at inference time. This makes the admin form field optional to match. - ManagedModelCreate / ManagedModel: max_output_tokens -> Optional[int] - DynamoDB write: omit maxOutputTokens when absent (matches other optionals) - Form control: drop Validators.required, default null (number | null); the 0-default + min(1) combo would otherwise still block submit - SPA interfaces typed number | null; catalog card null-guarded (shows "— out") - Both ceiling validators already skipped an absent value — no change needed Co-Authored-By: Claude Opus 4.8 * fix(docker): float curl security patch to survive Debian mirror purges Debian removes the superseded point version of curl from the trixie mirror on each security update, so an exact +deb13uN pin breaks every build once the next CVE lands. Pin to +deb13u* to track the live patch while keeping the minor version fixed; the digest-pinned base image is what actually provides reproducibility. Co-Authored-By: Claude Opus 4.8 * feat(web-sources): allow removing a web source A web source could be added but never removed. There was no DELETE route, no client method, and no UI affordance — the only way to drop one was to delete every page document it produced and let the orphan cascade in cleanup_service pick up the crawl row as a side effect. Add the operation as a first-class one, inverting that existing cascade: DELETE /assistants/{id}/web-sources/crawls/{crawl_id} removes the crawl's sync policy, soft-deletes every page under its root URL (vectors and S3 teardown hand off to the same background cleanup the single-document path uses), then hard-deletes the crawl row. A crawl that is genuinely in flight is refused with a 409 rather than raced — the crawler would keep writing pages we just enumerated. A crawl stuck at 'running' because its process died is not in flight and stays deletable, so a zombie source can't become permanently undeletable. The route is edit-gated (owner or editor), matching the documents surface that renders the list. Co-Authored-By: Claude Opus 4.8 * fix(web-sources): let editors start and view crawls start_crawl, list_crawls and get_crawl gated on the owner-keyed get_assistant(), which returns None for a user holding only an editor share — so an editor got a 404 from the "Add web content" button the SPA already renders for them (canManageSync() shows it to anyone who isn't a viewer). Route them through the same _require_edit_permission helper the documents, sync-policies and delete-crawl surfaces use, so owner|editor is the gate and a viewer gets a 403 instead of a misleading 404. No owner_id threading is needed on these three: the document writes are keyed on the assistant (PK=AST#), not its owner, and imported_by_user_id/started_by_user_id intentionally record the *acting* user — substituting the owner there would credit an editor's import to the owner. owner_id stays confined to the delete path, whose soft_delete_document/_list_crawl_pages calls really are owner-keyed. Co-Authored-By: Claude Opus 4.8 * fix(rbac): make AppRole the single source of truth for model access The model admin page and the role admin page wrote to two different, unlinked fields. Enabling a model for a role on the model page wrote `allowedAppRoles` onto the model record — a field no access check ever read — so the grant silently did nothing: the role page still showed the model unchecked, and users never saw it in the chat picker. Only editing the role's `grantedModels` had any effect. Make the role record the single source of truth, matching the pattern tools and skills already use: - The model form's role picker now writes THROUGH to each selected role's `grantedModels` (new ModelRoleService.set_roles_for_model), mirroring set_roles_for_tool. Create/update/delete routes wire it up, migrating grants on a modelId rename and revoking them on delete. - `allowedAppRoles` is no longer persisted on the model; it is derived from the role records on read (hydrate_model_roles), so the model page and role page can no longer disagree. Adds `inheritedAppRoles` for wildcard/inherited grants, surfaced read-only in the form. - can_access_model and filter_accessible_models both delegate to one `_grants_access` predicate. They previously diverged (one gated on allowed_app_roles, one didn't), so a model could be listed by the catalog yet denied on use. - Removes the dead POST /sync-roles endpoint (never called; only existed to paper over the drift); replaces it with GET /managed-models/{id}/roles. - Drops Validators.required on the picker, since a model reachable only via a wildcard grant legitimately has zero direct grants. Adds regression coverage for the write-through, the derived read, and the two access checks agreeing. Full backend + frontend suites green (the 8 pre-existing get_metadata_storage failures are unrelated). Co-Authored-By: Claude Opus 4.8 * fix(tests): repoint storage patch target and harden integration gate Two pre-existing failures on develop, both unrelated to the code under test: - test_cache_savings.py patched apis.app_api.storage.get_metadata_storage, but that accessor moved to apis.shared.storage (the app_api.storage module is now an empty stub). Repoint all 5 patch targets. Production code in sessions/services/metadata.py already imports from the new location. - test_compaction_integration.py gated its real-AWS integration tests on AGENTCORE_MEMORY_ID. That variable leaks into the process mid-suite when other tests reload apis.app_api.main (load_dotenv(override=True) injects a local backend/src/.env), so the tests ran order-dependently against invalid credentials instead of skipping. Gate on an explicit RUN_AGENTCORE_INTEGRATION_TESTS=1 opt-in instead. Full suite: 4771 passed, 6 skipped. Co-Authored-By: Claude Opus 4.8 * fix(chat): keep SSE stream open across tab switches @microsoft/fetch-event-source defaults to openWhenHidden:false, which aborts the SSE connection on visibilitychange-to-hidden and reopens it — issuing a fresh POST /invocations for the SAME turn — when the tab becomes visible again. That reopen happens inside the library, reusing the request and bypassing the SPA's per-session double-submit and streamId supersession guards. Because a client abort does not propagate through the AgentCore Runtime data plane, the original backend agent keeps running while the reopened one runs the same turn concurrently. Both persist tool-use/tool-result events to the same AgentCore Memory session, corrupting history with duplicate / interleaved toolResult turns and bricking the conversation with a Bedrock "toolResult blocks exceed toolUse blocks" ValidationException. Set openWhenHidden:true on both fetchEventSource call sites so a single stream stays alive across tab switches (also correct for long agentic turns). The server-side restore-time repair is the safety net for already -corrupted histories. Co-Authored-By: Claude Opus 4.8 * fix(sessions): repair tool-use/tool-result pairing on restore Bedrock Converse rejects any history where a user turn's toolResult blocks do not exactly match the preceding assistant turn's toolUse blocks ("The number of toolResult blocks at messages.N exceeds the number of toolUse blocks of previous turn"). A single such violation anywhere in a session's persisted history makes every subsequent turn fail, permanently bricking the conversation. Such corruption can be written by concurrent/interrupted turns with parallel tool calls (e.g. a duplicate invocation spawned by a tab switch): duplicate toolResult turns, toolResults reordered away from their toolUse turn (assistant/assistant/user/user), or toolResults orphaned after a synthetic error turn. The SDK's own _fix_broken_tool_use only rebuilds the single message after each toolUse turn, so it does not repair these shapes. Add TurnBasedSessionManager._repair_tool_pairing, an unconditional restore-time normalizer (sibling to _strip_document_bytes) that rebuilds a Bedrock-valid history on the final agent.messages: every toolUse turn is immediately followed by exactly one matching result turn (missing ones synthesized as errors), duplicate/orphaned result turns are dropped, and consecutive same-role turns are merged. No-op (identity) on healthy history. Kill switch: AGENTCORE_MEMORY_HISTORY_REPAIR_ENABLED=false. Validated against a real bricked production history (24 violations -> 0, idempotent). Self-heals affected sessions on their next turn. Co-Authored-By: Claude Opus 4.8 * test(sessions): make compaction fixtures valid Converse histories Two pre-existing compaction tests fed the session manager a user toolResult turn with no preceding assistant toolUse (make_tool_result_message alone) — an invalid Converse history that Bedrock would also reject. The new restore-time _repair_tool_pairing correctly drops/merges those orphaned turns, changing the message counts the tests asserted. Give each fixture a matching toolUse turn before the toolResult so the repair no-ops and the tests exercise compaction/truncation and checkpoint slicing in isolation. Counts updated accordingly (4->5 kept; slice 2->3). Co-Authored-By: Claude Opus 4.8 * fix(sessions): guard synthetic error persistence against role-alternation breaks When a turn errors inside the agent stream, the handler persists a synthetic "⚠️ Something went wrong" assistant turn. If the last persisted message was already an assistant turn (a dangling assistant toolUse, or a prior synthetic error turn), this appends a second consecutive assistant message, breaking Bedrock's strict user/assistant alternation. The next turn then fails and persists yet another assistant error turn — an amplifier that turns one bad turn into a permanently bricked session. Add a centralized role-alternation guard in persist_synthetic_messages via a new last_persisted_role param: any synthetic turn that would land adjacent to a same-role turn is dropped (the error stays a live-only UI affordance, the same choice the max_tokens path already makes). Callers in stream_coordinator pass the history tail role via a new _last_persisted_role(agent) helper. Complements PR #653's restore-time _repair_tool_pairing, which masks this on the model-request path; this fixes the write side so storage and the message display stay clean too. Co-Authored-By: Claude Opus 4.8 * fix(chat): reject duplicate concurrent turns with a per-session single-flight lease A client-side abort (Stop, tab switch, dropped socket, retry) does not propagate through the AgentCore Runtime data plane, and the Runtime can route a duplicate POST /invocations to a different container. Two agent loops then run concurrently against one AgentCore Memory session and corrupt tool-pairing history, which Bedrock Converse rejects on every subsequent turn ("toolResult blocks exceed toolUse blocks"). This bricked prod session f761f59b. Follow-up to PR #653, which closed the frontend tab-switch vector. Add a distributed single-flight guard at the inference-api /invocations turn-start chokepoint: - session_lease.py: acquire/renew/release on a dedicated sessions-metadata item (PK=USER#{uid}, SK=LEASE#{sid}) via an atomic conditional write. leaseExpiresAt is the app-level check; ttl is a coarse auto-reap backstop. Owner-scoped renew and release. Fail-open on any non-conflict DynamoDB error. - routes.py: acquire at turn-start; reject a duplicate with 409. Resume / max-tokens continuation re-enter an already-ended loop, so they acquire with force=True (never blocked, still install a lease). Heartbeat renews the lease while the turn streams; release in the generator finally + both except handlers. Preview / no-DynamoDB paths skip the guard. - SPA: handle the 409 as a soft "Already responding" notice (AlreadyStreamingError) instead of a hard "Chat Request Failed" toast; unwrap the BFF's double-encoded detail; loading clears so the user can retry once the prior turn finishes. Design note + distributed-cancel follow-on: docs/specs/session-single-flight-guard.md Co-Authored-By: Claude Opus 4.8 * feat(chat): distributed turn cancellation — make Stop actually stop the server turn Follow-on to the single-flight lease (#655). A client abort doesn't propagate through the AgentCore Runtime data plane, so Stop was cosmetic server-side: the container ran to completion, held the lease, and burned model/tool spend. That left "Stop → resend" returning 409 until the prior turn finished naturally. Reuse the lease as the cross-container signalling channel: - Signal: the app-api user_stopped endpoint calls request_session_cancel, which stamps cancelRequestedFor= on the lease item (owner-scoped, so a stale Stop can't kill a later turn). Best-effort — never fails the Stop. - Observe: the inference-api lease heartbeat (tightened 30s→10s) renews with ReturnValues=ALL_NEW and, on cancelRequestedFor==owner, flips session_manager.cancelled. - Effect A (tools): the always-on StopHook cancels the next tool call. - Effect B (model stream): a cooperative check at the top of the StreamCoordinator loop raises _CooperativeStopSignal; a dedicated arm persists the partial via _persist_interruption (marked user_stopped), emits terminal SSE frames, and ends cleanly (no re-raise) so a still-connected client closes and the lease releases. This is what ends a pure-chat turn, which has no tool boundary for StopHook. - acquire clears any stale cancel marker (REMOVE) on takeover. Net: Stop ends the server turn; the 409-on-resend window shrinks from a full turn to ~one heartbeat (10s), and wasted spend after Stop is halted. Rides the existing interrupted-turn teardown/persist path (hardened by #653's _repair_tool_pairing), so stopping mid-stream never orphans or corrupts history. Residual (documented): in-flight tool calls finish before cancel is seen; already- generated Bedrock tokens are billed. Design note: docs/specs/session-single-flight-guard.md Co-Authored-By: Claude Opus 4.8 * fix(infra): grant app-api task role access to shared-conversations table The shared-conversations DynamoDB table was threaded into the app-api container as an env var (SHARED_CONVERSATIONS_TABLE_NAME) but never granted on the task role. Every conversation-share operation therefore failed against DynamoDB: - POST /conversations/{id}/share -> PutItem AccessDeniedException - GET /conversations/{id}/shares -> Query AccessDeniedException on the SessionShareIndex GSI Both surfaced to users as a generic 500 "Failed to create share". Add `SharedConversationsAccess` to the app-api coreTables grant list so the role gets the standard DynamoDB action set on the table and its GSIs (index/*), matching every other table the app-api touches. Also add a regression test that synthesizes PlatformStack and asserts the app-api role has a SharedConversationsAccess statement granting PutItem/Query/GetItem with a GSI resource and no wildcard. Verified the test fails without the grant. Co-Authored-By: Claude Opus 4.8 * feat(shares): offload large-conversation snapshots to S3 Sharing a large conversation failed: ShareService.create_share inlined the full message list into a single DynamoDB item, exceeding the 400 KB item limit and surfacing to users as a bare 500 (observed in prod-ai as a PutItem ValidationException). This is separate from the IAM-grant bug in PR #657. Offload the snapshot body (messages + metadata) to a new private shared-conversations S3 bucket, keeping only control fields plus a body_ref pointer in DynamoDB — mirroring the Memory Spaces / Artifacts / Skills S3-offload pattern. Reads fall back to inline for legacy shares, so existing shares keep working with no migration and the SPA contract is unchanged. - New ShareSnapshotStore (content-addressed S3 put/get/delete, SSE-S3, dedupe) - create_share writes body to S3 + body_ref item; revoke/session-cleanup best-effort delete the object - _load_snapshot_body reads from S3 or falls back to legacy inline items - ShareStorageUnavailableError -> friendly 503 instead of a bare 500 - CDK: shared-conversations bucket + SSM param, compute-ref, app-api env (SHARED_CONVERSATIONS_BUCKET_NAME), and SharedConversationsBucketReadWrite IAM grant (app-api only) - Tests: store round-trip/dedupe, >400 KB regression, S3 + legacy reads, export-from-S3, revoke cleanup, storage-unavailable Spec: docs/specs/share-large-conversations-s3-offload.md Co-Authored-By: Claude Opus 4.8 * fix(agents): resolve model provider for agent-bound invocations Agent (assistant) model bindings persist only `model_id` — never `provider` — so previewing/invoking an agent bound to a Mantle model (e.g. `openai.gpt-5.4`) resolved to provider=None. That misroutes the model to Bedrock ConverseStream, which rejects it with "The provided model identifier is invalid", even though the same model works from the normal chat path (which always sends `provider` alongside `model_id`). Two complementary fixes: - Backend (server-authoritative): `_resolve_model_settings` now also returns the model's registered `provider` from the managed-model registry, and the invocation path backfills `effective_provider` from it when the request/binding didn't carry one. This fixes all existing agents with a provider-less stored binding — no data backfill needed — and mirrors how `mantle_api_mode`/`mantle_region` are already recovered. The app-tool-call / app-context-update rebuild paths get the same fallback so a rebuilt agent keys on the same provider as its main turn. - Frontend: the Agent Designer save payload now persists the selected model's `provider` (from the catalog `meta.provider`) alongside `modelId`, so newly created/edited bindings are self-describing. Co-Authored-By: Claude Opus 4.8 * fix(inference): bind effective_enabled_tools on resume path Resume turns (interrupt_responses set — OAuth-gated MCP consent or tool-approval) crashed with `NameError: cannot access free variable 'effective_enabled_tools'`. The variable is referenced unconditionally by the `stream_with_quota_warning` streaming closure (attachment guidance + tabular inventory) but was only assigned in the non-resume branch. On resume the closure raised before its first yield, the inference-api container returned 500, and the AgentCore Runtime data plane translated that into a 424 Failed Dependency to app-api and the SPA. This broke every interrupt-resume turn since the agent-designer tool-binding refactor (0b9b039a) — most visibly "connect to Gmail for employees", which completes via an OAuth-consent resume. Bind effective_enabled_tools from the paused-turn snapshot on the resume branch (the same source the resume get_agent call uses). Adds a resume-path regression test to tests/routes/test_inference.py that drives /invocations with interrupt_responses and asserts a 200 stream; without the fix it fails with the NameError. Co-Authored-By: Claude Opus 4.8 * chore(kaizen): weekly research scan 2026-07-17 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) * docs: add session-metadata static sort key spec (issue #175) Root-cause spec for the SessionMetadata parse-failure warnings: the session row's sort key encodes lastMessageAt, forcing a delete+put row move every turn. Concurrent writers race that move and upsert bare ghost rows. Also drives the first-turn duplicate-row race. Fix: static SK (S#{session_id}) + sparse SessionRecencyIndex GSI for recency listing. Covers the expand -> migrate -> backfill -> contract migration, downstream/forked-deployment safety (marker gate + graceful GSI-missing fallback), pagination-token compatibility, and the test matrix. Co-Authored-By: Claude Opus 4.8 * feat(infra): add SessionRecencyIndex GSI to sessions-metadata (issue #175 Phase 0) Sparse recency index (GSI4_PK=USER#{id}, GSI4_SK={lastMessageAt}#{session_id}, projection ALL) for newest-first active-session listing once the base sort key becomes static. Phase 0 of the static-sort-key migration: adding the index is a no-op until rows populate GSI4 keys, so it deploys safely ahead of any code change. IAM already covers it via the SessionsMetadataAccess /index/* wildcard. Update tables-detailed test to assert all four GSIs (the "2 GSIs" title was already stale after DueScheduleIndex) and the new index's key schema. Co-Authored-By: Claude Opus 4.8 * feat(sessions): dual-scheme union read for session listing (issue #175 Phase 1a) Expand-read step of the static-sort-key migration. list_user_sessions now reads the UNION of two disjoint sources so a session is visible whether or not its base sort key has been migrated to the static S#{session_id} form: - legacy (un-migrated): base table, SK begins_with 'S#ACTIVE#' - migrated: SessionRecencyIndex GSI (GSI4_PK=USER#{id}, GSI4_SK={lastMessageAt}#{id}) Pagination switches to a value cursor ({lastMessageAt}#{session_id}) so each page is derived independently from the last returned position, with no cross-page buffering; fetching limit+1 valid rows per source is provably enough to detect a next page. The cursor decoder is tolerant — legacy/undecodable tokens fall back to first page (a harmless reset across the deploy boundary). Degrades to legacy-only if SessionRecencyIndex doesn't exist yet (code ahead of the CDK GSI): the GSI query's ResourceNotFoundException is caught. No writes change and no row migrates in this phase — this only teaches every reader to cope with both schemes, which must be fully rolled out before Phase 1b turns on self-migrating writes. Tests: union ordering, cross-union pagination (no dupes/gaps), migrated-only via GSI, ghost/preview skip, and graceful fallback when the index is absent. conftest sessions_metadata_table fixture gains the SessionRecencyIndex GSI to match prod. Co-Authored-By: Claude Opus 4.8 * fix(deps): bump strands-agents to 1.48.0 for cachePoint-attachment fix Auto prompt caching (CacheConfig strategy=auto) appended its cachePoint after the last user message's content, so any turn attaching a non-PDF document (txt/docx/csv/...) sent [text, document, cachePoint] and Bedrock's Anthropic adapter rejected it with "ValidationException ... messages.N.content.M.type: Field required", surfacing to users as "Agent force-stopped" (prod incidents Jul 14-16, e.g. session dd1a647a on a .txt transcript upload). strands 1.48.0 places the cache point before the first non-PDF document block instead (upstream issue #1966); every placement it produces was verified live against global.anthropic.claude-sonnet-4-6 ConverseStream. Also corrects the model_config comment that credited PR #1438/1.39.0 with this fix - #1438 was the auto-caching feature itself. Co-Authored-By: Claude Fable 5 * fix(sessions): degrade to legacy-only on real ValidationException for missing GSI (issue #175) The Phase 1a dual-scheme read (PR #667) catches a missing SessionRecencyIndex to fall back to legacy-only listing, but only handled ResourceNotFoundException — what moto raises. Real DynamoDB raises ValidationException ("The table does not have the specified index") for a missing GSI (verified against the prod table). So if the 1a backend deployed to an environment before the CDK GSI existed, list_user_sessions would 503 instead of degrading. Broaden the catch to also handle ValidationException (scoped by the "specified index" message so genuinely malformed queries still surface). This restores the intended order-independence: 1a is safe whether or not SessionRecencyIndex exists yet, which matters for prod deploy ordering (backend.yml vs platform.yml) and for forked deployments. Add a test that reproduces the real ValidationException on the index query (moto masks it), asserting fallback to legacy-only results. Co-Authored-By: Claude Opus 4.8 * Add Word document tools (create/modify/list/read) Provision a full Word (.docx) toolset behind the single create_word_document capability toggle. Each tool runs python-docx in Bedrock Code Interpreter and uses the existing user-files store (S3 + DynamoDB) for persistence and delivery. - create/modify/list/read tools in agents/builtin_tools/word_document_tool.py, injected per-request via _build_word_document_tools (inference_api/chat/routes.py). - Frontend inline-visual 'word_document' renderer with an accessible download button (Tailwind utilities, no scoped CSS). - Restore-time content-block sanitizer in TurnBasedSessionManager: drops empty/typeless blocks from restored history that caused Bedrock ConverseStream 'messages.N.content.M.type: Field required'. - Seed create_word_document in bootstrap DEFAULT_TOOLS ('Word Documents') + updated seed tests. * Release/1.7.0 Feature release: Word (.docx) document toolset for the agent plus the read-side phases of the session-metadata static-sort-key migration (issue #175). - Add create/modify/list/read Word document tools (python-docx in Code Interpreter, user-files store) behind the create_word_document toggle, with an inline word_document renderer + download button (#670) - Session listing dual-scheme union read across legacy + SessionRecencyIndex, value-cursor pagination (issue #175 Phase 1a) (#667) - New sparse SessionRecencyIndex GSI on sessions-metadata (issue #175 Phase 0) (#666) - Degrade session listing to legacy-only on ValidationException for a missing GSI (#669) - Bump strands-agents to 1.48.0 to fix cachePoint-after-attachment 'Agent force-stopped' on non-PDF uploads (#668) - Restore-time content-block sanitizer in TurnBasedSessionManager (#670) Bump VERSION 1.6.1 -> 1.7.0 and sync manifests + lockfiles. --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: Colin Smith <7762103+colinmxs@users.noreply.github.com> Co-authored-by: derrickfink Co-authored-by: Derrick Fink Co-authored-by: Roman Meredith Co-authored-by: Roman meredith <48036775+ramenNoodles1998@users.noreply.github.com> --- CHANGELOG.md | 26 + README.md | 4 +- RELEASE_NOTES.md | 56 ++ VERSION | 2 +- backend/pyproject.toml | 6 +- backend/scripts/seed_bootstrap_data.py | 15 + .../builtin_tools/word_document_tool.py | 734 ++++++++++++++++++ .../agents/main_agent/core/model_config.py | 16 +- .../session/turn_based_session_manager.py | 51 ++ backend/src/apis/inference_api/chat/routes.py | 45 ++ backend/src/apis/shared/sessions/metadata.py | 270 ++++--- backend/tests/shared/conftest.py | 3 + .../tests/shared/test_sessions_metadata.py | 152 ++++ backend/tests/test_seed_system_admin_jwt.py | 20 +- backend/uv.lock | 12 +- docs/kaizen/research/2026-07-10.md | 213 +++++ docs/kaizen/research/2026-07-17.md | 211 +++++ docs/kaizen/review-queue.md | 75 ++ .../specs/session-metadata-static-sort-key.md | 304 ++++++++ frontend/ai.client/package-lock.json | 4 +- frontend/ai.client/package.json | 2 +- .../inline-visual/inline-visual.component.ts | 6 +- .../word-document-renderer.component.ts | 96 +++ .../data/cost-tracking-tables-construct.ts | 18 + infrastructure/package-lock.json | 4 +- infrastructure/package.json | 2 +- infrastructure/test/tables-detailed.test.ts | 19 +- 27 files changed, 2240 insertions(+), 126 deletions(-) create mode 100644 backend/src/agents/builtin_tools/word_document_tool.py create mode 100644 docs/kaizen/research/2026-07-10.md create mode 100644 docs/kaizen/research/2026-07-17.md create mode 100644 docs/specs/session-metadata-static-sort-key.md create mode 100644 frontend/ai.client/src/app/session/components/message-list/components/inline-visual/renderers/word-document-renderer.component.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 86ee89729..b9fe82f2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,32 @@ 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.7.0] - 2026-07-17 + +Feature release adding a full **Word (.docx) document toolset** for the agent and advancing the **session-metadata static-sort-key migration** (issue #175) through its read-side phases. The agent can now create, modify, list, and read Word documents — rendered inline in chat with a download button — behind the `create_word_document` capability toggle. On the storage side, a new sparse `SessionRecencyIndex` GSI plus a dual-scheme union reader let session listing work whether or not a session's base sort key has been migrated, deploying safely in any order. Also bumps `strands-agents` to 1.48.0 to fix an "Agent force-stopped" crash on non-PDF document uploads. Requires a CDK deploy for the new GSI; ships the rest via `backend.yml` + the frontend pipeline. + +### 🚀 Added + +- Word document tools — `create_word_document`, `modify_word_document`, `list_word_documents`, and `read_word_document`, each running `python-docx` inside a Bedrock Code Interpreter session and persisting to the existing user-files store (S3 + DynamoDB). Injected per-request via `_build_word_document_tools`, gated by the single `create_word_document` capability toggle, and seeded into bootstrap `DEFAULT_TOOLS` as "Word Documents". A new frontend `word_document` inline-visual renderer shows the generated file with an accessible download button (#670) + +### ✨ Improved + +- Dual-scheme union read for session listing (issue #175 Phase 1a) — `list_user_sessions` now reads the union of legacy (base-table `S#ACTIVE#` sort key) and migrated (`SessionRecencyIndex` GSI) sessions, so a session is visible regardless of migration state. Pagination uses a self-derived value cursor (`{lastMessageAt}#{session_id}`) with no cross-page buffering, and undecodable/legacy cursors fall back to the first page across the deploy boundary. No writes change and no row migrates in this phase (#667) + +### 🐛 Fixed + +- Session listing degrades to legacy-only when `SessionRecencyIndex` is absent — the Phase 1a reader caught only `ResourceNotFoundException` (what moto raises), but real DynamoDB raises `ValidationException` ("The table does not have the specified index") for a missing GSI, so a 1a backend deployed ahead of the CDK GSI would 503 instead of degrading. The catch now also handles the scoped `ValidationException`, restoring order-independent deploys (#669) +- "Agent force-stopped" on non-PDF document uploads — auto prompt caching appended its `cachePoint` after the last user message's content, so any turn attaching a `.txt`/`.docx`/`.csv`/… document sent `[text, document, cachePoint]` and Bedrock's Anthropic adapter rejected it with `messages.N.content.M.type: Field required`. Bumping `strands-agents` to 1.48.0 places the cache point before the first non-PDF document block instead (upstream issue #1966); every placement verified live against ConverseStream (#668) +- Restore-time content-block sanitizer — `TurnBasedSessionManager` now drops empty/typeless content blocks from restored history that could trigger Bedrock ConverseStream `messages.N.content.M.type: Field required` on resume (#670) + +### 🏗️ Infrastructure + +- New sparse `SessionRecencyIndex` GSI on the sessions-metadata table (`GSI4_PK=USER#{id}`, `GSI4_SK={lastMessageAt}#{session_id}`, projection ALL) for newest-first active-session listing once the base sort key becomes static (issue #175 Phase 0). Adding the index is a no-op until rows populate its keys, so it deploys safely ahead of any code change; IAM is already covered by the `SessionsMetadataAccess` `index/*` wildcard (#666) + +### 📦 Dependencies + +- `strands-agents` 1.47.0 → 1.48.0 (cachePoint-before-document fix, upstream #1966) (#668) + ## [1.6.1] - 2026-07-16 Patch release fixing two agent-invocation regressions. Agents bound to a Mantle-provider model (e.g. `openai.gpt-5.4`) no longer misroute to Bedrock and fail with "invalid model identifier" — the invocation path now backfills the model's registered `provider` server-side. And interrupt-resume turns (OAuth-consent or tool-approval flows, most visibly "connect to Gmail") no longer 500/424: `effective_enabled_tools` is now bound on the resume branch. No infra or migration; ship through `backend.yml`. diff --git a/README.md b/README.md index 06114924b..201df9400 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.6.1-6366f1?style=flat&logo=github&logoColor=white)](RELEASE_NOTES.md) +[![Release](https://img.shields.io/badge/Release-v1.7.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.6.1 +**Current release:** v1.7.0 --- diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 4e05577ea..92540c286 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,59 @@ +# Release Notes — v1.7.0 + +**Release Date:** July 17, 2026 +**Previous Release:** v1.6.1 (July 16, 2026) + +--- + +> 🏗️ **Platform (CDK) deploy required.** This release adds a new `SessionRecencyIndex` GSI on the sessions-metadata table, so it ships through `platform.yml` (CDK) **before** `backend.yml`. Adding the index is a no-op until rows populate its keys and the backend degrades gracefully if it's missing, so deploy order is not load-bearing — but the GSI must exist before the static-sort-key migration proceeds past this release. No data migration and no breaking changes. + +--- + +## Highlights + +v1.7.0 gives the agent a full **Word (.docx) document toolset** and advances the **session-metadata static-sort-key migration** (issue #175) through its read-side phases. The agent can now **create, modify, list, and read Word documents** — each backed by `python-docx` running in a Bedrock Code Interpreter sandbox and persisted to the same user-files store as every other generated file — and the result renders inline in chat with a download button. The whole toolset sits behind the single `create_word_document` capability toggle. On the storage side, a new sparse `SessionRecencyIndex` GSI and a **dual-scheme union reader** let session listing work whether or not a session's base sort key has been migrated yet, so the migration can roll out safely in any deploy order. This release also bumps `strands-agents` to 1.48.0 to fix an "Agent force-stopped" crash that hit any turn attaching a non-PDF document. Operators run a CDK deploy for the new GSI; everything else ships through the backend and frontend pipelines. + +## Word document toolset + +The agent can now produce and edit real Word documents. Four tools — `create_word_document`, `modify_word_document`, `list_word_documents`, `read_word_document` — run `python-docx` inside a Bedrock Code Interpreter session and write to the existing user-files store (S3 + DynamoDB), so generated `.docx` files are persisted and delivered exactly like every other user file. The finished document renders inline in the chat transcript with an accessible download button, no separate export step. The entire toolset is provisioned per-request behind one capability toggle (`create_word_document`), so admins enable Word support with a single grant. + +### Backend + +- `agents/builtin_tools/word_document_tool.py` — the create/modify/list/read toolset (~730 lines), each tool executing `python-docx` in a Code Interpreter session and round-tripping through the user-files store. +- `apis/inference_api/chat/routes.py` — `_build_word_document_tools` injects the toolset per request when the `create_word_document` capability is enabled. +- `scripts/seed_bootstrap_data.py` — seeds `create_word_document` into `DEFAULT_TOOLS` as "Word Documents" (with updated seed tests). + +### Frontend + +- `renderers/word-document-renderer.component.ts` — a new `word_document` inline-visual renderer showing the generated file with an accessible download button, styled with Tailwind utilities (no scoped CSS); wired into `inline-visual.component.ts`. + +### Related fix + +- `TurnBasedSessionManager` gains a restore-time content-block sanitizer that drops empty/typeless blocks from restored history, which had caused Bedrock ConverseStream `messages.N.content.M.type: Field required` errors on resume. + +## Session-metadata static-sort-key migration (issue #175, read-side) + +Active-session listing is being migrated to a **static** base sort key (`S#{session_id}`) with recency served by a dedicated index, replacing a scheme that encoded `lastMessageAt` into the sort key and rotated rows on every message (the source of ghost rows and duplicate-row races). This release lands the read side so every reader tolerates both schemes before any write starts self-migrating rows. + +### Infrastructure — Phase 0 + +- `data/cost-tracking-tables-construct.ts` — new sparse `SessionRecencyIndex` GSI (`GSI4_PK=USER#{id}`, `GSI4_SK={lastMessageAt}#{session_id}`, projection ALL) for newest-first active-session listing once the base sort key becomes static. Adding the index is a no-op until rows populate its keys, so it deploys safely ahead of any code change; IAM is already covered by the `SessionsMetadataAccess` `index/*` wildcard. The `tables-detailed` test now asserts all four GSIs. + +### Backend — Phase 1a + +- `apis/shared/sessions/metadata.py` — `list_user_sessions` now reads the **union** of two disjoint sources: legacy un-migrated rows (base table, `SK begins_with 'S#ACTIVE#'`) and migrated rows (via `SessionRecencyIndex`), so a session is visible whether or not its base sort key has been migrated. Pagination switches to a self-derived value cursor (`{lastMessageAt}#{session_id}`) — each page is computed independently from the last returned position with no cross-page buffering, and fetching `limit+1` valid rows per source provably detects a next page. Undecodable or legacy cursors fall back to the first page (a harmless reset across the deploy boundary). No writes change and no row migrates in this phase. +- The reader **degrades to legacy-only** if `SessionRecencyIndex` doesn't exist yet, so the backend is safe whether or not the CDK GSI has been deployed. This initially caught only `ResourceNotFoundException` (what moto raises); real DynamoDB raises `ValidationException` ("The table does not have the specified index") for a missing GSI, so the catch was broadened (scoped by the "specified index" message) to also degrade on the real error — a 1a backend deployed ahead of the GSI now falls back to legacy listing instead of returning a 503. Verified against the prod table. + +## Fixed — "Agent force-stopped" on non-PDF document uploads + +Auto prompt caching (`CacheConfig` strategy `auto`) appended its `cachePoint` after the last user message's content, so any turn attaching a non-PDF document (`.txt`, `.docx`, `.csv`, …) sent `[text, document, cachePoint]` — and Bedrock's Anthropic adapter rejected that ordering with `ValidationException … messages.N.content.M.type: Field required`, which surfaced to users as "Agent force-stopped" (prod incidents July 14–16, e.g. a `.txt` transcript upload). Bumping `strands-agents` to **1.48.0** places the cache point *before* the first non-PDF document block instead (upstream issue #1966); every placement it produces was verified live against ConverseStream. The bump also corrects a stale `model_config.py` comment that had credited the wrong upstream PR with this behavior. + +## 🚀 Deployment notes + +Run `platform.yml` (CDK) to create the `SessionRecencyIndex` GSI, then `backend.yml` (app-api + inference-api) and the frontend deploy. Because the Phase 1a reader degrades gracefully when the GSI is absent, a backend deploy that lands before the CDK deploy will still list sessions (legacy-only) rather than error — but run the CDK deploy so recency listing is ready for the next migration phase. No data migration and no breaking changes; the Word toolset is dark until an admin enables the `create_word_document` capability. + +--- + # Release Notes — v1.6.1 **Release Date:** July 16, 2026 diff --git a/VERSION b/VERSION index 9c6d6293b..bd8bf882d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.6.1 +1.7.0 diff --git a/backend/pyproject.toml b/backend/pyproject.toml index b83d9526c..f8911caf4 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "agentcore-stack" -version = "1.6.1" +version = "1.7.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.47.0", + "strands-agents==1.48.0", "strands-agents-tools==0.5.2", "aws-opentelemetry-distro==0.17.0", @@ -69,7 +69,7 @@ agentcore = [ # Voice/BidiAgent dependencies (Nova Sonic speech-to-speech) bidi = [ - "strands-agents[bidi]==1.47.0", + "strands-agents[bidi]==1.48.0", ] # Document ingestion pipeline dependencies (for Lambda deployment) diff --git a/backend/scripts/seed_bootstrap_data.py b/backend/scripts/seed_bootstrap_data.py index b628d07da..128bce6d8 100644 --- a/backend/scripts/seed_bootstrap_data.py +++ b/backend/scripts/seed_bootstrap_data.py @@ -431,6 +431,21 @@ def seed_default_models( "isPublic": True, "forwardAuthToken": False, }, + { + # Single catalog entry / toggle that provisions the whole Word + # document toolset. Enabling this one id injects create/modify/list/ + # read at runtime — see WORD_DOCUMENT_TOOL_IDS and + # _build_word_document_tools in apis/inference_api/chat/routes.py. + # Keep the toolId as "create_word_document": it is the gate key. + "toolId": "create_word_document", + "displayName": "Word Documents", + "description": "Create, edit, read, and list Word (.docx) documents using python-docx in a sandboxed environment. Generated files are saved to the chat's Files with a download link.", + "category": "document", + "protocol": "local", + "enabledByDefault": False, + "isPublic": True, + "forwardAuthToken": False, + }, ] diff --git a/backend/src/agents/builtin_tools/word_document_tool.py b/backend/src/agents/builtin_tools/word_document_tool.py new file mode 100644 index 000000000..2d383beae --- /dev/null +++ b/backend/src/agents/builtin_tools/word_document_tool.py @@ -0,0 +1,734 @@ +"""Word document tools (create / modify / list / read). + +Each tool runs python-docx code inside AWS Bedrock Code Interpreter and uses +the existing user-files store (``apis.shared.files``) for persistence and +delivery — generated/modified ``.docx`` files land in +``S3_USER_FILES_BUCKET_NAME`` with a ``FileMetadata`` row (status READY) in +``DYNAMODB_USER_FILES_TABLE_NAME``, so they appear in the chat's Files panel +and are downloadable via the app-api ``/files/{id}/preview-url`` route. + +Tools +----- +* ``create_word_document`` — build a new document from python-docx code. +* ``modify_word_document`` — edit an existing document with python-docx code. +* ``list_word_documents`` — list the .docx files available in this chat. +* ``read_word_document`` — extract an existing document's text content. + +(A page-screenshot/preview tool is intentionally omitted: rasterizing a +.docx requires LibreOffice/poppler, which the Python-only Code Interpreter +sandbox does not provide.) + +Design notes +------------ +* Code Interpreter usage mirrors ``code_interpreter_diagram_tool.py`` — the + interpreter id is resolved from ``AGENTCORE_CODE_INTERPRETER_ID`` (or SSM), + a session is started with ``CodeInterpreter(region).start(identifier=...)``, + and always stopped in a ``finally`` block. +* Identity (``user_id`` / ``session_id``) is captured by closure via the + ``make_*`` factories — the same pattern used by the artifacts and + spreadsheet_analysis tools (the Strands runtime here does NOT populate + ``ToolContext.invocation_state`` with identity). The tools are injected + per-request through ``extra_tools`` (see ``_build_word_document_tools`` in + ``apis/inference_api/chat/routes.py``); they are deliberately NOT registered + in ``builtin_tools/__init__`` because they need request-scoped identity. +""" + +from __future__ import annotations + +import asyncio +import base64 +import json +import logging +import os +import re +import uuid +from datetime import datetime, timezone +from typing import Any, Dict, Optional, Tuple + +import boto3 +from botocore.config import Config + +from strands import tool + +logger = logging.getLogger(__name__) + +# Word document MIME type (matches apis.shared.files.ALLOWED_MIME_TYPES). +_DOCX_MIME = ( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" +) + +# Presigned download links are short-lived; long enough for the user to click. +_DOWNLOAD_URL_TTL = 60 * 60 # 1 hour + +# Sandbox path used to stage a source document loaded from S3. +_SANDBOX_SOURCE = "_source.docx" + + +class _DocGenError(Exception): + """Raised when Code Interpreter fails to run the document code.""" + + +def _region() -> str: + return ( + os.environ.get("AWS_REGION") + or os.environ.get("AWS_DEFAULT_REGION") + or "us-west-2" + ) + + +def _get_code_interpreter_id() -> Optional[str]: + """Resolve the Custom Code Interpreter id (env first, then SSM).""" + ci_id = os.getenv("AGENTCORE_CODE_INTERPRETER_ID") + if ci_id: + return ci_id + try: + project_name = os.getenv("PROJECT_NAME", "strands-agent-chatbot") + environment = os.getenv("ENVIRONMENT", "dev") + ssm = boto3.client("ssm", region_name=_region()) + resp = ssm.get_parameter( + Name=f"/{project_name}/{environment}/agentcore/code-interpreter-id" + ) + return resp["Parameter"]["Value"] + except Exception as exc: # pragma: no cover - best-effort fallback + logger.warning(f"Code Interpreter id not found in env or SSM: {exc}") + return None + + +def _validate_document_name(name: str) -> Tuple[bool, Optional[str]]: + """Validate a document name (without extension). + + Rules: letters, numbers, hyphens and underscores only; no spaces or other + special characters; no consecutive, leading, or trailing hyphens. + """ + if not name: + return False, "Document name cannot be empty" + + if not re.match(r"^[a-zA-Z0-9_\-]+$", name): + invalid = sorted(set(re.findall(r"[^a-zA-Z0-9_\-]", name))) + return ( + False, + f"Invalid characters in name: {invalid}. Use only letters, " + "numbers, hyphens, and underscores.", + ) + if "--" in name: + return False, "Name cannot contain consecutive hyphens (--)" + if name.startswith("-") or name.endswith("-"): + return False, "Name cannot start or end with a hyphen" + return True, None + + +_s3_client = None + + +def _s3(): + """Regional, SigV4 S3 client (matches FileUploadService config).""" + global _s3_client + if _s3_client is None: + region = _region() + _s3_client = boto3.client( + "s3", + region_name=region, + config=Config( + signature_version="s3v4", + s3={"addressing_style": "virtual"}, + ), + endpoint_url=f"https://s3.{region}.amazonaws.com", + ) + return _s3_client + + +def _user_files_bucket() -> str: + return os.environ.get("S3_USER_FILES_BUCKET_NAME", "user-files") + + +# --------------------------------------------------------------------------- +# Code Interpreter primitives +# --------------------------------------------------------------------------- + + +def _ci_exec(code_interpreter, code: str) -> str: + """Run Python in the sandbox; return stdout or raise _DocGenError.""" + response = code_interpreter.invoke( + "executeCode", + {"code": code, "language": "python", "clearContext": False}, + ) + stdout = "" + for event in response.get("stream", []): + result = event.get("result", {}) + if result.get("isError", False): + stderr = result.get("structuredContent", {}).get( + "stderr", "Unknown error" + ) + logger.error(f"Code Interpreter error: {stderr[:500]}") + raise _DocGenError(stderr[:1000]) + out = result.get("structuredContent", {}).get("stdout", "") + if out: + stdout += out + return stdout + + +def _ci_read_bytes(code_interpreter, filename: str) -> Optional[bytes]: + """Read a file out of the sandbox as bytes (or None if missing).""" + download = code_interpreter.invoke("readFiles", {"paths": [filename]}) + content = None + for event in download.get("stream", []): + result = event.get("result", {}) + for block in result.get("content", []) or []: + if "data" in block: + content = block["data"] + elif "resource" in block and "blob" in block["resource"]: + content = block["resource"]["blob"] + if content: + break + if content: + break + if content is None: + return None + # Code Interpreter may hand back raw bytes or a base64 string. + if isinstance(content, str): + content = base64.b64decode(content) + return content + + +def _ci_write_bytes(code_interpreter, path: str, data: bytes) -> None: + """Write binary bytes into the sandbox (base64 text + decode in-sandbox).""" + b64 = base64.b64encode(data).decode("ascii") + code_interpreter.invoke( + "writeFiles", + {"content": [{"path": f"{path}.b64", "text": b64}]}, + ) + _ci_exec( + code_interpreter, + ( + "import base64\n" + f"with open({path + '.b64'!r}) as _f:\n" + " _raw = base64.b64decode(_f.read())\n" + f"with open({path!r}, 'wb') as _o:\n" + " _o.write(_raw)\n" + ), + ) + + +_DOCX_PREAMBLE = ( + "from docx import Document\n" + "from docx.shared import Pt, RGBColor, Inches\n" + "from docx.enum.text import WD_ALIGN_PARAGRAPH\n" +) + + +def _generate_docx_bytes( + code_interpreter_id: str, python_code: str, filename: str +) -> bytes: + """Build a new .docx from user code and return its bytes. + + Blocking (boto3 / Code Interpreter) — call via ``asyncio.to_thread``. + """ + from bedrock_agentcore.tools.code_interpreter_client import CodeInterpreter + + code_interpreter = CodeInterpreter(_region()) + code_interpreter.start(identifier=code_interpreter_id) + try: + # The user's code operates on a pre-initialized ``doc`` and must not + # call Document()/doc.save() itself — we own the lifecycle. + _ci_exec( + code_interpreter, + ( + f"{_DOCX_PREAMBLE}\n" + "doc = Document()\n\n" + f"{python_code}\n\n" + f"doc.save({filename!r})\n" + ), + ) + data = _ci_read_bytes(code_interpreter, filename) + if data is None: + raise _DocGenError( + f"Document '{filename}' was not produced. Make sure your code " + "adds content to `doc`." + ) + return data + finally: + try: + code_interpreter.stop() + except Exception: # pragma: no cover - cleanup best-effort + pass + + +def _modify_docx_bytes( + code_interpreter_id: str, + source_bytes: bytes, + python_code: str, + output_filename: str, +) -> bytes: + """Load an existing .docx, apply user edits, return the new bytes. + + Blocking — call via ``asyncio.to_thread``. + """ + from bedrock_agentcore.tools.code_interpreter_client import CodeInterpreter + + code_interpreter = CodeInterpreter(_region()) + code_interpreter.start(identifier=code_interpreter_id) + try: + _ci_write_bytes(code_interpreter, _SANDBOX_SOURCE, source_bytes) + _ci_exec( + code_interpreter, + ( + f"{_DOCX_PREAMBLE}\n" + f"doc = Document({_SANDBOX_SOURCE!r})\n\n" + f"{python_code}\n\n" + f"doc.save({output_filename!r})\n" + ), + ) + data = _ci_read_bytes(code_interpreter, output_filename) + if data is None: + raise _DocGenError( + f"Modified document '{output_filename}' was not produced." + ) + return data + finally: + try: + code_interpreter.stop() + except Exception: # pragma: no cover - cleanup best-effort + pass + + +def _extract_docx_text(code_interpreter_id: str, source_bytes: bytes) -> str: + """Extract readable text (headings, paragraphs, tables) from a .docx. + + Blocking — call via ``asyncio.to_thread``. + """ + from bedrock_agentcore.tools.code_interpreter_client import CodeInterpreter + + code_interpreter = CodeInterpreter(_region()) + code_interpreter.start(identifier=code_interpreter_id) + try: + _ci_write_bytes(code_interpreter, _SANDBOX_SOURCE, source_bytes) + extraction = ( + "from docx import Document\n" + f"doc = Document({_SANDBOX_SOURCE!r})\n" + "lines = []\n" + "for p in doc.paragraphs:\n" + " t = p.text.strip()\n" + " if not t:\n" + " continue\n" + " style = (p.style.name if p.style else '') or ''\n" + " if style.startswith('Heading'):\n" + " level = ''.join(ch for ch in style if ch.isdigit()) or '1'\n" + " lines.append('#' * min(int(level), 6) + ' ' + t)\n" + " else:\n" + " lines.append(t)\n" + "for i, table in enumerate(doc.tables):\n" + " lines.append('')\n" + " lines.append('[Table %d]' % (i + 1))\n" + " for row in table.rows:\n" + " lines.append(' | '.join(c.text.strip() for c in row.cells))\n" + "print('\\n'.join(lines))\n" + ) + return _ci_exec(code_interpreter, extraction).strip() + finally: + try: + code_interpreter.stop() + except Exception: # pragma: no cover - cleanup best-effort + pass + + +# --------------------------------------------------------------------------- +# User-files store helpers +# --------------------------------------------------------------------------- + + +def _download_s3_bytes(bucket: str, key: str) -> bytes: + """Read an object's bytes from S3 (blocking — use ``asyncio.to_thread``).""" + resp = _s3().get_object(Bucket=bucket, Key=key) + return resp["Body"].read() + + +async def _find_word_document( + user_id: str, session_id: str, document_name: str +): + """Find the newest READY .docx in this session matching ``document_name``. + + Returns the ``FileMetadata`` or ``None``. ``list_session_files`` returns + newest-first, so the first match is the latest version. + """ + from apis.shared.files import FileStatus, get_file_upload_repository + + target = ( + document_name + if document_name.lower().endswith(".docx") + else f"{document_name}.docx" + ) + files = await get_file_upload_repository().list_session_files( + session_id, status=FileStatus.READY + ) + for meta in files: + if ( + meta.user_id == user_id + and meta.mime_type == _DOCX_MIME + and meta.filename.lower() == target.lower() + ): + return meta + return None + + +async def _store_document( + user_id: str, session_id: str, filename: str, file_bytes: bytes +) -> Tuple[str, str, str]: + """Persist the .docx to the user-files store and mint a download URL. + + Returns ``(upload_id, download_url, size_kb)``. + """ + from apis.shared.files import ( + FileMetadata, + FileStatus, + get_file_upload_repository, + ) + + bucket = _user_files_bucket() + timestamp_hex = format( + int(datetime.now(timezone.utc).timestamp() * 1000), "x" + ) + upload_id = f"{timestamp_hex}_{uuid.uuid4().hex[:16]}" + s3_key = f"user-files/{user_id}/{session_id}/{upload_id}/{filename}" + + await asyncio.to_thread( + _s3().put_object, + Bucket=bucket, + Key=s3_key, + Body=file_bytes, + ContentType=_DOCX_MIME, + ) + + metadata = FileMetadata( + upload_id=upload_id, + user_id=user_id, + session_id=session_id, + filename=filename, + mime_type=_DOCX_MIME, + size_bytes=len(file_bytes), + s3_key=s3_key, + s3_bucket=bucket, + status=FileStatus.READY, + ) + await get_file_upload_repository().create_file(metadata) + + download_url = await asyncio.to_thread( + _s3().generate_presigned_url, + "get_object", + Params={ + "Bucket": bucket, + "Key": s3_key, + "ResponseContentType": _DOCX_MIME, + "ResponseContentDisposition": f'attachment; filename="{filename}"', + }, + ExpiresIn=_DOWNLOAD_URL_TTL, + ) + + size_kb = f"{len(file_bytes) / 1024:.1f} KB" + return upload_id, download_url, size_kb + + +def _download_card(filename: str, download_url: str, size_kb: str, verb: str) -> str: + """Build the promoted inline-download-card tool result (JSON string). + + The ``ui_type``/``ui_display: inline`` discriminators make the frontend + render a first-class download card (see inline-visual.component.ts, + ``word_document``) instead of burying the link in the collapsed tool card. + """ + return json.dumps( + { + "success": True, + "ui_type": "word_document", + "ui_display": "inline", + "payload": { + "filename": filename, + "download_url": download_url, + "size_kb": size_kb, + }, + "summary": ( + f"{verb} {filename} ({size_kb}). Also saved to this chat's Files." + ), + } + ) + + +def _error(text: str) -> Dict[str, Any]: + return {"content": [{"text": text}], "status": "error"} + + +_NO_CI_MESSAGE = ( + "❌ Code Interpreter is not configured. AGENTCORE_CODE_INTERPRETER_ID was " + "not found in the environment or Parameter Store." +) + + +# --------------------------------------------------------------------------- +# Tool factories +# --------------------------------------------------------------------------- + + +def make_create_word_document_tool(session_id: str, user_id: str): + """Create a ``create_word_document`` tool bound to the given identity.""" + + @tool + async def create_word_document( + python_code: str, + document_name: str, + ) -> Any: + """Create a new Word (.docx) document using python-docx code. + + Executes python-docx code in a sandboxed Code Interpreter to build a + document from scratch, saves it to the user's files, and returns a + download card. Great for structured reports with headings, + paragraphs, tables, and matplotlib charts. + + Available libraries in the sandbox: python-docx, matplotlib, pandas, + numpy. + + Args: + python_code: python-docx code that builds the document. A blank + document is already available as ``doc = Document()`` — do NOT + call ``Document()`` or ``doc.save()`` yourself; the tool saves + it for you. ``Pt``, ``RGBColor``, ``Inches`` and + ``WD_ALIGN_PARAGRAPH`` are already imported. + + Example: + doc.add_heading('Quarterly Report', level=1) + doc.add_paragraph('Revenue increased by 15%...') + table = doc.add_table(rows=2, cols=2) + table.style = 'Light Grid Accent 1' + table.rows[0].cells[0].text = 'Quarter' + + To embed a chart, save a PNG with matplotlib then insert it: + import matplotlib.pyplot as plt + plt.figure(figsize=(8, 5)) + plt.bar(['Q1', 'Q2'], [100, 120]) + plt.savefig('chart.png', dpi=200, bbox_inches='tight') + plt.close() + doc.add_picture('chart.png', width=Inches(6)) + + document_name: File name WITHOUT extension (.docx is added + automatically). Use only letters, numbers, hyphens, and + underscores (e.g. "sales-report", "Q4_analysis"). + + Returns: + An inline download card. The document is also saved to this + chat's Files. + """ + is_valid, error_msg = _validate_document_name(document_name) + if not is_valid: + return _error( + f"❌ Invalid document name '{document_name}': {error_msg}\n\n" + "Examples: sales-report, Q4_analysis, report-final" + ) + + filename = f"{document_name}.docx" + code_interpreter_id = _get_code_interpreter_id() + if not code_interpreter_id: + return _error(_NO_CI_MESSAGE) + + try: + file_bytes = await asyncio.to_thread( + _generate_docx_bytes, code_interpreter_id, python_code, filename + ) + except _DocGenError as exc: + return _error( + f"❌ Failed to create '{filename}'.\n\n```\n{exc}\n```\n\n" + "Check the python-docx code for errors." + ) + except Exception as exc: # noqa: BLE001 - surface any sandbox error + logger.error(f"create_word_document sandbox error: {exc}") + return _error(f"❌ Failed to create '{filename}': {exc}") + + try: + _id, download_url, size_kb = await _store_document( + user_id, session_id, filename, file_bytes + ) + except Exception as exc: # noqa: BLE001 - storage failure is terminal + logger.error(f"create_word_document storage error: {exc}") + return _error(f"❌ Created '{filename}' but failed to save it: {exc}") + + return _download_card(filename, download_url, size_kb, "Created") + + return create_word_document + + +def make_modify_word_document_tool(session_id: str, user_id: str): + """Create a ``modify_word_document`` tool bound to the given identity.""" + + @tool + async def modify_word_document( + document_name: str, + python_code: str, + output_name: Optional[str] = None, + ) -> Any: + """Modify an existing Word (.docx) document with python-docx code. + + Loads a document previously created in this chat, runs your + python-docx code against it, and saves the result (as a new file so + the original is preserved). Returns a download card. + + Use ``list_word_documents`` first if you are unsure of the exact name. + + Args: + document_name: Name of the existing document to edit (with or + without the .docx extension), e.g. "sales-report". + python_code: python-docx code that edits the document. The loaded + document is available as ``doc = Document(...)`` — do NOT call + ``Document()`` or ``doc.save()`` yourself. ``Pt``, ``RGBColor``, + ``Inches`` and ``WD_ALIGN_PARAGRAPH`` are already imported. + + Example (append a section): + doc.add_heading('Addendum', level=1) + doc.add_paragraph('Updated figures for Q2.') + + output_name: Optional name (without extension) for the edited copy. + Defaults to the source name (a new versioned copy is saved). + + Returns: + An inline download card for the edited document. + """ + code_interpreter_id = _get_code_interpreter_id() + if not code_interpreter_id: + return _error(_NO_CI_MESSAGE) + + source = await _find_word_document(user_id, session_id, document_name) + if source is None: + return _error( + f"❌ No Word document named '{document_name}' was found in this " + "chat. Use list_word_documents to see what's available." + ) + + out_base = output_name or source.filename + if out_base.lower().endswith(".docx"): + out_base = out_base[: -len(".docx")] + is_valid, error_msg = _validate_document_name(out_base) + if not is_valid: + return _error( + f"❌ Invalid output name '{out_base}': {error_msg}" + ) + output_filename = f"{out_base}.docx" + + try: + source_bytes = await asyncio.to_thread( + _download_s3_bytes, source.s3_bucket, source.s3_key + ) + file_bytes = await asyncio.to_thread( + _modify_docx_bytes, + code_interpreter_id, + source_bytes, + python_code, + output_filename, + ) + except _DocGenError as exc: + return _error( + f"❌ Failed to modify '{source.filename}'.\n\n```\n{exc}\n```\n\n" + "Check the python-docx code for errors." + ) + except Exception as exc: # noqa: BLE001 - surface any sandbox error + logger.error(f"modify_word_document error: {exc}") + return _error(f"❌ Failed to modify '{source.filename}': {exc}") + + try: + _id, download_url, size_kb = await _store_document( + user_id, session_id, output_filename, file_bytes + ) + except Exception as exc: # noqa: BLE001 - storage failure is terminal + logger.error(f"modify_word_document storage error: {exc}") + return _error( + f"❌ Modified '{source.filename}' but failed to save it: {exc}" + ) + + return _download_card(output_filename, download_url, size_kb, "Updated") + + return modify_word_document + + +def make_list_word_documents_tool(session_id: str, user_id: str): + """Create a ``list_word_documents`` tool bound to the given identity.""" + + @tool + async def list_word_documents() -> Dict[str, Any]: + """List the Word (.docx) documents available in this chat. + + Returns the file names and sizes of documents created or modified in + this conversation. Use the names with modify_word_document or + read_word_document. + """ + from apis.shared.files import FileStatus, get_file_upload_repository + + files = await get_file_upload_repository().list_session_files( + session_id, status=FileStatus.READY + ) + seen: set[str] = set() + rows = [] + for meta in files: # newest-first + if meta.user_id != user_id or meta.mime_type != _DOCX_MIME: + continue + if meta.filename in seen: + continue + seen.add(meta.filename) + rows.append(f"- {meta.filename} ({meta.size_bytes / 1024:.1f} KB)") + + if not rows: + text = ( + "No Word documents in this chat yet. Use create_word_document " + "to make one." + ) + else: + text = "Word documents in this chat:\n" + "\n".join(rows) + return {"content": [{"text": text}], "status": "success"} + + return list_word_documents + + +def make_read_word_document_tool(session_id: str, user_id: str): + """Create a ``read_word_document`` tool bound to the given identity.""" + + @tool + async def read_word_document(document_name: str) -> Dict[str, Any]: + """Read the text content of an existing Word (.docx) document. + + Extracts headings, paragraphs, and tables from a document created in + this chat so you can reference or summarize its contents. Use + list_word_documents first if unsure of the exact name. + + Args: + document_name: Name of the document to read (with or without the + .docx extension), e.g. "sales-report". + + Returns: + The document's text content. + """ + code_interpreter_id = _get_code_interpreter_id() + if not code_interpreter_id: + return _error(_NO_CI_MESSAGE) + + source = await _find_word_document(user_id, session_id, document_name) + if source is None: + return _error( + f"❌ No Word document named '{document_name}' was found in this " + "chat. Use list_word_documents to see what's available." + ) + + try: + source_bytes = await asyncio.to_thread( + _download_s3_bytes, source.s3_bucket, source.s3_key + ) + text = await asyncio.to_thread( + _extract_docx_text, code_interpreter_id, source_bytes + ) + except _DocGenError as exc: + return _error(f"❌ Failed to read '{source.filename}': {exc}") + except Exception as exc: # noqa: BLE001 - surface any sandbox error + logger.error(f"read_word_document error: {exc}") + return _error(f"❌ Failed to read '{source.filename}': {exc}") + + body = text or "(The document has no extractable text.)" + return { + "content": [ + {"text": f"Content of {source.filename}:\n\n{body}"} + ], + "status": "success", + } + + return read_word_document diff --git a/backend/src/agents/main_agent/core/model_config.py b/backend/src/agents/main_agent/core/model_config.py index 761435d36..af99f9acf 100644 --- a/backend/src/agents/main_agent/core/model_config.py +++ b/backend/src/agents/main_agent/core/model_config.py @@ -334,12 +334,16 @@ def to_bedrock_config(self) -> Dict[str, Any]: # place cache points per-model: for a model that supports automatic # caching it injects a cachePoint on the system/tools/last-user blocks; # for one that doesn't it logs a warning and no-ops, so this is safe to - # set whenever caching is enabled. The earlier SDK blocker — strands PR - # #1438, where `cachePoint` blocks collided with non-PDF document - # attachments — was fixed in strands-agents 1.39.0 (we pin 1.40.0). - # Cache hits are user-visible in the cost/context badge the moment this - # is on. - # See: https://github.com/strands-agents/sdk-python/pull/1438 + # set whenever caching is enabled. Requires strands-agents>=1.48.0: a + # cachePoint trailing a non-PDF `document` attachment is rejected by + # Bedrock's Anthropic adapter with "ValidationException ... + # content.N.type: Field required" (agent force-stop on any turn with a + # txt/docx/csv attachment; upstream issue #1966). 1.48.0 inserts the + # cachePoint before the first non-PDF document instead, and skips the + # cache point entirely (uncached turn, not an error) when a non-PDF + # document is the first content block. Cache hits are user-visible in + # the cost/context badge the moment this is on. + # See: https://github.com/strands-agents/sdk-python/issues/1966 if self.caching_enabled: from strands.models import CacheConfig config["cache_config"] = CacheConfig(strategy="auto") diff --git a/backend/src/agents/main_agent/session/turn_based_session_manager.py b/backend/src/agents/main_agent/session/turn_based_session_manager.py index c52238df1..e1c98f799 100644 --- a/backend/src/agents/main_agent/session/turn_based_session_manager.py +++ b/backend/src/agents/main_agent/session/turn_based_session_manager.py @@ -213,6 +213,19 @@ def initialize(self, agent: "Agent", **kwargs: Any) -> None: except Exception as e: logger.warning(f"Document byte stripping failed, continuing: {e}", exc_info=True) + # Drop empty/unrecognized content blocks from restored history. The + # write side runs `_filter_empty_text` in `append_message`, but history + # restored from AgentCore Memory bypasses it — a single block that comes + # back without a recognized Bedrock discriminator (an empty {} block, or + # a key lost on the memory serialization round-trip) makes Bedrock reject + # EVERY subsequent turn with "messages.N.content.M.type: Field required", + # permanently bricking the session. Runs unconditionally, mirroring + # `_strip_document_bytes` / `_repair_restored_history`. + try: + agent.messages = self._sanitize_restored_content_blocks(agent.messages) + except Exception as e: + logger.warning(f"Content-block sanitize failed, continuing: {e}", exc_info=True) + if not self.compaction_config or not self.compaction_config.enabled: self._repair_restored_history(agent) return @@ -747,6 +760,44 @@ def _truncate_text(self, text: str, max_length: int) -> str: return text return text[:max_length] + f"\n... [truncated, {len(text) - max_length} chars removed]" + def _sanitize_restored_content_blocks(self, messages: List[Dict]) -> List[Dict]: + """Drop empty/unrecognized content blocks from restored history. + + ``append_message`` runs ``_filter_empty_text`` on the write side, but + history restored from AgentCore Memory bypasses it. A single block that + comes back without a recognized Bedrock discriminator — an empty ``{}`` + block, an empty/whitespace ``text`` block, or a key dropped on the + memory serialization round-trip — triggers Bedrock's + "messages.N.content.M.type: Field required" ValidationException, which + fails every subsequent turn on the session. This reuses the same + recognized-key filter as the write path and additionally drops any + message left with no content (``_repair_restored_history`` runs after + and fixes any role-alternation gap the drop introduces). + """ + sanitized: List[Dict] = [] + dropped_blocks = 0 + dropped_messages = 0 + for msg in messages: + if not isinstance(msg, dict): + continue + original = msg.get("content", []) + cleaned = self._filter_empty_text(msg) + content = cleaned.get("content", []) + if isinstance(original, list) and isinstance(content, list): + dropped_blocks += max(0, len(original) - len(content)) + if isinstance(content, list) and len(content) == 0: + dropped_messages += 1 + continue + sanitized.append(cleaned) + + if dropped_blocks or dropped_messages: + logger.warning( + "Restore sanitize: dropped %d invalid content block(s) and %d " + "empty message(s) from restored history for session %s", + dropped_blocks, dropped_messages, self.config.session_id, + ) + return sanitized + def _strip_document_bytes(self, messages: List[Dict]) -> List[Dict]: """Replace document content blocks' inline bytes with a text placeholder. diff --git a/backend/src/apis/inference_api/chat/routes.py b/backend/src/apis/inference_api/chat/routes.py index 36c7ccf0f..a6538038c 100644 --- a/backend/src/apis/inference_api/chat/routes.py +++ b/backend/src/apis/inference_api/chat/routes.py @@ -459,6 +459,47 @@ def _build_artifact_tools( return tools +# ============================================================ +# Word Document Tool Injection +# ============================================================ + +WORD_DOCUMENT_TOOL_IDS = {"create_word_document"} + + +def _build_word_document_tools( + enabled_tools: list | None, + session_id: str, + user_id: str, +) -> list: + """Create context-bound Word document tools if enabled by the user. + + Identity is captured by closure (same pattern as the artifact and + spreadsheet tools) since the runtime does not populate ToolContext. + """ + if not enabled_tools or not WORD_DOCUMENT_TOOL_IDS.intersection(enabled_tools): + return [] + + # The Word capability is a single toggle: enabling create_word_document + # provisions the full document toolset (create/modify/list/read) so the + # model can round-trip on a document without extra admin catalog entries. + from agents.builtin_tools.word_document_tool import ( + make_create_word_document_tool, + make_list_word_documents_tool, + make_modify_word_document_tool, + make_read_word_document_tool, + ) + + tools = [ + make_create_word_document_tool(session_id, user_id), + make_modify_word_document_tool(session_id, user_id), + make_list_word_documents_tool(session_id, user_id), + make_read_word_document_tool(session_id, user_id), + ] + + logger.info(f"Created {len(tools)} word document tools") + return tools + + def _build_memory_tools(agent_memory, user_id: str, user_email: str) -> list: """Context-bound Memory-Space tools for an Agent's resolved memory binding. @@ -1780,6 +1821,10 @@ async def invocations(request: InvocationRequest, current_user: User = Depends(g enabled_tools=effective_enabled_tools, session_id=input_data.session_id, user_id=user_id, + ) + _build_word_document_tools( + enabled_tools=effective_enabled_tools, + session_id=input_data.session_id, + user_id=user_id, ) + _build_memory_tools( agent_memory=agent_memory, user_id=user_id, diff --git a/backend/src/apis/shared/sessions/metadata.py b/backend/src/apis/shared/sessions/metadata.py index faf24091b..bb47d985e 100644 --- a/backend/src/apis/shared/sessions/metadata.py +++ b/backend/src/apis/shared/sessions/metadata.py @@ -1701,124 +1701,200 @@ async def list_user_sessions( ) -async def _list_user_sessions_cloud( - user_id: str, - table_name: str, - limit: Optional[int] = None, - next_token: Optional[str] = None -) -> Tuple[list[SessionMetadata], Optional[str]]: +def _item_to_session_metadata(item: Dict[str, Any]) -> Optional[SessionMetadata]: + """Parse one DynamoDB item into SessionMetadata, or None if it should be skipped. + + Skips preview sessions and ghost/corrupt rows (the latter logged). Shared by + both source queries in the transitional union read. """ - List active sessions for a user from DynamoDB with efficient pagination + try: + item = _convert_decimal_to_float(item) + for key in ('PK', 'SK', 'GSI_PK', 'GSI_SK', 'GSI4_PK', 'GSI4_SK'): + item.pop(key, None) - Args: - user_id: User identifier - table_name: DynamoDB table name - limit: Maximum number of sessions to return (optional) - next_token: Pagination token for retrieving next page (optional) + # Skip preview sessions - they should not appear in user's session list + if is_preview_session(item.get('sessionId', '')): + return None - Returns: - Tuple of (list of SessionMetadata objects, next_token if more sessions exist) - Sessions are sorted by last_message_at descending (most recent first) + if "pendingInterrupts" in item: + item["pendingInterrupts"] = _dedupe_interrupt_dicts(item["pendingInterrupts"]) - Schema: - PK: USER#{user_id} - SK: S#ACTIVE#{last_message_at}#{session_id} - - Performance improvements over old schema: - - Query only returns session records (no cost records with C# prefix) - - No in-memory filtering needed - - Sessions sorted by timestamp in SK (no in-memory sorting) - - True server-side pagination via DynamoDB's native mechanism - - O(page_size) instead of O(sessions + messages) + return SessionMetadata.model_validate(item) + except Exception as e: + # JUSTIFICATION: When listing sessions from DynamoDB, individual session parsing + # failures should not break the entire list operation. We skip corrupted sessions + # and continue processing others. This provides better UX than failing completely. + logger.warning(f"Failed to parse session item: {e}") + return None + + +def _collect_valid_sessions(table, query_params: Dict[str, Any], want: Optional[int]) -> list[SessionMetadata]: + """Query one source, following LastEvaluatedKey until ``want`` valid rows collected. + + DynamoDB ``Limit`` caps items *evaluated*, not *returned* after we drop previews + and ghosts, so we page until the partition is exhausted or ``want`` rows are in + hand. ``want`` is ``limit + 1`` at the call site — the extra row is the sentinel + that tells the merge whether another page exists. """ - try: - import boto3 - from boto3.dynamodb.conditions import Key + results: list[SessionMetadata] = [] + params = dict(query_params) + while True: + response = table.query(**params) + for item in response['Items']: + metadata = _item_to_session_metadata(item) + if metadata is None: + continue + results.append(metadata) + if want and len(results) >= want: + return results + lek = response.get('LastEvaluatedKey') + if not lek: + return results + params['ExclusiveStartKey'] = lek - dynamodb = boto3.resource('dynamodb') - table = dynamodb.Table(table_name) - # Decode next_token to get ExclusiveStartKey if provided - exclusive_start_key = None - if next_token: - try: - decoded = base64.b64decode(next_token).decode('utf-8') - exclusive_start_key = json.loads(decoded) - except Exception as e: - # JUSTIFICATION: Invalid pagination tokens should not break the request. - # We fall back to no pagination, which is a reasonable default. - # This handles cases where tokens are corrupted, expired, or malformed. - logger.warning(f"Invalid next_token: {e}") - - # Build query parameters with new S#ACTIVE# prefix - # This cleanly separates from: - # - S#DELETED# (soft-deleted sessions) - # - C# (cost records) - query_params = { - 'KeyConditionExpression': Key('PK').eq(f'USER#{user_id}') & Key('SK').begins_with('S#ACTIVE#'), - 'ScanIndexForward': False # Descending order (most recent first) - timestamp is in SK! - } +def _decode_list_cursor(next_token: Optional[str]) -> Optional[Tuple[str, str]]: + """Decode a session-list cursor into ``(last_message_at, session_id)``. + + Tolerant: an undecodable or legacy-format token (the pre-migration + ``base64(json(LastEvaluatedKey))``) falls back to no-cursor (first page) rather + than erroring — a harmless page reset across the migration deploy boundary. + """ + if not next_token: + return None + try: + obj = json.loads(base64.b64decode(next_token).decode('utf-8')) + if isinstance(obj, dict) and 'la' in obj: + return (obj['la'], obj.get('sid', '')) + except Exception as e: + logger.warning(f"Invalid next_token: {e}, starting from beginning") + return None - if exclusive_start_key: - query_params['ExclusiveStartKey'] = exclusive_start_key - if limit: - query_params['Limit'] = limit +def _encode_list_cursor(session: SessionMetadata) -> str: + """Encode the merge cursor from the last returned session (value-based, not key-based).""" + payload = {'la': session.last_message_at, 'sid': session.session_id} + return base64.b64encode(json.dumps(payload).encode('utf-8')).decode('utf-8') - # Pagination loop: DynamoDB's Limit caps items *evaluated*, not items - # *returned* after application-level filtering (preview sessions, parse - # failures). A single query may return fewer valid sessions than the - # requested limit while still having more data in the partition. We keep - # querying until we fill the page or exhaust the partition. - sessions: list[SessionMetadata] = [] - last_evaluated_key = None - while True: - response = table.query(**query_params) +async def _list_user_sessions_cloud( + user_id: str, + table_name: str, + limit: Optional[int] = None, + next_token: Optional[str] = None +) -> Tuple[list[SessionMetadata], Optional[str]]: + """ + List active sessions for a user from DynamoDB — transitional dual-scheme read. - for item in response['Items']: - try: - item = _convert_decimal_to_float(item) + Issue #175 Phase 1a (expand read): reads the UNION of two disjoint sources so a + session is visible whether or not its base sort key has been migrated to the + static ``S#{session_id}`` form: - for key in ['PK', 'SK', 'GSI_PK', 'GSI_SK']: - item.pop(key, None) + - Legacy rows (un-migrated): base table, ``SK begins_with 'S#ACTIVE#'`` (the SK + encodes ``lastMessageAt``, so it sorts by recency natively). + - Migrated rows: ``SessionRecencyIndex`` GSI (``GSI4_PK=USER#{id}``, + ``GSI4_SK={lastMessageAt}#{session_id}``), sparse + active-only. - # Skip preview sessions - they should not appear in user's session list - session_id = item.get('sessionId', '') - if is_preview_session(session_id): - continue + A session is in exactly one source at a time (a migrated row's base SK no longer + matches ``S#ACTIVE#`` and it has GSI4 keys; an un-migrated row has neither), so + the two result sets are disjoint — dedupe by ``session_id`` is belt-and-suspenders + for the brief mid-migration instant. - if "pendingInterrupts" in item: - item["pendingInterrupts"] = _dedupe_interrupt_dicts(item["pendingInterrupts"]) + Pagination uses a **value cursor** (``{lastMessageAt}#{session_id}``), not a + per-source ``LastEvaluatedKey``, so a page is derived independently from the last + returned position with no cross-page buffering. Fetching ``limit + 1`` valid rows + from each source is provably sufficient to know whether another page exists. - metadata = SessionMetadata.model_validate(item) - sessions.append(metadata) + If ``SessionRecencyIndex`` does not exist yet (code deployed before the CDK GSI), + the GSI query is skipped and the read degrades to legacy-only. - # Stop collecting once we have enough - if limit and len(sessions) >= limit: - break - except Exception as e: - # JUSTIFICATION: When listing sessions from DynamoDB, individual session parsing - # failures should not break the entire list operation. We skip corrupted sessions - # and continue processing others. This provides better UX than failing completely. - logger.warning(f"Failed to parse session item: {e}") - continue + Kept until the migration completes; Phase 3 collapses this to GSI-only. + """ + try: + import boto3 + from boto3.dynamodb.conditions import Key + from botocore.exceptions import ClientError - last_evaluated_key = response.get('LastEvaluatedKey') + dynamodb = boto3.resource('dynamodb') + table = dynamodb.Table(table_name) - # Stop if we've filled the page or there's no more data - if (limit and len(sessions) >= limit) or not last_evaluated_key: - break + cursor = _decode_list_cursor(next_token) + want = (limit + 1) if limit else None + pk = f'USER#{user_id}' + + # Legacy source: base table, S#ACTIVE# prefix. Resuming after a cursor uses + # between('S#ACTIVE#', 'S#ACTIVE#{la}#{sid}') — a single range condition that + # keeps the prefix filter while bounding the upper end (inclusive; the exact + # cursor row is dropped by the strict-less filter below). + if cursor: + la, sid = cursor + legacy_cond = Key('PK').eq(pk) & Key('SK').between('S#ACTIVE#', f'S#ACTIVE#{la}#{sid}') + else: + legacy_cond = Key('PK').eq(pk) & Key('SK').begins_with('S#ACTIVE#') + legacy_params: Dict[str, Any] = { + 'KeyConditionExpression': legacy_cond, + 'ScanIndexForward': False, + } + if want: + legacy_params['Limit'] = want + legacy_sessions = _collect_valid_sessions(table, legacy_params, want) + + # Migrated source: SessionRecencyIndex GSI. GSI4_SK < '{la}#{sid}' is a clean + # strict-less resume. Degrade to legacy-only if the index isn't there yet. + if cursor: + la, sid = cursor + gsi_cond = Key('GSI4_PK').eq(pk) & Key('GSI4_SK').lt(f'{la}#{sid}') + else: + gsi_cond = Key('GSI4_PK').eq(pk) + gsi_params: Dict[str, Any] = { + 'IndexName': 'SessionRecencyIndex', + 'KeyConditionExpression': gsi_cond, + 'ScanIndexForward': False, + } + if want: + gsi_params['Limit'] = want + try: + gsi_sessions = _collect_valid_sessions(table, gsi_params, want) + except ClientError as e: + # An index that doesn't exist yet (code deployed ahead of the CDK GSI) + # surfaces differently across engines: real DynamoDB raises + # ValidationException ("The table does not have the specified index"), + # while moto/table-absent raises ResourceNotFoundException. Catch both + # (scoped by message for the ValidationException so genuinely malformed + # queries still surface) and degrade to legacy-only. + err = e.response.get('Error', {}) + code = err.get('Code') + missing_index = code == 'ResourceNotFoundException' or ( + code == 'ValidationException' and 'specified index' in err.get('Message', '') + ) + if missing_index: + logger.warning( + "SessionRecencyIndex unavailable (%s); falling back to legacy-only " + "session listing", code + ) + gsi_sessions = [] + else: + raise - # Continue querying from where DynamoDB left off - query_params['ExclusiveStartKey'] = last_evaluated_key + # Merge: sort by (lastMessageAt, sessionId) descending, drop anything not + # strictly older than the cursor (removes the inclusive legacy cursor row), + # dedupe by session_id. + combined = legacy_sessions + gsi_sessions + if cursor: + combined = [s for s in combined if (s.last_message_at, s.session_id) < cursor] + combined.sort(key=lambda s: (s.last_message_at, s.session_id), reverse=True) + + seen: set = set() + deduped: list[SessionMetadata] = [] + for s in combined: + if s.session_id in seen: + continue + seen.add(s.session_id) + deduped.append(s) - # Generate next_token only when there is genuinely more data to fetch - next_page_token = None - if last_evaluated_key and limit and len(sessions) >= limit: - next_page_token = base64.b64encode( - json.dumps(last_evaluated_key).encode('utf-8') - ).decode('utf-8') + has_more = bool(limit) and len(deduped) > limit + sessions = deduped[:limit] if limit else deduped + next_page_token = _encode_list_cursor(sessions[-1]) if (has_more and sessions) else None logger.info(f"Listed {len(sessions)} sessions for user {user_id} from DynamoDB") diff --git a/backend/tests/shared/conftest.py b/backend/tests/shared/conftest.py index a9d6393c8..85998904a 100644 --- a/backend/tests/shared/conftest.py +++ b/backend/tests/shared/conftest.py @@ -235,11 +235,14 @@ def sessions_metadata_table(aws, monkeypatch): {"AttributeName": "GSI_SK", "AttributeType": "S"}, {"AttributeName": "GSI3_PK", "AttributeType": "S"}, {"AttributeName": "GSI3_SK", "AttributeType": "S"}, + {"AttributeName": "GSI4_PK", "AttributeType": "S"}, + {"AttributeName": "GSI4_SK", "AttributeType": "S"}, ], gsis=[ _gsi("UserTimestampIndex", "GSI1PK", "GSI1SK"), _gsi("SessionLookupIndex", "GSI_PK", "GSI_SK"), _gsi("DueScheduleIndex", "GSI3_PK", "GSI3_SK"), + _gsi("SessionRecencyIndex", "GSI4_PK", "GSI4_SK"), ], ) diff --git a/backend/tests/shared/test_sessions_metadata.py b/backend/tests/shared/test_sessions_metadata.py index 47bb37498..f23e5a77e 100644 --- a/backend/tests/shared/test_sessions_metadata.py +++ b/backend/tests/shared/test_sessions_metadata.py @@ -263,6 +263,158 @@ async def test_missing_env_raises(self, sessions_metadata_table, monkeypatch): await list_user_sessions("u1") +def _put_legacy_row(table, sid, la, user_id="u1", **extra): + """Un-migrated session row: SK encodes lastMessageAt, no GSI4 keys.""" + item = { + "PK": f"USER#{user_id}", "SK": f"S#ACTIVE#{la}#{sid}", + "GSI_PK": f"SESSION#{sid}", "GSI_SK": "META", + "sessionId": sid, "userId": user_id, "title": "T", "status": "active", + "createdAt": la, "lastMessageAt": la, "messageCount": 1, + } + item.update(extra) + table.put_item(Item=item) + + +def _put_migrated_row(table, sid, la, user_id="u1", **extra): + """Migrated session row: static SK + sparse SessionRecencyIndex (GSI4) keys.""" + item = { + "PK": f"USER#{user_id}", "SK": f"S#{sid}", + "GSI_PK": f"SESSION#{sid}", "GSI_SK": "META", + "GSI4_PK": f"USER#{user_id}", "GSI4_SK": f"{la}#{sid}", + "sessionId": sid, "userId": user_id, "title": "T", "status": "active", + "createdAt": la, "lastMessageAt": la, "messageCount": 1, + } + item.update(extra) + table.put_item(Item=item) + + +class TestListUserSessionsDualScheme: + """Issue #175 Phase 1a — union read over legacy + migrated (SessionRecencyIndex) rows.""" + + @pytest.mark.asyncio + async def test_migrated_only_listed_via_gsi(self, sessions_metadata_table): + from apis.shared.sessions.metadata import list_user_sessions + _put_migrated_row(sessions_metadata_table, "m1", "2026-01-02T00:00:00Z") + _put_migrated_row(sessions_metadata_table, "m2", "2026-01-01T00:00:00Z") + sessions, token = await list_user_sessions("u1") + assert [s.session_id for s in sessions] == ["m1", "m2"] + assert token is None + + @pytest.mark.asyncio + async def test_union_ordered_newest_first(self, sessions_metadata_table): + from apis.shared.sessions.metadata import list_user_sessions + # interleave the two schemes by timestamp + _put_migrated_row(sessions_metadata_table, "m_06", "2026-01-06T00:00:00Z") + _put_legacy_row(sessions_metadata_table, "l_05", "2026-01-05T00:00:00Z") + _put_migrated_row(sessions_metadata_table, "m_04", "2026-01-04T00:00:00Z") + _put_legacy_row(sessions_metadata_table, "l_03", "2026-01-03T00:00:00Z") + _put_migrated_row(sessions_metadata_table, "m_02", "2026-01-02T00:00:00Z") + _put_legacy_row(sessions_metadata_table, "l_01", "2026-01-01T00:00:00Z") + + sessions, token = await list_user_sessions("u1") + assert [s.session_id for s in sessions] == ["m_06", "l_05", "m_04", "l_03", "m_02", "l_01"] + assert token is None + + @pytest.mark.asyncio + async def test_pagination_across_union_no_dupes_or_gaps(self, sessions_metadata_table): + from apis.shared.sessions.metadata import list_user_sessions + _put_migrated_row(sessions_metadata_table, "m_06", "2026-01-06T00:00:00Z") + _put_legacy_row(sessions_metadata_table, "l_05", "2026-01-05T00:00:00Z") + _put_migrated_row(sessions_metadata_table, "m_04", "2026-01-04T00:00:00Z") + _put_legacy_row(sessions_metadata_table, "l_03", "2026-01-03T00:00:00Z") + _put_migrated_row(sessions_metadata_table, "m_02", "2026-01-02T00:00:00Z") + _put_legacy_row(sessions_metadata_table, "l_01", "2026-01-01T00:00:00Z") + + seen = [] + token = None + for _ in range(5): # safety bound + page, token = await list_user_sessions("u1", limit=2, next_token=token) + seen.extend(s.session_id for s in page) + if token is None: + break + assert seen == ["m_06", "l_05", "m_04", "l_03", "m_02", "l_01"] + assert len(seen) == len(set(seen)) # no duplicates across pages + + @pytest.mark.asyncio + async def test_ghost_and_preview_rows_skipped(self, sessions_metadata_table): + from apis.shared.sessions.metadata import list_user_sessions + _put_migrated_row(sessions_metadata_table, "good", "2026-01-02T00:00:00Z") + # ghost: bare key, missing required fields (the exact prod failure) + sessions_metadata_table.put_item( + Item={"PK": "USER#u1", "SK": "S#ACTIVE#2026-01-01T00:00:00Z#ghost"} + ) + # preview session should never surface + _put_legacy_row(sessions_metadata_table, "preview-abc", "2026-01-03T00:00:00Z") + + sessions, _ = await list_user_sessions("u1") + ids = [s.session_id for s in sessions] + assert ids == ["good"] + + @pytest.mark.asyncio + async def test_graceful_fallback_when_index_missing(self, aws, monkeypatch): + """Code deployed before the CDK GSI: GSI query 404s → legacy-only, no crash.""" + import boto3 + from apis.shared.sessions.metadata import list_user_sessions + + ddb = boto3.client("dynamodb", region_name="us-east-1") + name = "test-sessions-metadata-no-gsi" + ddb.create_table( + TableName=name, + KeySchema=[{"AttributeName": "PK", "KeyType": "HASH"}, + {"AttributeName": "SK", "KeyType": "RANGE"}], + AttributeDefinitions=[{"AttributeName": "PK", "AttributeType": "S"}, + {"AttributeName": "SK", "AttributeType": "S"}], + BillingMode="PAY_PER_REQUEST", + ) + monkeypatch.setenv("DYNAMODB_SESSIONS_METADATA_TABLE_NAME", name) + table = boto3.resource("dynamodb", region_name="us-east-1").Table(name) + _put_legacy_row(table, "l1", "2026-01-02T00:00:00Z") + _put_legacy_row(table, "l2", "2026-01-01T00:00:00Z") + + sessions, token = await list_user_sessions("u1") + assert [s.session_id for s in sessions] == ["l1", "l2"] + assert token is None + + @pytest.mark.asyncio + async def test_graceful_fallback_on_validationexception(self, sessions_metadata_table, monkeypatch): + """Real DynamoDB raises ValidationException (not ResourceNotFoundException) for a + missing GSI — moto masks this, so simulate the real error on the index query.""" + import boto3 + from botocore.exceptions import ClientError + from apis.shared.sessions.metadata import list_user_sessions + + _put_legacy_row(sessions_metadata_table, "l1", "2026-01-02T00:00:00Z") + _put_legacy_row(sessions_metadata_table, "l2", "2026-01-01T00:00:00Z") + + real_table = sessions_metadata_table + + class _GsiFailingTable: + def query(self, **kwargs): + if kwargs.get("IndexName") == "SessionRecencyIndex": + raise ClientError( + {"Error": { + "Code": "ValidationException", + "Message": "The table does not have the specified index: SessionRecencyIndex", + }}, + "Query", + ) + return real_table.query(**kwargs) + + class _FakeResource: + def Table(self, _name): + return _GsiFailingTable() + + real_resource = boto3.resource + monkeypatch.setattr( + boto3, "resource", + lambda svc, **kw: _FakeResource() if svc == "dynamodb" else real_resource(svc, **kw), + ) + + sessions, token = await list_user_sessions("u1") + assert [s.session_id for s in sessions] == ["l1", "l2"] + assert token is None + + class TestStoreUserDisplayText: """Tests for the displayText feature (D# records).""" diff --git a/backend/tests/test_seed_system_admin_jwt.py b/backend/tests/test_seed_system_admin_jwt.py index 44bb790c8..94f5319b3 100644 --- a/backend/tests/test_seed_system_admin_jwt.py +++ b/backend/tests/test_seed_system_admin_jwt.py @@ -131,7 +131,7 @@ def test_creates_default_tools(self, dynamodb_table): """Creates the default tool entries.""" result = seed_default_tools(TABLE_NAME, REGION) - assert result.created == 6 + assert result.created == 7 assert result.failed == 0 # Verify fetch_url_content @@ -207,13 +207,27 @@ def test_creates_default_tools(self, dynamodb_table): assert item["enabledByDefault"] is True assert item["isPublic"] is True + # Verify create_word_document (single toggle for the whole Word toolset) + resp = dynamodb_table.get_item( + Key={"PK": "TOOL#create_word_document", "SK": "METADATA"} + ) + item = resp["Item"] + assert item["toolId"] == "create_word_document" + assert item["displayName"] == "Word Documents" + assert item["category"] == "document" + assert item["protocol"] == "local" + assert item["enabledByDefault"] is False + assert item["isPublic"] is True + assert item["GSI1PK"] == "CATEGORY#document" + assert item["GSI1SK"] == "TOOL#create_word_document" + def test_skips_existing_tools(self, dynamodb_table): """Skips tools that already exist.""" seed_default_tools(TABLE_NAME, REGION) result = seed_default_tools(TABLE_NAME, REGION) - assert result.skipped == 6 + assert result.skipped == 7 assert result.created == 0 def test_partial_skip(self, dynamodb_table): @@ -227,7 +241,7 @@ def test_partial_skip(self, dynamodb_table): result = seed_default_tools(TABLE_NAME, REGION) - assert result.created == 5 + assert result.created == 6 assert result.skipped == 1 diff --git a/backend/uv.lock b/backend/uv.lock index 08813d1e9..a9b8a37fb 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -12,7 +12,7 @@ resolution-markers = [ [[package]] name = "agentcore-stack" -version = "1.6.1" +version = "1.7.0" source = { editable = "." } dependencies = [ { name = "aiofiles" }, @@ -116,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.47.0" }, - { name = "strands-agents", extras = ["bidi"], marker = "extra == 'bidi'", specifier = "==1.47.0" }, + { name = "strands-agents", marker = "extra == 'agentcore'", specifier = "==1.48.0" }, + { name = "strands-agents", extras = ["bidi"], marker = "extra == 'bidi'", specifier = "==1.48.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" }, @@ -4634,7 +4634,7 @@ wheels = [ [[package]] name = "strands-agents" -version = "1.47.0" +version = "1.48.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "boto3" }, @@ -4650,9 +4650,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "watchdog" }, ] -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" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/1e/834e4a63c6ad039d10924b5ba8c0651ccb24d7e28fd8c07c1a09b859120f/strands_agents-1.48.0.tar.gz", hash = "sha256:bfbf5af9d8f1cb348b617d5f8d17b949ef3c3fdd74fa8c849f1fda46885449c2", size = 1174326, upload-time = "2026-07-17T14:03:45.509Z" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/25/67/67d1e267405f6ae119cc0412891f66997a5a97eed2dbba18fc430a9ac00a/strands_agents-1.48.0-py3-none-any.whl", hash = "sha256:7118a644e844ab6ef77f4bc9883b7082b2e7a309bcd689cc5ef5345e07abcc9e", size = 612156, upload-time = "2026-07-17T14:03:43.06Z" }, ] [package.optional-dependencies] diff --git a/docs/kaizen/research/2026-07-10.md b/docs/kaizen/research/2026-07-10.md new file mode 100644 index 000000000..4409fe0ca --- /dev/null +++ b/docs/kaizen/research/2026-07-10.md @@ -0,0 +1,213 @@ +# Kaizen Research — Friday, July 10, 2026 +> Scan window: July 3 – July 10, 2026 (7 days) +> Web budget: ~58/50 used (modest overage — 13 subagents + version-pin registry churn; see Web Budget block). + +## TL;DR + +**Quiet external week, loud internal one — and they point the same direction.** The ecosystem is in a lull before the July 28 MCP RC GA, so the week's real signal is a *second, independent* reliability bug in the AgentCore SDK we haven't upgraded: **#571 — `AgentCoreMemorySessionManager` reorders events across processes (emits `tool_result` before `tool_use`), a class-level-counter regression that corrupts multi-replica Runtime deployments** (fixed upstream, pending a release after 1.17.0). It lands next to the still-unadopted **#482 SSE-deadlock fix (in 1.17.0)** — and both bite the exact surfaces the product *just* leaned harder on: 1.1.0/1.2.0 shipped **Scheduled Runs + Memory Spaces**, which make AgentCore Memory and multi-replica Runtime load-bearing. **The #1 idea is unchanged from last week and now doubly-forced: bump `bedrock-agentcore` off 1.9.1.** The most pressing internal signal is that last week's two recommended-Ship dep bumps (agentcore, Strands) **did not land** while two feature-dense releases did — the exposure grew as the feature surface grew. + +## External Scan + +### What's moving this week + +The shape of the week is **a pre-RC lull with two concrete reliability tremors.** No new frontier model reached Bedrock (OpenAI GPT-5.6 and Gemini 3.5 Pro's slip to July 17 are both off-Bedrock; Anthropic's week was governance/culture posts), no pricing change, no reference-repo commits, and the MCP blog is frozen until the July 28 final. Against that quiet backdrop, three things actually moved: **Strands shipped 1.46 then 1.47 (today)** — the headline is `continue_on_error` on the MCP client, a direct resilience lever for our flaky external/Gateway MCP servers; **FastMCP flipped its Host/Origin validation twice in four days** (3.4.3 hardened SSRF but broke serverless/reverse-proxy — our exact Lambda-behind-Gateway topology — and 3.4.4 restored it), making ≥3.4.4 the safe floor for our externally-hosted servers; and the **AgentCore SDK surfaced #571**, a fresh multi-replica Memory-corruption bug distinct from the known #564. On UX, Vercel's AI SDK matured tool-approval from last week's basic human-in-the-loop into a **server-side policy layer with cryptographically-signed approvals**, and MCP Apps' host matrix expanded (M365 Copilot, Goose, Postman, Archestra) — validating our SEP-1865 host implementation as on-spec. The surprise is the convergence: independent sources (agentcore #571, Strands 1.47 `continue_on_error`, FastMCP compat) all point at **MCP + Memory resilience** — the same paths Scheduled Runs and Memory Spaces just promoted to production-critical. + +### Notable items by source + +> **Annotation conventions:** +> - `*relevance*:` — impact-on-existing-code lens. +> - `*unlocks*:` — capability-unlock lens (new primitive / new product surface). + +#### AWS Bedrock / AgentCore +- **AgentCore Runtime now publishes an `ActiveSessionCount` CloudWatch metric** — Per-minute gauge under `AWS/Bedrock-AgentCore` (dimensioned by `Service`), the only net-new July release-note entry. Lets you alarm on the concurrent-session quota you're consuming. — https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/release-notes.html — *relevance*: our inference-api runtime; pair with the SSE 600s-timeout debugging note. Low-effort ops win, no code change. — *unlocks*: early-warning on session-leak/exhaustion — a defensive pairing with the #482 deadlock exposure (a hung container shows session pileup before the 429). +- **New Bedrock console: model-compare + eval projects** — Summit-era console refresh; console-only, no API/SDK surface. — https://aws.amazon.com/blogs/aws/top-announcements-of-the-aws-summit-in-new-york-2026/ — *relevance*: marginal (humans picking model ids); nothing to integrate. +- Everything else on the AgentCore release-notes page (Gateway stateful MCP sessions, Identity Secrets Manager ARNs, runtime quota increases, managed Harness GA, Managed KB GA) sits under **June 2026** — already logged. No new model/region/quota in-window. + +#### Strands Agents +**Latest `strands-agents==1.47.0` (Jul 10 — today); we pin 1.40.0 (May 14) — now 7 minors behind.** The dual-language monorepo lumps TS + Python commits into auto-drafted release notes; `CHANGELOG.md` no longer exists at repo root (use the Releases API). Only breaking change 1.45→1.47 is an irrelevant in-process memory-store rename (`LocalMemoryStore`→`TestMemoryStore`, not AgentCore Memory). +- **1.47.0 — `continue_on_error` on the MCP client (#3101)** — A flaky MCP server no longer aborts the whole turn. — https://github.com/strands-agents/sdk-python/releases/tag/python%2Fv1.47.0 — *relevance*: our external/Gateway MCP resilience (`FilteredMCPClient` / gateway targets) — a flaky server currently kills the turn. — *unlocks*: graceful degradation across multi-source tool turns; the most directly useful new capability in the 1.40→1.47 jump. +- **1.47.0 — durable message identifiers (#2836)** — *relevance*: touches session-persistence / interrupted-turn machinery (stable ids across rehydration). +- **1.46.0 — surface `cache_read`/`cache_write` input tokens in the metadata chunk (#2302)** (Anthropic provider) + **OTel `tool_trace` on interrupted tool calls (#3031)** — *relevance*: the cache-token accounting touches the same surface as `CountTokensBedrockModel` / the context-attribution badge; the interrupted-tool trace maps to our interrupt-resume work. +- **Open issue #3144 — `CacheConfig(strategy="auto")` never caches the system prompt** — *relevance*: **directly questions whether our cache-point config in `to_bedrock_config` actually engages** — a silent full-input-token cost regression if it doesn't. (See idea #3.) Also #3076 (nested agent-as-tool interrupts don't resume across rehydration) and #3120 (`serve_a2a` never hits AgentCore idle timeout — the CLAUDE.md A2A-server note) map to our roadmap. + +#### Reference repo (aws-samples/sample-strands-agent-with-agentcore) +- **No commits in the July 3–10 window** (verified via GitHub API `since/until` → empty). Latest activity is still July 1 (Sonnet 5 `NO_TEMPERATURE_MODELS` + the React/Tailwind frontend modernization) — all captured last week. — https://github.com/aws-samples/sample-strands-agent-with-agentcore/commits/main — *applicability*: nothing to port. If it stays dormant next Friday, drop this source to a **bi-weekly cadence**. + +#### MCP ecosystem +- **No new blog post or spec change July 3–10.** The 2026-07-28 RC stays locked until the July 28 final publish; the beta SDKs (Jun 29), EMA (Jun 18), and the RC itself (May 21) are all pre-window. — https://blog.modelcontextprotocol.io — *relevance*: next week is the GA-cutover scan to watch. Standing watch-item unchanged: the RC's stateless core routes on `Mcp-Method` and lets clients cache `tools/list` to a server-set `ttlMs` — affects how our Gateway MCP targets are fronted (round-robin vs sticky) once we adopt the July-28 SDKs. + +#### FastMCP +**Two in-window releases (was 3.4.2 / Jun 6); latest 3.4.4 (Jul 9).** Not pinned in our repo — lives in the external Lambda MCP server repos behind Gateway, so any auto-tracking build picks these up. +- **v3.4.3 (Jul 5) — SSRF + OAuth hardening** — Streamable HTTP now validates **Host and Origin before session handling** (blocks DNS rebinding + NAT64/IPv6 SSRF bypasses), rejects unsafe OAuth redirect schemes, fixes proxy session-teardown races. — https://github.com/jlowin/fastmcp/releases — *implications*: the stricter Host/Origin guard is the one to verify against the Gateway/Lambda-URL front — it caused a compat regression fixed four days later. +- **v3.4.4 (Jul 9) — regression fix + HF OAuth provider** — Relaxes the 3.4.3 host-origin defaults to **restore compatibility with ASGI/serverless/reverse-proxy deployments** (i.e. exactly our Lambda-behind-Gateway topology). No breaking changes. — https://github.com/jlowin/fastmcp/releases — *implications*: **if any of our external servers briefly ran 3.4.3, the Host/Origin guard could have rejected Gateway-fronted requests. ≥3.4.4 is the safe floor** — recommend pinning externally-hosted servers to `>=3.4.4` rather than tracking latest, given two guard flips in four days. (See Risks.) + +#### Agentic UI/UX patterns +- **Vercel AI SDK — policy-based tool approvals + signed approvals** — Beyond last week's basic human-in-the-loop: a 4-state model (`not-applicable`/`approved`/`denied`/`user-approval`) driven by **per-tool input-inspecting policy functions** (gate on `amount`/`recipient`/`role`) deciding auto-approve vs prompt server-side, plus **cryptographically-signed approvals** (`experimental_toolApprovalSecret`) so tampered approval history is rejected. — https://ai-sdk.dev/docs/agents/tool-approvals — *fit*: pattern-only (Angular signal store) — *where it'd land*: a policy check in the approval hook (`apis/shared`) + a new approval-state on the `tool_use` SSE card. The **policy layer** (not the raw per-call prompt) is the new idea. Enriches the queued [2026-07-03] tool-approval item. +- **MCP Apps — host matrix expanded** — Client list now spans Claude/Desktop, VS Code Copilot, **M365 Copilot, Goose, Postman, MCPJam, Archestra.AI**; the overview reinforces UI-preload-before-tool-call — exactly our early-mount `ui_resource` + `ui_tool_input_partial` design. — https://modelcontextprotocol.io/extensions/apps/overview — *fit*: **direct validation, no port** — our SEP-1865 host implementation is on-spec and interoperable with a growing host set. +- **assistant-ui 0.14.26 (Jul 4) — roving-tabindex keyboard nav** for thread/tool lists (arrow-key nav, Right→"More"); companion `assistant-stream@0.3.25` added server-side MCP connection timeouts. — https://github.com/Yonom/assistant-ui/releases — *fit*: pattern-only — *where it'd land*: a keyboard-nav directive on the chat message-list / session sidebar (signals focus index); a cheap WCAG 2.1 AA win. +- **assistant-ui `JSONGenerativeUI` compiler (0.0.8, Jul 4)** — streamed JSON → rendered components; a "model streams a UI spec → host compiles" pattern maturing as a discrete package. — https://github.com/Yonom/assistant-ui/releases — *fit*: pattern-only (**watch, don't build**) — an alternative to iframe-sandboxed MCP Apps for *trusted first-party* generative UI; a design option for the Agent Designer epic, not a near-term port. + +#### Frontier model announcements +- **Anthropic/Bedrock: quiet week, no capability delta.** July 3–10 posts are governance/culture (Bernanke to the LTBT, "reflect on your Claude usage," "The Making of Claude Code," Fable 5 cyber-safeguards). Opus 4.8 on Bedrock unchanged. — https://www.anthropic.com/news — *relevance*: none to our harness. +- **OpenAI GPT-5.6 (Sol/Terra/Luna) GA + Gemini 3.5 Pro slipped to July 17 (2M context, "Deep Think")** — both **off-Bedrock**; watchlist only. — *relevance*: no change to our Bedrock model set today. Gemini's 2M context is the one delta to re-check if it reaches Vertex/Bedrock. GPT-5.6's split of generation-number from capability-tier naming is a model-selector UX note. + +#### Agent harness patterns +- **Claude Code 2.1.200–206 — loop-resilience + MCP timeout** — `2.1.206` fixes **MCP servers ignoring per-server `request_timeout_ms`** (timing out at the 60s default); `2.1.205` fixes a **user turn silently lost at `--max-turns`** and stale terminal state on resumed background agents; `2.1.203` fixes background sessions hanging on a **stale daemon token**; `2.1.200` flips the permission default to **Manual**. — https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md — *relevance*: the per-tool timeout is a direct resilience item for our slow Gateway/MCP targets (configurable per-tool vs one global). The max-turns loss + stale-token hang reinforce the "surface as error, don't hang" pattern we apply to SSE — audit that our loop flushes pending input at a quota/max-iteration cutoff and refreshes OAuth/token-vault creds mid-stream rather than hanging. +- **(Idea-only) LangChain v3 content-block streaming + async background subagents + improved tool-error propagation** — https://docs.langchain.com/oss/python/releases/changelog — *relevance*: the typed per-channel streaming projection echoes our SSE event-type contract; confirms the industry direction (async subagents + tool-error propagation) our Claude Code baseline already leads. No Anthropic engineering post this week (index tops at Apr 23). + +#### Pricing / quota +- **No change this window.** Sonnet 5 promo still $2/$10 through Aug 31 (reverts $3/$15); Opus 4.8 unchanged. The runtime quota increase (concurrent sessions → 5,000 us-east-1/us-west-2, 200 interactions/s + 25 new-sessions/s) is dated **Jul 1** — pre-window, richer numbers than last week's note but no architectural impact. — https://aws.amazon.com/bedrock/pricing/ + +#### Community + GitHub issues +- **AgentCore SDK #571 — `AgentCoreMemorySessionManager` reorders events across processes** (emits `tool_result` before `tool_use`, model rejects the turn). Root cause: `_get_monotonic_timestamp()` inflates by a full 1s on collision and holds counter state at *class* level, so separate processes corrupt each other's ordering (regression from PR #83). **Fixed via PR #572 (updated Jul 9); lands in a version after 1.17.0.** — https://github.com/aws/bedrock-agentcore-sdk-python/issues/571 — **NEW, high relevance:** distinct from #564; the class-level counter directly bites multi-replica Runtime deployments like ours. Reinforces the upgrade case beyond the SSE fix — but note the fix is **not yet released** (latest is still 1.17.0), so watch for 1.18. +- **AgentCore SDK #567 — `AgentCoreToolSearchPlugin` should accept `tool_filters`** (OPEN, Jul 6) — https://github.com/aws/bedrock-agentcore-sdk-python/issues/567 — *relevance*: upstream converging on the filtering primitive we hand-rolled in `FilteredMCPClient` — watch for a native replacement (subtraction candidate). #564 (eventually-consistent `ListEvents` drops history) still OPEN, unresolved even in 1.17.0. +- **Anthropic cookbook — "plan-big-execute-small" coordinator cost pattern (PR #754, merged Jul 6)** — large model plans, small models execute, with an architecture diagram. — https://github.com/anthropics/anthropic-cookbook/commits/main — *relevance*: maps onto our multi-model Strands/Converse routing and the Nova-Micro-for-titles pattern; a candidate reference for a cost-tiered coordinator (cf. tool-search token-bloat). +- **HN/community quiet on our stack this week** — no in-window threads; third-party "when to skip the managed Harness" writeups continue to validate the #570 GO-with-boundary stance. — https://clawaws.com/blog/agentcore-harness-explained/ + +#### opencode (harness reference) +- **v1.17.14–18 (Jul 6–9); latest v1.17.18.** — https://github.com/anomalyco/opencode/releases — *Tooling*: v1.17.14 added a **"code mode MCP adapter for confined orchestration scripts"** (sandboxed script-driven tool orchestration beyond one-tool-per-call) + fixed paginated MCP catalogs losing tool metadata — worth a glance vs our multi-protocol ToolRegistry. *Cost*: continued per-model reasoning-effort + prompt-cache routing (xAI/Grok, OpenRouter small-model variants) — reinforces the cheap-vs-capable lever. *Context*: better context-overflow error classification (error-surfacing only, no new compaction machinery). + +#### LibreChat +- **No in-window release; latest confirmed v0.8.7 (Jun 24, 2026 — a genuine 2026 release, last week's "stale" worry resolved).** — https://github.com/danny-avila/LibreChat/releases — carry-over (pattern-only): the v0.8.7 **inline Context Usage gauge** confirms "surface context consumption inline" is now table-stakes (parallels our PR #433 badge); **on-the-fly skill authoring with GitHub sync** is a user/agent-self-authoring divergence from our admin-curated Skills model — worth watching. + +#### Seasonal +- Out of window — none scanned this week. + +### Patterns worth considering + +- **MCP + Memory resilience as the load-bearing surface** — Independent sources this week (agentcore #571 cross-process Memory reorder, Strands 1.47 `continue_on_error`, FastMCP 3.4.4 Gateway compat) all harden the MCP/Memory paths. Scheduled Runs + Memory Spaces (1.1.0) just made those paths production-critical. + - **Where**: Strands 1.47, agentcore SDK #571/#564, FastMCP 3.4.4. + - **Fit**: the two dep bumps we've queued for weeks now sit on top of *concrete, in-the-wild corruption/deadlock bugs* that the new features amplify. `continue_on_error` retires any hand-rolled MCP-abort handling. + - **Verdict**: Worth trying (ship the bumps). +- **Server-side approval policy + integrity, not just a prompt** — Vercel's approval model decides auto-vs-prompt server-side via input-inspecting policy functions, and signs approvals so history can't be forged. + - **Where**: Vercel AI SDK tool-approvals. + - **Fit**: enriches the queued tool-approval item with a policy layer + integrity check; lands in our approval hook (`apis/shared`), not the raw per-call prompt. + - **Verdict**: Worth trying (fold into the existing tool-approval item). + +## Internal Audit + +### Activity (last 7 days) +- **Commits on develop**: ~30 across the 1.1.0 + 1.2.0 release train — Agent Designer (skill/tool/model binding, live preview, param governance, KB-in-designer), Scheduled Runs (dispatcher + worker + interval cadence + Run-now), Memory Spaces (data layer → SPA panel → sharing → export → consolidation), KB Sync. High feature velocity. +- **PRs opened**: 1 open (#615 — target Agents instead of Assistants on scheduled runs) — **merged**: 1.1.0 (#604) + 1.2.0 (#612) release trains — **reverted**: 0. +- **Issues opened (7d)**: 1 — #559 (Epic: Agentic platform primitives — Harness + Registry). **closed**: n/a. +- **CI failures (workflow → count)**: **Nightly Build & Test → 2** (Jul 6, Jul 7 on `main`); Backend Deploy → 1 (Jul 6, develop push); per-PR CI failures on the Agent-Designer-Phase-4 and Memory-A5-SPA branches (both since merged — likely transient). + +### Repeated friction signals +- **Recommended-Ship dep bumps still haven't landed while two feature releases did** (the recurring pattern, now escalating) — the [2026-07-03] review marked **agentcore 1.17 (#482)** and **Strands 1.45** as *Ship first*; a week later the pins are **byte-identical** (strands 1.40.0, bedrock-agentcore 1.9.1), while 1.1.0/1.2.0 shipped Scheduled Runs + Memory Spaces + Agent Designer + KB Sync. + - **Hypothesis**: the kaizen bumps still lack a forcing function that competes with feature work — same as the "hygiene ships under a security label, not a kaizen one" read from 07-03. But the reliability case *strengthened* this week: **#571** is a second Memory-corruption bug in an unadopted version, and the new features increase exposure to exactly the Memory/multi-replica-Runtime surfaces #482/#571 corrupt. + - **Fix candidate**: ship the **agentcore bump** as an incident-prevention PR framed against Scheduled Runs/Memory Spaces (not hygiene), then the Strands 1.47 keystone with `continue_on_error`. Both now have concrete, feature-tied forcing functions. +- **Nightly still not confidently green** — failed Jul 6 and Jul 7 on `main`. The [2026-06-19] nightly item stays open; #518 was incomplete per last week. Still the dep-bump gate. + +### Version-pin lag +| Dep | Pinned | Latest | Release date | Lag | Notes | +|---|---|---|---|---|---| +| strands-agents | 1.40.0 | **1.47.0** | 2026-07-10 | 7 minors / 57d | 🔴 moved today. `continue_on_error` (#3101), durable msg ids; only breaking = in-process memory-store rename (N/A) | +| bedrock-agentcore | 1.9.1 | 1.17.0 | 2026-07-02 | 8 minors / 50d | **#482 SSE-deadlock fixed in 1.17.0; #571 Memory-reorder fix pending a post-1.17.0 release** — we're exposed to both | +| boto3 | 1.43.9 | 1.43.45 | 2026-07-09 | 36 patches / 55d | daily API-model patches; non-breaking | +| fastapi | 0.136.1 | 0.139.0 | 2026-07-01 | 3 minors / 68d | unchanged vs last week; 0.x minors can carry breaks | +| docling | 2.81.0 | **2.111.0** | 2026-07-08 | 33 minors / 109d | 🔴 moved (2.109→2.111); still blocks #405 `.txt` uploads | +| @angular/core | 21.2.17 | **22.0.6** | 2026-07-08 | **1 major** | 🔴 moved (22.0.5→22.0.6); major migration, not a kaizen bump — hold | +| vitest | 4.1.5 | 4.1.10 | 2026-07-06 | 5 patches | 🔴 moved (4.1.9→4.1.10); patch-only, safe | +| @analogjs/vitest-angular | 3.0.0-alpha.30 | 3.0.0-alpha.61 | 2026-07-08 | 31 alphas | `latest` tag is 2.6.3 (older track); we track 3.x alpha deliberately | +| aws-cdk-lib | 2.260.0 | 2.261.0 | 2026-07-02 | 1 release | no new release this week; non-breaking | +| constructs | 10.6.0 | 10.6.0 | 2026-03-23 | 0 | ✅ up to date | +| fastmcp (unpinned) | — | 3.4.4 | 2026-07-09 | — | external servers only; **≥3.4.4 is the safe floor** (3.4.3 Host/Origin guard breaks Gateway-fronted deployments) | + +### Retirement candidates +- **Reference repo (aws-samples/sample-strands-agent-with-agentcore) → consider bi-weekly cadence** — zero commits this window; dormant since July 1. Not a source to drop, but a weekly full-subagent scan is over-provisioned while it's quiet. Re-check next Friday. +- **`AgentCoreToolSearchPlugin` `tool_filters` (SDK #567)** — if this native filtering primitive ships, it's a subtraction candidate against our hand-rolled `FilteredMCPClient`. Watch, don't act. +- **`angualar-best-practices` skill (typo'd, 185+ days)** — carried from prior reviews; still **not** a confirmed retirement (verify usage before acting). No new dormant skills this week. + +### Risks introduced this week + +- **We are now exposed to TWO unadopted AgentCore SDK reliability bugs** — #482 (SSE deadlock, fixed in 1.17.0) and **#571 (cross-process Memory event-reorder corruption, fix pending)** — https://github.com/aws/bedrock-agentcore-sdk-python/issues/571 — Scheduled Runs (multi-replica Runtime) and Memory Spaces (heavier Memory use) amplify exactly these failure classes. #571's fix isn't released yet, so 1.17.0 closes #482 but not #571 — watch for 1.18. +- **FastMCP 3.4.3 Host/Origin guard can reject Gateway-fronted requests** — https://github.com/jlowin/fastmcp/releases — any externally-hosted MCP server that auto-tracked latest between Jul 5–9 could have started 403-ing Gateway traffic; **≥3.4.4 is the safe floor.** Verify our external server pins. +- **Prompt caching may silently not engage** — Strands #3144 (`CacheConfig(strategy="auto")` never caches the system prompt) — https://github.com/strands-agents/sdk-python/issues — if our `to_bedrock_config` cache-point config is silently inert, every turn pays full input-token cost. Auditable now that 1.46 surfaces `cache_read`/`cache_write` tokens. (Idea #3.) +- **Nightly green unconfirmed** — failed Jul 6–7 on `main`; the dep bumps (#1/#2) would land on a suite whose status isn't trustworthy. +- **Angular 22 major lag widening** (now 22.0.6) — carried watch item; schedule a migration spike, don't bump reactively. + +## Ideas — Top 5 (ranked) + +| # | Idea | Surface | Effort | Impact | Subtracts? | Unlocks? | +|---|---|---|---|---|---|---| +| 1 | Bump `bedrock-agentcore` off 1.9.1 — now double-forced (#482 SSE deadlock + NEW #571 Memory-reorder), exposure grown by Scheduled Runs/Memory Spaces | backend | M | H | Retires queued [2026-05-22] #482 guard | — | +| 2 | Bump Strands 1.40 → 1.47; adopt `continue_on_error` MCP resilience (#3101) + hook ordering | backend | M–H | H | Candidate: hand-rolled MCP-abort handling; custom cache-point plumbing | Flaky Gateway/external MCP no longer aborts the turn; `Limits` cost caps | +| 3 | Audit whether prompt caching actually engages in `to_bedrock_config` (Strands #3144) | backend | L–M | M–H | — (cost-correctness audit) | Potentially large per-turn cost cut if caching is silently off | +| 4 | Tool-approval **policy layer** + signed approvals (Vercel AI SDK) — evolve the queued approval item | frontend + backend | M | M | Replaces ad-hoc synthetic-error approval handling | Server-side auto-vs-prompt policy; tamper-proof approval history | +| 5 | Wire a CloudWatch `ActiveSessionCount` alarm on the inference-api runtime | infra | L | M | — (ops addition — justified: early-warning for the #482 failure class) | Catches session-leak/exhaustion before a 429 | + +### 1. Bump `bedrock-agentcore` off 1.9.1 — now double-forced (#482 + #571) +- **Source**: https://github.com/aws/bedrock-agentcore-sdk-python/pull/563 (#482, in 1.17.0) + **NEW** https://github.com/aws/bedrock-agentcore-sdk-python/issues/571 (Memory reorder, fix pending); internal exposure grown by Scheduled Runs + Memory Spaces. +- **Surface area**: `backend/pyproject.toml`, inference-api chat router, `AgentCoreMemorySessionManager` usage; full local pytest suite. +- **Change**: bump to 1.17.0 to close #482 now; **track 1.18** for the #571 fix (not yet released) and bump again when it lands. Verify the async→sync streaming-bridge fix is active on `/invocations`; drop any planned hand-written #482 guard. +- **Subtracts**: retires the queued [2026-05-22] hand-written #482 guard — library-native subtraction. +- **Effort × Impact**: Med × High. +- **Verdict**: Worth trying — **highest priority; two Memory/Runtime corruption bugs sit in versions we skip, and the week's new features amplify them.** Supersedes/updates the [2026-07-03] agentcore queue item with the #571 evidence + the feature-surface-growth forcing function. + +### 2. Bump Strands 1.40 → 1.47; adopt `continue_on_error` (#3101) + hook ordering +- **Source**: Strands 1.46–1.47 (https://github.com/strands-agents/sdk-python/releases); supersedes the [2026-07-03] 1.45 queue item. +- **Surface area**: `backend/src/agents/main_agent/` (hooks, `to_bedrock_config`, compaction), `FilteredMCPClient`/gateway targets, `CountTokensBedrockModel`. +- **Change**: bump to 1.47.0; **adopt `continue_on_error` on the MCP client** so a flaky external/Gateway MCP server degrades gracefully instead of aborting the turn; adopt optional hook ordering (#2559) for the OAuth-consent + tool-approval `BeforeToolCall` sequence through the tool-fold; audit `cache_tools_ttl` / `context_manager="auto"` / `Limits` (per decisions.md 2026-05-18 — not a bare compaction swap). Only breaking change is the N/A memory-store rename. +- **Subtracts**: candidate — hand-rolled MCP-abort handling; custom cache-point plumbing; runaway-guard via `Limits`. +- **Unlocks**: graceful multi-source MCP degradation; `Limits` per-invocation cost caps; deterministic hook ordering (the enabler for idea #4). +- **Effort × Impact**: Med–High × High. +- **Verdict**: Worth trying — after #1. `continue_on_error` is newly relevant given Scheduled Runs lean on external tools unattended. + +### 3. Audit whether prompt caching actually engages in `to_bedrock_config` (Strands #3144) +- **Source**: **NEW** — Strands open issue #3144 (`CacheConfig(strategy="auto")` never caches the system prompt); 1.46 now surfaces `cache_read`/`cache_write` tokens in the metadata chunk (#2302). +- **Surface area**: `backend/src/agents/main_agent/` cache-point config in `to_bedrock_config`; the metadata/usage path feeding `CountTokensBedrockModel` + the context-attribution badge. +- **Change**: assert cache points are actually written and read — inspect `cache_read`/`cache_write` token counts across a multi-turn session; if the system prompt (or tool schema) isn't caching, fix the cache-point placement. Cheap to measure now that the tokens are surfaced. +- **Subtracts**: no — a cost-correctness audit (may confirm we're fine, or expose a silent full-input-token regression). +- **Unlocks**: potentially large per-turn cost reduction if caching is silently off; a verifiable caching invariant. +- **Effort × Impact**: Low–Med × Med–High. +- **Verdict**: Worth trying — cheapest high-upside item this week; the measurement is nearly free. + +### 4. Tool-approval policy layer + signed approvals (evolve the queued approval item) +- **Source**: Vercel AI SDK tool-approvals (https://ai-sdk.dev/docs/agents/tool-approvals); builds on the queued [2026-07-03] tool-approval item. +- **Surface area**: tool-approval `BeforeToolCall` hook (`apis/shared`), `tool_use`/`tool_result` SSE contract, frontend tool-call card + signal store; reuses `beginContinuationStreaming`. +- **Change**: add a **server-side policy layer** deciding auto-approve vs `user-approval` via per-tool input-inspecting functions (gate on the tool's args, not just its identity); render the approval-requested state on the tool card with input args shown; optionally sign approvals so history can't be forged. Auto-resume once all approvals in a turn resolve. +- **Subtracts**: replaces ad-hoc synthetic-error approval handling with explicit lifecycle states. +- **Unlocks**: fine-grained auto-vs-prompt policy; tamper-proof approval audit; closes the "approval hook can't see through the tool-fold" hole (pairs with #2's hook ordering). +- **Effort × Impact**: Med × Med. +- **Verdict**: Worth trying — sequence after #2's hook ordering lands. Fold into the existing queued approval item rather than a separate track. + +### 5. Wire a CloudWatch `ActiveSessionCount` alarm on the inference-api runtime +- **Source**: **NEW** — AgentCore Runtime `ActiveSessionCount` metric (https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/release-notes.html). +- **Surface area**: infrastructure — a CloudWatch alarm on the inference-api runtime's `AWS/Bedrock-AgentCore` `ActiveSessionCount` gauge (PlatformStack observability). +- **Change**: alarm when concurrent sessions approach the (raised) quota; a hung container from the #482 deadlock manifests as session pileup — this catches it before a 429 and before users on unrelated sessions report stalls. +- **Subtracts**: no — an ops addition; justified as cheap early-warning for the exact failure class idea #1 fixes (defense-in-depth while the bump is pending). +- **Unlocks**: proactive detection of session-leak/exhaustion and the #482 hang. +- **Effort × Impact**: Low × Med. +- **Verdict**: Worth trying — low-effort ops win that pairs with #1. + +## Take + +The system is trending *with* the ecosystem, but this week the story is discipline, not discovery: the external world was quiet, and the loudest signal is that **the reliability bumps we've recommended for weeks still haven't shipped — while the product added exactly the features (Scheduled Runs, Memory Spaces) that make the un-fixed bugs more dangerous.** The single change that matters most is still the **`bedrock-agentcore` bump**, now doubly-forced by a second Memory-corruption bug (#571) landing next to the #482 deadlock. Phil would notice idea #3 first as an operator if it fires — silent caching-off would be a real bill — but idea #1 is the one to ship before the next incident, and it's cheaper to frame it against Scheduled Runs than as hygiene. If only one thing lands this cycle, land the agentcore bump; if two, add the Strands 1.47 `continue_on_error` keystone, because unattended scheduled runs against flaky external tools are a turn-aborting hazard we can now configure away. + +--- + +## Sources Scanned + +| # | Source | URL | Accessed | Items | +|---|---|---|---|---| +| 1 | AWS Bedrock/AgentCore What's New + release notes | https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/release-notes.html | 2026-07-10 | 2 | +| 2 | Strands Agents releases (monorepo) + PyPI | https://github.com/strands-agents/sdk-python/releases | 2026-07-10 | 5 | +| 3 | Reference repo commits | https://github.com/aws-samples/sample-strands-agent-with-agentcore/commits/main | 2026-07-10 | 0 (dormant) | +| 4 | MCP blog / spec | https://blog.modelcontextprotocol.io | 2026-07-10 | 1 (quiet) | +| 4a | FastMCP releases + PyPI | https://github.com/jlowin/fastmcp/releases | 2026-07-10 | 2 | +| 4b | Agentic UI/UX (Vercel AI SDK, assistant-ui, MCP Apps) | https://ai-sdk.dev/docs/agents/tool-approvals | 2026-07-10 | 4 | +| 5 | Frontier models (Anthropic/OpenAI/Google/Meta) | https://www.anthropic.com/news | 2026-07-10 | 3 | +| 6 | Agent harness (Claude Code CHANGELOG + LangChain) | https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md | 2026-07-10 | 2 | +| 6a | opencode releases | https://github.com/anomalyco/opencode/releases | 2026-07-10 | 3 | +| 7 | Bedrock pricing | https://aws.amazon.com/bedrock/pricing/ | 2026-07-10 | 0 (no change) | +| 8 | AgentCore SDK / toolkit issues | https://github.com/aws/bedrock-agentcore-sdk-python/issues/571 | 2026-07-10 | 4 | +| 9 | Community (HN) + Anthropic cookbook | https://github.com/anthropics/anthropic-cookbook/commits/main | 2026-07-10 | 2 | +| 12 | LibreChat releases | https://github.com/danny-avila/LibreChat/releases | 2026-07-10 | 0 (no in-window) | +| 19 | Version pins (PyPI + npm registry JSON) | https://pypi.org/pypi/strands-agents/json | 2026-07-10 | 10 deps | + +## Web Budget + +Used: **~58 / 50 requests** (modest overage). +Skipped (unreachable / rate-limited): Reddit (domain-blocked for WebFetch — WebSearch summaries only); one Anthropic "reflect on your Claude usage" post body (guessed URL 404'd — flagged title-only, not verified). +Skipped (other): none material. +Notes: overage driven by the **13-subagent fan-out** + the version-pin subagent fighting registry JSON parsing (an early WebFetch summarizer misparsed truncated dates; a curl+jq fallback produced authoritative numbers) and the Strands subagent disambiguating the dual-language monorepo release tags. Signal density was moderate — the material items (agentcore #571, Strands 1.47 `continue_on_error`, FastMCP 3.4.4 Gateway compat, Strands #3144 caching) justified the fan-out; frontier/pricing/reference were dry and could be lighter next quiet week. diff --git a/docs/kaizen/research/2026-07-17.md b/docs/kaizen/research/2026-07-17.md new file mode 100644 index 000000000..61107b21d --- /dev/null +++ b/docs/kaizen/research/2026-07-17.md @@ -0,0 +1,211 @@ +# Kaizen Research — Friday, July 17, 2026 +> Scan window: July 10 – July 17, 2026 (7 days) +> Web budget: ~55/50 used (modest overage — 11 subagents + version-pin registry churn; see Web Budget block). + +## TL;DR + +**The release we've queued for six weeks finally exists, and it landed inside this window.** `bedrock-agentcore` **1.18.0 shipped July 10** and carries the **#571 cross-process Memory event-reorder fix** (PR #572/#573) on top of the **#482 SSE-deadlock fix** already in 1.17.0 — one bump off our pinned **1.9.1** closes both. This matters more than last week because the internal delta is enormous: **147 non-merge commits** across releases 1.3.0→1.6.1 shipped Agent Designer, Scheduled Runs, Memory Spaces, conversation-sharing, and — most tellingly — a **1.6.0 reliability cluster** (single-flight lease, restore-time tool-pairing repair, tab-switch SSE fix) that *hand-built* protection against the exact multi-replica Memory-ordering corruption class the unadopted SDK fix addresses upstream. **The #1 idea is unchanged and now target-1.18.0: bump `bedrock-agentcore` off 1.9.1.** Strands is now current at **1.47.0** (last week's bump shipped, closing that queue item). External week was otherwise quiet — a pre-July-28 MCP-RC lull. + +## External Scan + +### What's moving this week + +The shape of the week is **a quiet ecosystem against a very loud repo.** Externally it's the pre-RC lull: the MCP 2026-07-28 spec is still locked (no in-window movement), the reference repo is dormant a second straight week, FastMCP holds at 3.4.4, LibreChat and NN/g were silent, and no new frontier model reached Bedrock with an actionable capability delta (Gemini 3.5 Pro's rumored July-17 launch is off-Bedrock/watchlist). The one concrete upstream event that *does* matter is a version number: **`bedrock-agentcore` 1.18.0 (Jul 10)** — the release we've been waiting on since the #482 fix, now with #571 folded in. The only other on-Bedrock signal is **GPT-5.6 reportedly reaching Bedrock (Mantle) ~Jul 13** (conflicting sources — flagged for verification) and an AWS ML-blog **Strands multi-agent (Swarm vs Graph) reference build** that is the closest public mirror of our own architecture. On the harness side, Claude Code (2.1.207–212) and opencode (1.18.x) **converged on the same new safety rail** — bounded sub-agent delegation depth/count — while assistant-ui shipped an **SSE-decoder hardening** release that lands squarely on the parser surface we spent all of 1.6.0 stabilizing. + +### Notable items by source + +> **Annotation conventions:** +> - `*relevance*:` — impact-on-existing-code lens. +> - `*unlocks*:` — capability-unlock lens (new primitive / new product surface). + +#### AWS Bedrock / AgentCore +- **`bedrock-agentcore` SDK 1.18.0 (Jul 10) — #571 cross-process Memory reorder fix + LangGraph payment integration** — Ships PR #572 ("order AgentCore Memory events at millisecond resolution") + PR #573 ("floor event timestamps to milliseconds"), closing the class-level-counter regression that made `AgentCoreMemorySessionManager` emit `tool_result` before `tool_use` across processes. — https://github.com/aws/bedrock-agentcore-sdk-python/releases — *relevance*: **the keystone bump** (see idea #1); we're on 1.9.1, ~9 minors behind, exposed to both #571 and #482 while Scheduled Runs + Memory Spaces + conversation-sharing now lean hard on Memory. Validate the ms-flooring against `TurnBasedSessionManager` flush ordering before rolling. +- **GPT-5.6 (Sol / Terra / Luna) reportedly GA on Bedrock via Mantle (~Jul 13)** — Three-tier GPT-5.6 family surfaced on the AWS What's New feed as Bedrock/Mantle-served. **Conflicting signal**: the frontier-model scan still classes GPT-5.6 as off-Bedrock, and the feed slug wasn't captured — **verify before acting.** — https://aws.amazon.com/about-aws/whats-new/recent/feed/ — *relevance*: if confirmed, net-new model IDs for the catalog that ride the *existing* `apiMode=responses` + per-model-region Mantle path we built for gpt-5.4 (mechanical add). — *unlocks*: a cheaper/newer Responses-API tier alongside gpt-5.4. **Gate on Bedrock-availability confirmation.** +- **AWS ML blog: "Multi-agent social intelligence with Strands Agents + Amazon Bedrock" (Jul 14)** — Thrad.ai reference build benchmarking **Swarm vs Graph** orchestration on latency/cost/quality; CDK stack = Runtime (microVMs + IAM) + Gateway (single MCP endpoint) + Strands `MCPClient` dynamic tool discovery at startup. — https://aws.amazon.com/blogs/machine-learning/multi-agent-social-intelligence-with-strands-agents-and-amazon-bedrock/ — *relevance*: closest public mirror of our exact architecture; the Swarm/Graph cost-latency numbers are directly comparable if/when we formalize a multi-agent path (A2A is client-only today). +- **AgentCore release-notes page: nothing new in-window** — the only July entry remains `ActiveSessionCount` (already logged). Two ML how-to posts (agentic vision via MCP servers Jul 15; Managed KB enterprise-search Jul 16) are adoption references, not new capability. — https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/release-notes.html + +#### Strands Agents +**Latest `strands-agents==1.47.0`; we now pin 1.47.0 — current, zero lag.** The [2026-07-03] queued 1.40→1.45 bump **shipped** (commit `42e69bc7`, "upgrade Strands to 1.47.0"), overshooting the target. No Python release after 1.47.0 in-window (only a TS-side `typescript/v1.9.0`). +- **#3144 — `CacheConfig(strategy="auto")` never caches the system prompt (OPEN, updated Jul 16; fix PR #3145 open, not merged)** — `strategy="auto"` only sets a breakpoint at the final user message; a static system prefix is written-but-not-read (~94% read ratio only after a manual system `cachePoint`). — https://github.com/strands-agents/sdk-python/issues/3144 — *relevance*: **validates keeping our manual cache-point injection in `to_bedrock_config`** — do NOT switch to `strategy="auto"` expecting system-prompt caching. Feeds idea #2 (the multi-provider caching audit, internal issue #642). +- **#3317 — strands-mcp search quality/ergonomics (tracking, OPEN Jul 17)** — umbrella for MCP search/tooling ergonomics. — https://github.com/strands-agents/sdk-python/issues/3317 — *relevance*: watch for `FilteredMCPClient`-adjacent improvements; no behavior change this week. +- No in-window hooks / AgentCore-Memory-session-manager / A2A-server regression touching `TurnBasedSessionManager`, the Before/AfterToolCall hooks, or `CountTokensBedrockModel`. + +#### Reference repo (aws-samples/sample-strands-agent-with-agentcore) +- **No commits July 10–17 (second consecutive dormant window).** Latest activity is still July 1 (Sonnet 5 `NO_TEMPERATURE_MODELS` + React/Tailwind modernization). — https://github.com/aws-samples/sample-strands-agent-with-agentcore/commits/main — *applicability*: nothing to port. **Cadence recommendation: drop this source to bi-weekly** (see Retirement Candidates); the signal is burst-y and low-frequency. + +#### MCP ecosystem +- **No in-window signal; July 28 RC still locked (not final, ~11 days out).** All datable movement clusters before the window (May 21 RC lock, June 18 EMA-stable, June 29 beta SDKs). — https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/ — *relevance*: next Friday is the GA-cutover scan to watch. +- **Standing watch-item sharpened — SEP-2567 also removes the `initialize`/`initialized` handshake (via SEP-2575)**: protocol version + client info + capabilities move into `_meta` on every request, plus a new `server/discover` method. — same RC URL — *relevance*: **high for our MCP Apps host** — the `initialize` `serverInfo` we read for the App-frame `serverName`/`icon` (per CLAUDE.md) is exactly what's being replaced; the icon/title-resolution path needs rework before we adopt the final-spec SDKs. Pair with SEP-2322 Multi-Round-Trip elicitation (request/echo state instead of a held-open SSE) for the App user-input flow. +- **MCP Apps host matrix: no change** (Claude/Desktop, VS Code Copilot, M365 Copilot, Goose, Postman, MCPJam, Archestra) — our SEP-1865 host stays on-spec and interoperable. — https://modelcontextprotocol.io/extensions/apps/overview + +#### FastMCP +- **No release in-window; latest holds at v3.4.4 (Jul 9).** ≥3.4.4 remains the safe floor for externally-hosted Lambda-behind-Gateway servers (restored the ASGI/serverless/reverse-proxy compat that 3.4.3's Host/Origin guard broke). No further Host/Origin/SSRF or transport/Lambda-adapter change this week. — https://github.com/jlowin/fastmcp/releases + +#### Agentic UI/UX patterns +- **assistant-stream@0.3.26 (Jul 16) — spec-complete SSE event decoder** — Standalone SSE-decoder export: parameterized content-types, strict line-ending compliance, and **discarding of unterminated frames**. — https://github.com/Yonom/assistant-ui/releases — *fit*: pattern-only (Angular) — *where it'd land*: our SPA SSE parser service (the one allowlisting `session_title` past Completed-state gating). **Strong fit** — the unterminated-frame + line-ending robustness is exactly the class of edge case behind the interrupt-resume and tab-switch-duplicate-invocation bugs we just fought in 1.6.0. See idea #4. +- **Cursor v3.11 "Side Chats + Conversation Search" (Jul 10)** — Branch an ephemeral sub-conversation off the main thread without derailing it. — https://www.cursor.com/blog — *fit*: pattern-only — *where it'd land*: a session-tree UX over our concurrent-streaming-per-session architecture (parser/loading/abort already keyed by `sessionId`); a side-chat is a child session sharing context but streaming independently. No new SSE event. **Queue-worthy pattern, not a near-term port.** +- **Vercel AI SDK tool-approvals — batching rule (undated, likely not in-window)** — `lastAssistantMessageIsCompleteWithApprovalResponses` gates auto-send until *every* pending approval in the turn resolves. — https://ai-sdk.dev/docs/agents/tool-approvals — *fit*: design cue for `beginContinuationStreaming` when a turn has multiple `oauth_required` providers; enriches the queued [2026-07-03] tool-approval item (not a fresh finding). +- **tw-shimmer@0.4.12 (Jul 16) — Firefox shimmer-speed fix** — https://github.com/Yonom/assistant-ui/releases — *fit*: QA nit — verify our MCP App header-shell + tool-running shimmers don't show the same Firefox regression. No code dependency. +- NN/g, Linear, Anthropic news: **quiet on agentic UI/UX this window** (Anthropic's only in-window post is "Claude for Teachers," a product launch, no UX writeup). + +#### Frontier model announcements +- **Gemini 3.5 Pro (2M context + "Deep Think") — rumored launch ~Jul 17** — Leak-sourced, specs unconfirmed by Google; conflicting reports (one Jul 16 source still reported a missed deadline). **Off-Bedrock (watchlist)** — ships via Google/Vertex; we only reach Gemma through Mantle. — https://finance.biggo.com/news/6f0c6bb2-795f-4c57-9d09-6db691d7638a — *relevance*: the 2M context is the delta to re-check only if it reaches Vertex/Bedrock. +- **Anthropic / Meta — no new frontier model in-window.** Anthropic posts are product/policy ("Claude for Teachers" Jul 14, a Canadian research commitment); no Bedrock/Claude capability change. OpenAI blog returned HTTP 403 (not re-fetched) — no confirmed in-window OpenAI signal beyond the GPT-5.6-on-Bedrock item above. + +#### Agent harness patterns +- **Convergent safety rail — bounded sub-agent delegation (Claude Code 2.1.212 + opencode 1.18.2)** — Claude Code added a per-session **subagent-spawn cap (default 200)** + WebSearch cap "to stop runaway delegation loops"; opencode added a configurable **`subagent_depth`** limit stopping nested sub-agents by default. — https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md · https://github.com/anomalyco/opencode/releases — *relevance*: two independent harnesses shipped the same rail in the same week; maps to our multi-protocol tool architecture + the **unattended Scheduled Runs lane** (highest blast radius for a runaway loop). Pairs with Strands `Limits` — see idea #3. +- **Claude Code 2.1.212 / 2.1.208 — MCP + stream resilience** — 2.1.212 auto-backgrounds MCP tool calls running >2 min and fixes a `continue:false` hook-halt dropped when a tool "fails or completes mid-stream"; 2.1.208 fixes crashes on server-closed HTTP/2 connections + truncated stream-json output. — same CHANGELOG — *relevance*: the >2-min MCP auto-background and mid-stream hook-halt map to our agent loop's MCP-timeout + mid-stream-partial handling; the truncated-stream fix mirrors our "compaction/metadata events at stream tail can be lost on truncation" risk. +- **opencode v1.17.19 (Jul 13) — per-model context pinning + default-off response storage for GPT-5.6** — https://github.com/anomalyco/opencode/releases — *relevance*: reinforces our per-model region/apiMode routing (Mantle Responses refactor); the "Codex context limits for GPT-5.6" pinning is a concrete precedent if GPT-5.6 lands in our catalog. + +#### Pricing / quota +- **No change July 10–17.** Sonnet 5 promo **$2/$10** per 1M in/out holds through Aug 31 (reverts $3/$15); Opus 4.8 unchanged ($6/$30). No July quota change. — https://aws.amazon.com/bedrock/pricing/ + +#### Community + GitHub issues +- **New SDK issues (in-window): #580 (Jul 17) tool-search plugin allow-list; #577 (Jul 13) gen_ai OTel spans omit caller identity + client IP; #567 (Jul 6, OPEN) `tool_filters` for `AgentCoreToolSearchPlugin`.** — https://github.com/aws/bedrock-agentcore-sdk-python/issues — *relevance*: #567/#580 track the native filtering primitive we hand-rolled in `FilteredMCPClient` (subtraction candidate when it ships); #577 matters if we lean on gen_ai spans for per-user attribution. +- **#564 (eventually-consistent `ListEvents` drops history) still OPEN — NOT fixed by 1.18.0** — https://github.com/aws/bedrock-agentcore-sdk-python/issues/564 — *relevance*: upgrading fixes ordering (#571) but not the eventual-consistency read gap; our agent-cache continuity (Memory write-only in cloud) already mitigates. Keep that path primary post-bump. +- **Starter-toolkit: #498 (Jul 12) no API to list/force-terminate active runtime sessions** — https://github.com/aws/bedrock-agentcore-starter-toolkit/issues — *relevance*: a cost-control gap adjacent to our session single-flight/lease work; #549 (`.dockerignore`) doesn't hit us (we ship out-of-band via `backend.yml`). +- **HN: zero in-window threads** on AgentCore/Strands/Bedrock-agent/Claude-Code — nothing to action. + +#### Cookbook / courses +- **Anthropic cookbook — maintenance only; one load-bearing fact: assistant-message *prefill* now returns 400 on current Claude models** (metaprompt notebook fix, PR #771, Jul 10–13). — https://github.com/anthropics/anthropic-cookbook/commit/5d5e2f4 — *relevance*: **cheap grep worth running** — if any Bedrock/Mantle prompt-assembly path in `agents/` or `apis/shared` still prefills an assistant turn to steer output, that's a latent break. No new caching/tool-use/agent-loop/MCP notebooks in-window. + +#### opencode / LibreChat (light references) +- **opencode**: covered under Agent harness above (delegation depth cap; per-model context pinning). +- **LibreChat: no new release; latest holds at v0.8.7 (Jun 24).** Nothing to evaluate this week. — https://github.com/danny-avila/LibreChat/releases + +#### Seasonal +- Out of window — none scanned this week. + +### Patterns worth considering + +- **Bounded delegation as a default safety rail** — Claude Code (spawn cap) and opencode (`subagent_depth`) independently shipped it the same week; the industry is standardizing "cap runaway agent fan-out" as a default, not an option. + - **Where**: Claude Code 2.1.212, opencode 1.18.2. + - **Fit**: our highest-exposure surface is the **unattended Scheduled Runs lane** — a runaway tool loop there burns quota silently with no human watching. Strands `Limits` (available now that we're on 1.47) is the library-native lever; a per-invocation cap on the headless lane is the concrete adoption. + - **Verdict**: Worth trying (scope to the scheduled/headless lane first). +- **Multi-provider caching correctness** — internal issue #642 (Jul 11) + Strands #3144 both point at the same gap: caching semantics diverge across Bedrock Converse / Mantle Responses / OpenAI, and the SDK's auto strategy doesn't even cache the system prompt. + - **Where**: issue #642, Strands #3144, our multi-provider routing (Mantle refactor `67e2653e`). + - **Fit**: we now route three provider shapes; only the Bedrock path has a validated manual cache-point. A Mantle/OpenAI turn may cache nothing — a silent full-input-token cost regression. + - **Verdict**: Worth trying (audit first, then consolidate). + +## Internal Audit + +### Activity (last 14 days — a double window; the July 10 research PR #616 is unmerged) +- **Commits on develop**: **147 non-merge** across releases 1.3.0 → 1.6.1 — Agent Designer (model-param governance, live preview, provider-persistence), Scheduled Runs (target Agents, dispatcher clock de-flake), Memory Spaces (MEMORY.md reserved slug, namespaced routing, app-api env wiring), conversation-sharing S3 offload, distributed turn cancellation, single-flight lease, tab-switch SSE fix, restore-time tool-pairing repair, model RBAC single-source-of-truth, Mantle Responses/region refactor, Sonnet 5 + GPT-5.4 curated cards, OAuth-gated MCP discover. **Very high velocity.** +- **PRs opened**: 1 open against develop — **#616 (the July 10 kaizen research scan, unmerged, no comments)**. — **merged**: the 1.3.0–1.6.1 release train — **reverted**: 0. +- **Issues opened (7d)**: 3 — **#649** Feature: Projects; **#642** Provider-agnostic prompt caching (enhancement, tech debt); **#640** monetize via x402 micropayments (low relevance). — **closed**: n/a. +- **CI failures (workflow → count)**: Nightly Build & Test → 2 (Jul 12–13, curl-pin); Backend Deploy → 3 (Jul 11–13, curl-pin); CI → 1 (Jul 15, tool-pairing branch). **Nightly is GREEN Jul 14–17** after the float-curl fix reached main via 1.5.0. + +### Repeated friction signals +- **Pinned-curl Docker builds broke deploys twice in-window** (Nightly Jul 12–13 + Backend Deploy Jul 11–13) — evidence: `E: Version '8.14.1-2+deb13u3' for 'curl' was not found` across app-api + inference-api image builds (runs 29241546259, 29294178533). Root cause: the Dockerfiles pinned curl to an **exact Debian security-patch version** that the Debian mirror purged. + - **Hypothesis**: Debian rolls its `deb13uN` security patch and drops the prior exact version from the mirror; any exact pin becomes un-installable the moment the patch increments. + - **Fix candidate (partially shipped)**: commit `74cd7b0a` (Jul 13, in 1.5.0) floated the pin to `curl=8.14.1-2+deb13u*` — nightly went green Jul 14. **But `deb13u*` is still a version constraint**: a Debian *series* bump (`8.14.1-2` → `8.14.1-3`, or a base-image minor) re-breaks it. The durable fix is to not pin curl to an upstream version at all (install the latest security patch, or use the base image's `curl-minimal` as the Lambda images already do). **See idea #5** — a simplification that removes a recurring deploy-breaker class. + +### Version-pin lag +| Dep | Pinned | Latest | Lag | Notes | +|---|---|---|---|---| +| bedrock-agentcore | 1.9.1 | **1.18.0 (Jul 10)** | ~9 minors | **#571 (cross-process Memory reorder) fixed in 1.18.0 + #482 (SSE deadlock) in 1.17.0 — we're exposed to both today.** Keystone bump. | +| strands-agents | **1.47.0** | 1.47.0 | 0 | **Current** — the [2026-07-03] bump shipped (`42e69bc7`). Queue item resolvable. Only breaking history is Mistral (N/A). | +| fastapi | 0.136.1 | 0.139.2 | ~3 minors | routine | +| boto3 | 1.43.9 | 1.43.50 | ~41 patches | routine (coupled to the agentcore bump — bump together) | +| pydantic | (latest) | 2.13.4 | — | not pinned exact; informational | +| mcp | (latest) | 1.28.1 | — | informational | +| @angular/core | 21.2.17 | 22.0.7 | **1 major** | Angular 22 — hold; major migration, not a kaizen bump | +| vitest | 4.1.5 | 4.1.10 | 5 patches | routine | +| aws-cdk-lib | 2.260.0 | 2.261.0 | 1 minor | routine | +| constructs | 10.6.0 | 10.7.0 | 1 minor | routine | +| fastmcp (unpinned) | — | 3.4.4 (Jul 9) | — | not pinned; ≥3.4.4 safe floor for external Lambda MCP servers | + +### Retirement candidates +- **Reference-repo source → bi-weekly cadence** — `aws-samples/sample-strands-agent-with-agentcore` has been dormant two consecutive weekly windows (no commits July 3–17; latest activity July 1). Its signal is burst-y; a fortnightly check saves web budget with negligible miss risk. Skill-hygiene subtraction (edit `kaizen-research/SKILL.md` source 3 cadence note — not this run's scope, flag for a skill-maintenance PR). +- **Hand-rolled `FilteredMCPClient` — watch for native replacement** — AgentCore SDK #567/#580 track `tool_filters` on `AgentCoreToolSearchPlugin`; when it ships, our filtering shim becomes a subtraction candidate. Not yet actionable. +- No dormant skills flagged (all `.claude/skills/*` referenced in recent PRs or actively maintained). + +### Risks introduced this week +- **Still exposed to #571 (cross-process Memory event reorder) + #482 (SSE deadlock) at 1.9.1** — https://github.com/aws/bedrock-agentcore-sdk-python/releases — the 1.6.0 reliability work (single-flight lease, tool-pairing repair) reduces the *symptom* surface but the SDK-level ordering bug persists; multi-replica Runtime is the exposure and Scheduled Runs + Memory Spaces + conversation-sharing just increased Memory load. +- **`deb13u*` curl pin is a partial fix** — a Debian series/base-image bump re-breaks all image builds; the failure is invisible until the next deploy (see Friction). +- **Assistant-prefill 400 on current Claude models** — https://github.com/anthropics/anthropic-cookbook/commit/5d5e2f4 — if any prompt-assembly path prefills an assistant turn, it now hard-fails; unverified against our code (cheap grep). +- **SEP-2567 will remove the `initialize` handshake our MCP App header reads for `serverName`/`icon`** — not in-window, but the July 28 RC makes it near-term; the icon/title-resolution path needs rework before adopting final-spec SDKs. + +## Ideas — Top 5 (ranked) + +| # | Idea | Surface | Effort | Impact | Subtracts? | Unlocks? | +|---|---|---|---|---|---|---| +| 1 | Bump `bedrock-agentcore` 1.9.1 → 1.18.0 (closes #571 + #482) | backend | M | H | Retires the queued [2026-05-22] hand-written #482 guard; complements the just-shipped tool-pairing repair | — | +| 2 | Audit multi-provider prompt caching (issue #642 + Strands #3144) | backend | L–M | M–H | Consolidates per-provider cache logic; kills silent Mantle/OpenAI token-cost regression | — | +| 3 | Adopt Strands `Limits` on the unattended Scheduled Runs / headless lane | backend | L–M | M | Retires any hand-rolled turn/cost guard; library-native | Per-invocation cost/turn cap on the highest-blast-radius lane | +| 4 | Harden the SPA SSE parser (unterminated-frame + line-ending handling) | frontend | L–M | M | Replaces ad-hoc frame handling with spec-complete decode | Fewer restore/interrupt-resume corruption edge cases | +| 5 | Durable curl fix — stop pinning curl to an upstream version in the Dockerfiles | infra/CI | L | M–H | Removes a recurring deploy-breaker class (the `deb13u*` wildcard is still fragile) | — | + +### 1. Bump `bedrock-agentcore` 1.9.1 → 1.18.0 (closes #571 + #482) +- **Source**: https://github.com/aws/bedrock-agentcore-sdk-python/releases (1.18.0, Jul 10 — PR #572/#573 close #571); #482 fix in 1.17.0 (PR #563); internal 1.6.0 reliability cluster + Memory-heavy features. +- **Surface area**: `backend/pyproject.toml` (coupled `boto3` bump), inference-api chat router + `TurnBasedSessionManager` flush ordering; full local pytest suite (the PR-gate as of #490). +- **Change**: bump the pin to `1.18.0`; verify (a) the async→sync streaming-bridge fix on `/invocations`, and (b) the millisecond event-flooring against our Memory flush ordering; keep the agent-cache continuity path primary (#564 read-gap unfixed). +- **Subtracts**: retires the queued [2026-05-22] hand-written #482 guard (library-native); the #571 ordering fix complements — does not replace — the hand-rolled `_repair_tool_pairing` (belt-and-suspenders on a corruption class the product just leaned harder into). +- **Effort × Impact**: Med × High. +- **Verdict**: Worth trying — **highest priority; active exposure, the release we queued now exists.** Supersedes the [2026-07-03] "→ 1.17.0" queue item (retarget 1.18.0). + +### 2. Audit multi-provider prompt caching (issue #642 + Strands #3144) +- **Source**: internal issue #642 (Jul 11, "Provider-agnostic prompt caching — Bedrock Converse vs Mantle vs OpenAI/Gemini"); Strands #3144 (`CacheConfig(strategy="auto")` never caches the system prompt). +- **Surface area**: `to_bedrock_config` cache-point injection (Bedrock), the Mantle Responses builder (`build_mantle_model` / `_create_mantle_model`), any OpenAI-compatible caching path; `CountTokensBedrockModel` / context-attribution read of cache tokens. +- **Change**: instrument `cache_read` / `cache_write` token ratios per provider (Strands 1.46 surfaces them in the metadata chunk); confirm whether Mantle/OpenAI turns cache at all, and whether the Bedrock manual cache-point still engages post-1.47. Consolidate the per-provider cache decision behind one predicate rather than provider-scattered logic. +- **Subtracts**: consolidates per-provider cache plumbing; kills a silent full-input-token cost regression if a provider path caches nothing. +- **Effort × Impact**: Low–Med × Med–High. +- **Verdict**: Worth trying — a filed internal issue with a library-confirmed failure mode; strongest internal-signal fit after the bump. + +### 3. Adopt Strands `Limits` on the unattended Scheduled Runs / headless lane +- **Source**: convergent harness signal — Claude Code 2.1.212 spawn cap + opencode 1.18.2 `subagent_depth`; Strands `Limits` now available (we're on 1.47). Internal: Scheduled Runs shipped (1.1.0+) and run unattended. +- **Surface area**: the headless/scheduled invocation path (`apis/shared/harness/run_agent_headless` per the [2026-07-06] managed-Harness spike) + the agent loop's per-invocation config. +- **Change**: wire Strands `Limits` (per-invocation token/turn cap) on the scheduled/headless lane first — where a runaway tool loop burns quota with no human watching — before extending to interactive. Dovetails with the queued quota-cooldown work. +- **Subtracts**: retires any need for a hand-rolled `max_turns` guard on the headless lane (library-native). +- **Unlocks**: a first-class cost/turn ceiling on the highest-blast-radius lane — a capability the [2026-07-03] Strands bump listed but that still needs wiring now that the bump landed. +- **Effort × Impact**: Low–Med × Med. +- **Verdict**: Worth trying — the bump unlocked it; the convergent harness rail + unattended lane make it timely. + +### 4. Harden the SPA SSE parser (unterminated-frame + line-ending handling) +- **Source**: assistant-stream@0.3.26 (Jul 16, spec-complete SSE decoder — discards unterminated frames, strict line endings). Internal: the 1.6.0 SSE/restore bug cluster (#653 tab-switch duplicate invocation, tool-pairing repair, single-flight lease). +- **Surface area**: the SPA SSE parser service (the one allowlisting `session_title` past Completed-state gating; keyed per-session per the concurrent-streaming architecture). +- **Change**: adopt the pattern (implement in Angular signals — do NOT add the React dep): discard unterminated/partial SSE frames rather than mis-parsing them, and tighten line-ending handling, on the interrupt-resume + tab-switch paths we just stabilized. +- **Subtracts**: replaces ad-hoc frame handling with a spec-complete decode pass. +- **Effort × Impact**: Low–Med × Med. +- **Verdict**: Worth trying — defensive, and it lands on exactly the surface a full release just hardened. + +### 5. Durable curl fix — stop pinning curl to an upstream version in the Dockerfiles +- **Source**: internal friction — Nightly + Backend Deploy broke Jul 11–13 on `curl=8.14.1-2+deb13u3` "version not found"; partially fixed by floating to `deb13u*` (commit `74cd7b0a`, 1.5.0). +- **Surface area**: `backend/Dockerfile.app-api`, `backend/Dockerfile.inference-api` (line 42, the `apt-get install curl=...` step); `Dockerfile.scheduled-runs` / `kb-sync` if they carry the same pin. +- **Change**: replace the version-pinned `curl=8.14.1-2+deb13u*` with an unpinned `curl` (latest security patch) — or drop to the base image's `curl-minimal` as the Lambda images already do — so a Debian series/base-image bump can't re-break every image build. Keep the HEALTHCHECK intent; the pin was never a supply-chain control (it's a runtime health probe). +- **Subtracts**: removes a recurring deploy-breaker class; the current wildcard only defers the next break to a series bump. +- **Effort × Impact**: Low × Med–High. +- **Verdict**: Worth trying — cheapest durable win; the wildcard band-aid will bite again. + +## Take + +The system is trending hard *toward* the ecosystem — Strands is current, the reliability release cluster mirrors where the industry is (bounded delegation, SSE hardening, Memory-ordering correctness), and the one upstream release that matters landed exactly when we needed it. The single change that matters most is the **`bedrock-agentcore` 1.18.0 bump**: it is simultaneously a subtraction (retires a queued guard), a reliability fix for *two* corruption classes (#571 + #482), and belt-and-suspenders under the exact Memory-heavy features (Scheduled Runs, Memory Spaces, sharing) the last four releases shipped. Phil would notice idea #2 or #5 as an operator (a caching-cost drop, or deploys that stop breaking on curl), but idea #1 is what he'd want in before the next multi-replica Memory incident. The external week was quiet by design — the real story is that a very productive fortnight of feature work left one reliability bump on the table, and the fix for the bug that work was defending against is now one line in `pyproject.toml`. + +--- + +## Sources Scanned + +| # | Source | URL | Accessed | Items | +|---|---|---|---|---| +| 1 | AWS Bedrock/AgentCore What's New + release notes + ML blog | https://aws.amazon.com/about-aws/whats-new/recent/feed/ | 2026-07-17 | 4 | +| 2 | Strands Agents releases + issues | https://github.com/strands-agents/sdk-python/releases | 2026-07-17 | 3 | +| 3 | Reference repo commits | https://github.com/aws-samples/sample-strands-agent-with-agentcore/commits/main | 2026-07-17 | 0 (dormant 2 wks) | +| 4 | MCP blog / RC / apps overview / servers | https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/ | 2026-07-17 | 0 in-window (RC locked) | +| 4a | FastMCP releases + PyPI | https://github.com/jlowin/fastmcp/releases | 2026-07-17 | 0 (holds 3.4.4) | +| 4b | Agentic UI/UX (assistant-ui, AI SDK, Cursor, NN/g, Linear) | https://github.com/Yonom/assistant-ui/releases | 2026-07-17 | 3 | +| 5 | Frontier models (Anthropic/Google/Meta/OpenAI) | https://www.anthropic.com/news | 2026-07-17 | 2 | +| 6 | Agent harness (Claude Code CHANGELOG + Anthropic eng) | https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md | 2026-07-17 | 3 | +| 6a | opencode releases | https://github.com/anomalyco/opencode/releases | 2026-07-17 | 2 | +| 7 | Bedrock pricing | https://aws.amazon.com/bedrock/pricing/ | 2026-07-17 | 0 (no change) | +| 8 | AgentCore SDK / starter-toolkit issues + releases | https://github.com/aws/bedrock-agentcore-sdk-python/releases | 2026-07-17 | 5 | +| 9 | Community (HN) | https://hn.algolia.com/ | 2026-07-17 | 0 | +| 10 | Anthropic cookbook commits | https://github.com/anthropics/anthropic-cookbook/commits/main | 2026-07-17 | 1 | +| 12 | LibreChat releases | https://github.com/danny-avila/LibreChat/releases | 2026-07-17 | 0 (holds v0.8.7) | +| 19 | Version pins (PyPI + npm registry JSON) | https://pypi.org/pypi/bedrock-agentcore/json | 2026-07-17 | 10 deps | + +## Web Budget + +Used: **~55 / 50 requests** (modest overage). +Skipped (unreachable / rate-limited): openai.com/blog (HTTP 403 — no in-window OpenAI signal confirmed; GPT-5.6-on-Bedrock item flagged for verification); NN/g AI topic page (404); Reddit (domain-blocked, per decisions.md 2026-05-18). +Skipped (other): none material. +Notes: overage driven by 11 subagents + the version-pin registry sweep (11 rows). Signal density justified it — the `bedrock-agentcore` 1.18.0 release (with the #571 fix) landing in-window is the single highest-value confirmation of the run, and the GPT-5.6-on-Bedrock conflict needed a second cross-check. diff --git a/docs/kaizen/review-queue.md b/docs/kaizen/review-queue.md index 270b8f2de..5f7eeecc9 100644 --- a/docs/kaizen/review-queue.md +++ b/docs/kaizen/review-queue.md @@ -5,6 +5,81 @@ Items added by `kaizen-research`, consumed by `kaizen-review-prep`. ## Open +### [2026-07-17] Bump `bedrock-agentcore` 1.9.1 → 1.18.0 (closes #571 cross-process Memory reorder + #482 SSE deadlock) +- **Source**: research/2026-07-17.md ▸ Top 5 #1 — bedrock-agentcore 1.18.0 (Jul 10, PR #572/#573 close #571; #482 fix in 1.17.0/PR #563) — https://github.com/aws/bedrock-agentcore-sdk-python/releases +- **Surface**: backend (`backend/pyproject.toml` + coupled `boto3`; inference-api chat router + `TurnBasedSessionManager` flush ordering; full local pytest suite) +- **Effort × Impact**: M × H +- **Subtracts**: yes — retires the queued [2026-05-22] hand-written #482 guard (library-native); the #571 ordering fix complements (not replaces) the just-shipped `_repair_tool_pairing` +- **Status**: open — **highest priority; the release we've queued for six weeks now exists and landed in-window.** SUPERSEDES the [2026-07-03] "→ 1.17.0" item AND the [2026-07-10] "double-forced" item below (which said "bump to 1.17.0, track 1.18 for #571" — 1.18.0 now exists with the #571 fix, so retarget 1.18.0 and consolidate the two into this one). We're on 1.9.1, ~9 minors behind, exposed to both #571 and #482 while Scheduled Runs + Memory Spaces + conversation-sharing lean hard on Memory. Validate ms event-flooring vs our flush ordering; #564 (eventual-consistency read gap) stays unfixed — keep agent-cache continuity primary. + +### [2026-07-17] Audit multi-provider prompt caching (issue #642 + Strands #3144) +- **Source**: research/2026-07-17.md ▸ Top 5 #2 — internal issue #642 (Jul 11); Strands #3144 (`CacheConfig(strategy="auto")` never caches system prompt, fix PR #3145 open) — https://github.com/strands-agents/sdk-python/issues/3144 +- **Surface**: backend (`to_bedrock_config` cache-point injection; Mantle Responses builder `build_mantle_model`/`_create_mantle_model`; OpenAI-compatible path; `CountTokensBedrockModel` cache-token read) +- **Effort × Impact**: L–M × M–H +- **Subtracts**: yes — consolidates per-provider cache logic; kills a silent full-input-token cost regression if a Mantle/OpenAI path caches nothing +- **Status**: open — a filed internal tech-debt issue with a library-confirmed failure mode. Instrument `cache_read`/`cache_write` ratios per provider (Strands 1.46 surfaces them); confirm the Bedrock manual cache-point still engages post-1.47 and do NOT switch to `strategy="auto"` expecting system-prompt caching. Strongest internal-signal fit after the bump. + +### [2026-07-17] Adopt Strands `Limits` on the unattended Scheduled Runs / headless lane +- **Source**: research/2026-07-17.md ▸ Top 5 #3 — convergent harness rail (Claude Code 2.1.212 spawn cap + opencode 1.18.2 `subagent_depth`); Strands `Limits` now available (we're on 1.47). +- **Surface**: backend (headless/scheduled path `apis/shared/harness/run_agent_headless` per [2026-07-06] managed-Harness spike + agent-loop per-invocation config) +- **Effort × Impact**: L–M × M +- **Subtracts**: yes — retires any hand-rolled `max_turns` guard on the headless lane (library-native) +- **Unlocks**: first-class per-invocation cost/turn cap on the highest-blast-radius lane (a runaway loop there burns quota unattended) — the capability the [2026-07-03] Strands bump listed but that still needs wiring now the bump landed +- **Status**: open — scope to the scheduled/headless lane first; dovetails with the queued quota-cooldown work. + +### [2026-07-17] Harden the SPA SSE parser (unterminated-frame + line-ending handling) +- **Source**: research/2026-07-17.md ▸ Top 5 #4 — assistant-stream@0.3.26 (Jul 16, spec-complete SSE decoder) — https://github.com/Yonom/assistant-ui/releases — reinforced by the 1.6.0 SSE/restore bug cluster (#653 tab-switch duplicate invocation, tool-pairing repair, single-flight lease) +- **Surface**: frontend (SPA SSE parser service — the one allowlisting `session_title` past Completed-state gating; keyed per-session) +- **Effort × Impact**: L–M × M +- **Subtracts**: yes — replaces ad-hoc frame handling with a spec-complete decode pass +- **Status**: open — pattern-only (implement in Angular signals, do NOT add the React dep): discard unterminated/partial frames + tighten line-endings on the interrupt-resume + tab-switch paths just stabilized. Defensive, lands on exactly the surface 1.6.0 hardened. + +### [2026-07-17] Durable curl fix — stop pinning curl to an upstream version in the Dockerfiles +- **Source**: research/2026-07-17.md ▸ Top 5 #5 — internal friction (Nightly + Backend Deploy broke Jul 11–13 on `curl=8.14.1-2+deb13u3` "version not found"); partially fixed by floating to `deb13u*` (commit `74cd7b0a`, 1.5.0) +- **Surface**: infra/CI (`backend/Dockerfile.app-api` + `Dockerfile.inference-api` line 42 `apt-get install curl=...`; check `scheduled-runs`/`kb-sync`) +- **Effort × Impact**: L × M–H +- **Subtracts**: yes — removes a recurring deploy-breaker class; the `deb13u*` wildcard only defers the next break to a Debian series/base-image bump +- **Status**: open — replace the version-pinned curl with unpinned (latest security patch) or the base image's `curl-minimal` (as the Lambda images already do). The pin was never a supply-chain control — it's a HEALTHCHECK runtime probe. Cheapest durable win; the band-aid will bite again. + +### [2026-07-10] Bump `bedrock-agentcore` off 1.9.1 — now double-forced (#482 SSE deadlock + NEW #571 Memory-reorder) +- **Source**: research/2026-07-10.md ▸ Top 5 #1 — https://github.com/aws/bedrock-agentcore-sdk-python/pull/563 (#482, in 1.17.0) + **NEW** https://github.com/aws/bedrock-agentcore-sdk-python/issues/571 (cross-process Memory event-reorder corruption, fix pending a post-1.17.0 release). +- **Surface**: backend (`backend/pyproject.toml`, inference-api chat router, `AgentCoreMemorySessionManager` usage; full local pytest suite) +- **Effort × Impact**: M × H +- **Subtracts**: yes — retires the queued [2026-05-22] #482 hand-written guard (library-native subtraction) +- **Status**: open — **CONSOLIDATED into the [2026-07-17] "→ 1.18.0" item above** (the #571 fix this entry was tracking landed in 1.18.0 on Jul 10 — the post-1.17.0 release it awaited). Treat as one item; review-prep should resolve this stub. 1.1.0/1.2.0 shipped Scheduled Runs (multi-replica Runtime) + Memory Spaces (heavier Memory use), amplifying exactly the failure classes #482/#571 corrupt. + +### [2026-07-10] Bump Strands 1.40 → 1.47; adopt `continue_on_error` MCP resilience (#3101) + hook ordering +- **Source**: research/2026-07-10.md ▸ Top 5 #2 — Strands 1.46–1.47 (https://github.com/strands-agents/sdk-python/releases). **Supersedes the [2026-07-03] 1.45 item** (now 7 minors behind). +- **Surface**: backend (`agents/main_agent/` hooks + `to_bedrock_config` + compaction, `FilteredMCPClient`/gateway targets, `CountTokensBedrockModel`) +- **Effort × Impact**: M–H × H +- **Subtracts**: candidate — hand-rolled MCP-abort handling (`continue_on_error`), custom cache-point plumbing (`cache_tools_ttl`), runaway guard (`Limits`) +- **Unlocks**: a flaky external/Gateway MCP server no longer aborts the turn (newly relevant — Scheduled Runs use external tools unattended); `Limits` per-invocation cost caps; deterministic hook ordering (the enabler for the tool-approval fix) +- **Status**: open — **the bump itself SHIPPED** (commit `42e69bc7` "upgrade Strands to 1.47.0"; the pin is now 1.47.0). Remaining follow-on work is separately queued: `Limits` adoption on the headless lane is the [2026-07-17] item above; `continue_on_error` on the MCP client + optional hook ordering (#2559) + the `cache_tools_ttl`/`context_manager="auto"` audits are still open here (decisions.md 2026-05-18 bars a bare compaction swap). Review-prep should split "bump = done" from the un-adopted capabilities. + +### [2026-07-10] Audit whether prompt caching actually engages in `to_bedrock_config` (Strands #3144) +- **Source**: research/2026-07-10.md ▸ Top 5 #3 — **NEW** Strands open issue #3144 (`CacheConfig(strategy="auto")` never caches the system prompt); Strands 1.46 now surfaces `cache_read`/`cache_write` tokens in the metadata chunk (#2302). https://github.com/strands-agents/sdk-python/issues +- **Surface**: backend (`agents/main_agent/` cache-point config in `to_bedrock_config`; the metadata/usage path feeding `CountTokensBedrockModel` + the context-attribution badge) +- **Effort × Impact**: L–M × M–H +- **Subtracts**: no — a cost-correctness audit (may confirm we're fine, or expose a silent full-input-token regression) +- **Unlocks**: potentially large per-turn cost cut if caching is silently off; a verifiable caching invariant +- **Status**: open — **subsumed by / merge with the [2026-07-17] "multi-provider prompt caching" item above** (same Bedrock cache-point surface + Strands #3144, extended to the Mantle/OpenAI provider shapes). Assert cache points are written *and* read via `cache_read`/`cache_write` counts; measurement is nearly free now that 1.46 surfaces the tokens. + +### [2026-07-10] Tool-approval policy layer + signed approvals (Vercel AI SDK) — evolve the queued approval item +- **Source**: research/2026-07-10.md ▸ Top 5 #4 — Vercel AI SDK tool-approvals (https://ai-sdk.dev/docs/agents/tool-approvals). Builds on the [2026-07-03] tool-approval item. +- **Surface**: frontend + backend (tool-approval `BeforeToolCall` hook in `apis/shared`, `tool_use`/`tool_result` SSE contract, frontend tool-call card + signal store; reuses `beginContinuationStreaming`) +- **Effort × Impact**: M × M +- **Subtracts**: yes — replaces ad-hoc synthetic-error approval handling with explicit approve/deny/user-approval lifecycle states +- **Unlocks**: server-side **policy layer** deciding auto-approve vs prompt via per-tool input-inspecting functions (gate on the tool's args, not just identity); cryptographically-signed, tamper-proof approval history; closes the "approval hook can't see through the tool-fold" hole (pairs with the Strands hook-ordering bump) +- **Status**: open — **fold into the queued [2026-07-03] tool-approval item** rather than run a separate track; sequence after the Strands hook-ordering bump lands. The new-this-week piece is the policy layer + integrity check, beyond last week's basic human-in-the-loop. + +### [2026-07-10] Wire a CloudWatch `ActiveSessionCount` alarm on the inference-api runtime +- **Source**: research/2026-07-10.md ▸ Top 5 #5 — **NEW** AgentCore Runtime `ActiveSessionCount` metric (https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/release-notes.html) +- **Surface**: infrastructure (CloudWatch alarm on the inference-api runtime's `AWS/Bedrock-AgentCore` `ActiveSessionCount` gauge; PlatformStack observability) +- **Effort × Impact**: L × M +- **Subtracts**: no — ops addition; justified as cheap early-warning for the exact failure class the agentcore bump fixes (defense-in-depth while the bump is pending) +- **Unlocks**: proactive detection of session-leak/exhaustion and the #482 hang (a hung container manifests as session pileup) before a 429 +- **Status**: open — low-effort ops win that pairs with the agentcore bump. Alarm when concurrent sessions approach the raised quota (5,000 us-west-2). + ### [2026-07-06] Spike: managed AgentCore Harness as the headless/scheduled run engine — ✅ SPIKE + Q2 LIVE PROBE COMPLETE, recommend Ship (headless-only, GO-with-boundary) - **Source**: `scoping/2026-07-06-managed-harness-build-vs-adopt.md` (brief) + `scoping/2026-07-06-managed-harness-spike-findings.md` (**findings — 3 gating questions answered**). Surfaced while dogfooding scheduled runs (Phil asked whether we use the AWS Harness feature; we use the lower-level Runtime). AWS **managed Harness** is now GA. - **Surface**: backend (`apis/shared/harness/run_agent_headless` — swap the Runtime `/invocations` target for an `InvokeHarness` endpoint on the headless lane only; swap `sse.py` accumulator for a Converse-stream one → same `RunResult`) + infra (a managed-Harness resource + OAuth-inbound JWT authorizer — a 1:1 port of our existing Runtime `customJwtAuthorizer`, `inference-agentcore-construct.ts:275`). Interactive `inference-api` untouched. diff --git a/docs/specs/session-metadata-static-sort-key.md b/docs/specs/session-metadata-static-sort-key.md new file mode 100644 index 000000000..6dff56659 --- /dev/null +++ b/docs/specs/session-metadata-static-sort-key.md @@ -0,0 +1,304 @@ +# Session-metadata static sort key (issue #175) + +**Status:** proposed (spec only — no branch yet) +**Supersedes the band-aids for:** `SessionMetadata` parse-failure warnings, first-turn +duplicate rows, and the documented `update_session_activity` race window. +**Code touched:** `backend/src/apis/shared/sessions/metadata.py`, +`backend/src/apis/app_api/sessions/services/session_service.py` (the **live** +soft-delete — see below), `infrastructure/lib/constructs/data/cost-tracking-tables-construct.ts`. + +## Problem + +The session-metadata row encodes `lastMessageAt` **inside its sort key**: + +``` +PK = USER#{user_id} +SK = S#ACTIVE#{lastMessageAt}#{session_id} +``` + +In DynamoDB a key is immutable identity, so "the session had activity" — a value +that changes every turn — cannot be an in-place update. It forces a **row move**: +`put_item` at the new SK + `delete_item` at the old +([`update_session_activity`](../../backend/src/apis/shared/sessions/metadata.py) Phase B, ~line 1018). + +Two whole classes of bug fall out of that single decision: + +1. **Ghost rows / parse failures.** ~20 other writers (`_bump_session_aggregates`, + the `REMOVE pausedTurn` / `REMOVE lastTurnContinuable` / `REMOVE lastTurnInterrupted` + paths, title/starred/tags/interrupt writers) resolve the SK via a GSI read, then + issue a targeted `update_item`. If a concurrent activity-update **rotates the SK + away** between that read and the write, the write lands on a now-deleted key — + and `update_item` on a missing key **upserts**. A `REMOVE` on a missing key still + creates the item, so you get a bare `{PK, SK}` **ghost** with none of the 6 + required fields. It fails `SessionMetadata.model_validate` and is skipped on + list ([metadata.py:1794-1804](../../backend/src/apis/shared/sessions/metadata.py)), + emitting `Failed to parse session item`. Observed in prod: 5 ghosts / ~15k rows, + 47 warnings/day. Harmless today, but it is a **lost write** (the intended + `REMOVE`/`SET` never applied to the real row) — e.g. a `pausedTurn` that should + have been cleared can persist. + +2. **First-turn duplicate rows.** Because every call computes a *different* SK, + `ensure_session_metadata_exists` cannot use a real `attribute_not_exists(PK)` + conditional put — the condition is always vacuously true at a never-before-seen + key ([metadata.py:749-756](../../backend/src/apis/shared/sessions/metadata.py)). + Two concurrent first turns for one session mint two rows. + +Both are symptoms of one disease: **the row moves, and writers race a moving +target.** Condition-guarding each writer (`attribute_exists(SK)`) and reaping +ghosts on read are O(N-writers) ongoing patches that leave the lost-write and +duplicate-row problems in place. This spec removes the cause. + +## Decision summary + +| Question | Decision | +|----------|----------| +| Root change | **Make the sort key static** — `SK = S#{session_id}`, no timestamp | +| Where does `lastMessageAt` live | A **plain attribute** on the row (already present) | +| How is active-vs-deleted expressed | The existing **`status`** attribute, not an SK prefix | +| How is recency listing served | A **new sparse GSI** `SessionRecencyIndex` keyed on `USER#{id}` / `{lastMessageAt}#{session_id}` | +| Reuse `UserTimestampIndex`? | **No** — it's owned by per-message cost records; co-mingling sessions into that hot partition is muddy. Dedicated sparse GSI is cleaner and cheaper. | +| Sparse how | GSI keys written **only when `status == active`**; soft-delete `REMOVE`s them so the row drops out of the active listing automatically (mirrors `DueScheduleIndex`/GSI3 pattern already in this table) | +| Migration shape | **Forward-only strangler**: writers self-migrate their row on next touch + a one-shot backfill for cold rows | +| Ghost cleanup | Backfill deletes existing ghosts; no reaper needed long-term (rotation gone → new ghosts structurally impossible) | + +## Target schema + +``` +Session row (one per session, active OR deleted): + PK = USER#{user_id} + SK = S#{session_id} ← STATIC. never rotates. + GSI_PK = SESSION#{session_id} GSI_SK = META (SessionLookupIndex — unchanged) + GSI4_PK = USER#{user_id} GSI4_SK = {lastMessageAt}#{session_id} + (SessionRecencyIndex — sparse, active-only) + status = "active" | "archived" | "deleted" + lastMessageAt, createdAt, title, messageCount, ... (all as today) +``` + +- `S#` prefix retained so session rows stay distinguishable from `C#` cost / + `D#` records in the same `USER#{id}` partition. +- `GSI4_SK` suffixes `#{session_id}` to disambiguate identical `lastMessageAt` + values and give a stable pagination cursor. +- On soft-delete / archive: `SET status=... REMOVE GSI4_PK, GSI4_SK` → the row + leaves `SessionRecencyIndex` (sparse) but the base row stays for direct lookup + and any deleted-view. On restore: re-add GSI4 keys. + +### CDK addition (`cost-tracking-tables-construct.ts`) + +```ts +this.sessionsMetadataTable.addGlobalSecondaryIndex({ + indexName: 'SessionRecencyIndex', + partitionKey: { name: 'GSI4_PK', type: dynamodb.AttributeType.STRING }, + sortKey: { name: 'GSI4_SK', type: dynamodb.AttributeType.STRING }, + projectionType: dynamodb.ProjectionType.ALL, // list needs the full item +}); +``` + +(GSI3 is `DueScheduleIndex`; GSI4 is the next free slot. DynamoDB allows 20.) + +## What each path becomes + +**Reads** +- `list_user_sessions` / `_list_user_sessions_cloud` — the only substantive read + change. Was: base-table `query(PK, begins_with SK 'S#ACTIVE#', ScanIndexForward=False)`. + Becomes: `query(SessionRecencyIndex, GSI4_PK='USER#{id}', ScanIndexForward=False)`. + Same newest-first ordering + native pagination, now from an index whose sort key + is *allowed* to track a mutating attribute (DynamoDB re-positions the index entry + when `lastMessageAt` is `SET` — no move, no race). +- `_get_session_by_gsi` (SessionLookupIndex) — **unchanged**, already SK-agnostic. +- **Bonus:** with a deterministic SK, callers holding both `user_id` and + `session_id` can `get_item(PK=USER#{id}, SK=S#{id})` directly and skip the GSI + read entirely (optional follow-up, not required for correctness). + +**Writes** (all ~20 in `metadata.py`) +- Every `update_item` now targets the **stable** `S#{session_id}` → pure in-place + update. No rotation ⇒ no upsert-on-missing ⇒ **ghosts structurally impossible**, + **no lost writes**, **no condition guards required anywhere**. +- `update_session_activity` loses Phase B entirely: it becomes a single + `SET lastMessageAt, preferences ADD messageCount` on the static SK. DynamoDB + updates `GSI4_SK` automatically. +- `ensure_session_metadata_exists` uses a **real** `put_item(ConditionExpression= + "attribute_not_exists(PK)")` on the deterministic key → genuinely idempotent, + killing the first-turn duplicate race. +- Soft-delete stops moving `S#ACTIVE#`→`S#DELETED#`; instead + `SET status='deleted' REMOVE GSI4_*` on the static SK. **The live soft-delete is + `SessionService.delete_session`** (`app_api/sessions/services/session_service.py`, + used by the `DELETE /{session_id}` route), which today reconstructs + `old_sk = S#ACTIVE#{last_message_at}#{id}` from a read-back `last_message_at` and + transactionally moves to `S#DELETED#`. That reconstruction is itself racy (a + concurrent activity-update changes `last_message_at`, so the rebuilt `old_sk` is + stale) — and it **vanishes** under the static SK (the key is just `S#{id}`). + `shared/sessions/metadata.py`'s `_store_session_metadata_cloud` active→deleted + branch (~line 649) is the other builder. **Note:** + `app_api/sessions/services/metadata.py` is a **dead duplicate** (zero production + importers — only one test references `_update_cost_summary_async`); confirm and + delete it as part of this work rather than migrating it. + +## Migration (forward-only strangler) + +Changing a base-table SK means every existing row must be rewritten (delete+put) — +inherently a full migration. Kept safe and online via an **expand → migrate → +backfill → contract** sequence. The split between 1a and 1b is load-bearing: no row +may start migrating until *every* running container can already read both schemes, +or an old container mid-deploy would list legacy-only and a just-migrated row would +briefly vanish from a user's sidebar. + +**Phase 0 — infra.** Deploy CDK adding `SessionRecencyIndex`. No behavior change; +index is empty until rows gain GSI4 keys. (`platform.yml`.) + +**Phase 1a — expand read (deploy).** `metadata.py` that **reads** both schemes: +`list_user_sessions` returns the UNION of the new `SessionRecencyIndex` query + the +legacy `begins_with('S#ACTIVE#')` base query, deduped by `session_id`. Writes still +use the legacy scheme; **no row migrates yet.** New sessions may already be written +in target shape (they're visible via the union regardless). This deploy must be +**fully rolled out** — every task on 1a — before 1b begins. + +**Phase 1b — migrate writes (deploy).** Now that all readers cope with both schemes, +turn on conversion: on any write, if the GSI-resolved SK is legacy +(`S#ACTIVE#…`/`S#DELETED#…`), perform the row's **final** rotation to `S#{id}` + +populate/clear GSI4, then apply the write. Each write self-migrates its row once. +Ghost creation ends for every migrated row (in-place updates). Hot sessions convert +themselves from here on. + +**Phase 2 — backfill.** One-shot throttled script (below) migrates the cold tail that +1b traffic didn't touch, and deletes existing ghosts. Idempotent; PITR (already +enabled on this table) is the rollback net. Sets the migration-complete marker once a +full scan finds zero legacy rows. + +**Phase 3 — contract.** `list_user_sessions` goes GSI-only once a persisted +"migration complete" marker is set (the backfill sets it after confirming zero legacy +rows). The legacy union branch and self-migration shim are **retained behind that +marker**, not hard-deleted, so a downstream fork that hasn't run the backfill stays +safely in dual-read rather than losing sight of un-migrated rows. See +[Downstream / forked deployments](#downstream--forked-deployments). A later release +can drop the legacy code entirely once all known deployments report the marker set. + +### Backfill script sketch (`backend/scripts/`) + +``` +scan sessions-metadata (paginate, throttled) +for each item: + if SK starts_with 'S#ACTIVE#' or 'S#DELETED#': + if is_ghost(item): # no title/status/GSI_SK=META + delete_item(PK, SK); continue + sid = session_id from item (or parse tail of SK) + new = {**item, 'SK': f'S#{sid}', + 'status': 'deleted' if SK.startswith('S#DELETED#') else item['status']} + if new['status'] == 'active': + new['GSI4_PK'] = item['PK']; new['GSI4_SK'] = f"{item['lastMessageAt']}#{sid}" + else: + new.pop('GSI4_PK', None); new.pop('GSI4_SK', None) + put_item(new, ConditionExpression=attribute_not_exists(SK) OR SK == new.SK) + if new['SK'] != item['SK']: delete_item(PK, item['SK']) # drop legacy row + +# final pass: if a full scan finds no remaining S#ACTIVE#/S#DELETED# legacy rows, +# set the "migration complete" marker that flips list_user_sessions to GSI-only +if no_legacy_rows_remain(): + put_item({'PK': 'MIGRATION#session-sk', 'SK': 'STATE', 'complete': True}) +``` + +Run against **prod-ai** and **dev-ai**. ~15k rows → minutes at modest WCU. +Idempotent and re-runnable; the marker write is what advances Phase 3. + +## Downstream / forked deployments + +This is a public stack; forks run their own tables and data and upgrade by pulling. +The migration must be safe for them without assuming they read an upgrade guide or +run a script by hand. + +**Fresh deployments — zero risk.** A new install has no legacy rows: sessions are +born in target shape, the `SessionRecencyIndex` GSI ships in their CDK, nothing to +migrate. Only the normal `platform.yml`-before-`backend.yml` ordering applies. + +**Existing deployments — safe except at one boundary.** Deploying the expand/migrate +code onto legacy data is safe: dual-read shows legacy rows, writers self-migrate on +touch, cold rows keep working. The one hazard is **contract (Phase 3) reached without +the backfill having run** — cold, un-migrated sessions have no GSI4 keys and vanish +from the GSI-only list. This is **invisibility, not loss** (rows persist, PITR +recovers, a late backfill makes them reappear), but it's an unacceptable upgrade +surprise. A `git pull` never runs the backfill, and `develop`→`main` squash-merges +can land all phases in one release, so git ordering alone cannot protect a forker. +Three protections make the migration self-defending: + +1. **Gate contract on a persisted "migration complete" marker — do not hard-delete + the legacy read path.** Keep the union/legacy fallback in code, guarded by a flag + that the backfill sets **only after it confirms zero legacy rows remain**. Store + the marker as a sentinel item in the table (e.g. `PK=MIGRATION#session-sk`, + `SK=STATE`) or an SSM param. Effect: a fork that never migrates simply stays in + dual-read forever — correct, just slightly less tidy — and **cannot brick its + sidebar by pulling across the boundary**. The maintainer flips to GSI-only by + virtue of the marker being set; forkers do so automatically once their own data + is converted. This is the single most important protection. +2. **Degrade gracefully when the GSI is absent.** If a fork runs only `backend.yml` + and the new list query hits `SessionRecencyIndex` before the CDK created it, + DynamoDB raises `ResourceNotFoundException`. Catch it and fall back to the legacy + base-table query, so a missed infra deploy is a soft degradation, not a broken + session list. +3. **Optional: ship the backfill as an auto-run migration.** A CDK custom-resource + Lambda (or a run-once, lock-guarded startup migration in app-api) converts cold + rows with no human step. More moving parts; the marker gate (1) already prevents + the dangerous outcome, so this is a nicety, not a requirement. + +**Preconditions to document** (`RELEASE_NOTES.md`, via the `cutting-a-release` flow): +- PITR is the safety net and is on in the shipped CDK + (`pointInTimeRecoveryEnabled: true`); forks that disabled it lose the backstop. +- Deploy `platform.yml` (GSI) with/before `backend.yml`. +- Prefer spreading the phases across **tagged releases** so downstream upgrades cross + one boundary at a time; if bundled, the marker gate (1) still holds. + +## Pagination token + +Current token = `base64(lastMessageAt)` tied to the old SK +([metadata.py:1666](../../backend/src/apis/shared/sessions/metadata.py)). New token = +`base64(json(GSI4 LastEvaluatedKey))`. Make the decoder **tolerant**: an +undecodable/legacy token falls back to no-cursor (first page) rather than erroring — +so in-flight tokens spanning the Phase 1a→3 deploys degrade to a harmless page reset, +never a 500. + +## Testing (moto-backed, `tests/…/sessions/`) + +1. **No rotation** — `update_session_activity` twice ⇒ SK unchanged; exactly one + row for the session; `lastMessageAt`/`messageCount` advanced. +2. **Ghost impossible** — simulate the race (resolve SK, then activity-update, then + a `REMOVE` write) ⇒ no `{PK,SK}`-only stub; the `REMOVE` applied to the real row. +3. **Idempotent create** — two concurrent `ensure_session_metadata_exists` ⇒ one row. +4. **Recency + pagination** — N sessions, bump middle one ⇒ it sorts to front via + `SessionRecencyIndex`; token round-trips; tolerant decode of a legacy token. +5. **Soft-delete drops from index** — delete ⇒ absent from recency query, base row + still `get_item`-able; restore re-adds. +6. **Backfill** — seed legacy rows + a ghost ⇒ script migrates rows, deletes ghost, + is idempotent on re-run. + +## Risks / open questions + +- **`SessionRecencyIndex` partition heat** — `GSI4_PK = USER#{id}`; per-user session + counts are modest, no hot-partition concern (same cardinality as today's base query). +- **Deleted/archived listing — RESOLVED, none exists.** Verified: the list endpoint + (`GET /sessions`) takes only `limit`/`next_token`, no status filter; `"archived"` + status is never written (dead enum value); `S#DELETED#` is a **write-only tombstone** + — no code path ever queries or `begins_with('S#DELETED#')` it; and the SPA has no + trash/archive/deleted-sessions view. So the sparse active-only `SessionRecencyIndex` + is fully sufficient and **Phase 3 is unblocked** — no second GSI or scan path needed. + A soft-deleted row keeps its static SK + `GSI_SK=META`, so it stays directly + retrievable for the memory/files purge fan-out. +- **Backfill vs live traffic** — Phase 1b self-migration means the script mostly + handles cold rows; the conditional put + legacy-SK-only delete avoids clobbering a + concurrently-migrated row. PITR is the backstop. +- **GSI eventual consistency** — list can be ~sub-100ms stale, identical to today's + GSI-resolved writes; acceptable. + +## Appendix — before / after item + +``` +BEFORE (rotates every turn; races spawn ghosts) + PK USER#u123 + SK S#ACTIVE#2026-07-08T17:33:08.442948+00:00#c13e1dfd ← moves each turn + GSI_PK SESSION#c13e1dfd GSI_SK META + title, status=active, lastMessageAt, ... + +AFTER (stable identity; recency via sparse GSI) + PK USER#u123 + SK S#c13e1dfd ← never moves + GSI_PK SESSION#c13e1dfd GSI_SK META + GSI4_PK USER#u123 GSI4_SK 2026-07-08T17:33:08.442948+00:00#c13e1dfd + title, status=active, lastMessageAt, ... +``` diff --git a/frontend/ai.client/package-lock.json b/frontend/ai.client/package-lock.json index c5261d69c..155b070f0 100644 --- a/frontend/ai.client/package-lock.json +++ b/frontend/ai.client/package-lock.json @@ -1,12 +1,12 @@ { "name": "ai.client", - "version": "1.6.1", + "version": "1.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ai.client", - "version": "1.6.1", + "version": "1.7.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 b53859381..68681f70d 100644 --- a/frontend/ai.client/package.json +++ b/frontend/ai.client/package.json @@ -1,6 +1,6 @@ { "name": "ai.client", - "version": "1.6.1", + "version": "1.7.0", "scripts": { "ng": "ng", "start": "ng serve", diff --git a/frontend/ai.client/src/app/session/components/message-list/components/inline-visual/inline-visual.component.ts b/frontend/ai.client/src/app/session/components/message-list/components/inline-visual/inline-visual.component.ts index 25adbc8cf..335c18354 100644 --- a/frontend/ai.client/src/app/session/components/message-list/components/inline-visual/inline-visual.component.ts +++ b/frontend/ai.client/src/app/session/components/message-list/components/inline-visual/inline-visual.component.ts @@ -1,6 +1,7 @@ import { Component, input, computed, inject, ChangeDetectionStrategy } from '@angular/core'; import { ChartRendererComponent } from './renderers/chart-renderer.component'; import { DefaultRendererComponent } from './renderers/default-renderer.component'; +import { WordDocumentRendererComponent } from './renderers/word-document-renderer.component'; import { VisualStateService } from '../../../../services/visual-state/visual-state.service'; /** @@ -10,7 +11,7 @@ import { VisualStateService } from '../../../../services/visual-state/visual-sta @Component({ selector: 'app-inline-visual', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ChartRendererComponent, DefaultRendererComponent], + imports: [ChartRendererComponent, DefaultRendererComponent, WordDocumentRendererComponent], template: ` @if (!isDismissed()) {
@@ -23,6 +24,9 @@ import { VisualStateService } from '../../../../services/visual-state/visual-sta (toggleExpanded)="onToggleExpanded()" /> } + @case ('word_document') { + + } @default { + +
+ +
+ + +
+

+ {{ d.filename }} +

+ @if (d.size_kb) { +

{{ d.size_kb }}

+ } +
+ + + + + Download + +
+ } + `, +}) +export class WordDocumentRendererComponent { + /** The payload data from the backend tool result. */ + payload = input.required(); + + /** Narrowed, validated payload (null when malformed). */ + doc = computed(() => { + const raw = this.payload(); + if (!raw || typeof raw !== 'object') return null; + const p = raw as Partial; + if (!p.filename || !p.download_url) return null; + return { filename: p.filename, download_url: p.download_url, size_kb: p.size_kb }; + }); +} diff --git a/infrastructure/lib/constructs/data/cost-tracking-tables-construct.ts b/infrastructure/lib/constructs/data/cost-tracking-tables-construct.ts index b25b24765..6a943f279 100644 --- a/infrastructure/lib/constructs/data/cost-tracking-tables-construct.ts +++ b/infrastructure/lib/constructs/data/cost-tracking-tables-construct.ts @@ -80,6 +80,24 @@ export class CostTrackingTablesConstruct extends Construct { projectionType: dynamodb.ProjectionType.ALL, }); + // SessionRecencyIndex — sparse recency listing for active sessions + // (issue #175: docs/specs/session-metadata-static-sort-key.md). Once the + // session row's base SK is static (S#{session_id}) rather than encoding + // lastMessageAt, newest-first listing moves here: GSI4_PK=USER#{user_id}, + // GSI4_SK={lastMessageAt}#{session_id} (session_id suffix disambiguates + // equal timestamps and gives a stable pagination cursor). Sparse — keys + // are written only while status == active; soft-delete removes them so the + // row drops out of the active list (mirrors DueScheduleIndex/GSI3). Distinct + // GSI4_PK/GSI4_SK attribute names avoid colliding with the other indexes. + // Phase 0 of the migration: adding the index is a no-op until rows populate + // GSI4 keys, so this deploys safely ahead of any code change. + this.sessionsMetadataTable.addGlobalSecondaryIndex({ + indexName: 'SessionRecencyIndex', + partitionKey: { name: 'GSI4_PK', type: dynamodb.AttributeType.STRING }, + sortKey: { name: 'GSI4_SK', type: dynamodb.AttributeType.STRING }, + projectionType: dynamodb.ProjectionType.ALL, + }); + // UserCostSummary Table diff --git a/infrastructure/package-lock.json b/infrastructure/package-lock.json index a19993b85..947df56e3 100644 --- a/infrastructure/package-lock.json +++ b/infrastructure/package-lock.json @@ -1,12 +1,12 @@ { "name": "infrastructure", - "version": "1.6.1", + "version": "1.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "infrastructure", - "version": "1.6.1", + "version": "1.7.0", "dependencies": { "aws-cdk-lib": "2.260.0", "constructs": "10.6.0" diff --git a/infrastructure/package.json b/infrastructure/package.json index ca449e9c7..c6299b283 100644 --- a/infrastructure/package.json +++ b/infrastructure/package.json @@ -1,6 +1,6 @@ { "name": "infrastructure", - "version": "1.6.1", + "version": "1.7.0", "bin": { "infrastructure": "bin/infrastructure.js" }, diff --git a/infrastructure/test/tables-detailed.test.ts b/infrastructure/test/tables-detailed.test.ts index 2e56fdcbc..ff62ca169 100644 --- a/infrastructure/test/tables-detailed.test.ts +++ b/infrastructure/test/tables-detailed.test.ts @@ -196,12 +196,29 @@ describe('CostTrackingTablesConstruct — detailed', () => { }); }); - it('SessionsMetadata has 2 GSIs', () => { + it('SessionsMetadata has 4 GSIs', () => { t.hasResourceProperties('AWS::DynamoDB::Table', { TableName: 'test-project-sessions-metadata', GlobalSecondaryIndexes: Match.arrayWith([ Match.objectLike({ IndexName: 'UserTimestampIndex' }), Match.objectLike({ IndexName: 'SessionLookupIndex' }), + Match.objectLike({ IndexName: 'DueScheduleIndex' }), + Match.objectLike({ IndexName: 'SessionRecencyIndex' }), + ]), + }); + }); + + it('SessionRecencyIndex is keyed GSI4_PK / GSI4_SK', () => { + t.hasResourceProperties('AWS::DynamoDB::Table', { + TableName: 'test-project-sessions-metadata', + GlobalSecondaryIndexes: Match.arrayWith([ + Match.objectLike({ + IndexName: 'SessionRecencyIndex', + KeySchema: [ + { AttributeName: 'GSI4_PK', KeyType: 'HASH' }, + { AttributeName: 'GSI4_SK', KeyType: 'RANGE' }, + ], + }), ]), }); });