diff --git a/CHANGELOG.md b/CHANGELOG.md index c6b30c72f..6a8a39eaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ 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.10.0] - 2026-07-21 + +Feature release adding **Excel spreadsheet creation and editing to chat** — a four-tool `.xlsx` toolset behind a single "Excel Spreadsheets" catalog toggle, built on a new shared office-document storage module — and fixing the CSP gap that **blocked every MCP App iframe on deployed environments**. Requires a CDK deploy (SPA CloudFront response-headers change); no data migration. + +### 🚀 Added + +- Excel spreadsheet toolset — `create_excel_spreadsheet`, `modify_excel_spreadsheet`, `list_excel_spreadsheets`, `read_excel_spreadsheet` build and edit `.xlsx` files via openpyxl in the sandboxed Code Interpreter; generated files persist to the user-files S3 bucket and appear in the chat Files panel with a download link. One catalog entry ("Excel Spreadsheets", gate key `create_excel_spreadsheet`, off by default) provisions the whole set (#709) + +### ✨ Improved + +- Word-document tools refactored onto the new shared `office/_storage.py` module (Code Interpreter + S3 persistence now common to Word and Excel), and all generated office documents render through a generic file-download renderer that replaces the Word-specific one (#709) + +### 🐛 Fixed + +- MCP App UIs now render on deployed environments — the SPA distribution's CSP `frame-src` never included the `mcp-sandbox.{domain}` origin, so every domained deploy blocked the App iframe with a CSP violation (localhost bypasses CloudFront's headers, which masked it). `PlatformStack` now threads the sandbox proxy origin into `SpaDistributionConstruct`, with a synth-time regression test (#714) + ## [1.9.0] - 2026-07-20 Feature release making **Bedrock prompt-cache economics stable and measurable**. Three cache-busting defects in the model-call path are fixed (per-turn history mutation, nondeterministic skill ordering, single-cachePoint fragility), and a new observability layer makes every model call's cache behavior diagnosable: prefix fingerprints and a `cacheStatus` classification on each cost row, an admin Session Cost Anatomy drill-down page, CloudWatch EMF metrics, and a dashboard with alarms. Also fixes chat-input textarea sizing and points the deployed runtime's Word-document tools at the real user-files bucket. Requires a CDK deploy (new dashboard construct + one runtime env var); no data migration. diff --git a/README.md b/README.md index c014c9a75..9742c0d8f 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.9.0-6366f1?style=flat&logo=github&logoColor=white)](RELEASE_NOTES.md) +[![Release](https://img.shields.io/badge/Release-v1.10.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.9.0 +**Current release:** v1.10.0 --- diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index dd55d3682..69e3b73a4 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,45 @@ +# Release Notes — v1.10.0 + +**Release Date:** July 21, 2026 +**Previous Release:** v1.9.0 (July 20, 2026) + +--- + +> 🏗️ **CDK deploy required this release** — the SPA CloudFront distribution's response-headers policy changes (MCP Apps CSP fix). No new AWS resources, no data migration, no dependency changes. Standard order: `platform.yml` → `backend.yml` → `frontend-deploy.yml`. + +--- + +## Highlights + +v1.10.0 brings **Excel spreadsheets to chat**: the agent can now create, edit, read, and list real `.xlsx` workbooks — built with openpyxl inside the sandboxed Code Interpreter and delivered through the chat Files panel with a download link — governed by a single "Excel Spreadsheets" catalog toggle. The same work extracts a shared office-document storage module that the Word tools now ride on. The release also fixes a day-one CSP gap that **blocked every MCP App iframe on deployed environments**: the SPA's `frame-src` never allowed the `mcp-sandbox` origin, and localhost testing (which bypasses CloudFront's headers) had masked it since the feature shipped. + +## Excel spreadsheets in chat + +Users can ask the agent to build or revise real Excel workbooks mid-conversation — budget templates, rosters, data exports — and get a downloadable `.xlsx` back in the chat's Files panel. + +### Backend + +- `agents/builtin_tools/excel_spreadsheet_tool.py` (530+ lines) — four tools: `create_excel_spreadsheet`, `modify_excel_spreadsheet`, `list_excel_spreadsheets`, `read_excel_spreadsheet`. Generation and edits run openpyxl inside the sandboxed AgentCore Code Interpreter; nothing executes in the API container. +- `agents/builtin_tools/office/_storage.py` — new shared storage module (Code Interpreter execution + S3 persistence) common to Word and Excel; `word_document_tool.py` is refactored onto it, dropping ~270 lines of duplicated plumbing. +- `apis/inference_api/chat/routes.py` — the toolset is injected at runtime when the catalog toggle is enabled. One catalog entry ("Excel Spreadsheets", gate key `create_excel_spreadsheet`, `enabledByDefault: false`) provisions all four tools; it is distinct from the spreadsheet *analysis* tools (`list_spreadsheets`/`analyze_spreadsheet`), which read uploaded tabular files. +- Generated files persist to the user-files bucket (`S3_USER_FILES_BUCKET_NAME`) and surface in the session's Files panel. + +### Frontend + +- New generic `file-download-renderer` component replaces the Word-specific `word-document-renderer` — all generated office documents (Word and Excel) now share one inline card with filename, type, and download link. + +## 🐛 Bug fixes + +- **MCP App UIs were blank on every deployed environment** — demoing an MCP App (e.g. Excalidraw) on a domained deploy failed with `Framing 'https://mcp-sandbox.{domain}/' violates the Content Security Policy directive: "frame-src 'self' https://artifacts.{domain}"`. Root cause: the MCP Apps rollout wired the *inbound* direction (the sandbox proxy's `frame-ancestors` is locked to the SPA origin) but never extended the SPA's own *outbound* `frame-src`, and all live verification ran on localhost:4200, which bypasses CloudFront's response headers. `PlatformStack` now threads the sandbox proxy origin (`https://mcp-sandbox.{domain}`) into `SpaDistributionConstruct` as a required prop, and a new synth-time test (`infrastructure/test/spa-frame-src-csp.test.ts`) asserts both iframe origins are present in the frontend headers policy so the gap can't silently reopen (#714) + +## 🚀 Deployment notes + +- **Run `platform.yml`** — the SPA distribution's `ResponseHeadersPolicy` changes (CSP `frame-src` gains the `mcp-sandbox.{domain}` origin). Quick, low-risk CloudFront-only update; then `backend.yml` and `frontend-deploy.yml` as usual. +- **Enable the Excel tool per environment** — the "Excel Spreadsheets" catalog entry ships in the bootstrap seed data with `enabledByDefault: false`. Environments seeded before this release won't have the row: add it via the admin Tools page (or re-run the tools seeding) and grant it to the appropriate roles via RBAC. +- The MCP Apps fix needs no configuration — environments where `mcp-sandbox.{domain}` is deployed start working as soon as the new headers policy is live (a hard refresh may be needed to drop the cached CSP). + +--- + # Release Notes — v1.9.0 **Release Date:** July 20, 2026 diff --git a/VERSION b/VERSION index f8e233b27..81c871de4 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.9.0 +1.10.0 diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 5ccd017eb..f153f28a4 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "agentcore-stack" -version = "1.9.0" +version = "1.10.0" requires-python = ">=3.10" description = "Multi-agent conversational AI system with AWS Bedrock AgentCore" readme = "README.md" diff --git a/backend/scripts/seed_bootstrap_data.py b/backend/scripts/seed_bootstrap_data.py index 41be7954b..26f9fb06c 100644 --- a/backend/scripts/seed_bootstrap_data.py +++ b/backend/scripts/seed_bootstrap_data.py @@ -442,6 +442,23 @@ def seed_default_models( "isPublic": True, "forwardAuthToken": False, }, + { + # Single catalog entry / toggle that provisions the whole Excel + # spreadsheet toolset. Enabling this one id injects create/modify/list/ + # read at runtime — see EXCEL_SPREADSHEET_TOOL_IDS and + # _build_excel_spreadsheet_tools in apis/inference_api/chat/routes.py. + # Keep the toolId as "create_excel_spreadsheet": it is the gate key. + # Distinct from the spreadsheet *analysis* tools (list_spreadsheets / + # analyze_spreadsheet), which read uploaded tabular files. + "toolId": "create_excel_spreadsheet", + "displayName": "Excel Spreadsheets", + "description": "Create, edit, read, and list Excel (.xlsx) spreadsheets using openpyxl 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/excel_spreadsheet_tool.py b/backend/src/agents/builtin_tools/excel_spreadsheet_tool.py new file mode 100644 index 000000000..77f8e416f --- /dev/null +++ b/backend/src/agents/builtin_tools/excel_spreadsheet_tool.py @@ -0,0 +1,532 @@ +"""Excel spreadsheet tools (create / modify / list / read). + +Each tool runs openpyxl code inside AWS Bedrock Code Interpreter and uses the +existing user-files store (``apis.shared.files``) for persistence and delivery +— generated/modified ``.xlsx`` 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_excel_spreadsheet`` — build a new workbook from openpyxl code. +* ``modify_excel_spreadsheet`` — edit an existing workbook with openpyxl code. +* ``list_excel_spreadsheets`` — list the .xlsx files available in this chat. +* ``read_excel_spreadsheet`` — extract an existing workbook's cell values. + +This is the create/modify/read/list toolset for *generated* ``.xlsx`` files. It +is distinct from the spreadsheet *analysis* tools (``list_spreadsheets`` / +``analyze_spreadsheet`` in ``builtin_tools.spreadsheet_analysis``), which read +and aggregate existing uploaded/knowledge-base tabular files with pandas. + +Design notes +------------ +* The Code Interpreter + user-files storage plumbing is shared with the Word + toolset and lives in ``builtin_tools.office._storage``; this module keeps + only the openpyxl specifics (preamble, generate/modify/extract) and the four + tool factories. +* Identity (``user_id`` / ``session_id``) is captured by closure via the + ``make_*`` factories — the same pattern used by the artifacts, Word document, + 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_excel_spreadsheet_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 logging +from typing import Any, Dict, Optional + +from strands import tool + +from agents.builtin_tools.office._storage import ( + _DocGenError, + _ci_exec, + _ci_read_bytes, + _ci_write_bytes, + _download_card, + _download_s3_bytes, + _error, + _get_code_interpreter_id, + _NO_CI_MESSAGE, + _region, + _storage_configured, + _store_document, + _validate_document_name, +) + +logger = logging.getLogger(__name__) + +# Excel workbook MIME type (matches apis.shared.files.ALLOWED_MIME_TYPES). +_XLSX_MIME = ( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" +) + +# Sandbox path used to stage a source workbook loaded from S3. +_SANDBOX_SOURCE = "_source.xlsx" + +_NO_STORAGE_MESSAGE = ( + "❌ Excel spreadsheet storage is not configured " + "(S3_USER_FILES_BUCKET_NAME is not set on the runtime)." +) + + +# --------------------------------------------------------------------------- +# openpyxl workbook builders (run in Code Interpreter) +# --------------------------------------------------------------------------- + + +_XLSX_PREAMBLE = ( + "from openpyxl import Workbook, load_workbook\n" + "from openpyxl.styles import Font, PatternFill, Alignment, Border, Side\n" + "from openpyxl.utils import get_column_letter\n" + "from openpyxl.chart import BarChart, LineChart, PieChart, Reference\n" +) + + +def _generate_xlsx_bytes( + code_interpreter_id: str, python_code: str, filename: str +) -> bytes: + """Build a new .xlsx 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 ``wb`` / ``ws`` and must + # not call Workbook()/wb.save() itself — we own the lifecycle. + _ci_exec( + code_interpreter, + ( + f"{_XLSX_PREAMBLE}\n" + "wb = Workbook()\n" + "ws = wb.active\n\n" + f"{python_code}\n\n" + f"wb.save({filename!r})\n" + ), + ) + data = _ci_read_bytes(code_interpreter, filename) + if data is None: + raise _DocGenError( + f"Workbook '{filename}' was not produced. Make sure your code " + "writes cells to `ws` (or another sheet on `wb`)." + ) + return data + finally: + try: + code_interpreter.stop() + except Exception: # pragma: no cover - cleanup best-effort + pass + + +def _modify_xlsx_bytes( + code_interpreter_id: str, + source_bytes: bytes, + python_code: str, + output_filename: str, +) -> bytes: + """Load an existing .xlsx, 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"{_XLSX_PREAMBLE}\n" + f"wb = load_workbook({_SANDBOX_SOURCE!r})\n" + "ws = wb.active\n\n" + f"{python_code}\n\n" + f"wb.save({output_filename!r})\n" + ), + ) + data = _ci_read_bytes(code_interpreter, output_filename) + if data is None: + raise _DocGenError( + f"Modified workbook '{output_filename}' was not produced." + ) + return data + finally: + try: + code_interpreter.stop() + except Exception: # pragma: no cover - cleanup best-effort + pass + + +def _extract_xlsx_text(code_interpreter_id: str, source_bytes: bytes) -> str: + """Extract readable cell values (per sheet, pipe-delimited rows) from a .xlsx. + + Uses ``data_only=True`` so cached formula results are shown when present. + 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 openpyxl import load_workbook\n" + f"wb = load_workbook({_SANDBOX_SOURCE!r}, data_only=True)\n" + "lines = []\n" + "for ws in wb.worksheets:\n" + " lines.append('## Sheet: ' + str(ws.title))\n" + " empty = True\n" + " for row in ws.iter_rows(values_only=True):\n" + " cells = ['' if c is None else str(c) for c in row]\n" + " while cells and cells[-1] == '':\n" + " cells.pop()\n" + " if not cells:\n" + " continue\n" + " empty = False\n" + " lines.append(' | '.join(cells))\n" + " if empty:\n" + " lines.append('(empty sheet)')\n" + " lines.append('')\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 lookup +# --------------------------------------------------------------------------- + + +async def _find_excel_spreadsheet( + user_id: str, session_id: str, spreadsheet_name: str +): + """Find the newest READY .xlsx in this session matching ``spreadsheet_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 = ( + spreadsheet_name + if spreadsheet_name.lower().endswith(".xlsx") + else f"{spreadsheet_name}.xlsx" + ) + 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 == _XLSX_MIME + and meta.filename.lower() == target.lower() + ): + return meta + return None + + +# --------------------------------------------------------------------------- +# Tool factories +# --------------------------------------------------------------------------- + + +def make_create_excel_spreadsheet_tool(session_id: str, user_id: str): + """Create a ``create_excel_spreadsheet`` tool bound to the given identity.""" + + @tool + async def create_excel_spreadsheet( + python_code: str, + spreadsheet_name: str, + ) -> Any: + """Create a new Excel (.xlsx) spreadsheet using openpyxl code. + + Executes openpyxl code in a sandboxed Code Interpreter to build a + workbook from scratch, saves it to the user's files, and returns a + download card. Great for tabular data, multi-sheet workbooks, formatted + headers, formulas, and native Excel charts. + + Available libraries in the sandbox: openpyxl, pandas, numpy. + + Args: + python_code: openpyxl code that builds the workbook. A blank + workbook is already available as ``wb = Workbook()`` with its + first sheet as ``ws = wb.active`` — do NOT call ``Workbook()`` + or ``wb.save()`` yourself; the tool saves it for you. + ``Workbook``, ``load_workbook``, ``Font``, ``PatternFill``, + ``Alignment``, ``Border``, ``Side``, ``get_column_letter`` and + the chart classes (``BarChart``, ``LineChart``, ``PieChart``, + ``Reference``) are already imported. + + Example (headers + rows + a formula): + ws.title = 'Sales' + ws.append(['Quarter', 'Revenue']) + ws['A1'].font = Font(bold=True) + ws['B1'].font = Font(bold=True) + ws.append(['Q1', 100]) + ws.append(['Q2', 120]) + ws['B4'] = '=SUM(B2:B3)' + + Example (add a second sheet + a bar chart): + ws2 = wb.create_sheet('Chart') + data = Reference(ws, min_col=2, min_row=1, max_row=3) + cats = Reference(ws, min_col=1, min_row=2, max_row=3) + chart = BarChart() + chart.add_data(data, titles_from_data=True) + chart.set_categories(cats) + ws2.add_chart(chart, 'A1') + + To load data from a pandas DataFrame: + from openpyxl.utils.dataframe import dataframe_to_rows + for r in dataframe_to_rows(df, index=False, header=True): + ws.append(r) + + spreadsheet_name: File name WITHOUT extension (.xlsx is added + automatically). Use only letters, numbers, hyphens, and + underscores (e.g. "sales-2026", "Q4_budget"). + + Returns: + An inline download card. The workbook is also saved to this + chat's Files. + """ + is_valid, error_msg = _validate_document_name(spreadsheet_name) + if not is_valid: + return _error( + f"❌ Invalid spreadsheet name '{spreadsheet_name}': {error_msg}\n\n" + "Examples: sales-2026, Q4_budget, report-final" + ) + + filename = f"{spreadsheet_name}.xlsx" + code_interpreter_id = _get_code_interpreter_id() + if not code_interpreter_id: + return _error(_NO_CI_MESSAGE) + if not _storage_configured(): + return _error(_NO_STORAGE_MESSAGE) + + try: + file_bytes = await asyncio.to_thread( + _generate_xlsx_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 openpyxl code for errors." + ) + except Exception as exc: # noqa: BLE001 - surface any sandbox error + logger.error(f"create_excel_spreadsheet 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, _XLSX_MIME + ) + except Exception as exc: # noqa: BLE001 - storage failure is terminal + logger.error(f"create_excel_spreadsheet 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_excel_spreadsheet + + +def make_modify_excel_spreadsheet_tool(session_id: str, user_id: str): + """Create a ``modify_excel_spreadsheet`` tool bound to the given identity.""" + + @tool + async def modify_excel_spreadsheet( + spreadsheet_name: str, + python_code: str, + output_name: Optional[str] = None, + ) -> Any: + """Modify an existing Excel (.xlsx) spreadsheet with openpyxl code. + + Loads a workbook previously created in this chat, runs your openpyxl + code against it, and saves the result (as a new file so the original is + preserved). Returns a download card. + + Use ``list_excel_spreadsheets`` first if you are unsure of the exact + name. + + Args: + spreadsheet_name: Name of the existing workbook to edit (with or + without the .xlsx extension), e.g. "sales-2026". + python_code: openpyxl code that edits the workbook. The loaded + workbook is available as ``wb = load_workbook(...)`` and its + active sheet as ``ws = wb.active`` — do NOT call + ``load_workbook()`` or ``wb.save()`` yourself. Access other + sheets with ``wb['SheetName']`` and add sheets with + ``wb.create_sheet('Name')``. ``Font``, ``PatternFill``, + ``Alignment``, ``Border``, ``Side``, ``get_column_letter`` and + the chart classes are already imported. + + Example (append rows to the active sheet): + ws.append(['Q3', 140]) + ws.append(['Q4', 160]) + + 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 workbook. + """ + code_interpreter_id = _get_code_interpreter_id() + if not code_interpreter_id: + return _error(_NO_CI_MESSAGE) + if not _storage_configured(): + return _error(_NO_STORAGE_MESSAGE) + + source = await _find_excel_spreadsheet(user_id, session_id, spreadsheet_name) + if source is None: + return _error( + f"❌ No Excel spreadsheet named '{spreadsheet_name}' was found in " + "this chat. Use list_excel_spreadsheets to see what's available." + ) + + out_base = output_name or source.filename + if out_base.lower().endswith(".xlsx"): + out_base = out_base[: -len(".xlsx")] + 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}.xlsx" + + try: + source_bytes = await asyncio.to_thread( + _download_s3_bytes, source.s3_bucket, source.s3_key + ) + file_bytes = await asyncio.to_thread( + _modify_xlsx_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 openpyxl code for errors." + ) + except Exception as exc: # noqa: BLE001 - surface any sandbox error + logger.error(f"modify_excel_spreadsheet 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, _XLSX_MIME + ) + except Exception as exc: # noqa: BLE001 - storage failure is terminal + logger.error(f"modify_excel_spreadsheet 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_excel_spreadsheet + + +def make_list_excel_spreadsheets_tool(session_id: str, user_id: str): + """Create a ``list_excel_spreadsheets`` tool bound to the given identity.""" + + @tool + async def list_excel_spreadsheets() -> Dict[str, Any]: + """List the Excel (.xlsx) spreadsheets available in this chat. + + Returns the file names and sizes of workbooks created or modified in + this conversation. Use the names with modify_excel_spreadsheet or + read_excel_spreadsheet. + """ + 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 != _XLSX_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 Excel spreadsheets in this chat yet. Use " + "create_excel_spreadsheet to make one." + ) + else: + text = "Excel spreadsheets in this chat:\n" + "\n".join(rows) + return {"content": [{"text": text}], "status": "success"} + + return list_excel_spreadsheets + + +def make_read_excel_spreadsheet_tool(session_id: str, user_id: str): + """Create a ``read_excel_spreadsheet`` tool bound to the given identity.""" + + @tool + async def read_excel_spreadsheet(spreadsheet_name: str) -> Dict[str, Any]: + """Read the cell values of an existing Excel (.xlsx) spreadsheet. + + Extracts each sheet's rows (pipe-delimited, cached formula values when + present) from a workbook created in this chat so you can reference or + summarize its contents. Use list_excel_spreadsheets first if unsure of + the exact name. + + Args: + spreadsheet_name: Name of the workbook to read (with or without the + .xlsx extension), e.g. "sales-2026". + + Returns: + The workbook's cell values, grouped by sheet. + """ + code_interpreter_id = _get_code_interpreter_id() + if not code_interpreter_id: + return _error(_NO_CI_MESSAGE) + if not _storage_configured(): + return _error(_NO_STORAGE_MESSAGE) + + source = await _find_excel_spreadsheet(user_id, session_id, spreadsheet_name) + if source is None: + return _error( + f"❌ No Excel spreadsheet named '{spreadsheet_name}' was found in " + "this chat. Use list_excel_spreadsheets 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_xlsx_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_excel_spreadsheet error: {exc}") + return _error(f"❌ Failed to read '{source.filename}': {exc}") + + body = text or "(The workbook has no extractable cell values.)" + return { + "content": [ + {"text": f"Content of {source.filename}:\n\n{body}"} + ], + "status": "success", + } + + return read_excel_spreadsheet diff --git a/backend/src/agents/builtin_tools/office/__init__.py b/backend/src/agents/builtin_tools/office/__init__.py new file mode 100644 index 000000000..85f970bac --- /dev/null +++ b/backend/src/agents/builtin_tools/office/__init__.py @@ -0,0 +1,6 @@ +"""Office document tooling shared internals. + +Holds the Code Interpreter + user-files storage helpers shared by the Word +(``word_document_tool``) and Excel (``excel_spreadsheet_tool``) toolsets. See +``_storage`` for the details. +""" diff --git a/backend/src/agents/builtin_tools/office/_storage.py b/backend/src/agents/builtin_tools/office/_storage.py new file mode 100644 index 000000000..87c6b85f8 --- /dev/null +++ b/backend/src/agents/builtin_tools/office/_storage.py @@ -0,0 +1,369 @@ +"""Shared storage + Code Interpreter helpers for the office document tools. + +Both the Word (``word_document_tool``) and Excel (``excel_spreadsheet_tool``) +toolsets build a binary Office file inside AWS Bedrock Code Interpreter and +persist it to the existing user-files store (``apis.shared.files``): the file +lands in ``S3_USER_FILES_BUCKET_NAME`` with a ``FileMetadata`` row (status +READY) in ``DYNAMODB_USER_FILES_TABLE_NAME``, so it appears in the chat's Files +panel and is downloadable via the app-api ``/files/{id}/preview-url`` route. + +The two toolsets differ only in the document format (``.docx`` vs ``.xlsx``) +and the library used inside the sandbox (python-docx vs openpyxl); everything +about talking to Code Interpreter and the user-files store is identical, so it +lives here to avoid drift. + +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 by the caller. +* Storage resolves the user-files bucket's real region via ``HeadBucket`` + (NOT ``AWS_REGION``, which the AgentCore Runtime does not reliably pin to the + bucket region) and does not hard-pin ``endpoint_url`` so botocore can still + auto-correct the region. ``S3_USER_FILES_BUCKET_NAME`` is required; the + helpers fail loudly when it is unset rather than targeting a bogus default + bucket the runtime has no access to. +""" + +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 botocore.exceptions import ClientError + +logger = logging.getLogger(__name__) + +# Presigned download links are short-lived; long enough for the user to click. +_DOWNLOAD_URL_TTL = 60 * 60 # 1 hour + + +class _DocGenError(Exception): + """Raised when Code Interpreter fails to run the document code.""" + + +class _StorageNotConfiguredError(Exception): + """Raised when the user-files S3 bucket is not configured for the runtime.""" + + +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 +_bucket_region: Optional[str] = None + + +def _resolve_bucket_region(bucket: str) -> str: + """Discover the user-files bucket's real region. + + The AgentCore Runtime's ``AWS_REGION`` does not reliably match the + deployment/bucket region. Pinning the S3 client to the wrong region makes + ``PutObject`` fail with ``PermanentRedirect``. ``HeadBucket`` (maps to + ``s3:ListBucket``, which the runtime role has) returns the true region in + the ``x-amz-bucket-region`` header — on a 200 when probed from the matching + region and on the 301 otherwise. This avoids depending on + ``s3:GetBucketLocation``, which the inference-api role is not granted. Falls + back to the env region if the lookup is unavailable — ``PutObject`` still + succeeds in that case because the client below no longer hard-pins + ``endpoint_url``, so botocore's built-in S3 region redirect can correct it. + """ + global _bucket_region + if _bucket_region: + return _bucket_region + region = None + try: + probe = boto3.client("s3", region_name="us-east-1") + resp = probe.head_bucket(Bucket=bucket) + region = ( + resp.get("ResponseMetadata", {}) + .get("HTTPHeaders", {}) + .get("x-amz-bucket-region") + ) + except ClientError as exc: + region = ( + exc.response.get("ResponseMetadata", {}) + .get("HTTPHeaders", {}) + .get("x-amz-bucket-region") + ) + if not region: + logger.warning(f"Could not resolve region for bucket {bucket}: {exc}") + except Exception as exc: # pragma: no cover - fall back to env region + logger.warning(f"Could not resolve region for bucket {bucket}: {exc}") + _bucket_region = region or _region() + return _bucket_region + + +def _s3(): + """SigV4 S3 client pinned to the user-files bucket's actual region. + + Uses the bucket's real region (not ``AWS_REGION``) so ``PutObject`` never + hits ``PermanentRedirect`` in the AgentCore Runtime. No explicit + ``endpoint_url``: botocore then builds the correct regional virtual-host + endpoint (which keeps presigned download URLs CORS-safe) and can still + auto-correct the region if the resolved value is off. + """ + global _s3_client + if _s3_client is None: + region = _resolve_bucket_region(_user_files_bucket()) + _s3_client = boto3.client( + "s3", + region_name=region, + config=Config( + signature_version="s3v4", + s3={"addressing_style": "virtual"}, + ), + ) + return _s3_client + + +def _user_files_bucket() -> str: + # Fail loudly rather than silently targeting a literal "user-files" bucket + # the runtime has no access to. That default misreported a missing env var + # as an S3 PermanentRedirect / AccessDenied and cost real debugging time. + # The runtime env is wired in infrastructure's + # inference-agentcore-construct.ts (S3_USER_FILES_BUCKET_NAME). + bucket = os.environ.get("S3_USER_FILES_BUCKET_NAME") + if not bucket: + raise _StorageNotConfiguredError( + "S3_USER_FILES_BUCKET_NAME is not set; the runtime cannot store or " + "retrieve generated documents." + ) + return bucket + + +def _storage_configured() -> bool: + """True when the user-files bucket env var is set.""" + return bool(os.environ.get("S3_USER_FILES_BUCKET_NAME")) + + +# --------------------------------------------------------------------------- +# 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" + ), + ) + + +# --------------------------------------------------------------------------- +# 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 _store_document( + user_id: str, + session_id: str, + filename: str, + file_bytes: bytes, + mime_type: str, +) -> Tuple[str, str, str]: + """Persist a generated file to the user-files store and mint a download URL. + + Returns ``(upload_id, download_url, size_kb)``. ``mime_type`` is stored on + the ``FileMetadata`` row and used for the download ``Content-Type``. + """ + 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=mime_type, + ) + + metadata = FileMetadata( + upload_id=upload_id, + user_id=user_id, + session_id=session_id, + filename=filename, + mime_type=mime_type, + 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": mime_type, + "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, + ``file_download``) instead of burying the link in the collapsed tool card. + The renderer picks its icon from the filename extension, so a single + ``file_download`` ui_type serves Word, Excel, and any future office file. + """ + return json.dumps( + { + "success": True, + "ui_type": "file_download", + "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." +) diff --git a/backend/src/agents/builtin_tools/word_document_tool.py b/backend/src/agents/builtin_tools/word_document_tool.py index e336fb7ad..fc6d7ba49 100644 --- a/backend/src/agents/builtin_tools/word_document_tool.py +++ b/backend/src/agents/builtin_tools/word_document_tool.py @@ -20,10 +20,10 @@ 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. +* The Code Interpreter + user-files storage plumbing is shared with the Excel + toolset and lives in ``builtin_tools.office._storage``; this module keeps + only the python-docx specifics (preamble, generate/modify/extract) and the + four tool factories. * 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 @@ -36,21 +36,27 @@ 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 botocore.exceptions import ClientError +from typing import Any, Dict, Optional from strands import tool +from agents.builtin_tools.office._storage import ( + _DocGenError, + _ci_exec, + _ci_read_bytes, + _ci_write_bytes, + _download_card, + _download_s3_bytes, + _error, + _get_code_interpreter_id, + _NO_CI_MESSAGE, + _region, + _storage_configured, + _store_document, + _validate_document_name, +) + logger = logging.getLogger(__name__) # Word document MIME type (matches apis.shared.files.ALLOWED_MIME_TYPES). @@ -58,224 +64,20 @@ "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.""" - - -class _StorageNotConfiguredError(Exception): - """Raised when the user-files S3 bucket is not configured for the runtime.""" - - -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 -_bucket_region: Optional[str] = None - - -def _resolve_bucket_region(bucket: str) -> str: - """Discover the user-files bucket's real region. - - The AgentCore Runtime's ``AWS_REGION`` does not reliably match the - deployment/bucket region. Pinning the S3 client to the wrong region makes - ``PutObject`` fail with ``PermanentRedirect``. ``get_bucket_location`` is - region-agnostic (queried against us-east-1) and returns the true region; - a null ``LocationConstraint`` means us-east-1. Falls back to the env - region if the lookup is unavailable (e.g. missing s3:GetBucketLocation) — - ``PutObject`` still succeeds in that case because the client below no - longer hard-pins ``endpoint_url``, so botocore's built-in S3 region - redirect can correct it. - """ - global _bucket_region - if _bucket_region: - return _bucket_region - # HeadBucket (maps to s3:ListBucket, which the runtime role has) returns - # the true region in the ``x-amz-bucket-region`` header — on a 200 when - # probed from the matching region and on the 301 otherwise. This avoids - # depending on s3:GetBucketLocation, which the inference-api role is not - # granted. - region = None - try: - probe = boto3.client("s3", region_name="us-east-1") - resp = probe.head_bucket(Bucket=bucket) - region = ( - resp.get("ResponseMetadata", {}) - .get("HTTPHeaders", {}) - .get("x-amz-bucket-region") - ) - except ClientError as exc: - region = ( - exc.response.get("ResponseMetadata", {}) - .get("HTTPHeaders", {}) - .get("x-amz-bucket-region") - ) - if not region: - logger.warning(f"Could not resolve region for bucket {bucket}: {exc}") - except Exception as exc: # pragma: no cover - fall back to env region - logger.warning(f"Could not resolve region for bucket {bucket}: {exc}") - _bucket_region = region or _region() - return _bucket_region - - -def _s3(): - """SigV4 S3 client pinned to the user-files bucket's actual region. - - Uses the bucket's real region (not ``AWS_REGION``) so ``PutObject`` never - hits ``PermanentRedirect`` in the AgentCore Runtime. No explicit - ``endpoint_url``: botocore then builds the correct regional virtual-host - endpoint (which keeps presigned download URLs CORS-safe) and can still - auto-correct the region if the resolved value is off. - """ - global _s3_client - if _s3_client is None: - region = _resolve_bucket_region(_user_files_bucket()) - _s3_client = boto3.client( - "s3", - region_name=region, - config=Config( - signature_version="s3v4", - s3={"addressing_style": "virtual"}, - ), - ) - return _s3_client - - -def _user_files_bucket() -> str: - # Fail loudly rather than silently targeting a literal "user-files" bucket - # the runtime has no access to. That default misreported a missing env var - # as an S3 PermanentRedirect / AccessDenied and cost real debugging time. - # The runtime env is wired in infrastructure's - # inference-agentcore-construct.ts (S3_USER_FILES_BUCKET_NAME). - bucket = os.environ.get("S3_USER_FILES_BUCKET_NAME") - if not bucket: - raise _StorageNotConfiguredError( - "S3_USER_FILES_BUCKET_NAME is not set; the runtime cannot store or " - "retrieve Word documents." - ) - return bucket +_NO_STORAGE_MESSAGE = ( + "❌ Word document storage is not configured " + "(S3_USER_FILES_BUCKET_NAME is not set on the runtime)." +) # --------------------------------------------------------------------------- -# Code Interpreter primitives +# python-docx document builders (run in Code Interpreter) # --------------------------------------------------------------------------- -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" @@ -399,16 +201,10 @@ def _extract_docx_text(code_interpreter_id: str, source_bytes: bytes) -> str: # --------------------------------------------------------------------------- -# User-files store helpers +# User-files lookup # --------------------------------------------------------------------------- -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 ): @@ -437,107 +233,6 @@ async def _find_word_document( 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." -) - -_NO_STORAGE_MESSAGE = ( - "❌ Word document storage is not configured " - "(S3_USER_FILES_BUCKET_NAME is not set on the runtime)." -) - - -def _storage_configured() -> bool: - """True when the user-files bucket env var is set.""" - return bool(os.environ.get("S3_USER_FILES_BUCKET_NAME")) - - # --------------------------------------------------------------------------- # Tool factories # --------------------------------------------------------------------------- @@ -620,7 +315,7 @@ async def create_word_document( try: _id, download_url, size_kb = await _store_document( - user_id, session_id, filename, file_bytes + user_id, session_id, filename, file_bytes, _DOCX_MIME ) except Exception as exc: # noqa: BLE001 - storage failure is terminal logger.error(f"create_word_document storage error: {exc}") @@ -711,7 +406,7 @@ async def modify_word_document( try: _id, download_url, size_kb = await _store_document( - user_id, session_id, output_filename, file_bytes + user_id, session_id, output_filename, file_bytes, _DOCX_MIME ) except Exception as exc: # noqa: BLE001 - storage failure is terminal logger.error(f"modify_word_document storage error: {exc}") diff --git a/backend/src/apis/inference_api/chat/routes.py b/backend/src/apis/inference_api/chat/routes.py index 1e37af085..b69b535d3 100644 --- a/backend/src/apis/inference_api/chat/routes.py +++ b/backend/src/apis/inference_api/chat/routes.py @@ -487,6 +487,50 @@ def _build_word_document_tools( return tools +# ============================================================ +# Excel Spreadsheet Tool Injection +# ============================================================ + +EXCEL_SPREADSHEET_TOOL_IDS = {"create_excel_spreadsheet"} + + +def _build_excel_spreadsheet_tools( + enabled_tools: list | None, + session_id: str, + user_id: str, +) -> list: + """Create context-bound Excel spreadsheet tools if enabled by the user. + + Identity is captured by closure (same pattern as the Word document and + spreadsheet analysis tools) since the runtime does not populate ToolContext. + Distinct from the spreadsheet *analysis* tools (list_spreadsheets / + analyze_spreadsheet): this toolset creates/modifies/reads/lists generated + .xlsx files, it doesn't analyze uploaded ones. + """ + if not enabled_tools or not EXCEL_SPREADSHEET_TOOL_IDS.intersection(enabled_tools): + return [] + + # The Excel capability is a single toggle: enabling create_excel_spreadsheet + # provisions the full workbook toolset (create/modify/list/read) so the + # model can round-trip on a spreadsheet without extra admin catalog entries. + from agents.builtin_tools.excel_spreadsheet_tool import ( + make_create_excel_spreadsheet_tool, + make_list_excel_spreadsheets_tool, + make_modify_excel_spreadsheet_tool, + make_read_excel_spreadsheet_tool, + ) + + tools = [ + make_create_excel_spreadsheet_tool(session_id, user_id), + make_modify_excel_spreadsheet_tool(session_id, user_id), + make_list_excel_spreadsheets_tool(session_id, user_id), + make_read_excel_spreadsheet_tool(session_id, user_id), + ] + + logger.info(f"Created {len(tools)} excel spreadsheet 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. @@ -1793,6 +1837,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_excel_spreadsheet_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/tests/test_seed_system_admin_jwt.py b/backend/tests/test_seed_system_admin_jwt.py index aece22925..4eeb6e112 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 @@ -216,13 +216,27 @@ def test_creates_default_tools(self, dynamodb_table): assert item["GSI1PK"] == "CATEGORY#document" assert item["GSI1SK"] == "TOOL#create_word_document" + # Verify create_excel_spreadsheet (single toggle for the whole Excel toolset) + resp = dynamodb_table.get_item( + Key={"PK": "TOOL#create_excel_spreadsheet", "SK": "METADATA"} + ) + item = resp["Item"] + assert item["toolId"] == "create_excel_spreadsheet" + assert item["displayName"] == "Excel Spreadsheets" + 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_excel_spreadsheet" + 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): @@ -236,7 +250,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 48a5cada1..4c6376832 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -12,7 +12,7 @@ resolution-markers = [ [[package]] name = "agentcore-stack" -version = "1.9.0" +version = "1.10.0" source = { editable = "." } dependencies = [ { name = "aiofiles" }, diff --git a/frontend/ai.client/package-lock.json b/frontend/ai.client/package-lock.json index 0e6b860e5..87f5f8574 100644 --- a/frontend/ai.client/package-lock.json +++ b/frontend/ai.client/package-lock.json @@ -1,12 +1,12 @@ { "name": "ai.client", - "version": "1.9.0", + "version": "1.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ai.client", - "version": "1.9.0", + "version": "1.10.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 051419903..69bae0039 100644 --- a/frontend/ai.client/package.json +++ b/frontend/ai.client/package.json @@ -1,6 +1,6 @@ { "name": "ai.client", - "version": "1.9.0", + "version": "1.10.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 335c18354..3cb42f87c 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,7 +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 { FileDownloadRendererComponent } from './renderers/file-download-renderer.component'; import { VisualStateService } from '../../../../services/visual-state/visual-state.service'; /** @@ -11,7 +11,7 @@ import { VisualStateService } from '../../../../services/visual-state/visual-sta @Component({ selector: 'app-inline-visual', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ChartRendererComponent, DefaultRendererComponent, WordDocumentRendererComponent], + imports: [ChartRendererComponent, DefaultRendererComponent, FileDownloadRendererComponent], template: ` @if (!isDismissed()) {
@@ -24,8 +24,13 @@ import { VisualStateService } from '../../../../services/visual-state/visual-sta (toggleExpanded)="onToggleExpanded()" /> } + @case ('file_download') { + + } @case ('word_document') { - + + } @default { + +
+ +
+ + +
+

+ {{ f.filename }} +

+ @if (f.size_kb) { +

{{ f.size_kb }}

+ } +
+ + + + + Download + +
+ } + `, +}) +export class FileDownloadRendererComponent { + /** The payload data from the backend tool result. */ + payload = input.required(); + + /** Narrowed, validated payload with resolved icon styling (null when malformed). */ + file = computed<(FileDownloadPayload & FileKindStyle) | null>(() => { + 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, + ...styleForFilename(p.filename), + }; + }); +} diff --git a/frontend/ai.client/src/app/session/components/message-list/components/inline-visual/renderers/word-document-renderer.component.ts b/frontend/ai.client/src/app/session/components/message-list/components/inline-visual/renderers/word-document-renderer.component.ts deleted file mode 100644 index 53a688d9c..000000000 --- a/frontend/ai.client/src/app/session/components/message-list/components/inline-visual/renderers/word-document-renderer.component.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; - -/** - * Payload for the word_document inline visual, produced by the - * create_word_document tool. download_url is a short-lived presigned S3 GET - * URL whose response forces Content-Disposition: attachment, so a plain - * click downloads the file (no new tab / navigation needed). - */ -interface WordDocumentPayload { - filename: string; - download_url: string; - size_kb?: string; -} - -/** - * Inline download card for a generated Word document. Rendered as a - * first-class message block (not inside the collapsed tool-output card), so - * the download action is always visible and clickable. - * - * The download link uses the trailing-! important modifiers (text-white! and - * no-underline!) because the global ".message-block a" rule in styles.css - * (dark-blue text + underline) outranks a plain text-white utility by - * specificity. The ! modifier emits !important, which wins regardless. - */ -@Component({ - selector: 'app-word-document-renderer', - changeDetection: ChangeDetectionStrategy.OnPush, - host: { class: 'block' }, - template: ` - @if (doc(); as d) { -
- -
- -
- - -
-

- {{ 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/spa/spa-distribution-construct.ts b/infrastructure/lib/constructs/spa/spa-distribution-construct.ts index 604a66ae6..71f2e64ef 100644 --- a/infrastructure/lib/constructs/spa/spa-distribution-construct.ts +++ b/infrastructure/lib/constructs/spa/spa-distribution-construct.ts @@ -25,6 +25,14 @@ export interface SpaDistributionConstructProps { * the resolved string. */ appApiUrl: string; + /** + * MCP Apps sandbox-proxy origin (`https://mcp-sandbox.{domainName}` on + * domained deploys). Included in the `frame-src` CSP directive so the + * SPA can frame MCP App UIs; without it CloudFront's CSP blocks the + * sandbox iframe (localhost dev bypasses CloudFront, so the block only + * shows up on deployed environments). + */ + mcpSandboxOrigin: string; } /** @@ -46,8 +54,9 @@ export interface SpaDistributionConstructProps { * embedding), Referrer-Policy=strict-origin-when-cross-origin, HSTS * 1y w/ subdomains, X-XSS-Protection. * - The `frame-src` CSP directive opens `https://artifacts.{domainName}` - * so the SPA can embed artifact iframes. Other resource types remain unrestricted - * by CSP (defended by the other security headers). + * and the MCP Apps sandbox-proxy origin (`https://mcp-sandbox.{domainName}`) + * so the SPA can embed artifact and MCP App iframes. Other resource types + * remain unrestricted by CSP (defended by the other security headers). * * Custom domain: if `config.domainName` and * `config.frontend.certificateArn` are both set, the distribution @@ -70,7 +79,7 @@ export class SpaDistributionConstruct extends Construct { ) { super(scope, id); - const { config, bucket, appApiUrl } = props; + const { config, bucket, appApiUrl, mcpSandboxOrigin } = props; // OAC for CloudFront → S3 access (S3 bucket has block-public-access). new cloudfront.CfnOriginAccessControl(this, 'FrontendOAC', { @@ -129,7 +138,7 @@ export class SpaDistributionConstruct extends Construct { ...(artifactsOrigin ? { contentSecurityPolicy: { - contentSecurityPolicy: `frame-src 'self' ${artifactsOrigin}`, + contentSecurityPolicy: `frame-src 'self' ${artifactsOrigin} ${mcpSandboxOrigin}`, override: true, }, } diff --git a/infrastructure/lib/platform-stack.ts b/infrastructure/lib/platform-stack.ts index 5ed350997..e6a36ee79 100644 --- a/infrastructure/lib/platform-stack.ts +++ b/infrastructure/lib/platform-stack.ts @@ -617,6 +617,7 @@ export class PlatformStack extends cdk.Stack { config, bucket: this.spaBucket, appApiUrl: this._albDns.albUrl, + mcpSandboxOrigin: this.mcpSandboxProxyOrigin, }); this.spaDistribution = spaDist.distribution; this.spaDistributionDomainName = spaDist.distributionDomainName; diff --git a/infrastructure/package-lock.json b/infrastructure/package-lock.json index d9666af9b..c694c14a3 100644 --- a/infrastructure/package-lock.json +++ b/infrastructure/package-lock.json @@ -1,12 +1,12 @@ { "name": "infrastructure", - "version": "1.9.0", + "version": "1.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "infrastructure", - "version": "1.9.0", + "version": "1.10.0", "dependencies": { "aws-cdk-lib": "2.260.0", "constructs": "10.6.0" diff --git a/infrastructure/package.json b/infrastructure/package.json index d12452285..4f0ed2a23 100644 --- a/infrastructure/package.json +++ b/infrastructure/package.json @@ -1,6 +1,6 @@ { "name": "infrastructure", - "version": "1.9.0", + "version": "1.10.0", "bin": { "infrastructure": "bin/infrastructure.js" }, diff --git a/infrastructure/test/spa-frame-src-csp.test.ts b/infrastructure/test/spa-frame-src-csp.test.ts new file mode 100644 index 000000000..e7d0c3561 --- /dev/null +++ b/infrastructure/test/spa-frame-src-csp.test.ts @@ -0,0 +1,88 @@ +/** + * Regression cover for the SPA distribution's `frame-src` CSP. + * + * The SPA frames two cross-origin iframes on domained deploys: artifact + * previews (`artifacts.{domain}`) and MCP App UIs via the sandbox proxy + * (`mcp-sandbox.{domain}`). The sandbox side's `frame-ancestors` was + * locked to the SPA origin from the start, but the SPA side's + * `frame-src` originally listed only the artifacts origin — so deployed + * environments blocked every MCP App iframe with a CSP violation while + * localhost dev (which bypasses CloudFront's response headers) worked, + * masking the gap through live verification. + */ +import * as cdk from 'aws-cdk-lib'; +import { Template } from 'aws-cdk-lib/assertions'; +import { loadConfig } from '../lib/config'; +import { PlatformStack } from '../lib/platform-stack'; +import { mockSsmContext, MOCK_ACCOUNT, MOCK_REGION } from './helpers/mock-config'; + +const SHARED_CF_CERT = 'arn:aws:acm:us-east-1:123456789012:certificate/shared-wildcard'; + +/** Seed every context value loadConfig requires for a domained deploy. */ +function seedRequiredContext(app: cdk.App): void { + app.node.setContext('projectPrefix', 'test-project'); + app.node.setContext('awsRegion', MOCK_REGION); + app.node.setContext('awsAccount', MOCK_ACCOUNT); + app.node.setContext('vpcCidr', '10.0.0.0/16'); + app.node.setContext('production', false); + app.node.setContext('retainDataOnDelete', false); + app.node.setContext('domainName', 'example.com'); + app.node.setContext('infrastructureHostedZoneDomain', 'example.com'); + app.node.setContext('frontend', { cloudFrontPriceClass: 'PriceClass_100' }); + app.node.setContext('appApi', { cpu: 256, memory: 512, desiredCount: 1, maxCapacity: 2 }); + app.node.setContext('inferenceApi', {}); + app.node.setContext('fineTuning', {}); + app.node.setContext('artifacts', { retentionDays: 90, extraFrameAncestors: [] }); + app.node.setContext('mcpSandbox', { extraFrameAncestors: [] }); + app.node.setContext('ragIngestion', { + additionalCorsOrigins: '', + lambdaMemorySize: 10240, + lambdaTimeout: 900, + embeddingModel: 'amazon.titan-embed-text-v2', + vectorDimension: 1024, + vectorDistanceMetric: 'cosine', + }); +} + +describe('SPA distribution frame-src CSP', () => { + const PREV = process.env.CDK_CLOUDFRONT_CERTIFICATE_ARN; + + afterAll(() => { + if (PREV === undefined) { + delete process.env.CDK_CLOUDFRONT_CERTIFICATE_ARN; + } else { + process.env.CDK_CLOUDFRONT_CERTIFICATE_ARN = PREV; + } + }); + + it('allows both the artifacts and mcp-sandbox origins on a domained deploy', () => { + delete process.env.CDK_FRONTEND_CERTIFICATE_ARN; + delete process.env.CDK_ARTIFACTS_CERTIFICATE_ARN; + delete process.env.CDK_MCP_SANDBOX_CERTIFICATE_ARN; + process.env.CDK_CLOUDFRONT_CERTIFICATE_ARN = SHARED_CF_CERT; + + const app = new cdk.App(); + seedRequiredContext(app); + const config = loadConfig(app); + mockSsmContext(app, config); + + const stack = new PlatformStack(app, 'FrameSrcCspPlatformStack', { + config, + env: { account: MOCK_ACCOUNT, region: MOCK_REGION }, + }); + const template = Template.fromStack(stack); + + template.hasResourceProperties('AWS::CloudFront::ResponseHeadersPolicy', { + ResponseHeadersPolicyConfig: { + Name: 'test-project-frontend-headers', + SecurityHeadersConfig: { + ContentSecurityPolicy: { + ContentSecurityPolicy: + "frame-src 'self' https://artifacts.example.com https://mcp-sandbox.example.com", + Override: true, + }, + }, + }, + }); + }); +}); diff --git a/infrastructure/test/transport-security.test.ts b/infrastructure/test/transport-security.test.ts index aa10d7c8e..9724c81e5 100644 --- a/infrastructure/test/transport-security.test.ts +++ b/infrastructure/test/transport-security.test.ts @@ -59,6 +59,7 @@ describe('Transport security baseline', () => { config, bucket: bucket.bucket, appApiUrl: 'https://api.example.com', + mcpSandboxOrigin: 'https://mcp-sandbox.example.com', }); const t = Template.fromStack(stack);