diff --git a/CHANGELOG.md b/CHANGELOG.md index 1612dfc1..f908cdc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,61 @@ 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.22.0] - 2026-09-14 + +The agent can stop guessing. **Clarifying questions** ship end to end: when a request is genuinely ambiguous the agent pauses the turn, the SPA renders a multiple-choice picker in the transcript, and the answer resumes that same tool call — surviving a page refresh. The tool worked from PR-2 but the model reached for it 4 times in 24 ambiguous requests; a measured system-prompt clause takes that to 24/24 while leaving clear requests at 0/18. On the admin side, the **cost drill-down** closes the gap between "top users by cost" and the per-session anatomy: an admin walks user → conversations → session profile with 15 diagnosis rules, a context trajectory chart and a copyable diagnostic JSON — all **content-free by construction**, enforced by a denylist test and a moto test that seeds content and proves none returns. Two silent data bugs are fixed: deleting a knowledge-base document mid-upload **permanently leaked its byte reservation**, and born-managed provisioning **mistook an established legacy agent for a new one** and stranded its corpus. And an `@`-mention now **binds the conversation** instead of borrowing one turn — measured on prod, 247 of 247 mentions started the conversation, so the borrow was paying an invisible tool-loss failure for a case that has never occurred. **No CDK deploy required.** One operator step: enable the Clarifying Questions tool in each existing environment's catalog — the seed skips a tool row that already exists. + +### 🚀 Added + +- **Clarifying questions (`ask_user_question`)** — the agent pauses a turn to ask structured multiple-choice questions. The interrupt is raised by the tool itself via `ToolContext` rather than a `BeforeToolCall` hook, so Strands' `_stop_for_interrupts` carries the `PausedTurnSnapshot`, the resume route and the `PendingInterrupt` breadcrumb with no special case. New `user_question_required` SSE event; gated by `ASK_USER_QUESTION_ENABLED` (default on with a kill switch) (#1100) +- **Clarifying-questions picker in the chat transcript** — renders the questions inline, always offers Other + Skip (so the model must not supply them; model-supplied duplicates are stripped server-side), and resumes the same turn by POSTing an `interrupt_responses` entry whose `response` is always an object — a null would re-raise the interrupt forever (#1102, #1105) +- **The picker survives a refresh** — pending prompts rehydrate from the `user_question` `PendingInterrupt` breadcrumb on `GET /messages`, so a reload lands back on the question instead of a dead turn (#1103) +- **System-prompt guidance that makes the tool actually fire** — appended only when `ask_user_question` is in the turn's **post-filter** effective tool list (the request's `enabled_tools` and the registered set diverge), and applied to the prompt handed to the agent, never to `self.system_prompt`, which is snapshotted for resume and hashed into the agent cache key. ~63 tokens, constant per configuration. Catalog seed flips to `enabledByDefault: True` (#1106) +- **Admin cost drill-down** — `GET /admin/costs/users/{id}/sessions` and `GET /admin/costs/sessions/{id}/profile` (scope `admin.costs`), plus `apis/shared/observability/content_policy.py`: a denylist of every content-bearing attribute on the session/cost/upload row families with three aliased allowlist projections. Unrecorded cost renders `costKnown=false`, never `$0`. No new table, no GSI operation, no feature flag (#1093) +- **15 cost diagnosis rules** — `admin/costs/diagnoses.py` encodes the classifications prior quota investigations reached by hand (prefix spiral, partial-miss heavy, over-threshold, summary over budget, prompt/`toolConfig` mutation, agent-cache bypass, attachment-heavy…), each with numeric evidence and the fix (#1093) +- **Conversations section on `/admin/users/:userId`** — period/sort controls, per-row model, tools on, context bar against the window, cost with share of the user's month, cache waste, and a severity dot for the diagnoses that fired; row links to the anatomy. Top-users table now shows email / tier / quota %, replacing a hard-coded `None` (#1094) +- **Session profile band + context trajectory chart** on `/admin/costs/sessions/:id` — messages, model calls and mix, tool calls, attachments, compactions, peak context of window, write:read ratio (each "not tracked" wherever a counter predates the session), an expandable Diagnoses list, and "Copy diagnostic JSON" meant to be handed to a model for a second opinion (#1094) +- **Content-free tool census and compaction counter** — `ToolCensusHook` tallies tool name → `{calls, errors}` per model call and the stream coordinator attaches each tally to that call's `C#` cost row as `toolCalls`; `toolCallCount`/`toolErrorCount` ride the existing session-aggregate `UpdateItem` and a monotonic `compactionCount` rides the compaction-state update (the persisted `compaction` map is last-write-wins and cannot count occurrences). Additive attributes only — no table, no index, no backfill. Gated by `COST_DIAGNOSTICS_ENABLED` (default on with a kill switch) (#1095) +- **`GET /files/{uploadId}/download`** — cookie-authed, owner-scoped, 302 to a freshly minted presigned URL with `Cache-Control: no-store`. A link to it keeps working for as long as the file does (#1101) +- **Knowledge-base storage usage bar** — new `KbUsage` (`engine`, `storedBytes`, `reservedBytes`, `cap`, `elevated`) on `DocumentsListResponse`. Managed KBs show "X of Y used" against the binding's effective cap (min of owner tier and per-KB ceiling), green/yellow/red at <75 / 75–90 / ≥90%. Best-effort: a record-read failure never breaks the documents list (#1108) + +### ⚠️ Changed + +- **An `@`-mention now binds the conversation instead of running one turn.** Two outcomes, no third: mentioning into an **empty** thread binds the Agent to it, exactly like launching from its card; mentioning into a thread that **has messages** opens a **new** conversation with that Agent, and the SPA says so. This reverses decision D11 on new evidence — of **247 prod mentions, 247 started the conversation** (dev: 60 of 61), so the borrow bought nothing and cost an invisible failure. The SPA now carries the binding in the `assistantId` query param and stops sending `agent_mention`; the backend still honours that flag for older clients, and `binds_conversation` gains `thread_is_empty` so a stale tab lands where a current one does. Retires the ~$0.12-per-mention prefix re-write and the history fork (#1115) +- **Send button is an up arrow**, not a paper airplane (#1114) + +### 🐛 Fixed + +- **A mentioned Agent silently lost its tools after the first turn.** The thread still looked like the Agent's while its tools, skills and model were gone, and nothing surfaced it — not the UI, and not the model, which cannot know its own toolset shrank. Asked for a tool it had used a moment earlier it returned `Unknown tool: create_rubric` and told the user to toggle a setting that was already correct (#1115) +- **"Continue" after a `max_tokens` truncation dropped the Agent entirely.** The SPA was already resending `rag_assistant_id` — only a `not is_continuation` guard discarded it — so a properly launched Agent finished its reply with none of its tools, skills, model or instructions. The block now runs for a continuation, with binding validation, persistence and RAG skipped (#1115) +- **Generated-document download links were dead on arrival and dead on reload.** The tool result handed the model a ~1,400-character presigned S3 URL and the model re-emitted it in prose truncated at the `?` — signature gone, `AccessDenied` (observed twice in one prod session). The card's own button was on a clock too: the signature expired an hour after the message was written. The office tools and `workspace_write` now put `upload_id` in the card payload and tell the model the card is already on screen, dropping the tool result from ~1,500 to ~330 characters **in the cacheable prefix, for the life of the session**. The SPA resolves through the new download route, recovers the upload id from a legacy `download_url`'s S3 key so persisted cards heal on render, and the global `marked` link renderer rewrites raw user-files S3 hrefs — fixing links already sitting in shipped conversations (#1101) +- **Deleting a document mid-upload leaked its byte reservation, permanently.** The request-time reservation is released on every abandon path except deletion, so each cancelled upload shaved bytes off that assistant's allowance forever — surfacing months later as "uploads stopped working", with no failure anywhere near the deletes that caused it. `soft_delete_document` now releases through `release_reservation_if_managed`, whose `settle_once` stamp makes it exactly-once against the other three paths (#1059) +- **The same delete popped five "Not found" dialogs.** The poll tolerates five consecutive 404s and the component handled `DOCUMENT_NOT_FOUND` cleanly, but the global `errorInterceptor` pops a dialog for every failed request *before* any caller's catch runs. The poll's reads now set `SUPPRESS_ERROR_TOAST`, and the loop is finally stoppable — `deleteDocument` already dropped the id from `pollingDocuments`, but that signal was display-only and the running loop never read it (#1059) +- **Born-managed provisioned over an established legacy agent.** Legacy KBs share one S3-Vectors index and never write a `KB_Record`, so an established legacy agent looked identical to a new one — its *next* upload was mistaken for a first upload, flipping retrieval to an empty managed KB and stranding the existing corpus. The record-is-`None` branch is now guarded on a cheap existing-documents probe (`Limit=1` COUNT), and fails toward legacy on any probe error (#1109) +- **The storage usage bar showed for legacy (Classic) KBs**, which are uncapped and have no denominator to show (#1110) +- **19 of 51 chat greetings wrapped to a second line** in the 616px text column, and because the greeting types out a character at a time the wrap happened in full view and pushed the composer down mid-animation. Twenty offenders rewritten shorter; a new `greeting-line-length.spec.ts` sums per-character advance widths captured from the real InterVariable woff2 (jsdom has no font metrics), tracking browser layout to within ±7px across 455 name/greeting combinations. Two of the offenders were `DEFAULT_GREETING_TEMPLATES` entries a golden spec pinned verbatim — the pin was preserving the bug (#1116) +- **Cost diagnostics crashed the scheduled-runs image** — `feature_flags` was not shipped in `Dockerfile.scheduled-runs` (#1095) + +### 🔒 Security + +- **Remaining log-injection sinks sanitized** — user-controlled values reaching `logger` calls in admin role pins, model icons, fine-tuning, sessions, skills (routes, service, user service), tool discovery and the inference-api chat routes now pass through `scrub_log()` (#1098) +- **Nightly workflow ref allowlist guarded by a test** — `tests/supply_chain/test_nightly_ref_allowlist.py` pins which refs the nightly build may check out (#1098) + +### 📦 Dependencies + +- Backend (dev): `pytest-xdist` 3.6.1 added — the suite is xdist-safe (moto mocks and hypothesis are per-worker; no test mutates shared on-disk state) + +### 🔧 CI/CD + +- **Backend pytest runs in parallel** — `pytest -n auto` on the PR gate fans ~3k tests across all runner cores instead of running single-threaded. `-v` dropped from `pytest.ini` (thousands of `PASSED` lines with no diagnostic value); the nightly coverage run stays serial on purpose (#1111) +- **Infra jest is transpile-only** — `isolatedModules` stops each jest worker re-type-checking the whole project, which was the dominant cost of the infra suite once backend went parallel. Type safety is preserved by a single `tsc --noEmit` step added to the infra CI job, where previously only ts-jest enforced it on PRs. Workers stay at 2 — the `--maxWorkers` bump regressed 2.5× in #1112 (#1113) + +### 📚 Docs + +- **Authenticated web assessment via browser takeover** — a 468-line spec for the browser-takeover surface (#1107) +- **Managed-KB specs closed out**, with `MANAGED_KB_NEW_DEFAULT` armed in dev recorded (#1104) +- **Backfill instructions corrected across all six `backfill_*.py` scripts** — give the backend venv's interpreter (a bare `python` fails on `boto3` before doing anything), and stop naming `describe-table` `ItemCount` as the verification: DynamoDB refreshes those counts roughly every six hours, so a correct backfill reads as a failure (#1098) + ## [1.21.0] - 2026-09-13 Global preferences get a home. **Customize** (`/customize/{tools,skills,connectors}`) replaces the composer's settings drawer, which had been presenting durable, account-wide state as "settings for this conversation" — a user who enabled a tool to get through one question had changed the `toolConfig` of every future turn, and nothing said so. Tools and skills gain full detail pages, an MCP server's sub-tools can be switched one at a time, and `/skill-name` in the composer invokes a skill for a single message the way `@agent` already did. Chat itself stops guessing: a four-tool answer now renders as **one** card instead of five, the loading indicator states what the agent is actually doing (`Running browse_web · 4s`) from a new `agent_status` event, and each finished tool batch gets a model-written summary line off a Nova Micro side-channel that never touches the cacheable prefix. On the cost side, the tool catalog moves off a full-table Scan onto a new `EntityTypeIndex` (95 items read to return 24, before), four tenant-global catalogs gain a TTL + single-flight cache, and the per-request user-profile upsert is throttled — together roughly 24 DynamoDB writes and four uncached scans removed from every SPA first load. **Requires a CDK deploy, and a backfill must be run — see the deployment notes.** ⚠️ **This release removes the only way to select a Conversation Mode**; prod uses one (Guided Learning, ~60 sessions in the first 12 days of September). diff --git a/README.md b/README.md index 87588267..6b49d63d 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_NOTES.md) +[](RELEASE_NOTES.md) [](https://github.com/Boise-State-Development/agentcore-public-stack/actions/workflows/nightly.yml)  @@ -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.21.0 +**Current release:** v1.22.0 --- diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 1b728486..33fd9f27 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,254 @@ +# Release Notes — v1.22.0 + +**Release Date:** September 14, 2026 +**Previous Release:** v1.21.0 (September 13, 2026) + +--- + +> ✅ **No CDK deploy required.** The only infrastructure changes in this release are a jest config and a version bump. Deploy `backend.yml` then `frontend-deploy.yml`. No new AWS resources, no GSI operation, no SSM parameter, no IAM change, no data backfill. +> +> 🛠️ **One operator step, per environment: enable the Clarifying Questions tool.** `seed_bootstrap_data.py` **skips any tool row that already exists**, so flipping `enabledByDefault: True` in the seed reaches a fresh bootstrap only. Wherever an `ask_user_question` row is already in the catalog, an admin has to turn it on; wherever the row has never existed, the seed will create it enabled — but a role whose `grantedTools` is not `*` still needs the grant added. Without this, the feature ships invisible: the model simply keeps asking in prose, exactly as before. See Deployment notes. +> +> ⚠️ **An `@`-mention now binds the conversation.** Mentioning an Agent into an **empty** thread binds it, like launching from its card; mentioning into a thread that **already has messages** opens a **new** conversation with that Agent. There is no third outcome — the mention no longer runs one turn and reverts. This reverses design decision D11 on measured evidence, and it retires a failure mode where the Agent's tools vanished silently after the first turn. See Breaking changes. +> +> 🗂️ **Nothing carries forward from v1.21.0's backfill note if you have already run it.** If you have *not*, `backend/scripts/backfill_tool_catalog_index.py` is still required in that environment — it is unaffected by this release, but the tool catalog's sparse-index read still answers "nothing matched" rather than "something is wrong". + +--- + +## Highlights + +The agent can stop guessing. **Clarifying questions** ship end to end: when a request is genuinely ambiguous, the agent pauses the turn, the SPA renders a multiple-choice picker inline in the transcript, and the answer resumes that same tool call — surviving a page refresh. The tool itself worked from the second PR; the interesting part is that the model almost never reached for it — 4 times in 24 deliberately ambiguous requests. Rewording the tool description and moving it in the tool list both stayed inside the noise band. A short system-prompt clause, added only when the tool is actually in the turn's effective list, took it to **24/24 on ambiguous requests while leaving clear requests at 0/18** — it asks when asking helps, and does not turn direct questions into interrogations. + +Administrators can now follow a cost number to its cause. The **cost drill-down** closes the gap between "top users by cost" and the per-session anatomy: user → conversations → session profile, with 15 diagnosis rules that encode classifications prior quota investigations reached by hand, a per-call context trajectory chart with the compaction threshold always in view, and a "Copy diagnostic JSON" button meant to be handed to a model for a second opinion. It is **content-free by construction** — a denylist of every content-bearing attribute on the session, cost and upload row families, enforced by a test that walks every admin cost response model and a moto test that seeds real content and proves none of it comes back. + +Two silent data bugs are fixed, both of the worst shape: invisible, cumulative, and delayed. Deleting a knowledge-base document **while it was still uploading permanently stranded its byte reservation** — every cancelled upload shaved bytes off that assistant's allowance forever, and would have surfaced months later as "uploads stopped working" with no failure anywhere near the deletes that caused it. And **born-managed provisioning could not tell an established legacy agent from a new one** — legacy KBs share one S3-Vectors index and never write a `KB_Record` — so an established agent's *next* upload was mistaken for its first, flipping retrieval to an empty managed KB and stranding the existing corpus. + +Generated documents are downloadable again. A `.docx` from `create_word_document` rendered a card whose button worked while the markdown link beside it returned `AccessDenied`: the tool result handed the model a ~1,400-character presigned S3 URL, and the model re-emitted it in prose truncated at the `?`. Signed URLs no longer go anywhere they can be copied or persisted, which also drops that tool result from ~1,500 to ~330 characters **inside the cacheable prefix, for the life of the session**. + +--- + +## Clarifying questions + +When a request is genuinely ambiguous, the agent stops and asks instead of guessing — and the user answers with two clicks rather than by retyping the request. + +### Backend + +- `agents/builtin_tools/ask_user_question.py` — the tool. Unlike `oauth_required` and `tool_approval_required`, the interrupt is raised by the **tool itself** through `ToolContext`, not by a `BeforeToolCall` hook. Strands routes both through `_stop_for_interrupts`, so the `PausedTurnSnapshot`, the resume route and the `PendingInterrupt` breadcrumb (`kind: "user_question"`, questions JSON-encoded) needed no special case. +- `apis/shared/user_questions/models.py` — the question schema, and the normalizer that **strips model-supplied Other/Skip options**. The picker always offers both, so a model that includes them produces duplicates. The same module discourages a second round of questions. +- New `user_question_required` SSE event, emitted after `message_stop` in the same `done` block as `oauth_required`. Payload `{type, interruptId, toolUseId, questions}`, each question `{header, question, multiSelect, options: [{label, description?}]}`. +- The resume contract has one sharp edge, and it is pinned by tests: the POSTed `response` must **always be an object**, never `null`. `ToolContext.interrupt` only treats a non-`None` response as an answer, so a null re-raises the interrupt forever. "Skip" sends `{skipped: true}`. +- `apis/shared/sessions/metadata.py` — the breadcrumb that lets a pending prompt survive a reload. +- `chat_agent.py` — `_system_prompt_for(tools)` appends the guidance clause. Three deliberate choices: gated on the tool so a user without it never carries an instruction to call it; keyed on the **post-filter** tool list rather than the request's `enabled_tools`, because the two diverge (`ToolFilter` drops a catalog id the registry does not know — `canvas_faculty` does this in dev today) and keying on the request would advertise a tool absent from `toolConfig`; and applied to the prompt handed to the agent, **never** to `self.system_prompt`, which is snapshotted for resume and hashed into the agent cache key. + +Three approaches were measured and ruled out, recorded so they are not retried: rewording the tool description (44–56%, inside a 17–44% baseline band), moving the tool's position in the list (25–38%), and removing the prompt's "Cost Awareness" clause (38%). Only the system-prompt clause escapes the noise, so the text is load-bearing in that position and ships byte-identical to what was measured, with a test pinning it. + +### Frontend + +- `services/user-question/user-question.service.ts` and `user-question-prompt.component.ts` — the picker in the transcript: full-width, one stroke on a selected option, Other and Skip always offered. +- `stream-parser-core.ts` / `stream-parser-types.ts` — `user_question_required` parsing. +- `session/user-question-hydration` — rehydrates a pending prompt from `GET /messages` after a refresh, so a reload lands back on the question rather than a turn that can never finish. + +### Cost + +The tool spec is a constant in the cacheable `toolConfig` prefix (~630 tokens) and the questions travel on the SSE channel only — just the one-line formatted answer block re-enters the conversation. The system-prompt clause is ~63 tokens, constant per configuration. Gated by `ASK_USER_QUESTION_ENABLED` (default on with a kill switch); while off the tool is never registered, so the model falls back to asking in prose. + +Spec: `docs/specs/ask-user-question.md`. + +--- + +## Admin cost drill-down + +An admin can start from a user, list their conversations **without reading any of them**, and open one for the diagnostic profile a developer — or a model handed the JSON — needs to find cost-effectiveness work. + +### Backend + +- `apis/shared/observability/content_policy.py` — the denylist of every content-bearing attribute on the session/cost/upload row families, three aliased allowlist projections, and the walkers. Enforced two ways: a test that walks **every** admin cost response model (one named exemption, `TopSessionCost.title`, kept by decision) and a moto test that seeds content and proves none of it returns. +- `GET /admin/costs/users/{id}/sessions` and `GET /admin/costs/sessions/{id}/profile`, scope `admin.costs`. Unrecorded cost renders `costKnown=false` — never `$0`, which would read as "this conversation was free." +- `admin/costs/diagnoses.py` — 15 pure rules with numeric evidence and a stated fix: prefix spiral, partial-miss heavy, over-threshold, summary over budget, prompt/`toolConfig` mutation, agent-cache bypass, attachment-heavy, and more. +- `get_session_cost_records` now projects the twelve attributes the anatomy consumes, which closes the `citations[].text` read on the existing path. +- Top-users enrichment (email, tier, quota %) replaces a hard-coded `None`. + +No new table, no GSI operation, no feature flag — read-only, admin-only, and it degrades to "not tracked" wherever a counter predates the session. + +### Frontend + +- `/admin/users/:userId` gains a **Conversations** section (owned by the costs feature, rendered only for admins holding `admin.costs`): period and sort controls, per-row model, tools on, a context bar against the window, cost with share of the user's month, cache waste, and a severity dot for the diagnoses that fired. A row opens the anatomy. +- `/admin/costs/sessions/:id` gains a **profile band** (messages, model calls and mix, tool calls, attachments, compactions, peak context of window, write:read), an expandable **Diagnoses** list (severity chip, headline, code, suggestion, evidence, ref), a **context trajectory chart** with the compaction threshold always in view, a back-to-user link, and **Copy diagnostic JSON**. The profile loads independently of the anatomy, so one failing does not hide the other. + +Verified in-browser against dev data: 93 conversations listed for September's top user, profile + diagnoses + chart rendered, dark mode through both levers, and the copy produced a 15.8 KB document with no denylisted keys. + +### The one signal that had to be recorded + +Which tools a conversation called, how often, and how often they failed could not be derived from existing rows. `ToolCensusHook` tallies tool name → `{calls, errors}` per model call using the same cycle counter `AgentStatusHook` uses; the stream coordinator attaches each call's tally to that call's `C#` cost row as `toolCalls`. `toolCallCount` / `toolErrorCount` ride the existing session-aggregate `UpdateItem` alongside `totalCost`, and a monotonic `compactionCount` rides the compaction-state update — the persisted `compaction` map is last-write-wins and cannot count occurrences. + +Everything here is **additive attributes on rows the turn already writes**: no table, no index, no backfill, and nothing reaches the prompt, so the cacheable prefix is untouched. Sessions that predate it read "not tracked" rather than `0`. Gated by `COST_DIAGNOSTICS_ENABLED` (default on with a kill switch). + +Spec: `docs/specs/admin-cost-drilldown.md`. + +--- + +## Knowledge base — storage usage, and two silent data bugs + +### Storage usage on the KB card + +The agent's knowledge-base card now shows how much of the byte cap is in use. `_resolve_kb_usage` reads the `KB_Record` once: managed KBs report their bytes and the binding's effective cap (the min of owner tier and per-KB ceiling); legacy S3-Vectors KBs are uncapped and show "X stored" with no denominator and no colour ramp. Green / yellow / red at <75 / 75–90 / ≥90%. Best-effort throughout — a record-read failure never breaks the documents list. + +### Deleting mid-upload leaked bytes, permanently + +The request-time byte reservation is released on every abandon path except one: deletion. Ingestion reaching terminal, a client-reported upload failure and the stale sweep all release; a deleted document reached none of them, so its reservation was stranded forever. Each cancelled upload permanently shaved bytes off that assistant's allowance — surfacing months later as "uploads stopped working", with no failure anywhere near the deletes that caused it, which is exactly what `byte_cap.release`'s own docstring warns about. `soft_delete_document` now releases through `release_reservation_if_managed`, whose `settle_once` stamp makes it exactly-once against the other three paths. + +The same delete also popped **five "Not found" dialogs**. The polling loop tolerates five consecutive 404s and the component already handled `DOCUMENT_NOT_FOUND` cleanly — but the global `errorInterceptor` pops a dialog for every failed request *before* any caller's catch runs, so correct handling was invisible and the user got one dialog per tolerated retry. The poll's reads now set `SUPPRESS_ERROR_TOAST`, and the loop is finally stoppable: `deleteDocument` had always dropped the id from `pollingDocuments`, but that signal was display-only and the running loop never read it. + +**Known and deliberately not fixed here:** neither ingestion pipeline checks whether a document is `deleting`. If the S3 PUT completes after the delete, the managed consumer ingests it and writes `complete` over `deleting` — deleted content becomes answerable again and the row returns. That is a data-correctness bug on the live ingest path and needs its own change with its own mutation guards. + +### Born-managed provisioned over an established legacy agent + +Born-managed treated the absence of a `KB_Record` as "brand-new agent". But legacy KBs are not first-class — they share one S3-Vectors index and never write a record — so an established legacy agent looked identical to a new one. Its **next** upload was therefore mistaken for a first upload, flipping retrieval to an empty managed KB and stranding the existing corpus on the legacy index. The record-is-`None` branch is now guarded on an existing-documents check (`assistant_has_documents`: cheap COUNT, `Limit=1`), provisioning only at zero documents and failing toward legacy on any probe error. + +--- + +## Generated documents download reliably + +A `.docx` produced by `create_word_document` rendered a download card whose button worked, while the markdown link the model wrote underneath it returned S3 `AccessDenied` (reported on prod session `6b247682`). The conversation shows why: the tool result handed the model a ~1,400-character presigned S3 URL, and the model re-emitted it in prose **truncated at the `?`** — signature gone. Two turns in that one session did it. The card's own button was on a clock too: the signature expired an hour after the message was written, so reopening an older thread would have failed the same way. + +Signed URLs no longer go anywhere they can be copied or persisted: + +- The office tools and `workspace_write` put `upload_id` in the card payload instead of a presigned URL, and the summary tells the model the card is already on screen so it does not compose a link of its own. **The tool result drops from ~1,500 to ~330 characters — per document, in the cacheable prefix, for the life of the session.** +- New `GET /files/{uploadId}/download` on app-api: cookie-authed, owner-scoped, 302 to a freshly minted presigned URL with `Cache-Control: no-store`. A link to it works for as long as the file does. +- The SPA card resolves `upload_id` through that route and falls back to recovering the upload id out of a legacy `download_url`'s S3 key, so cards already persisted in conversations heal on render. The global `marked` `renderer.link` rewrites raw user-files S3 hrefs the same way — which fixes the links already sitting in shipped conversations. + +Verified against dev: the route 302s for an owned file and 404s otherwise, the minted URL serves 200 while the truncated form is 403, and the exact link from the bug report renders as `/api/files/{uploadId}/download` while unrelated links are untouched. + +--- + +## 🐛 Bug fixes + +- **A mentioned Agent silently lost its tools after the first turn.** The thread still looked like the Agent's while its tools, skills and model were gone, and nothing surfaced the change — not the UI, and not the model, which cannot know its own toolset shrank. Asked to use a tool it had used a moment earlier it got `Unknown tool: create_rubric`, and told the user to toggle that tool in the picker: a confident wrong diagnosis sending them to fix a setting that was already correct. Fixed by the binding change under Breaking changes. +- **"Continue" after a `max_tokens` truncation dropped the Agent entirely** — on the path users are explicitly told to use. The SPA was already resending `rag_assistant_id` (`continueTruncatedTurn`'s own comment says "so the backend rebuilds the same model/tools/assistant agent"); only a `not is_continuation` guard discarded it, so a properly launched Agent finished its reply with none of its tools, skills, model or instructions. The block now runs for a continuation, with binding validation and persistence skipped (it binds nothing new) and RAG skipped (the turn carries an empty message, so a KB search would spend a query on `""`). +- **19 of 51 chat greetings wrapped to a second line.** The greeting heading sits in a 616px text column at `text-4xl/tight`, and because it types out a character at a time, the wrap happened in full view and pushed the composer down mid-animation. Twenty offenders rewritten shorter, keeping the voice. Two were stock `DEFAULT_GREETING_TEMPLATES` entries pinned verbatim by a golden spec — "How can I help you today, {name}?" wrapped for any first name of 8 characters or more, so the pin was preserving a bug. A new `greeting-line-length.spec.ts` holds the line: jsdom has no font metrics, so it sums per-character advance widths captured from the real InterVariable woff2 in the app's own `