diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a8a39eaf..526bfad45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ 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.11.0] - 2026-07-24 + +Feature release adding **two new agent capabilities to chat**: a PowerPoint (`.pptx`) presentation toolset that mirrors the existing Excel/Word tools, and a generic **File Workspace** toolset that lets the agent list, read, and save text files in a conversation's workspace. Both ship as single catalog toggles, off by default. Also fixes Markdown artifacts that rendered as raw source when authored under the default HTML type, and empty "Thinking" blocks that appeared on conversation reload. No new AWS resources, no dependency changes, no CDK deploy required β€” ships via `backend.yml` + `frontend-deploy.yml`; two new tool catalog entries must be seeded per environment. + +### πŸš€ Added + +- PowerPoint presentation toolset β€” `create_powerpoint_presentation`, `modify_powerpoint_presentation`, `list_powerpoint_presentations`, `read_powerpoint_presentation`, and `list_powerpoint_layouts` build and edit `.pptx` decks via python-pptx 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 ("PowerPoint Presentations", gate key `create_powerpoint_presentation`, off by default) provisions the whole set (#713) +- File Workspace toolset β€” `workspace_list`, `workspace_read`, `workspace_write` give the agent a generic file surface over the conversation's user-files store: read uploaded text files on demand and save text deliverables (Markdown, CSV, JSON) to the chat Files panel with a download link. One catalog entry ("File Workspace", gate key `workspace_files`, off by default) provisions the set, gated per environment by the `WORKSPACE_TOOLS_ENABLED` kill switch (default ON). Backed by a new `apis/shared/files/workspace.py` module; file records gain a display-only `source` provenance field (#716) + +### πŸ› Fixed + +- Markdown artifacts authored under the default `text/html` content type now render as formatted documents instead of run-together `#`/`**` source β€” HTML-typed content that lacks a full HTML document shell is reclassified as Markdown, and the `create_artifact` tool guidance now steers prose deliverables to Markdown mode (#720) +- Empty "Thinking" blocks no longer appear on conversation reload β€” signature-only reasoning blocks (which some models, e.g. Sonnet 5, emit and persist for API correctness) are kept in the message for the Bedrock signature/prompt-cache contract but are no longer painted as an empty collapsible, matching the live stream parser's guard (#721) + ## [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. diff --git a/README.md b/README.md index 9742c0d8f..ace2eaa2f 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.10.0-6366f1?style=flat&logo=github&logoColor=white)](RELEASE_NOTES.md) +[![Release](https://img.shields.io/badge/Release-v1.11.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.10.0 +**Current release:** v1.11.0 --- diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 69e3b73a4..bbd90e369 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,61 @@ +# Release Notes β€” v1.11.0 + +**Release Date:** July 24, 2026 +**Previous Release:** v1.10.0 (July 21, 2026) + +--- + +> πŸš€ **No CDK deploy required this release** β€” no new AWS resources, no dependency changes, no infrastructure edits. Ship backend code via `backend.yml` and the SPA via `frontend-deploy.yml`. **Per-environment action:** two new tool catalog entries ("PowerPoint Presentations", "File Workspace") must be seeded and granted via RBAC before users can enable them β€” see Deployment notes. + +--- + +## Highlights + +v1.11.0 adds **two new agent capabilities to chat**. First, a **PowerPoint presentation toolset** β€” the agent can create, edit, read, and list real `.pptx` decks, built with python-pptx inside the sandboxed Code Interpreter and delivered through the chat Files panel with a download link β€” completing the office-document trio alongside the existing Excel and Word tools. Second, a generic **File Workspace toolset** that gives the agent a first-class way to list, read, and save text files (Markdown, CSV, JSON) in a conversation's workspace, over the same user-files store. Both ship as single catalog toggles, off by default. The release also fixes two rendering bugs: Markdown artifacts that came out as raw `#`/`**` source when authored under the default HTML type, and empty "Thinking" collapsibles that appeared on conversation reload for signature-only reasoning blocks. + +## PowerPoint presentations in chat + +Users can ask the agent to build or revise real PowerPoint decks mid-conversation β€” slide outlines, briefing decks, templated layouts β€” and get a downloadable `.pptx` back in the chat's Files panel. The capability mirrors the Excel and Word toolsets: one admin toggle provisions the whole round-trip. + +### Backend + +- `agents/builtin_tools/powerpoint_presentation_tool.py` (750+ lines) β€” five tool factories: `make_create_powerpoint_presentation_tool`, `make_modify_powerpoint_presentation_tool`, `make_list_powerpoint_presentations_tool`, `make_read_powerpoint_presentation_tool`, and `make_list_powerpoint_layouts_tool`. Generation and edits run python-pptx inside the sandboxed AgentCore Code Interpreter; nothing executes in the API container, and identity is captured by closure (same pattern as the Word/Excel tools, since the runtime does not populate `ToolContext`). +- `apis/inference_api/chat/routes.py` β€” `_build_powerpoint_presentation_tools` injects the toolset at runtime when the catalog toggle is enabled. One catalog entry ("PowerPoint Presentations", gate key `create_powerpoint_presentation`, `enabledByDefault: false`) provisions all five tools. +- Generated files persist to the user-files bucket (`S3_USER_FILES_BUCKET_NAME`) and surface in the session's Files panel. + +### Frontend + +- The generic `file-download-renderer` component (introduced in v1.10.0 for Word/Excel) now also handles `.pptx`, so generated presentations render through the same inline download card. + +## File Workspace toolset + +Gives the agent a durable, generic file surface over a conversation's workspace β€” distinct from the format-specific office tools. The model can enumerate what files exist, read uploaded text files on demand instead of front-loading them into context, and save text deliverables back to the conversation. + +### Backend + +- `agents/builtin_tools/workspace_tools.py` β€” three tools: `workspace_list`, `workspace_read`, `workspace_write`. Reads uploaded text files on demand and writes text deliverables (Markdown, CSV, JSON) to the user-files store, where they appear in the chat Files panel with a download link. +- `apis/shared/files/workspace.py` (430+ lines) β€” new shared module implementing the workspace read/write surface over the user-files store. +- `apis/shared/feature_flags.py` β€” `workspace_tools_enabled()` gates the feature per environment via `WORKSPACE_TOOLS_ENABLED` (**default ON, kill switch** β€” only the literal `false` disables). This is independent of the `workspace_files` catalog entry, which governs *who* may use the tools via RBAC. +- `apis/shared/files/models.py` β€” file records gain a display-only `source` provenance field ("upload" or the id of the tool that produced the file); never part of an access decision. +- `apis/inference_api/chat/routes.py` β€” `_build_workspace_tools` injects the set when the catalog toggle is enabled. One catalog entry ("File Workspace", gate key `workspace_files`, `enabledByDefault: false`). + +### Test Coverage + +430+ lines of new tests across `tests/agents/builtin_tools/test_workspace_tools.py` and `tests/shared/test_workspace.py` covering the tool surface and the workspace store. Design captured in `docs/specs/session-workspace-tools.md`. + +## πŸ› Bug fixes + +- **Markdown artifacts rendered as raw source.** `create_artifact`'s `content_type` defaults to `text/html`, so a request like "make a markdown recipe" easily produced raw Markdown stored under the HTML type β€” which then rendered as run-together `#`/`**` source instead of a formatted document. The service now reclassifies HTML-typed content that lacks a full HTML document shell (`` / ``) as Markdown, which the writer wraps into a proper render document; the tool guidance also now steers prose deliverables (reports, articles, notes, recipes) to Markdown mode. Non-HTML and genuine HTML-document content pass through untouched (#720) +- **Empty "Thinking" blocks appeared on reload.** Some models (e.g. Sonnet 5) persist a signature-only `reasoningContent` block β€” empty `reasoningText.text`, no redacted content β€” which Bedrock requires kept in the message for follow-up calls. The live stream parser already guarded on this, but the history-rehydration path bypassed it and painted an empty "Thinking" collapsible on reload. A `hasRenderableReasoning()` guard now mirrors the component's own visibility logic and the parser's guard, so the block is only painted when it has reasoning text or redacted content. Display-only: the block is left untouched in the persisted message to preserve the signature/prompt-cache contract (#721) + +## πŸš€ Deployment notes + +- **No CDK deploy needed** β€” no new AWS resources, no infrastructure changes, no dependency changes (python-pptx runs in the sandboxed Code Interpreter, like openpyxl for Excel). Run `backend.yml` (app-api / inference-api) and `frontend-deploy.yml` as usual. +- **Seed and grant the two new tool catalog entries per environment** β€” "PowerPoint Presentations" (gate key `create_powerpoint_presentation`) and "File Workspace" (gate key `workspace_files`) ship in the bootstrap seed data with `enabledByDefault: false`. Environments seeded before this release won't have the rows: add them via the admin Tools page (or re-run the tools seeding) and grant them to the appropriate roles via RBAC. +- **File Workspace kill switch** β€” `WORKSPACE_TOOLS_ENABLED` defaults ON; no configuration is required to enable the feature. Set it to `false` on the inference-api environment to disable the workspace tools entirely for an environment, independent of the catalog grant. + +--- + # Release Notes β€” v1.10.0 **Release Date:** July 21, 2026 diff --git a/VERSION b/VERSION index 81c871de4..1cac385c6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.10.0 +1.11.0 diff --git a/backend/pyproject.toml b/backend/pyproject.toml index f153f28a4..c44a9e031 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "agentcore-stack" -version = "1.10.0" +version = "1.11.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 26f9fb06c..8477eb543 100644 --- a/backend/scripts/seed_bootstrap_data.py +++ b/backend/scripts/seed_bootstrap_data.py @@ -442,6 +442,21 @@ def seed_default_models( "isPublic": True, "forwardAuthToken": False, }, + { + # Single catalog entry / toggle that provisions the whole workspace + # toolset. Enabling this one id injects list/read/write at runtime β€” + # see WORKSPACE_TOOL_IDS and _build_workspace_tools in + # apis/inference_api/chat/routes.py. Keep the toolId as + # "workspace_files": it is the gate key. + "toolId": "workspace_files", + "displayName": "File Workspace", + "description": "List, read, and save files in the conversation's workspace. Reads uploaded text files on demand and saves text deliverables (markdown, CSV, JSON) to the chat's Files with a download link.", + "category": "document", + "protocol": "local", + "enabledByDefault": False, + "isPublic": True, + "forwardAuthToken": False, + }, { # Single catalog entry / toggle that provisions the whole Excel # spreadsheet toolset. Enabling this one id injects create/modify/list/ @@ -459,6 +474,21 @@ def seed_default_models( "isPublic": True, "forwardAuthToken": False, }, + { + # Single catalog entry / toggle that provisions the whole PowerPoint + # presentation toolset. Enabling this one id injects create/modify/list/ + # read at runtime β€” see POWERPOINT_PRESENTATION_TOOL_IDS and + # _build_powerpoint_presentation_tools in apis/inference_api/chat/routes.py. + # Keep the toolId as "create_powerpoint_presentation": it is the gate key. + "toolId": "create_powerpoint_presentation", + "displayName": "PowerPoint Presentations", + "description": "Create, edit, read, and list PowerPoint (.pptx) presentations using python-pptx 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/artifacts/service.py b/backend/src/agents/builtin_tools/artifacts/service.py index 86a5cb232..a47c3e264 100644 --- a/backend/src/agents/builtin_tools/artifacts/service.py +++ b/backend/src/agents/builtin_tools/artifacts/service.py @@ -43,6 +43,7 @@ # for a Markdown artifact: a self-contained HTML render wrapper. _RENDERED_CONTENT_TYPE = "text/html; charset=utf-8" _MARKDOWN_MIME_TYPES = frozenset({"text/markdown", "text/x-markdown"}) +_HTML_MIME_TYPES = frozenset({"text/html", "application/xhtml+xml"}) # Markdown is base64-embedded so no character ever needs HTML/JS escaping # and there is no second network fetch (the artifact-origin CSP sets @@ -132,10 +133,47 @@ """ +def _bare_type(content_type: Optional[str]) -> str: + """MIME type with any `; charset=` suffix stripped, lowercased.""" + return (content_type or "").split(";")[0].strip().lower() + + def _is_markdown(content_type: Optional[str]) -> bool: """True for a Markdown MIME type, ignoring any `; charset=` suffix.""" - bare = (content_type or "").split(";")[0].strip().lower() - return bare in _MARKDOWN_MIME_TYPES + return _bare_type(content_type) in _MARKDOWN_MIME_TYPES + + +def _looks_like_html_document(content: str) -> bool: + """True if `content` opens like a full standalone HTML document. + + The create_artifact contract requires HTML artifacts to be a complete + document (`` + a full `…`), which is what + the sandboxed iframe needs to render. + """ + head = content.lstrip()[:1024].lower() + return head.startswith(" str: + """Safety net for HTML-typed content that isn't an HTML document. + + `content_type` defaults to `text/html`, so a request like "make a + markdown recipe" easily produces raw Markdown authored under the HTML + type. Stored verbatim as HTML, that renders as run-together `#`/`**` + source instead of a formatted document. When HTML-typed content lacks + the required document shell it is almost always Markdown the model + forgot to type, so reclassify it as Markdown (which the writer wraps + into a proper render document). Markdown and non-HTML types pass + through untouched. + """ + if _bare_type(content_type) not in _HTML_MIME_TYPES: + return content_type + if _looks_like_html_document(content): + return content_type + logger.info( + "reclassifying HTML-typed artifact as markdown (no HTML document shell)" + ) + return "text/markdown" def _wrap_markdown(title: str, markdown: str) -> str: @@ -265,7 +303,7 @@ def create_artifact_record( """Create v1 of a new artifact. Returns (artifact_id, version).""" artifact_id = uuid.uuid4().hex version = 1 - content_type = content_type or _DEFAULT_CONTENT_TYPE + content_type = _coerce_content_type(content, content_type or _DEFAULT_CONTENT_TYPE) now = _now_iso() content_key = _put_object( user_id, artifact_id, version, content, content_type, title @@ -337,7 +375,9 @@ def update_artifact_record( current = int(head["version"]) version = current + 1 title = title or head.get("title", "") - content_type = content_type or head.get("content_type") or _DEFAULT_CONTENT_TYPE + content_type = _coerce_content_type( + content, content_type or head.get("content_type") or _DEFAULT_CONTENT_TYPE + ) now = _now_iso() content_key = _put_object( user_id, artifact_id, version, content, content_type, title diff --git a/backend/src/agents/builtin_tools/artifacts/tools.py b/backend/src/agents/builtin_tools/artifacts/tools.py index a202e0349..ef247ff32 100644 --- a/backend/src/agents/builtin_tools/artifacts/tools.py +++ b/backend/src/agents/builtin_tools/artifacts/tools.py @@ -32,6 +32,13 @@ async def create_artifact( will want to view, keep, or iterate on β€” an HTML page, a chart, an interactive widget, a formatted report, or a written document. + Choosing a mode: if the deliverable is prose β€” a report, an + article, notes, documentation, a recipe, any mostly-text + document β€” author it as Markdown (`content_type="text/markdown"`), + NOT HTML. Only reach for HTML when you genuinely need custom + layout, styling, charts, or interactivity. When the user asks for + "markdown", always use the Markdown mode. + Two authoring modes: - HTML (default): `content` MUST be a complete standalone HTML diff --git a/backend/src/agents/builtin_tools/powerpoint_presentation_tool.py b/backend/src/agents/builtin_tools/powerpoint_presentation_tool.py new file mode 100644 index 000000000..b64d988e3 --- /dev/null +++ b/backend/src/agents/builtin_tools/powerpoint_presentation_tool.py @@ -0,0 +1,750 @@ +"""PowerPoint presentation tools (create / modify / list / read). + +Each tool runs python-pptx code inside AWS Bedrock Code Interpreter and uses the +existing user-files store (``apis.shared.files``) for persistence and delivery +β€” generated/modified ``.pptx`` 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_powerpoint_presentation`` β€” build a new deck from python-pptx code. +* ``modify_powerpoint_presentation`` β€” edit an existing deck with python-pptx. +* ``list_powerpoint_presentations`` β€” list the .pptx files in this chat. +* ``read_powerpoint_presentation`` β€” extract a deck's slide text + notes. + +(A slide-screenshot/preview tool is intentionally omitted: rasterizing a .pptx +requires LibreOffice/poppler, which the Python-only Code Interpreter sandbox +does not provide β€” the same reason the Word toolset omits one.) + +Design notes +------------ +* The Code Interpreter + user-files storage plumbing is shared with the Word + and Excel toolsets and lives in ``builtin_tools.office._storage``; this module + keeps only the python-pptx 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 Excel spreadsheet tools (the Strands runtime here does NOT populate + ``ToolContext.invocation_state`` with identity). The tools are injected + per-request through ``extra_tools`` (see ``_build_powerpoint_presentation_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__) + +# PowerPoint presentation MIME type (matches apis.shared.files.ALLOWED_MIME_TYPES). +_PPTX_MIME = ( + "application/vnd.openxmlformats-officedocument.presentationml.presentation" +) + +# Sandbox path used to stage a source presentation loaded from S3. +_SANDBOX_SOURCE = "_source.pptx" + +# Sandbox path used to stage a template presentation loaded from S3. +_SANDBOX_TEMPLATE = "_template.pptx" + +_NO_STORAGE_MESSAGE = ( + "❌ PowerPoint presentation storage is not configured " + "(S3_USER_FILES_BUCKET_NAME is not set on the runtime)." +) + + +# --------------------------------------------------------------------------- +# python-pptx presentation builders (run in Code Interpreter) +# --------------------------------------------------------------------------- + + +_PPTX_PREAMBLE = ( + "from pptx import Presentation\n" + "from pptx.util import Inches, Pt, Emu\n" + "from pptx.dml.color import RGBColor\n" + "from pptx.enum.text import PP_ALIGN, MSO_ANCHOR\n" + "from pptx.enum.shapes import MSO_SHAPE\n" +) + + +def _generate_pptx_bytes( + code_interpreter_id: str, + python_code: str, + filename: str, + template_bytes: Optional[bytes] = None, +) -> bytes: + """Build a new .pptx from user code and return its bytes. + + When ``template_bytes`` is given, the deck is built on top of that template + so generated slides inherit its slide masters, layouts, theme colors, fonts, + and any master/layout branding (logo, footer). Otherwise a blank 16:9 deck + is used. + + 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: + if template_bytes is not None: + # Start from the uploaded template, then strip its own example + # slides β€” removing the slide-id references leaves the slide + # masters and layouts (and their theme/branding) intact, so the + # deck starts themed-but-empty and the model's code adds fresh + # slides via the template's layouts (``prs.slide_layouts[...]``). + # Slide size is inherited from the template (not forced to 16:9). + _ci_write_bytes(code_interpreter, _SANDBOX_TEMPLATE, template_bytes) + init = ( + f"prs = Presentation({_SANDBOX_TEMPLATE!r})\n" + "for _sid in list(prs.slides._sldIdLst):\n" + " prs.slides._sldIdLst.remove(_sid)\n" + ) + else: + init = ( + "prs = Presentation()\n" + "prs.slide_width = Inches(13.333)\n" + "prs.slide_height = Inches(7.5)\n" + ) + # The user's code operates on a pre-initialized ``prs`` and must not + # call Presentation()/prs.save() itself β€” we own the lifecycle. + _ci_exec( + code_interpreter, + ( + f"{_PPTX_PREAMBLE}\n" + f"{init}\n" + f"{python_code}\n\n" + f"prs.save({filename!r})\n" + ), + ) + data = _ci_read_bytes(code_interpreter, filename) + if data is None: + raise _DocGenError( + f"Presentation '{filename}' was not produced. Make sure your " + "code adds slides to `prs`." + ) + return data + finally: + try: + code_interpreter.stop() + except Exception: # pragma: no cover - cleanup best-effort + pass + + +def _modify_pptx_bytes( + code_interpreter_id: str, + source_bytes: bytes, + python_code: str, + output_filename: str, +) -> bytes: + """Load an existing .pptx, 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"{_PPTX_PREAMBLE}\n" + f"prs = Presentation({_SANDBOX_SOURCE!r})\n\n" + f"{python_code}\n\n" + f"prs.save({output_filename!r})\n" + ), + ) + data = _ci_read_bytes(code_interpreter, output_filename) + if data is None: + raise _DocGenError( + f"Modified presentation '{output_filename}' was not produced." + ) + return data + finally: + try: + code_interpreter.stop() + except Exception: # pragma: no cover - cleanup best-effort + pass + + +def _extract_pptx_text(code_interpreter_id: str, source_bytes: bytes) -> str: + """Extract readable text (per slide: shapes, tables, notes) from a .pptx. + + 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 pptx import Presentation\n" + f"prs = Presentation({_SANDBOX_SOURCE!r})\n" + "lines = []\n" + "for i, slide in enumerate(prs.slides):\n" + " lines.append('## Slide %d' % (i + 1))\n" + " for shape in slide.shapes:\n" + " if shape.has_text_frame:\n" + " t = shape.text_frame.text.strip()\n" + " if t:\n" + " lines.append(t)\n" + " if shape.has_table:\n" + " for row in shape.table.rows:\n" + " cells = [c.text.strip() for c in row.cells]\n" + " lines.append(' | '.join(cells))\n" + " if slide.has_notes_slide:\n" + " notes = slide.notes_slide.notes_text_frame.text.strip()\n" + " if notes:\n" + " lines.append('[Notes] ' + notes)\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 + + +def _extract_pptx_layouts(code_interpreter_id: str, source_bytes: bytes) -> str: + """List a presentation's slide layouts (index, name, placeholders). + + Useful before building on a template: the model can see which layout + indices exist and which placeholders each one exposes, so + ``prs.slides.add_slide(prs.slide_layouts[i])`` targets the right layout and + ``slide.placeholders[idx]`` fills the right slot. 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 pptx import Presentation\n" + f"prs = Presentation({_SANDBOX_SOURCE!r})\n" + "layouts = prs.slide_layouts\n" + "lines = ['Total layouts: %d' % len(layouts)]\n" + "for i, layout in enumerate(layouts):\n" + " phs = []\n" + " for ph in layout.placeholders:\n" + " phs.append('%d=%s' % (ph.placeholder_format.idx, ph.name))\n" + " detail = ', '.join(phs) if phs else '(no placeholders)'\n" + " lines.append('[%d] %s | placeholders: %s' % (i, layout.name, detail))\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_powerpoint_presentation( + user_id: str, session_id: str, presentation_name: str +): + """Find the newest READY .pptx in this session matching ``presentation_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 = ( + presentation_name + if presentation_name.lower().endswith(".pptx") + else f"{presentation_name}.pptx" + ) + 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 == _PPTX_MIME + and meta.filename.lower() == target.lower() + ): + return meta + return None + + +# --------------------------------------------------------------------------- +# Tool factories +# --------------------------------------------------------------------------- + + +def make_create_powerpoint_presentation_tool(session_id: str, user_id: str): + """Create a ``create_powerpoint_presentation`` tool bound to the identity.""" + + @tool + async def create_powerpoint_presentation( + python_code: str, + presentation_name: str, + template_name: Optional[str] = None, + ) -> Any: + """Create a new PowerPoint (.pptx) presentation using python-pptx code. + + Executes python-pptx code in a sandboxed Code Interpreter to build a + 16:9 widescreen deck, saves it to the user's files, and returns a + download card. Great for pitch decks, reports, and summaries with + titled slides, bullet content, tables, and embedded charts. + + Optionally builds on a template (see ``template_name``) so the deck + inherits a branded theme, fonts, and layouts instead of the plain + default. Prefer a template when the user has uploaded one or asked for + their branding. + + Available libraries in the sandbox: python-pptx, matplotlib, pandas, + numpy. + + Args: + python_code: python-pptx code that builds the deck. A blank 16:9 + presentation is already available as ``prs = Presentation()`` + (slide size preset to 13.33" x 7.5") β€” do NOT call + ``Presentation()`` or ``prs.save()`` yourself; the tool saves it + for you. ``Inches``, ``Pt``, ``Emu``, ``RGBColor``, ``PP_ALIGN``, + ``MSO_ANCHOR`` and ``MSO_SHAPE`` are already imported. + + Add slides from the built-in layouts (0=title, 1=title+content, + 5=title only, 6=blank), e.g.: + slide = prs.slides.add_slide(prs.slide_layouts[0]) + slide.shapes.title.text = 'Quarterly Review' + slide.placeholders[1].text = 'FY2026 β€” Q4' + + A dark title slide with a custom text box: + slide = prs.slides.add_slide(prs.slide_layouts[6]) + slide.background.fill.solid() + slide.background.fill.fore_color.rgb = RGBColor(0x1E, 0x27, 0x61) + box = slide.shapes.add_textbox(Inches(0.8), Inches(2.6), Inches(11.7), Inches(2)) + tf = box.text_frame + tf.text = 'Product Strategy' + r = tf.paragraphs[0].runs[0] + r.font.size, r.font.bold = Pt(44), True + r.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF) + + A bullet content slide (keep to <= 4 bullets): + slide = prs.slides.add_slide(prs.slide_layouts[1]) + slide.shapes.title.text = 'Highlights' + body = slide.placeholders[1].text_frame + body.text = 'Revenue up 15%' + p = body.add_paragraph(); p.text = 'Churn down to 2%' + + A table: + tbl = slide.shapes.add_table(2, 2, Inches(1), Inches(2), + Inches(8), Inches(2)).table + tbl.cell(0, 0).text = 'Quarter'; tbl.cell(0, 1).text = 'Revenue' + + A matplotlib chart image: + import matplotlib.pyplot as plt + plt.figure(figsize=(8, 4.5)) + plt.bar(['Q1', 'Q2'], [100, 120]) + plt.savefig('chart.png', dpi=200, bbox_inches='tight') + plt.close() + slide.shapes.add_picture('chart.png', Inches(2.5), Inches(1.5), width=Inches(8)) + + Design guidance (aim for a polished, cohesive deck): + - Pick ONE color palette for the whole deck and reuse it. Good + options (primary / secondary / accent hex): + Midnight Executive 1E2761 / CADCFC / FFFFFF; + Teal Trust 028090 / 00A896 / 02C39A; + Forest & Moss 2C5F2D / 97BC62 / F5F5F5; + Coral Energy F96167 / F9E795 / 2F3C7E; + Charcoal Minimal 36454F / F2F2F2 / 212121. + One color dominates (60-70%); dark backgrounds for the + title/closing slides, light for content. Never plain white, + never default PowerPoint blue. + - Typography: titles 36-44pt bold, section headers 20-24pt, + body 14-16pt, big stat callouts 48-120pt. Left-align body + text; center only titles and stats. + - Every slide should carry a visual element (a shape, colored + accent bar, icon circle, table, or chart) β€” avoid text-only + slides, and vary the layout slide to slide. + + presentation_name: File name WITHOUT extension (.pptx is added + automatically). Use only letters, numbers, hyphens, and + underscores (e.g. "sales-deck", "Q4_review"). + + template_name: Optional name of a .pptx template already available + in this chat (an uploaded deck or a previously generated one, + with or without the .pptx extension). When given, the new deck + is built ON TOP of that template so it inherits the template's + slide masters, layouts, theme colors, fonts, and any master- or + layout-level branding (logo, footer). The template's own example + slides are stripped first, so you start themed-but-empty. + + When using a template: + - Add slides from the TEMPLATE's layouts, e.g. + ``slide = prs.slides.add_slide(prs.slide_layouts[1])``, and + fill the layout's placeholders (``slide.shapes.title``, + ``slide.placeholders[idx]``) rather than drawing everything + from scratch β€” that's what preserves the branded look. + - Do NOT override slide backgrounds/fonts with the palette + below; let the template's theme drive the design. + - Use ``list_powerpoint_presentations`` to see available names, + and ``read_powerpoint_presentation`` to inspect a template's + existing content if helpful. + + Returns: + An inline download card. The presentation is also saved to this + chat's Files. + """ + is_valid, error_msg = _validate_document_name(presentation_name) + if not is_valid: + return _error( + f"❌ Invalid presentation name '{presentation_name}': {error_msg}\n\n" + "Examples: sales-deck, Q4_review, pitch-final" + ) + + filename = f"{presentation_name}.pptx" + 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) + + # Resolve an optional template from this chat's files. When provided, + # the deck is built on top of it so it inherits the template's theme. + template_bytes = None + if template_name: + template_src = await _find_powerpoint_presentation( + user_id, session_id, template_name + ) + if template_src is None: + return _error( + f"❌ No PowerPoint template named '{template_name}' was found " + "in this chat. Upload a .pptx template first, or use " + "list_powerpoint_presentations to see what's available." + ) + try: + template_bytes = await asyncio.to_thread( + _download_s3_bytes, template_src.s3_bucket, template_src.s3_key + ) + except Exception as exc: # noqa: BLE001 - surface storage errors + logger.error(f"create_powerpoint_presentation template load error: {exc}") + return _error( + f"❌ Failed to load template '{template_src.filename}': {exc}" + ) + + try: + file_bytes = await asyncio.to_thread( + _generate_pptx_bytes, + code_interpreter_id, + python_code, + filename, + template_bytes, + ) + except _DocGenError as exc: + return _error( + f"❌ Failed to create '{filename}'.\n\n```\n{exc}\n```\n\n" + "Check the python-pptx code for errors." + ) + except Exception as exc: # noqa: BLE001 - surface any sandbox error + logger.error(f"create_powerpoint_presentation 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, _PPTX_MIME + ) + except Exception as exc: # noqa: BLE001 - storage failure is terminal + logger.error(f"create_powerpoint_presentation 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_powerpoint_presentation + + +def make_modify_powerpoint_presentation_tool(session_id: str, user_id: str): + """Create a ``modify_powerpoint_presentation`` tool bound to the identity.""" + + @tool + async def modify_powerpoint_presentation( + presentation_name: str, + python_code: str, + output_name: Optional[str] = None, + ) -> Any: + """Modify an existing PowerPoint (.pptx) presentation with python-pptx code. + + Loads a deck previously created in this chat, runs your python-pptx code + against it, and saves the result (as a new file so the original is + preserved). Returns a download card. + + Use ``list_powerpoint_presentations`` first if you are unsure of the + exact name. + + Args: + presentation_name: Name of the existing deck to edit (with or + without the .pptx extension), e.g. "sales-deck". + python_code: python-pptx code that edits the deck. The loaded + presentation is available as ``prs = Presentation(...)`` β€” do + NOT call ``Presentation()`` or ``prs.save()`` yourself. Existing + slides are ``prs.slides``; add new ones with + ``prs.slides.add_slide(prs.slide_layouts[...])``. ``Inches``, + ``Pt``, ``Emu``, ``RGBColor``, ``PP_ALIGN``, ``MSO_ANCHOR`` and + ``MSO_SHAPE`` are already imported. + + Example (append a closing slide): + slide = prs.slides.add_slide(prs.slide_layouts[5]) + slide.shapes.title.text = 'Thank You' + + Example (edit the first slide's title): + prs.slides[0].shapes.title.text = 'Updated Title' + + 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 presentation. + """ + 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_powerpoint_presentation( + user_id, session_id, presentation_name + ) + if source is None: + return _error( + f"❌ No PowerPoint presentation named '{presentation_name}' was " + "found in this chat. Use list_powerpoint_presentations to see " + "what's available." + ) + + out_base = output_name or source.filename + if out_base.lower().endswith(".pptx"): + out_base = out_base[: -len(".pptx")] + 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}.pptx" + + try: + source_bytes = await asyncio.to_thread( + _download_s3_bytes, source.s3_bucket, source.s3_key + ) + file_bytes = await asyncio.to_thread( + _modify_pptx_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-pptx code for errors." + ) + except Exception as exc: # noqa: BLE001 - surface any sandbox error + logger.error(f"modify_powerpoint_presentation 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, _PPTX_MIME + ) + except Exception as exc: # noqa: BLE001 - storage failure is terminal + logger.error(f"modify_powerpoint_presentation 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_powerpoint_presentation + + +def make_list_powerpoint_presentations_tool(session_id: str, user_id: str): + """Create a ``list_powerpoint_presentations`` tool bound to the identity.""" + + @tool + async def list_powerpoint_presentations() -> Dict[str, Any]: + """List the PowerPoint (.pptx) presentations available in this chat. + + Returns the file names and sizes of decks created or modified in this + conversation. Use the names with modify_powerpoint_presentation or + read_powerpoint_presentation. + """ + 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 != _PPTX_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 PowerPoint presentations in this chat yet. Use " + "create_powerpoint_presentation to make one." + ) + else: + text = "PowerPoint presentations in this chat:\n" + "\n".join(rows) + return {"content": [{"text": text}], "status": "success"} + + return list_powerpoint_presentations + + +def make_read_powerpoint_presentation_tool(session_id: str, user_id: str): + """Create a ``read_powerpoint_presentation`` tool bound to the identity.""" + + @tool + async def read_powerpoint_presentation(presentation_name: str) -> Dict[str, Any]: + """Read the text content of an existing PowerPoint (.pptx) presentation. + + Extracts each slide's text, tables, and speaker notes from a deck + created in this chat so you can reference or summarize its contents. Use + list_powerpoint_presentations first if unsure of the exact name. + + Args: + presentation_name: Name of the deck to read (with or without the + .pptx extension), e.g. "sales-deck". + + Returns: + The presentation's text content, grouped by slide. + """ + 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_powerpoint_presentation( + user_id, session_id, presentation_name + ) + if source is None: + return _error( + f"❌ No PowerPoint presentation named '{presentation_name}' was " + "found in this chat. Use list_powerpoint_presentations 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_pptx_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_powerpoint_presentation error: {exc}") + return _error(f"❌ Failed to read '{source.filename}': {exc}") + + body = text or "(The presentation has no extractable text.)" + return { + "content": [ + {"text": f"Content of {source.filename}:\n\n{body}"} + ], + "status": "success", + } + + return read_powerpoint_presentation + + +def make_list_powerpoint_layouts_tool(session_id: str, user_id: str): + """Create a ``list_powerpoint_layouts`` tool bound to the identity.""" + + @tool + async def list_powerpoint_layouts(presentation_name: str) -> Dict[str, Any]: + """List the slide layouts of a PowerPoint (.pptx) file in this chat. + + Reports each layout's index, name, and placeholder slots. Call this on a + template BEFORE building on it (create_powerpoint_presentation with + template_name) so you add slides from the right layout + (``prs.slide_layouts[index]``) and fill the correct placeholders + (``slide.placeholders[idx]``) β€” that's what preserves the template's + branded design. Works on any .pptx available in this chat (an uploaded + template or a deck generated here). + + Args: + presentation_name: Name of the .pptx to inspect (with or without the + .pptx extension), e.g. "brand-template". + + Returns: + The layout inventory (index, name, placeholders per layout). + """ + 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_powerpoint_presentation( + user_id, session_id, presentation_name + ) + if source is None: + return _error( + f"❌ No PowerPoint presentation named '{presentation_name}' was " + "found in this chat. Use list_powerpoint_presentations 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_pptx_layouts, code_interpreter_id, source_bytes + ) + except _DocGenError as exc: + return _error(f"❌ Failed to inspect '{source.filename}': {exc}") + except Exception as exc: # noqa: BLE001 - surface any sandbox error + logger.error(f"list_powerpoint_layouts error: {exc}") + return _error(f"❌ Failed to inspect '{source.filename}': {exc}") + + body = text or "(No layouts found.)" + return { + "content": [ + {"text": f"Layouts in {source.filename}:\n\n{body}"} + ], + "status": "success", + } + + return list_powerpoint_layouts diff --git a/backend/src/agents/builtin_tools/workspace_tools.py b/backend/src/agents/builtin_tools/workspace_tools.py new file mode 100644 index 000000000..1b15e8e54 --- /dev/null +++ b/backend/src/agents/builtin_tools/workspace_tools.py @@ -0,0 +1,190 @@ +"""Workspace tools (list / read / write) over the user-files store. + +A generic file surface for the agent: enumerate the user's files, read text +content on demand (bounded), and write text deliverables the user can +download. All storage flows through ``apis.shared.files.workspace`` β€” the +DynamoDB user-files table is the source of truth, so workspace files appear +in the chat's Files panel alongside uploads. + +Design notes +------------ +* Identity (``user_id`` / ``session_id``) is captured by closure via the + ``make_*`` factories β€” the same pattern as the artifact, spreadsheet, and + word-document tools (the Strands runtime here does NOT populate + ``ToolContext.invocation_state`` with identity). The tools are injected + per-request through ``extra_tools`` (see ``_build_workspace_tools`` in + ``apis/inference_api/chat/routes.py``); they are deliberately NOT registered + in ``builtin_tools/__init__`` because they need request-scoped identity. +* Binary files are returned by reference (presigned URL), never base64 β€” file + bytes do not flow through the model (token-cost tenet, CLAUDE.md). +* ``workspace_write`` returns the same inline download-card contract as the + word tool (``ui_type: "file_download"``) so the SPA renders a first-class + download card. +""" + +from __future__ import annotations + +import json +import logging +from typing import Any, Dict + +from strands import tool + +from apis.shared.files.workspace import ( + WorkspaceError, + WorkspaceStorageNotConfiguredError, + list_workspace_files, + read_workspace_file, + write_workspace_file, +) + +logger = logging.getLogger(__name__) + +_NO_STORAGE_MESSAGE = ( + "❌ File workspace storage is not configured " + "(S3_USER_FILES_BUCKET_NAME is not set on the runtime)." +) + + +def _error(text: str) -> Dict[str, Any]: + return {"content": [{"text": text}], "status": "error"} + + +def _success(payload: Dict[str, Any]) -> Dict[str, Any]: + return {"content": [{"json": payload}], "status": "success"} + + +def make_workspace_list_tool(session_id: str, user_id: str): + """Create a ``workspace_list`` tool bound to the given identity.""" + + @tool + async def workspace_list(scope: str = "session") -> Any: + """List the files available in the user's workspace. + + Covers files the user attached and files tools have produced (Word + documents, workspace writes, …). Entries with ``readable: true`` can + be opened with ``workspace_read``; others are binary and move by + reference. + + Args: + scope: "session" (default) lists files from this conversation; + "user" lists the user's files across all conversations + (newest first) β€” use it when the user refers to a file from + an earlier conversation. + + Returns: + Files with upload_id, filename, mime_type, size_bytes, source, + and readable. Capped at the newest ~100 entries + (``truncated: true`` when more exist). + """ + try: + result = await list_workspace_files(user_id, session_id, scope=scope) + except WorkspaceStorageNotConfiguredError: + return _error(_NO_STORAGE_MESSAGE) + except WorkspaceError as exc: + return _error(f"❌ {exc}") + except Exception as exc: # noqa: BLE001 - surface conversationally + logger.error(f"workspace_list error: {exc}") + return _error(f"❌ Failed to list workspace files: {exc}") + return _success(result) + + return workspace_list + + +def make_workspace_read_tool(session_id: str, user_id: str): + """Create a ``workspace_read`` tool bound to the given identity.""" + + @tool + async def workspace_read(upload_id: str, offset: int = 0) -> Any: + """Read a file from the user's workspace. + + Text files (plain, markdown, CSV, JSON, HTML) return their content + inline, up to ~48KB per call β€” when ``truncated`` is true, call again + with ``offset`` set to ``next_offset`` to continue. Binary files + (PDF, Office, images) return metadata plus a download URL instead of + content; hand tabular files to ``analyze_spreadsheet``. + + Args: + upload_id: The file's id, as returned by ``workspace_list``. + offset: Byte offset to continue a previous truncated read + (default 0). + + Returns: + For text: content, truncated flag, and next_offset. For binary: + metadata and a short-lived download URL. + """ + try: + result = await read_workspace_file(user_id, upload_id, offset=offset) + except WorkspaceStorageNotConfiguredError: + return _error(_NO_STORAGE_MESSAGE) + except WorkspaceError as exc: + return _error(f"❌ {exc}") + except Exception as exc: # noqa: BLE001 - surface conversationally + logger.error(f"workspace_read error: {exc}") + return _error(f"❌ Failed to read file '{upload_id}': {exc}") + return _success(result) + + return workspace_read + + +def make_workspace_write_tool(session_id: str, user_id: str): + """Create a ``workspace_write`` tool bound to the given identity.""" + + @tool + async def workspace_write( + filename: str, + content: str, + mime_type: str = "text/plain", + ) -> Any: + """Save a text file to the user's workspace with a download card. + + Use this for text deliverables the user should keep: markdown + reports, CSV exports, JSON data, code listings. The file is saved to + this chat's Files and presented with a download button. For Word + documents use ``create_word_document``; for interactive documents + use ``create_artifact``. + + Args: + filename: File name, a single path segment (letters, numbers, + dots, hyphens, underscores, spaces). The extension must match + ``mime_type`` and is added automatically if omitted. + content: The file content as plain text (max 1MB). + mime_type: One of text/plain (default), text/markdown, text/csv, + text/html, application/json. + + Returns: + An inline download card. The file is also saved to this chat's + Files, and each write creates a new file version (no overwrite). + """ + try: + result = await write_workspace_file( + user_id, session_id, filename, content, mime_type=mime_type + ) + except WorkspaceStorageNotConfiguredError: + return _error(_NO_STORAGE_MESSAGE) + except WorkspaceError as exc: + return _error(f"❌ {exc}") + except Exception as exc: # noqa: BLE001 - surface conversationally + logger.error(f"workspace_write error: {exc}") + return _error(f"❌ Failed to save '{filename}': {exc}") + + # Same promoted download-card contract as word_document_tool β€” the + # SPA routes ui_type "file_download" to the inline download card. + return json.dumps( + { + "success": True, + "ui_type": "file_download", + "ui_display": "inline", + "payload": { + "filename": result["filename"], + "download_url": result["download_url"], + "size_kb": result["size_kb"], + }, + "summary": ( + f"Saved {result['filename']} ({result['size_kb']}). " + "Also saved to this chat's Files." + ), + } + ) + + return workspace_write diff --git a/backend/src/apis/inference_api/chat/routes.py b/backend/src/apis/inference_api/chat/routes.py index b69b535d3..fe8204ea8 100644 --- a/backend/src/apis/inference_api/chat/routes.py +++ b/backend/src/apis/inference_api/chat/routes.py @@ -487,6 +487,47 @@ def _build_word_document_tools( return tools +# ============================================================ +# Workspace Tool Injection +# ============================================================ + +WORKSPACE_TOOL_IDS = {"workspace_files"} + + +def _build_workspace_tools( + enabled_tools: list | None, + session_id: str, + user_id: str, +) -> list: + """Create context-bound workspace file tools if enabled by the user. + + Identity is captured by closure (same pattern as the artifact and word + document tools). The "workspace_files" catalog entry is a single toggle + that provisions the full toolset (list/read/write). + """ + from apis.shared.feature_flags import workspace_tools_enabled + + if not workspace_tools_enabled(): + return [] + if not enabled_tools or not WORKSPACE_TOOL_IDS.intersection(enabled_tools): + return [] + + from agents.builtin_tools.workspace_tools import ( + make_workspace_list_tool, + make_workspace_read_tool, + make_workspace_write_tool, + ) + + tools = [ + make_workspace_list_tool(session_id, user_id), + make_workspace_read_tool(session_id, user_id), + make_workspace_write_tool(session_id, user_id), + ] + + logger.info(f"Created {len(tools)} workspace tools") + return tools + + # ============================================================ # Excel Spreadsheet Tool Injection # ============================================================ @@ -531,6 +572,50 @@ def _build_excel_spreadsheet_tools( return tools +# ============================================================ +# PowerPoint Presentation Tool Injection +# ============================================================ + +POWERPOINT_PRESENTATION_TOOL_IDS = {"create_powerpoint_presentation"} + + +def _build_powerpoint_presentation_tools( + enabled_tools: list | None, + session_id: str, + user_id: str, +) -> list: + """Create context-bound PowerPoint presentation tools if enabled by the user. + + Identity is captured by closure (same pattern as the Word document and Excel + spreadsheet tools) since the runtime does not populate ToolContext. + """ + if not enabled_tools or not POWERPOINT_PRESENTATION_TOOL_IDS.intersection(enabled_tools): + return [] + + # The PowerPoint capability is a single toggle: enabling + # create_powerpoint_presentation provisions the full deck toolset + # (create/modify/list/read) so the model can round-trip on a presentation + # without extra admin catalog entries. + from agents.builtin_tools.powerpoint_presentation_tool import ( + make_create_powerpoint_presentation_tool, + make_list_powerpoint_layouts_tool, + make_list_powerpoint_presentations_tool, + make_modify_powerpoint_presentation_tool, + make_read_powerpoint_presentation_tool, + ) + + tools = [ + make_create_powerpoint_presentation_tool(session_id, user_id), + make_modify_powerpoint_presentation_tool(session_id, user_id), + make_list_powerpoint_presentations_tool(session_id, user_id), + make_read_powerpoint_presentation_tool(session_id, user_id), + make_list_powerpoint_layouts_tool(session_id, user_id), + ] + + logger.info(f"Created {len(tools)} powerpoint presentation 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. @@ -1837,10 +1922,18 @@ 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_workspace_tools( + 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_powerpoint_presentation_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/feature_flags.py b/backend/src/apis/shared/feature_flags.py index 2392c0f73..ca31783ea 100644 --- a/backend/src/apis/shared/feature_flags.py +++ b/backend/src/apis/shared/feature_flags.py @@ -75,6 +75,23 @@ def memory_spaces_enabled() -> bool: return os.environ.get("MEMORY_SPACES_ENABLED", "false").lower() == "true" +def workspace_tools_enabled() -> bool: + """Whether the workspace file tools are enabled for this environment. + + Covers the ``workspace_list`` / ``workspace_read`` / ``workspace_write`` + agent tools (the generic file surface over the user-files store β€” see + ``docs/specs/session-workspace-tools.md``). **Default ON with a kill + switch** (house style, mirroring ``scheduled_runs_enabled``): unset or + empty resolves to enabled; only the literal ``"false"`` + (case-insensitive) disables. + + Note this flag gates *feature existence* per environment; *who* may use + the tools is the ``workspace_files`` catalog entry granted via roles β€” + two independent controls. + """ + return os.environ.get("WORKSPACE_TOOLS_ENABLED", "").strip().lower() != "false" + + def agents_enabled() -> bool: """Whether the Agent Designer surface is enabled for this environment. diff --git a/backend/src/apis/shared/files/__init__.py b/backend/src/apis/shared/files/__init__.py index a29ba6306..de83b81e5 100644 --- a/backend/src/apis/shared/files/__init__.py +++ b/backend/src/apis/shared/files/__init__.py @@ -37,6 +37,17 @@ get_file_resolver, ) +from .workspace import ( + WorkspaceError, + WorkspaceFileNotFoundError, + WorkspaceQuotaExceededError, + WorkspaceStorageNotConfiguredError, + WorkspaceValidationError, + list_workspace_files, + read_workspace_file, + write_workspace_file, +) + __all__ = [ # Models "FileStatus", @@ -65,4 +76,13 @@ "FileResolverError", "FileResolver", "get_file_resolver", + # Workspace + "WorkspaceError", + "WorkspaceFileNotFoundError", + "WorkspaceQuotaExceededError", + "WorkspaceStorageNotConfiguredError", + "WorkspaceValidationError", + "list_workspace_files", + "read_workspace_file", + "write_workspace_file", ] diff --git a/backend/src/apis/shared/files/models.py b/backend/src/apis/shared/files/models.py index 1d0210aaf..2306814d3 100644 --- a/backend/src/apis/shared/files/models.py +++ b/backend/src/apis/shared/files/models.py @@ -33,6 +33,7 @@ class FileStatus(str, Enum): "text/csv": "csv", "application/vnd.ms-excel": "xls", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx", + "application/vnd.openxmlformats-officedocument.presentationml.presentation": "pptx", "text/markdown": "md", # Images (Bedrock-supported) "image/png": "png", @@ -50,6 +51,7 @@ class FileStatus(str, Enum): ".csv": "text/csv", ".xls": "application/vnd.ms-excel", ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", ".md": "text/markdown", # Images ".png": "image/png", @@ -153,6 +155,11 @@ class FileMetadata(BaseModel): # Status status: FileStatus = Field(default=FileStatus.PENDING) + # Provenance: "upload" (SPA attachment flow) or the id of the agent tool + # that produced the file (e.g. "agent", "word_document"). Display-only β€” + # never part of an access decision. + source: str = Field(default="upload", description="Origin of the file") + # Timestamps created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) @@ -193,6 +200,7 @@ def to_dynamo_item(self) -> dict: "s3Key": self.s3_key, "s3Bucket": self.s3_bucket, "s3Uri": self.s3_uri, + "source": self.source, "status": self.status if isinstance(self.status, str) else self.status.value, "createdAt": self.created_at.isoformat() + "Z", "updatedAt": self.updated_at.isoformat() + "Z", @@ -215,6 +223,7 @@ def from_dynamo_item(cls, item: dict) -> "FileMetadata": s3_key=item.get("s3Key", ""), s3_bucket=item.get("s3Bucket", ""), status=item.get("status", FileStatus.PENDING), + source=item.get("source", "upload"), created_at=datetime.fromisoformat(created_at.rstrip("Z")) if created_at else datetime.now(timezone.utc), updated_at=datetime.fromisoformat(updated_at.rstrip("Z")) if updated_at else datetime.now(timezone.utc), ttl=item.get("ttl"), diff --git a/backend/src/apis/shared/files/workspace.py b/backend/src/apis/shared/files/workspace.py new file mode 100644 index 000000000..d72c5627d --- /dev/null +++ b/backend/src/apis/shared/files/workspace.py @@ -0,0 +1,430 @@ +"""Session workspace service β€” bounded list/read/write over the user-files store. + +Backs the ``workspace_list`` / ``workspace_read`` / ``workspace_write`` agent +tools (see ``agents/builtin_tools/workspace_tools.py`` and +``docs/specs/session-workspace-tools.md``). Every operation goes through the +DynamoDB user-files table (`FileMetadata` + `FileUploadRepository`) β€” the table +is the source of truth, never a raw S3 listing β€” so workspace files appear in +the SPA Files panel and participate in quota accounting exactly like uploads. + +Hard rules encoded here: +* Reads and writes are bounded (`WORKSPACE_READ_MAX_BYTES`, + `WORKSPACE_WRITE_MAX_BYTES`) β€” file bytes never flow through the model + unbounded, and binary files move by reference (presigned URL) only. +* Identity is caller-supplied and mandatory: a missing ``user_id`` / + ``session_id`` raises instead of defaulting (a silent default would collapse + sessions into a shared namespace). +* Ownership is enforced by the table's key shape (``PK = USER#{userId}``); + no operation accepts a model-supplied S3 key. +""" + +from __future__ import annotations + +import asyncio +import logging +import os +import re +import uuid +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + +import boto3 +from botocore.config import Config +from botocore.exceptions import ClientError + +from .models import FileMetadata, FileStatus +from .repository import get_file_upload_repository + +logger = logging.getLogger(__name__) + +# Per-call byte cap for text reads; continuation via `offset`. +WORKSPACE_READ_MAX_BYTES = int( + os.environ.get("WORKSPACE_READ_MAX_BYTES", 48 * 1024) # 48KB +) +# Per-call byte cap for writes. Model-generated text is inherently small; the +# cap bounds runaway loops, not legitimate deliverables. +WORKSPACE_WRITE_MAX_BYTES = int( + os.environ.get("WORKSPACE_WRITE_MAX_BYTES", 1024 * 1024) # 1MB +) +# A tool result is a per-turn payload too β€” cap listings. +WORKSPACE_LIST_MAX_ENTRIES = int(os.environ.get("WORKSPACE_LIST_MAX_ENTRIES", 100)) + +# Same ceiling the upload flow enforces (apis/app_api/files/service.py). +_USER_QUOTA_BYTES = int( + os.environ.get("FILE_UPLOAD_USER_QUOTA_BYTES", 1024 * 1024 * 1024) # 1GB +) + +_DOWNLOAD_URL_TTL = 60 * 60 # 1 hour, matches word_document_tool + +# MIME types whose content may be returned inline as text. +_TEXT_MIME_PREFIXES = ("text/",) +_TEXT_MIME_EXACT = frozenset({"application/json"}) + +# MIME types workspace_write accepts, with their canonical extension. +WRITABLE_MIME_TYPES: Dict[str, str] = { + "text/plain": ".txt", + "text/markdown": ".md", + "text/csv": ".csv", + "text/html": ".html", + "application/json": ".json", +} + +# Filename: single path segment, no traversal, sane characters. +_FILENAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._\- ]{0,120}$") + + +class WorkspaceError(Exception): + """Base for workspace failures surfaced conversationally by the tools.""" + + +class WorkspaceStorageNotConfiguredError(WorkspaceError): + """S3_USER_FILES_BUCKET_NAME is not set on the runtime.""" + + +class WorkspaceFileNotFoundError(WorkspaceError): + """No READY file with that upload_id belongs to this user.""" + + +class WorkspaceQuotaExceededError(WorkspaceError): + """The write would push the user past their storage quota.""" + + +class WorkspaceValidationError(WorkspaceError): + """Bad filename / MIME type / size / offset.""" + + +def is_text_mime(mime_type: str) -> bool: + """True when the MIME type's content can be returned inline as text.""" + mt = (mime_type or "").lower().split(";")[0].strip() + return mt.startswith(_TEXT_MIME_PREFIXES) or mt in _TEXT_MIME_EXACT + + +def _require_identity(user_id: str, session_id: Optional[str] = None) -> None: + """Fail loudly on missing identity β€” never default to a shared namespace.""" + if not user_id: + raise WorkspaceError("workspace called without a user_id") + if session_id is not None and not session_id: + raise WorkspaceError("workspace called without a session_id") + + +def _bucket() -> str: + bucket = os.environ.get("S3_USER_FILES_BUCKET_NAME") + if not bucket: + raise WorkspaceStorageNotConfiguredError( + "S3_USER_FILES_BUCKET_NAME is not set on the runtime" + ) + return bucket + + +_s3_client = None +_bucket_region: Optional[str] = None + + +def _region() -> str: + return ( + os.environ.get("AWS_REGION") + or os.environ.get("AWS_DEFAULT_REGION") + or "us-west-2" + ) + + +def _resolve_bucket_region(bucket: str) -> str: + """Discover the user-files bucket's real region (see word_document_tool: + a client pinned to the wrong region fails PutObject with + PermanentRedirect; ``head_bucket`` reports the true region either way). + """ + global _bucket_region + if _bucket_region: + return _bucket_region + + region = None + probe = boto3.client("s3", region_name=_region()) + try: + 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 - network edge + 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.""" + global _s3_client + if _s3_client is None: + region = _resolve_bucket_region(_bucket()) + _s3_client = boto3.client( + "s3", + region_name=region, + config=Config(signature_version="s3v4", s3={"addressing_style": "virtual"}), + ) + return _s3_client + + +def _entry(meta: FileMetadata, include_session: bool) -> Dict[str, Any]: + entry: Dict[str, Any] = { + "upload_id": meta.upload_id, + "filename": meta.filename, + "mime_type": meta.mime_type, + "size_bytes": meta.size_bytes, + "source": meta.source, + "created_at": meta.created_at.isoformat(), + "readable": is_text_mime(meta.mime_type), + } + if include_session: + entry["session_id"] = meta.session_id + return entry + + +async def list_workspace_files( + user_id: str, session_id: str, scope: str = "session" +) -> Dict[str, Any]: + """List the user's READY files β€” this conversation or all conversations. + + DynamoDB-only (never an S3 listing). Output is capped at + ``WORKSPACE_LIST_MAX_ENTRIES`` newest-first entries with a ``truncated`` + flag. + """ + _require_identity(user_id, session_id) + if scope not in ("session", "user"): + raise WorkspaceValidationError( + f"Unknown scope '{scope}' β€” use 'session' or 'user'" + ) + + repo = get_file_upload_repository() + truncated = False + + if scope == "session": + files = await repo.list_session_files(session_id, status=FileStatus.READY) + # The GSI partition is the session; enforce ownership explicitly. + files = [m for m in files if m.user_id == user_id] + if len(files) > WORKSPACE_LIST_MAX_ENTRIES: + files = files[:WORKSPACE_LIST_MAX_ENTRIES] + truncated = True + else: + files, next_cursor = await repo.list_user_files( + user_id, limit=WORKSPACE_LIST_MAX_ENTRIES, status=FileStatus.READY + ) + truncated = next_cursor is not None + + return { + "scope": scope, + "files": [_entry(m, include_session=scope == "user") for m in files], + "count": len(files), + "truncated": truncated, + } + + +async def _get_owned_ready_file(user_id: str, upload_id: str) -> FileMetadata: + meta = await get_file_upload_repository().get_file(user_id, upload_id) + if meta is None: + raise WorkspaceFileNotFoundError( + f"No file with id '{upload_id}' found in your workspace" + ) + status = meta.status if isinstance(meta.status, str) else meta.status.value + if status != FileStatus.READY.value: + raise WorkspaceFileNotFoundError( + f"No file with id '{upload_id}' found in your workspace" + ) + return meta + + +def _ranged_get(bucket: str, key: str, offset: int, length: int) -> bytes: + """Blocking ranged S3 GET β€” only `length` bytes ever enter memory.""" + resp = _s3().get_object( + Bucket=bucket, Key=key, Range=f"bytes={offset}-{offset + length - 1}" + ) + return resp["Body"].read() + + +async def read_workspace_file( + user_id: str, upload_id: str, offset: int = 0 +) -> Dict[str, Any]: + """Read a file's content (text, bounded) or mint a reference (binary). + + Text MIME types return up to ``WORKSPACE_READ_MAX_BYTES`` UTF-8 bytes from + ``offset`` with ``truncated`` + ``next_offset`` for continuation. All other + types return metadata plus a short-lived presigned GET URL β€” never base64. + """ + _require_identity(user_id) + if offset < 0: + raise WorkspaceValidationError("offset must be >= 0") + + meta = await _get_owned_ready_file(user_id, upload_id) + base = { + "upload_id": meta.upload_id, + "filename": meta.filename, + "mime_type": meta.mime_type, + "size_bytes": meta.size_bytes, + "source": meta.source, + } + + if not is_text_mime(meta.mime_type): + url = await asyncio.to_thread( + _s3().generate_presigned_url, + "get_object", + Params={"Bucket": meta.s3_bucket, "Key": meta.s3_key}, + ExpiresIn=_DOWNLOAD_URL_TTL, + ) + return { + **base, + "encoding": "reference", + "download_url": url, + "note": ( + "Binary file β€” content is not returned inline. Use the URL for " + "delivery, analyze_spreadsheet for tabular data, or the code " + "interpreter for byte-level processing." + ), + } + + if offset >= meta.size_bytes: + raise WorkspaceValidationError( + f"offset {offset} is beyond the end of the file " + f"({meta.size_bytes} bytes)" + ) + + data = await asyncio.to_thread( + _ranged_get, meta.s3_bucket, meta.s3_key, offset, WORKSPACE_READ_MAX_BYTES + ) + end = offset + len(data) + truncated = end < meta.size_bytes + return { + **base, + "encoding": "text", + "content": data.decode("utf-8", errors="replace"), + "offset": offset, + "truncated": truncated, + "next_offset": end if truncated else None, + } + + +def _validate_filename(filename: str, mime_type: str) -> str: + """Sanitize the filename and reconcile its extension with the MIME type. + + Returns the final filename. A missing extension gets the MIME type's + canonical one appended; a mismatched extension is rejected. + """ + if mime_type not in WRITABLE_MIME_TYPES: + allowed = ", ".join(sorted(WRITABLE_MIME_TYPES)) + raise WorkspaceValidationError( + f"Unsupported mime_type '{mime_type}'. Workspace writes are " + f"text-only: {allowed}. Use the dedicated document tools for " + "binary formats." + ) + if not filename or not _FILENAME_RE.match(filename) or ".." in filename: + raise WorkspaceValidationError( + f"Invalid filename '{filename}'. Use a single name (no path " + "separators) with letters, numbers, dots, hyphens, underscores, " + "or spaces." + ) + + expected_ext = WRITABLE_MIME_TYPES[mime_type] + root, dot, ext = filename.rpartition(".") + if not root: + return f"{filename}{expected_ext}" + if f".{ext.lower()}" != expected_ext: + raise WorkspaceValidationError( + f"Filename extension '.{ext}' does not match mime_type " + f"'{mime_type}' (expected '{expected_ext}')" + ) + return filename + + +async def write_workspace_file( + user_id: str, + session_id: str, + filename: str, + content: str, + mime_type: str = "text/plain", + source: str = "agent", +) -> Dict[str, Any]: + """Write a text file into the current session's workspace. + + Mirrors the canonical agent write path + (``word_document_tool._store_document``): put_object under the session's + prefix β†’ READY ``FileMetadata`` row β†’ quota increment β†’ presigned + download URL. Each write is a new ``upload_id`` (no in-place overwrite); + listings return newest-first, so a same-name write supersedes. + """ + _require_identity(user_id, session_id) + final_name = _validate_filename(filename, mime_type) + + data = content.encode("utf-8") + if len(data) > WORKSPACE_WRITE_MAX_BYTES: + raise WorkspaceValidationError( + f"Content is {len(data)} bytes; the per-write limit is " + f"{WORKSPACE_WRITE_MAX_BYTES} bytes" + ) + + repo = get_file_upload_repository() + quota = await repo.get_user_quota(user_id) + if quota.total_bytes + len(data) > _USER_QUOTA_BYTES: + raise WorkspaceQuotaExceededError( + f"Storage quota exceeded ({quota.total_bytes} of " + f"{_USER_QUOTA_BYTES} bytes used)" + ) + + bucket = _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}/{final_name}" + + await asyncio.to_thread( + _s3().put_object, + Bucket=bucket, + Key=s3_key, + Body=data, + ContentType=mime_type, + ) + + metadata = FileMetadata( + upload_id=upload_id, + user_id=user_id, + session_id=session_id, + filename=final_name, + mime_type=mime_type, + size_bytes=len(data), + s3_key=s3_key, + s3_bucket=bucket, + status=FileStatus.READY, + source=source, + ) + await repo.create_file(metadata) + await repo.increment_quota(user_id, len(data)) + + 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="{final_name}"', + }, + ExpiresIn=_DOWNLOAD_URL_TTL, + ) + + logger.info( + f"[workspace_write] {len(data)} bytes β†’ {final_name} " + f"(upload_id={upload_id}, source={source})" + ) + return { + "upload_id": upload_id, + "filename": final_name, + "mime_type": mime_type, + "size_bytes": len(data), + "size_kb": f"{len(data) / 1024:.1f} KB", + "download_url": download_url, + } diff --git a/backend/tests/agents/builtin_tools/artifacts/test_artifact_tools.py b/backend/tests/agents/builtin_tools/artifacts/test_artifact_tools.py index 02d6cceb4..152e1b324 100644 --- a/backend/tests/agents/builtin_tools/artifacts/test_artifact_tools.py +++ b/backend/tests/agents/builtin_tools/artifacts/test_artifact_tools.py @@ -188,6 +188,60 @@ def test_markdown_update_rewraps_inherited_type(aws) -> None: assert _embedded_markdown(body) == new_md +def test_markdown_content_under_default_type_is_reclassified(aws) -> None: + """Regression: raw Markdown authored under the default HTML type (the + model forgot content_type="text/markdown") must be treated as Markdown + so it renders, not stored verbatim as run-together HTML source.""" + ddb, s3 = aws + aid, _ = service.create_artifact_record(USER, SESSION, "Recipe", MD, "") + + # Row is corrected to Markdown β†’ card badge reads "MD", not "HTML". + assert _item(ddb, aid, "V#00001")["content_type"] == "text/markdown" + + body = s3.get_object( + Bucket=BUCKET, Key=f"{USER}/{aid}/v1/index.html" + )["Body"].read().decode() + assert body.lstrip().startswith("") + assert "https://esm.sh/marked@14.1.4" in body + assert _embedded_markdown(body) == MD + + +def test_markdown_content_under_explicit_html_type_is_reclassified(aws) -> None: + ddb, s3 = aws + aid, _ = service.create_artifact_record(USER, SESSION, "Recipe", MD, "text/html") + assert _item(ddb, aid, "V#00001")["content_type"] == "text/markdown" + body = s3.get_object( + Bucket=BUCKET, Key=f"{USER}/{aid}/v1/index.html" + )["Body"].read().decode() + assert _embedded_markdown(body) == MD + + +def test_full_html_document_not_reclassified(aws) -> None: + """A genuine standalone HTML document keeps its HTML type and is + stored verbatim (the safety net must not touch real HTML).""" + ddb, s3 = aws + aid, _ = service.create_artifact_record(USER, SESSION, "Page", DOC, "text/html") + assert _item(ddb, aid, "V#00001")["content_type"] == "text/html" + assert s3.get_object( + Bucket=BUCKET, Key=f"{USER}/{aid}/v1/index.html" + )["Body"].read().decode() == DOC + + +def test_update_reclassifies_markdown_under_inherited_html_type(aws) -> None: + """An HTML artifact updated with raw Markdown (content_type omitted β†’ + inherits HTML from HEAD) is reclassified so the new version renders.""" + ddb, s3 = aws + aid, _ = service.create_artifact_record(USER, SESSION, "Page", DOC, "text/html") + new_md = "# Rewritten\n\nNow it's **markdown**.\n" + ver = service.update_artifact_record(USER, aid, new_md, None, None) + assert ver == 2 + assert _item(ddb, aid, "V#00002")["content_type"] == "text/markdown" + body = s3.get_object( + Bucket=BUCKET, Key=f"{USER}/{aid}/v2/index.html" + )["Body"].read().decode() + assert _embedded_markdown(body) == new_md + + def test_html_artifact_not_wrapped(aws) -> None: _, s3 = aws aid, _ = service.create_artifact_record(USER, SESSION, "Page", DOC, "text/html") diff --git a/backend/tests/agents/builtin_tools/test_workspace_tools.py b/backend/tests/agents/builtin_tools/test_workspace_tools.py new file mode 100644 index 000000000..9b63f0e1d --- /dev/null +++ b/backend/tests/agents/builtin_tools/test_workspace_tools.py @@ -0,0 +1,154 @@ +"""Workspace tool factories (agents/builtin_tools/workspace_tools.py). + +Each tool is closed over the request identity; the shared workspace service +functions are patched. Verifies success payload shapes, the workspace_file +download-card contract on write, and that service failures surface as error +tool-results rather than raising. +""" + +import json +from unittest.mock import AsyncMock + +import pytest + +from agents.builtin_tools.workspace_tools import ( + make_workspace_list_tool, + make_workspace_read_tool, + make_workspace_write_tool, +) +from apis.shared.files.workspace import ( + WorkspaceFileNotFoundError, + WorkspaceStorageNotConfiguredError, + WorkspaceValidationError, +) + +MODULE = "agents.builtin_tools.workspace_tools" + + +class TestRoutesGating: + """_build_workspace_tools in apis/inference_api/chat/routes.py.""" + + @pytest.mark.parametrize( + "enabled,expected", + [(None, 0), ([], 0), (["calculator"], 0), (["workspace_files"], 3)], + ) + def test_gate_key_provisions_full_toolset(self, enabled, expected): + from apis.inference_api.chat.routes import _build_workspace_tools + + tools = _build_workspace_tools(enabled, "s1", "u1") + assert len(tools) == expected + + def test_kill_switch_disables(self, monkeypatch): + from apis.inference_api.chat.routes import _build_workspace_tools + + monkeypatch.setenv("WORKSPACE_TOOLS_ENABLED", "false") + assert _build_workspace_tools(["workspace_files"], "s1", "u1") == [] + + def test_empty_flag_value_stays_enabled(self, monkeypatch): + from apis.inference_api.chat.routes import _build_workspace_tools + + monkeypatch.setenv("WORKSPACE_TOOLS_ENABLED", "") + assert len(_build_workspace_tools(["workspace_files"], "s1", "u1")) == 3 + + +async def _call(tool, *args, **kwargs): + fn = getattr(tool, "__wrapped__", None) or tool + return await fn(*args, **kwargs) + + +class TestWorkspaceList: + @pytest.mark.asyncio + async def test_lists_files(self, monkeypatch): + svc = AsyncMock(return_value={"scope": "session", "files": [], "count": 0, "truncated": False}) + monkeypatch.setattr(f"{MODULE}.list_workspace_files", svc) + tool = make_workspace_list_tool("s1", "u1") + result = await _call(tool) + assert result["status"] == "success" + assert result["content"][0]["json"]["scope"] == "session" + svc.assert_awaited_once_with("u1", "s1", scope="session") + + @pytest.mark.asyncio + async def test_scope_passthrough(self, monkeypatch): + svc = AsyncMock(return_value={"scope": "user", "files": [], "count": 0, "truncated": False}) + monkeypatch.setattr(f"{MODULE}.list_workspace_files", svc) + tool = make_workspace_list_tool("s1", "u1") + await _call(tool, scope="user") + svc.assert_awaited_once_with("u1", "s1", scope="user") + + @pytest.mark.asyncio + async def test_storage_not_configured_is_error_result(self, monkeypatch): + svc = AsyncMock(side_effect=WorkspaceStorageNotConfiguredError("no bucket")) + monkeypatch.setattr(f"{MODULE}.list_workspace_files", svc) + tool = make_workspace_list_tool("s1", "u1") + result = await _call(tool) + assert result["status"] == "error" + assert "not configured" in result["content"][0]["text"] + + +class TestWorkspaceRead: + @pytest.mark.asyncio + async def test_reads_with_offset(self, monkeypatch): + svc = AsyncMock(return_value={"encoding": "text", "content": "hi"}) + monkeypatch.setattr(f"{MODULE}.read_workspace_file", svc) + tool = make_workspace_read_tool("s1", "u1") + result = await _call(tool, upload_id="f1", offset=8) + assert result["status"] == "success" + assert result["content"][0]["json"]["content"] == "hi" + svc.assert_awaited_once_with("u1", "f1", offset=8) + + @pytest.mark.asyncio + async def test_not_found_is_error_result(self, monkeypatch): + svc = AsyncMock(side_effect=WorkspaceFileNotFoundError("No file with id 'ghost'")) + monkeypatch.setattr(f"{MODULE}.read_workspace_file", svc) + tool = make_workspace_read_tool("s1", "u1") + result = await _call(tool, upload_id="ghost") + assert result["status"] == "error" + assert "ghost" in result["content"][0]["text"] + + @pytest.mark.asyncio + async def test_unexpected_exception_is_error_result(self, monkeypatch): + svc = AsyncMock(side_effect=RuntimeError("boom")) + monkeypatch.setattr(f"{MODULE}.read_workspace_file", svc) + tool = make_workspace_read_tool("s1", "u1") + result = await _call(tool, upload_id="f1") + assert result["status"] == "error" + + +class TestWorkspaceWrite: + @pytest.mark.asyncio + async def test_returns_workspace_file_download_card(self, monkeypatch): + svc = AsyncMock( + return_value={ + "upload_id": "up1", + "filename": "report.md", + "mime_type": "text/markdown", + "size_bytes": 4, + "size_kb": "0.0 KB", + "download_url": "https://signed.example/f", + } + ) + monkeypatch.setattr(f"{MODULE}.write_workspace_file", svc) + tool = make_workspace_write_tool("s1", "u1") + result = await _call(tool, filename="report.md", content="# Hi", mime_type="text/markdown") + + card = json.loads(result) + assert card["success"] is True + assert card["ui_type"] == "file_download" + assert card["ui_display"] == "inline" + assert card["payload"] == { + "filename": "report.md", + "download_url": "https://signed.example/f", + "size_kb": "0.0 KB", + } + svc.assert_awaited_once_with( + "u1", "s1", "report.md", "# Hi", mime_type="text/markdown" + ) + + @pytest.mark.asyncio + async def test_validation_failure_is_error_result(self, monkeypatch): + svc = AsyncMock(side_effect=WorkspaceValidationError("bad extension")) + monkeypatch.setattr(f"{MODULE}.write_workspace_file", svc) + tool = make_workspace_write_tool("s1", "u1") + result = await _call(tool, filename="x.csv", content="a", mime_type="text/markdown") + assert result["status"] == "error" + assert "bad extension" in result["content"][0]["text"] diff --git a/backend/tests/shared/test_workspace.py b/backend/tests/shared/test_workspace.py new file mode 100644 index 000000000..d930d3b7b --- /dev/null +++ b/backend/tests/shared/test_workspace.py @@ -0,0 +1,278 @@ +"""Session workspace service (apis/shared/files/workspace.py). + +The DynamoDB repository and S3 client are mocked; these tests pin the +contract the workspace tools rely on: metadata-table-first listing, bounded +ranged text reads with continuation, binary-by-reference, the +_store_document-style write path (READY row + quota increment), and the +fail-loudly identity / validation rules. +""" + +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock + +import pytest + +import apis.shared.files.workspace as workspace +from apis.shared.files.models import FileMetadata, FileStatus, UserFileQuota +from apis.shared.files.workspace import ( + WorkspaceError, + WorkspaceFileNotFoundError, + WorkspaceQuotaExceededError, + WorkspaceValidationError, + list_workspace_files, + read_workspace_file, + write_workspace_file, +) + +USER = "user-1" +SESSION = "sess-1" + + +def _meta( + upload_id: str = "f1", + user_id: str = USER, + session_id: str = SESSION, + filename: str = "notes.md", + mime_type: str = "text/markdown", + size_bytes: int = 10, + status: FileStatus = FileStatus.READY, + source: str = "upload", +) -> FileMetadata: + return FileMetadata( + upload_id=upload_id, + user_id=user_id, + session_id=session_id, + filename=filename, + mime_type=mime_type, + size_bytes=size_bytes, + s3_key=f"user-files/{user_id}/{session_id}/{upload_id}/{filename}", + s3_bucket="bucket", + status=status, + source=source, + created_at=datetime(2026, 7, 21, tzinfo=timezone.utc), + ) + + +@pytest.fixture +def repo(monkeypatch) -> MagicMock: + repo = MagicMock() + repo.list_session_files = AsyncMock(return_value=[]) + repo.list_user_files = AsyncMock(return_value=([], None)) + repo.get_file = AsyncMock(return_value=None) + repo.create_file = AsyncMock() + repo.increment_quota = AsyncMock() + repo.get_user_quota = AsyncMock(return_value=UserFileQuota(user_id=USER)) + monkeypatch.setattr(workspace, "get_file_upload_repository", lambda: repo) + return repo + + +@pytest.fixture +def s3(monkeypatch) -> MagicMock: + client = MagicMock() + client.generate_presigned_url.return_value = "https://signed.example/f" + monkeypatch.setattr(workspace, "_s3", lambda: client) + monkeypatch.setenv("S3_USER_FILES_BUCKET_NAME", "bucket") + return client + + +def _s3_body(data: bytes) -> dict: + body = MagicMock() + body.read.return_value = data + return {"Body": body} + + +class TestListWorkspaceFiles: + @pytest.mark.asyncio + async def test_session_scope_filters_ownership(self, repo, s3): + repo.list_session_files.return_value = [ + _meta(upload_id="mine"), + _meta(upload_id="theirs", user_id="someone-else"), + ] + result = await list_workspace_files(USER, SESSION) + assert [f["upload_id"] for f in result["files"]] == ["mine"] + assert result["scope"] == "session" + assert result["truncated"] is False + repo.list_session_files.assert_awaited_once_with( + SESSION, status=FileStatus.READY + ) + + @pytest.mark.asyncio + async def test_entry_shape_and_readable_flag(self, repo, s3): + repo.list_session_files.return_value = [ + _meta(upload_id="t", mime_type="text/plain"), + _meta(upload_id="j", mime_type="application/json"), + _meta(upload_id="b", filename="deck.pdf", mime_type="application/pdf"), + ] + result = await list_workspace_files(USER, SESSION) + by_id = {f["upload_id"]: f for f in result["files"]} + assert by_id["t"]["readable"] and by_id["j"]["readable"] + assert not by_id["b"]["readable"] + assert by_id["t"]["source"] == "upload" + # session scope omits session_id (it's implied) + assert "session_id" not in by_id["t"] + + @pytest.mark.asyncio + async def test_user_scope_includes_session_and_truncation(self, repo, s3): + repo.list_user_files.return_value = ([_meta()], "next-cursor") + result = await list_workspace_files(USER, SESSION, scope="user") + assert result["truncated"] is True + assert result["files"][0]["session_id"] == SESSION + repo.list_user_files.assert_awaited_once_with( + USER, limit=workspace.WORKSPACE_LIST_MAX_ENTRIES, status=FileStatus.READY + ) + + @pytest.mark.asyncio + async def test_session_scope_caps_entries(self, repo, s3, monkeypatch): + monkeypatch.setattr(workspace, "WORKSPACE_LIST_MAX_ENTRIES", 2) + repo.list_session_files.return_value = [ + _meta(upload_id=f"f{i}") for i in range(4) + ] + result = await list_workspace_files(USER, SESSION) + assert result["count"] == 2 and result["truncated"] is True + + @pytest.mark.asyncio + async def test_unknown_scope_rejected(self, repo, s3): + with pytest.raises(WorkspaceValidationError): + await list_workspace_files(USER, SESSION, scope="everything") + + @pytest.mark.asyncio + async def test_missing_identity_fails_loudly(self, repo, s3): + with pytest.raises(WorkspaceError): + await list_workspace_files("", SESSION) + with pytest.raises(WorkspaceError): + await list_workspace_files(USER, "") + + +class TestReadWorkspaceFile: + @pytest.mark.asyncio + async def test_text_read_full(self, repo, s3): + repo.get_file.return_value = _meta(size_bytes=5) + s3.get_object.return_value = _s3_body(b"hello") + result = await read_workspace_file(USER, "f1") + assert result["encoding"] == "text" + assert result["content"] == "hello" + assert result["truncated"] is False and result["next_offset"] is None + # ranged GET, never a full-object read + assert "Range" in s3.get_object.call_args.kwargs + + @pytest.mark.asyncio + async def test_text_read_truncates_with_continuation(self, repo, s3, monkeypatch): + monkeypatch.setattr(workspace, "WORKSPACE_READ_MAX_BYTES", 4) + repo.get_file.return_value = _meta(size_bytes=10) + s3.get_object.return_value = _s3_body(b"abcd") + result = await read_workspace_file(USER, "f1") + assert result["truncated"] is True and result["next_offset"] == 4 + assert s3.get_object.call_args.kwargs["Range"] == "bytes=0-3" + + @pytest.mark.asyncio + async def test_text_read_offset_continues(self, repo, s3, monkeypatch): + monkeypatch.setattr(workspace, "WORKSPACE_READ_MAX_BYTES", 4) + repo.get_file.return_value = _meta(size_bytes=6) + s3.get_object.return_value = _s3_body(b"ef") + result = await read_workspace_file(USER, "f1", offset=4) + assert result["content"] == "ef" + assert result["truncated"] is False and result["next_offset"] is None + assert s3.get_object.call_args.kwargs["Range"] == "bytes=4-7" + + @pytest.mark.asyncio + async def test_binary_returns_reference_never_content(self, repo, s3): + repo.get_file.return_value = _meta( + filename="deck.pdf", mime_type="application/pdf" + ) + result = await read_workspace_file(USER, "f1") + assert result["encoding"] == "reference" + assert result["download_url"] == "https://signed.example/f" + assert "content" not in result + s3.get_object.assert_not_called() + + @pytest.mark.asyncio + async def test_missing_and_non_ready_are_not_found(self, repo, s3): + repo.get_file.return_value = None + with pytest.raises(WorkspaceFileNotFoundError): + await read_workspace_file(USER, "ghost") + repo.get_file.return_value = _meta(status=FileStatus.PENDING) + with pytest.raises(WorkspaceFileNotFoundError): + await read_workspace_file(USER, "f1") + + @pytest.mark.asyncio + async def test_offset_beyond_end_rejected(self, repo, s3): + repo.get_file.return_value = _meta(size_bytes=5) + with pytest.raises(WorkspaceValidationError): + await read_workspace_file(USER, "f1", offset=5) + with pytest.raises(WorkspaceValidationError): + await read_workspace_file(USER, "f1", offset=-1) + + +class TestWriteWorkspaceFile: + @pytest.mark.asyncio + async def test_happy_path_follows_store_document_contract(self, repo, s3): + result = await write_workspace_file( + USER, SESSION, "report.md", "# Hi", mime_type="text/markdown" + ) + + put_kwargs = s3.put_object.call_args.kwargs + assert put_kwargs["Bucket"] == "bucket" + assert put_kwargs["ContentType"] == "text/markdown" + assert put_kwargs["Key"].startswith(f"user-files/{USER}/{SESSION}/") + assert put_kwargs["Key"].endswith("/report.md") + + created: FileMetadata = repo.create_file.call_args.args[0] + assert created.source == "agent" + status = created.status if isinstance(created.status, str) else created.status.value + assert status == FileStatus.READY.value + repo.increment_quota.assert_awaited_once_with(USER, len(b"# Hi")) + + assert result["filename"] == "report.md" + assert result["download_url"] == "https://signed.example/f" + assert result["size_bytes"] == 4 + + @pytest.mark.asyncio + async def test_extension_appended_when_missing(self, repo, s3): + result = await write_workspace_file( + USER, SESSION, "report", "x", mime_type="text/markdown" + ) + assert result["filename"] == "report.md" + + @pytest.mark.asyncio + async def test_extension_mismatch_rejected(self, repo, s3): + with pytest.raises(WorkspaceValidationError): + await write_workspace_file( + USER, SESSION, "report.csv", "x", mime_type="text/markdown" + ) + + @pytest.mark.asyncio + @pytest.mark.parametrize("mime", ["application/pdf", "image/png", "junk"]) + async def test_non_text_mime_rejected(self, repo, s3, mime): + with pytest.raises(WorkspaceValidationError): + await write_workspace_file(USER, SESSION, "f.bin", "x", mime_type=mime) + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "name", ["../evil.txt", "a/b.txt", "a\\b.txt", "", ".hidden"] + ) + async def test_bad_filenames_rejected(self, repo, s3, name): + with pytest.raises(WorkspaceValidationError): + await write_workspace_file(USER, SESSION, name, "x") + + @pytest.mark.asyncio + async def test_oversized_content_rejected(self, repo, s3, monkeypatch): + monkeypatch.setattr(workspace, "WORKSPACE_WRITE_MAX_BYTES", 8) + with pytest.raises(WorkspaceValidationError): + await write_workspace_file(USER, SESSION, "big.txt", "123456789") + s3.put_object.assert_not_called() + + @pytest.mark.asyncio + async def test_quota_exceeded_rejected_before_write(self, repo, s3, monkeypatch): + monkeypatch.setattr(workspace, "_USER_QUOTA_BYTES", 10) + repo.get_user_quota.return_value = UserFileQuota(user_id=USER, total_bytes=9) + with pytest.raises(WorkspaceQuotaExceededError): + await write_workspace_file(USER, SESSION, "f.txt", "abc") + s3.put_object.assert_not_called() + repo.create_file.assert_not_awaited() + + @pytest.mark.asyncio + async def test_missing_identity_fails_loudly(self, repo, s3): + with pytest.raises(WorkspaceError): + await write_workspace_file("", SESSION, "f.txt", "x") + with pytest.raises(WorkspaceError): + await write_workspace_file(USER, "", "f.txt", "x") diff --git a/backend/tests/test_seed_system_admin_jwt.py b/backend/tests/test_seed_system_admin_jwt.py index 4eeb6e112..41f709392 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 == 7 + assert result.created == 9 assert result.failed == 0 # Verify fetch_url_content @@ -216,6 +216,20 @@ def test_creates_default_tools(self, dynamodb_table): assert item["GSI1PK"] == "CATEGORY#document" assert item["GSI1SK"] == "TOOL#create_word_document" + # Verify workspace_files (single toggle for the workspace toolset) + resp = dynamodb_table.get_item( + Key={"PK": "TOOL#workspace_files", "SK": "METADATA"} + ) + item = resp["Item"] + assert item["toolId"] == "workspace_files" + assert item["displayName"] == "File Workspace" + 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#workspace_files" + # Verify create_excel_spreadsheet (single toggle for the whole Excel toolset) resp = dynamodb_table.get_item( Key={"PK": "TOOL#create_excel_spreadsheet", "SK": "METADATA"} @@ -230,13 +244,27 @@ def test_creates_default_tools(self, dynamodb_table): assert item["GSI1PK"] == "CATEGORY#document" assert item["GSI1SK"] == "TOOL#create_excel_spreadsheet" + # Verify create_powerpoint_presentation (single toggle for the whole PowerPoint toolset) + resp = dynamodb_table.get_item( + Key={"PK": "TOOL#create_powerpoint_presentation", "SK": "METADATA"} + ) + item = resp["Item"] + assert item["toolId"] == "create_powerpoint_presentation" + assert item["displayName"] == "PowerPoint Presentations" + 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_powerpoint_presentation" + 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 == 7 + assert result.skipped == 9 assert result.created == 0 def test_partial_skip(self, dynamodb_table): @@ -250,7 +278,7 @@ def test_partial_skip(self, dynamodb_table): result = seed_default_tools(TABLE_NAME, REGION) - assert result.created == 6 + assert result.created == 8 assert result.skipped == 1 diff --git a/backend/uv.lock b/backend/uv.lock index 4c6376832..ee1b2cc65 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -12,7 +12,7 @@ resolution-markers = [ [[package]] name = "agentcore-stack" -version = "1.10.0" +version = "1.11.0" source = { editable = "." } dependencies = [ { name = "aiofiles" }, diff --git a/docs/specs/session-workspace-tools.md b/docs/specs/session-workspace-tools.md new file mode 100644 index 000000000..5e7828c36 --- /dev/null +++ b/docs/specs/session-workspace-tools.md @@ -0,0 +1,244 @@ +# Session workspace tools (`workspace_list` / `workspace_read` / `workspace_write`) + +**Status:** PR-1 implemented (branch `feature/session-workspace-tools`, off `develop`) +**Inspired by:** the shared-workspace tool pattern in +`aws-samples/sample-strands-agent-with-agentcore` (adapted, not adopted β€” see +"Why not the reference implementation as-is") + +## Problem + +The agent can already work with files β€” but only through vertical slices, each +of which reimplements the same substrate: + +- `word_document_tool.py` writes generated `.docx` to the user-files bucket, + registers `FileMetadata`, and returns an inline download card. +- `spreadsheet_analysis/` lists tabular attachments from DynamoDB and analyzes + them in the AgentCore Code Interpreter sandbox. +- `code_interpreter_diagram_tool.py` produces images. +- The SPA uploads attachments via presigned URLs + (`apis/app_api/files/service.py`) into + `user-files/{userId}/{sessionId}/{uploadId}/{filename}`. + +What's missing is the **horizontal primitive**: a generic list/read/write +surface over the user's files that any tool, skill, or future harness lane can +compose with. Concretely, today the agent cannot: + +- enumerate what the user has uploaded (except spreadsheets) or what other + tools have produced this session; +- read a text/markdown/CSV attachment *on demand* mid-turn (attachments are + push-only: inlined as content blocks on the user message by + `multimodal/prompt_builder.py`); +- write a plain text/markdown/CSV/JSON deliverable the user can download + (only `.docx` has a write path); +- chain tools through files (code-interpreter output β†’ document tool β†’ + download) without each pair growing a bespoke bridge. + +This is also a prerequisite primitive for the agentic-platform roadmap: skills +that produce files, the F1 headless/harness entrypoint, and any future +general-purpose code-interpreter tool all need a durable file surface between +ephemeral sandbox sessions. + +## Why not the reference implementation as-is + +The reference repo's `workspace_*` tools are raw S3 wrappers. Three conflicts +with this stack's conventions: + +1. **They bypass the metadata layer.** In this stack the DynamoDB user-files + table is the source of truth (SPA listing, status, quota, thumbnails, TTL). + A raw `put_object` produces a file invisible to the Files UI and outside + quota accounting; a raw `list_objects_v2` sees a different world than the + SPA does. Every workspace operation must go through + `apis.shared.files` (`FileMetadata` + `FileUploadRepository`), exactly like + `word_document_tool._store_document` already does. +2. **Base64 file content through the model violates the token-cost tenet.** + `workspace_read` returning base64 of a binary file is an unbounded per-turn + payload β€” a 2 MB PPTX β‰ˆ 2.7 MB of base64 in a tool result, at model prices, + likely blowing context outright. Binary files move **by reference** + (upload_id / presigned URL); byte processing belongs in the code-interpreter + sandbox. Text reads must be bounded. +3. **`invocation_state.get('user_id', 'default_user')` is a cross-tenant bug + waiting to happen.** A missing identity would silently collapse every + affected session into one shared namespace. This stack injects identity by + closure β€” `make_*_tool(session_id, user_id)` factories instantiated + per-request in `apis/inference_api/chat/routes.py` β€” so identity is + guaranteed present or the tool is never built. Same "fail loudly" posture + as PR #706 (unconfigured bucket). + +Also dropped: the parallel `code-agent-workspace/` / `documents//` +namespace scheme. That's an artifact of the reference repo's multiple +sub-agents. Here, the existing key layout plus a `source` attribute on the +metadata row carries the same information without a second key convention. + +## Decision summary + +| Question | Decision | +|----------|----------| +| Source of truth | **DynamoDB user-files table** β€” never raw S3 listing | +| Write path | Through `FileMetadata` + repository (the `_store_document` pattern), status `READY` | +| Read scope | **User-scoped** β€” all the user's `READY` files, any session | +| Write scope | **Session-scoped** β€” new files land under the current session's prefix | +| Binary handling | **By reference only** β€” metadata + presigned URL; no base64 through the model | +| Text read bound | `WORKSPACE_READ_MAX_BYTES` (default **48 KB**) per call, with `offset` for continuation | +| Identity | Closure-injected via `make_workspace_*_tool(session_id, user_id)` factories | +| Key layout | Existing `user-files/{userId}/{sessionId}/{uploadId}/{filename}` β€” no new namespaces | +| Provenance | New optional `source` field on `FileMetadata` (`"upload"` default, `"agent"`, `"word_document"`, …) | +| Quota | Agent-written files **count against** the user's existing quota; quota-exceeded is a friendly tool error | +| Overwrite semantics | **No in-place overwrite** β€” each write is a new `uploadId`; same-name writes supersede in listings | +| Lifecycle | Inherit existing 365-day DynamoDB TTL + matching S3 expiration β€” nothing new | +| Module home | Service in `apis/shared/files/workspace.py`; tools in `agents/builtin_tools/workspace_tools.py` | +| RBAC | One catalog entry (`workspace_files`) as the gate key provisioning all three tools (the `create_word_document` pattern) | +| Feature flag | `WORKSPACE_TOOLS_ENABLED`, **default true**, `=false` kill switch | +| UI | `workspace_write` returns the same inline download-card contract as the word tool | + +## Tool surface + +All three tools are `make_*` factories (closure identity), registered in +`ToolRegistry` alongside the word-document tools and instantiated per-request +in `apis/inference_api/chat/routes.py`. All returns are compact JSON strings; +errors return `{"error": ..., "status": "error"}` conversationally (never +raise through the agent loop). + +### `workspace_list` + +``` +workspace_list(scope: str = "session") -> str + scope: "session" (this conversation) | "user" (all conversations) +``` + +- Queries DynamoDB only: GSI1 (`CONV#{sessionId}`) for session scope, the + `USER#{userId}` partition for user scope. Never touches S3. +- Filters to `status == READY`. +- Returns per file: `upload_id`, `filename`, `mime_type`, `size_bytes`, + `source`, `session_id` (user scope only), `created_at`, and a `readable` + bool (text-type per the MIME allowlist). +- Bounded output: cap at ~100 most-recent entries with a `truncated` flag β€” + a tool result is a per-turn payload too. + +### `workspace_read` + +``` +workspace_read(upload_id: str, offset: int = 0) -> str +``` + +- Looks up `FileMetadata` by `(user_id, upload_id)` β€” ownership is enforced by + the table key shape (`PK = USER#{userId}`), so cross-user reads are + impossible by construction. Cross-*session* reads are allowed (precedent: + `word_document_tool._find_word_document` already reads across sessions). +- **Text MIME types** (`text/*`, `application/json`, csv/md/html): return + UTF-8 content from `offset`, capped at `WORKSPACE_READ_MAX_BYTES` (48 KB + default), with `truncated` + `next_offset` for continuation. Ranged S3 GET + (`Range` header) so a 200 MB file never enters process memory. +- **Binary / everything else**: return metadata + a short-lived presigned GET + URL and a hint naming the right tool for the bytes + (`analyze_spreadsheet` for tabular, code interpreter for the rest). Never + base64. PDFs and images already reach the model as content blocks via the + attachment flow; this tool does not duplicate that path in v1. + +### `workspace_write` + +``` +workspace_write(filename: str, content: str, mime_type: str = "text/plain") -> str +``` + +- **Text-only in v1.** `mime_type` must be in a text allowlist (plain, + markdown, csv, json, html). Binary production stays with the dedicated + tools (`create_word_document`, diagram tool); when a general + code-interpreter tool lands, its sandboxβ†’S3 sync becomes the binary write + path and this tool stays as-is. +- Size-capped per call (`WORKSPACE_WRITE_MAX_BYTES`, default 1 MB β€” content + the model just generated is inherently small). +- Follows `_store_document` exactly: quota check β†’ `put_object` under + `user-files/{userId}/{sessionId}/{uploadId}/{filename}` β†’ `FileMetadata` + row (`source="agent"`, `status=READY`) β†’ `increment_quota` β†’ presigned + download URL. +- Returns the inline download-card contract + (`ui_type` / `ui_display: "inline"` / payload) so the SPA renders a + first-class download card. Reuses the shared `file_download` `ui_type` + already routed to `FileDownloadRendererComponent` in + `inline-visual.component.ts`, so no frontend change is needed. +- Filename validation mirrors the word tool's (`_validate_document_name` + generalized): no path separators, no traversal, extension must match + `mime_type`. + +## Design notes + +### Read scope: why user-scoped + +`FileMetadata`'s native key shape is user-partitioned (`PK = USER#{userId}`, +GSI1 by session), so "all my files" is the cheap query, not the expensive one. +The compelling UX is *"use the CSV I uploaded yesterday"* β€” without +cross-session read, users must re-upload per conversation. The word tool +already crossed this line quietly; this spec just makes it the stated policy. +Writes stay session-scoped so provenance ("this file came from that +conversation") stays intact and the S3 key layout is unchanged. + +### Quota: why agent files count + +Simplest and safest: same repository path as uploads, no second accounting +regime, and it bounds a runaway agent loop writing files. The write cap plus +quota makes the worst case boring. If agent-generated deliverables ever crowd +out upload quota in practice, carve-out is a follow-up, not a v1 concern. + +### Token-cost audit (per CLAUDE.md tenet) + +- Nothing added to the cacheable prefix beyond three small static tool + definitions in `toolConfig` (deterministic ordering via the existing + registry path). +- Per-turn payloads are all bounded: list ≀ ~100 rows, read ≀ 48 KB/call, + write returns a small card. No unbounded pass-through anywhere. +- The pull model is a net token *saving* vs. today's push model for large + text attachments: the model reads the 5 KB it needs instead of receiving + the whole document as a content block on every restored turn. + +### Attachment-flow interaction (explicitly out of scope for v1) + +Today `prompt_builder.py` inlines every attachment as content blocks. Once +`workspace_read` exists, a future change could stop inlining large text files +and inject a one-line pointer ("attached: `report.md`, upload_id …") instead β€” +a meaningful prompt-size win, but it changes restored-history bytes and +therefore interacts with the prompt-cache byte-stability contract. Do it as +its own PR with `cacheStatus` verification, not as a rider on this one. + +## Security + +- **Tenant isolation by construction:** every repository call is keyed by the + closure-injected `user_id`; there is no path where model-supplied input + selects the partition. +- **No model-supplied S3 keys:** tools accept `upload_id` / `filename` only; + S3 keys are always derived server-side. +- **Filename sanitization** on write (no separators/traversal, extension ↔ + MIME agreement). +- **Presigned URLs** are short-TTL GET-only, same posture as the existing + preview-url endpoint. +- Governance posture per platform norm: identity-claims gating, no content + inspection. + +## Phasing + +**PR-1 β€” service + tools (backend) + download card (frontend).** +`apis/shared/files/workspace.py` (bounded ranged read, list queries, write +helper extracted/shared with `_store_document`), `source` field on +`FileMetadata` (additive, default `"upload"` on read), the three tool +factories + registry/catalog/RBAC wiring, `file_download` inline card, +tests (unit + import-boundary clean). + +**PR-2 (optional, later) β€” word tool convergence.** Reimplement +`_store_document` and `_find_word_document` on the workspace service so +there is one write path. Pure refactor, no behavior change. + +**Deferred:** attachment-flow pull-model change (cache-sensitive, own PR); +binary write via code-interpreter sandbox sync; SPA "Files" page surfacing +`source` provenance. + +## Open questions + +1. Does the SPA Files page need any change for v1 beyond the download card? + (Agent-written files will simply appear in the existing list; showing a + "generated" badge off `source` is nice-to-have.) +2. Should `workspace_read` serve image files as proper image content blocks + (size-gated) instead of URL-by-reference? Useful for "look at the diagram + you made", but content-block emission from a tool result needs Strands + plumbing verification first. +3. RBAC granularity: one `workspace_files` catalog entry vs. three. One entry is + recommended (they're useless separately), but confirm the admin-tools page + renders a multi-tool entry cleanly. diff --git a/frontend/ai.client/package-lock.json b/frontend/ai.client/package-lock.json index 87f5f8574..8dffdfabf 100644 --- a/frontend/ai.client/package-lock.json +++ b/frontend/ai.client/package-lock.json @@ -1,12 +1,12 @@ { "name": "ai.client", - "version": "1.10.0", + "version": "1.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ai.client", - "version": "1.10.0", + "version": "1.11.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 69bae0039..a175c970d 100644 --- a/frontend/ai.client/package.json +++ b/frontend/ai.client/package.json @@ -1,6 +1,6 @@ { "name": "ai.client", - "version": "1.10.0", + "version": "1.11.0", "scripts": { "ng": "ng", "start": "ng serve", diff --git a/frontend/ai.client/src/app/session/components/message-list/components/assistant-message.component.spec.ts b/frontend/ai.client/src/app/session/components/message-list/components/assistant-message.component.spec.ts index 700e95cef..798c31e62 100644 --- a/frontend/ai.client/src/app/session/components/message-list/components/assistant-message.component.spec.ts +++ b/frontend/ai.client/src/app/session/components/message-list/components/assistant-message.component.spec.ts @@ -334,6 +334,38 @@ describe('AssistantMessageComponent', () => { expect(blocks[2].type).toBe('tool_group'); expect(blocks[2].group!.calls.length).toBe(1); }); + + it('should not render a reasoning block with empty text and no redaction', () => { + // Signature-only / empty thinking blocks (e.g. Sonnet 5) are kept in the + // message for API correctness but must not paint an empty "Thinking" + // collapsible. + fixture.componentRef.setInput('message', makeMessage([ + { + type: 'reasoningContent', + reasoningContent: { reasoningText: { text: '', signature: 'abc123' } }, + }, + { type: 'text', text: 'Here is your answer.' }, + ])); + fixture.detectChanges(); + + const blocks = component.displayBlocks(); + expect(blocks.length).toBe(1); + expect(blocks[0].type).toBe('text'); + }); + + it('should render a reasoning block that has only redacted content', () => { + fixture.componentRef.setInput('message', makeMessage([ + { + type: 'reasoningContent', + reasoningContent: { redactedContent: 'encrypted-blob' }, + }, + ])); + fixture.detectChanges(); + + const blocks = component.displayBlocks(); + expect(blocks.length).toBe(1); + expect(blocks[0].type).toBe('reasoningContent'); + }); }); describe('tool call data mapping', () => { diff --git a/frontend/ai.client/src/app/session/components/message-list/components/assistant-message.component.ts b/frontend/ai.client/src/app/session/components/message-list/components/assistant-message.component.ts index 9fccd23ae..2e38fbae7 100644 --- a/frontend/ai.client/src/app/session/components/message-list/components/assistant-message.component.ts +++ b/frontend/ai.client/src/app/session/components/message-list/components/assistant-message.component.ts @@ -351,8 +351,16 @@ export class AssistantMessageComponent { }; for (const block of blocks) { - // Handle reasoning content - if (block.type === 'reasoningContent' && block.reasoningContent) { + // Handle reasoning content. Only render a "Thinking" block when it has + // something to show -- reasoning text or a redacted notice. A block with + // an empty `reasoningText.text` and no `redactedContent` (e.g. a + // signature-only thinking block that some models, like Sonnet 5, still + // emit/persist) would otherwise render an empty collapsible header. The + // block is kept in the persisted message for API correctness (the + // signature is required on subsequent Bedrock calls); we just don't paint + // it. This mirrors the live stream parser's `reasoningText` guard, which + // the history-rehydration path bypasses. + if (block.type === 'reasoningContent' && this.hasRenderableReasoning(block)) { flushToolGroup(); result.push({ type: 'reasoningContent', data: block }); continue; @@ -468,6 +476,20 @@ export class AssistantMessageComponent { return toolUse.result ?? { content: [], status: 'success' }; } + /** + * Whether a reasoningContent block has anything worth rendering as a + * "Thinking" section: actual reasoning text or a redacted-content notice. + * Signature-only / empty-text blocks are kept in the message (the signature + * is required for subsequent Bedrock calls) but paint nothing, so they must + * not produce an empty collapsible. Mirrors ReasoningContentComponent's own + * `hasReasoningText || hasRedactedContent` visibility checks. + */ + private hasRenderableReasoning(block: ContentBlock): boolean { + const data = block.reasoningContent; + if (!data) return false; + return !!data.reasoningText?.text || !!data.redactedContent; + } + /** * Extract promoted visual data from a tool use result. * Returns null if not a promoted visual (no ui_type or ui_display !== 'inline'). diff --git a/frontend/ai.client/src/app/session/components/message-list/components/inline-visual/renderers/file-download-renderer.component.ts b/frontend/ai.client/src/app/session/components/message-list/components/inline-visual/renderers/file-download-renderer.component.ts index 82518709b..643f77a26 100644 --- a/frontend/ai.client/src/app/session/components/message-list/components/inline-visual/renderers/file-download-renderer.component.ts +++ b/frontend/ai.client/src/app/session/components/message-list/components/inline-visual/renderers/file-download-renderer.component.ts @@ -25,6 +25,8 @@ const DOCUMENT_ICON = 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z'; const SPREADSHEET_ICON = 'M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2'; +const PRESENTATION_ICON = + 'M16 8v8m-4-5v5m-4-2v2m-2 4h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v10a2 2 0 002 2z'; const WORD_STYLE: FileKindStyle = { iconPath: DOCUMENT_ICON, @@ -34,6 +36,10 @@ const EXCEL_STYLE: FileKindStyle = { iconPath: SPREADSHEET_ICON, badgeClass: 'bg-green-50 text-green-600 dark:bg-green-900/30 dark:text-green-400', }; +const POWERPOINT_STYLE: FileKindStyle = { + iconPath: PRESENTATION_ICON, + badgeClass: 'bg-orange-50 text-orange-600 dark:bg-orange-900/30 dark:text-orange-400', +}; const GENERIC_STYLE: FileKindStyle = { iconPath: DOCUMENT_ICON, badgeClass: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300', @@ -47,6 +53,9 @@ function styleForFilename(filename: string): FileKindStyle { if (lower.endsWith('.docx') || lower.endsWith('.doc')) { return WORD_STYLE; } + if (lower.endsWith('.pptx') || lower.endsWith('.ppt')) { + return POWERPOINT_STYLE; + } return GENERIC_STYLE; } diff --git a/infrastructure/package-lock.json b/infrastructure/package-lock.json index c694c14a3..74167e76b 100644 --- a/infrastructure/package-lock.json +++ b/infrastructure/package-lock.json @@ -1,12 +1,12 @@ { "name": "infrastructure", - "version": "1.10.0", + "version": "1.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "infrastructure", - "version": "1.10.0", + "version": "1.11.0", "dependencies": { "aws-cdk-lib": "2.260.0", "constructs": "10.6.0" diff --git a/infrastructure/package.json b/infrastructure/package.json index 4f0ed2a23..22f2830da 100644 --- a/infrastructure/package.json +++ b/infrastructure/package.json @@ -1,6 +1,6 @@ { "name": "infrastructure", - "version": "1.10.0", + "version": "1.11.0", "bin": { "infrastructure": "bin/infrastructure.js" },