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' }, + ], + }), ]), }); });