Release/1.22.0 - #1117
Release/1.22.0#1117
Conversation
…-library-user-index-query perf(artifacts): serve the library listing from UserArtifactsIndex
`_serialize_content` read the result's content with `getattr`, but Strands' `MCPToolResult` extends `ToolResult`, a TypedDict — so what `call_tool_sync` returns is a plain dict at runtime and the attribute lookup found nothing. Every app-initiated tools/call therefore relayed `content: []` back to the iframe. The failure was silent end to end: app-api returned 200, inference-api returned 200, and the MCP server had really run the tool, so a write took effect while the App received nothing to render. Any MCP App that re-reads state after an edit appeared frozen. Handle the dict shape alongside the attribute one, mirroring the `isinstance(result, dict)` branch `_is_error` already has. The existing fakes in the dispatch tests are objects carrying a `.content` attribute, which is why the attribute-only path looked correct; the added test uses the dict shape the client really returns, with untagged Strands content blocks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Phil Merrell <philmerrell@boisestate.edu>
An app-initiated tools/call arrives between turns. `routes.py` rebuilds the
conversation's agent first, but with `cache_write=False` it reads a cached
agent — and Strands tore that agent's MCP client sessions down when the
turn that built them ended. `_resolve_client` then hands back the client
the UIToolCatalog recorded at some earlier build, so `call_tool_sync`
raises:
MCPClientInitializationError: the client session is not running.
which becomes AppToolCallError(502) and reaches the App as a 502 Bad
Gateway. It presents as intermittent because a call made while the turn is
still streaming finds the session alive.
Wrap the call so the client is reconnected for its duration and left as it
was found. A session that is already live belongs to an in-flight turn and
is used as-is, never stopped here. Overlapping app calls against the same
client share one revived session through a refcount, so no call has the
connection closed underneath it.
Note `_resolve_client` takes `agent` and does not use it; resolving the
live client from the freshly built agent would be the deeper fix, but it
reaches into how tool providers are held and cached. This keeps the blast
radius at the dispatch boundary.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Phil Merrell <philmerrell@boisestate.edu>
…xy-empty-tool-result fix(mcp-apps): keep tool-result content on app-initiated tools/call
…le-client-session fix(mcp-apps): revive a torn-down MCP session for app-initiated calls
…nto-develop-1.19.1 Backmerge: main → develop (1.19.1)
The 2,000-char MAX_CONTEXT_CHARS was sized for Docling chunks; Bedrock's are ~3x larger, so on the managed backend only ~1 of top_k=5 reranked chunks cleared the cap — top_k=5 became top_k=1 at the model, producing materially wrong answers (HANDOFF §5.40, e.g. a Major-Core course called an elective). Add resolve_context_cap (managed 8,000 / legacy 2,000) keyed on the same resolve_engine_for the backend resolver uses, wire both retrieval call sites, and pin the split with mutation-tested guards in test_kb_backend_parity.py. Amend Requirement 3.2 (was 2,000 on both) to an engine-aware cap: the asymmetry restores parity in chunks-reaching-the-model, not characters, sized from eval §13.6 (8,000 = all five managed chunks fit, ~966 extra input tokens/turn). Validated end-to-end on a prod-derived KINES advising corpus re-created in dev via scripts/local-dev/kb-cap-benchmark.py.
_filter_vectors_by_document_status opened with `if not doc_ids: return vectors` — the one fail-OPEN line left in an otherwise fail-closed function (§5.33). A non-empty batch where every chunk's document_id was absent/empty bypassed the DynamoDB status check and was served unverified, including deleted content. Now returns [] and emits METRIC_STATUS_FILTER_FAIL_CLOSED like the other unprovable paths; an empty input stays an empty result with no metric. Guard test_filter_fails_closed_when_no_chunk_carries_a_document_id, mutation-tested. Requirements 5.1/5.2 already mandated this; the path was overlooked.
…-led Two entries added to the kaizen review queue, both framed on the capability-unlock lens rather than subtraction: - AgentCore Runtime workspaces: filesystemConfigurations on CreateAgentRuntime (sessionStorage / s3FilesAccessPoint / efsAccessPoint / capacityProviderVolume), verified against pinned botocore 1.43.68. Recommends shipping the s3FilesAccessPoint bridge over the existing user-files layout and deferring sessionStorage behind the deletion-path and durability gates. - Strands Snapshots: already present in pinned strands-agents 1.51.0. Led by branch/regenerate — a capability the SPA does not have at all — with the four-candidate subtraction audit recorded as a labelled negative result so it is not re-investigated. Both carry the dual-lens correction Phil made on 2026-05-10, which was prompted by this same AgentCore filesystem feature being written up subtraction-first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Leaving a conversation with an MCP App and navigating back dropped every App frame to a plain tool card until a hard refresh. Two things collided. `session.page` reset `McpAppStateService` on every route change, and the only thing that re-seeded it — the `uiResources` sidecar on `GET /messages` — rides on a request that `loadMessagesForSession` deliberately skips once a conversation's messages are cached. Since `MessageMapService` never evicts, the second visit to a conversation always hit that short-circuit, so the reset had no way back. A refresh worked only because it destroyed the message cache. Re-key the registry `sessionId -> toolUseId -> resource` and retain every conversation for the SPA session, mirroring the message cache. Reads are scoped to the viewed conversation, so a `toolUseId` can only resolve inside the conversation that produced it. Iframe teardown is unaffected: frames unmount with their message-list components. Also drop the `isViewedSession` gate on `onUiResource` / `onToolInputPartial` and record under the streaming session's own id. That gate existed only because of the reset; with retention it became harmful — an App produced by a conversation streaming in the background would have been discarded for good, since the inline `ui_resource` event never re-streams and the persisted replay rides on the request navigate-back skips. Verified against the dev backend: navigate-away-and-back keeps the frame (with no `GET /messages` on the return trip, confirming the mechanism), hard refresh still hydrates, two App conversations retain independently, and an App produced while its conversation streamed in the background is present on return. Tool rails, artifacts and app-initiated cards unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The local app-api builds its CORS allowlist explicitly from `CORS_ORIGINS` with `allow_credentials=True` (which forbids a wildcard), and the Cognito localhost callback is registered for `http://localhost:4200`. A preview moved to any other port therefore has every API call blocked and cannot complete a login — a failure that surfaces as a broken app rather than a port conflict. `autoPort: false` makes the preview fail loudly on a busy port instead of silently relocating to one that cannot work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An App-initiated `tools/call` runs the MCP client directly instead of the agent's tool loop, so `BeforeToolCallEvent` never fires and `OAuthConsentHook` — the only thing that warms `oauth_token_cache` — never runs. The client's token provider is just a cache read, so on any container that has not served a model-driven turn for that (user, provider) the request goes out with no Authorization header at all: after a page reload lands the call on a fresh runtime (or a restarted local uvicorn, or once the cache's 3000s TTL lapses) every button in an embedded App fails. It fails silently rather than 401-ing because a server that accepts an unauthenticated `initialize`/`tools/list` — Google Tasks does — still registers the tool, so the App renders and only the calls fail, with the server's own "isn't connected yet" text. No 401 means `_recover_oauth_preflight`, which does warm the cache from the vault, never fires; a server that 401s its `tools/list` would have self-healed. `_ensure_oauth_token` now repeats the hook's warm-the-cache half explicitly: resolve the provider from the MCP client, honour the durable disconnect flag, then `resolve_token_or_consent_url` to warm the cache or report that consent is required. Two deliberate departures from the hook: * Consent-required answers 409, never 401. The SPA's error interceptor treats any 401 as an expired BFF session and redirects to login, so a 401 would sign the user out over an unconnected connector. 409 is already this codebase's "needs connecting" status (file-source browser, export dialog). * An auth-shaped failure clears the cached token but does not retry. An app call is whatever button the user pressed — `complete_task` — so a regex-triggered retry could apply a mutation twice. The next press misses the cache and re-resolves. The auth-failure regex moves to `apis/shared/oauth/auth_failure.py` so the hook and the dispatch cannot drift on what an auth failure looks like. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ueue-snapshots-workspaces docs(kaizen): queue Strands Snapshots + AgentCore workspaces, Unlocks-led
…-ui-resource-retention fix: retain MCP App UI resources per conversation
…connection-refresh-805264 fix(mcp-apps): resolve the OAuth token for app-initiated tool calls
App-initiated tool calls (MCP Apps PR #6) hydrated on reload as one static card apiece, stacked at the tail of the conversation. An interactive App like the Google Tasks board runs a tool on nearly every gesture, so a refresh produced a wall of "RAN BY APP" cards — detached from where they happened, duplicating state the re-mounted App already shows, and growing without bound. They now surface where they belong: on the App frame that ran them, grouped by the originating tool-use id the card store already records. The header carries a count chip ("3 actions", tinted and annotated when something failed); expanding it shows successes collapsed into a single `board_snapshot ×6, update_task ×2` summary line, with each failure listed separately alongside its error text — failures being the question this record exists to answer. Cards whose frame can't render (no mcp-sandbox origin → no frame) would otherwise vanish silently, so the message list keeps a fallback box for those orphans, summarized the same way. Provenance-only, as before: nothing here reaches the model or the prompt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…eady up The App HTML the SPA re-mounts on reload is whatever `resources/read` returned when the tool first ran, replayed verbatim from its `UIRES#` row along with the CSP and permissions captured at the same moment. A server that ships a new App version — or tightens the policy its App runs under — never reached conversations that already existed, and we had quietly become the durable store of record for a resource that belongs to the server. Re-reading needs a live MCP client, and the only path to one is a built agent. Revalidating on conversation open would therefore add a full agent rebuild (76% of sessions bypass the agent cache) to a page load that runs no model turn, for every App whether or not anyone touches it. So this piggybacks instead: an app-initiated tools/call has already built the agent and revived the client, which makes the extra read close to free. The refreshed shell lands on the next load rather than the current one. That is the deliberate trade — it converges for the Apps people actually use, and costs nothing for the ones they don't. Bounded and non-blocking: one refresh per resource per process, dispatched off the response path so the App's call is never slowed, and silent on every failure — a server that is down must not blank an App that still works. `get_provenance` projects only the producing tool name and the message anchor, never the stored HTML the refresh is about to replace. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SEP-1865 has the host send `ui/resource-teardown` before tearing a resource down for any reason, and wait for the response where it can, so the App can save its state to its own server. That matters more here than it looks: the host is deliberately not the store of record for App state, so a teardown the App never hears about is state nobody saves. `dispose()` defeated exactly that. It fired the notification and then, in the same tick, removed the message listener and rejected every pending request — so the ack landed on nothing, and an App that answered teardown by calling a save tool had its postMessage dropped on the floor. The comment said "we're going away regardless of the ack", which was true and was the bug. Two changes: * The bridge stays attached through a grace window after sending teardown, and `dispose()` returns a promise that settles on the ack or when the window expires. Inbound routing now gates on a new `detached` flag rather than `disposed`, so the window is live on purpose. The App's save call is proxied over HTTP from the host page, so once its message reaches us the request outlives the iframe. * Teardown fires at navigation intent, not just component destroy. By the time Angular destroys the frame it is removing the iframe in the same tick and the View is gone before it can run anything, so a new registry of live bridges lets the conversation-change path notify every open App while its iframe is still alive. Fired, not awaited: the value is in the timing rather than the wait, and that effect is synchronous. Known limit: a hard refresh or tab close still gets no window, and a blocking wait on in-SPA navigation would need a route guard the app does not currently have. Reparenting the iframe to outlive its component is not an option — moving an iframe in the DOM reloads it, destroying the state this is trying to save. The existing dispose test asserted the same-tick detach; it now asserts the grace window, which is the behavior change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…-card-collapse feat: collapse app-run tool cards behind the App frame's header
…ontext-cap fix(kb): engine-aware managed context cap (8,000) — task 16.1
…lter-fail-closed fix(kb): fail closed on the last fail-open status-filter path — task 16.3
Task 16.1 (engine-aware managed context cap) and 16.3 (fail-closed status filter) both shipped and merged to develop. Flip their STILL-OPEN markers to RESOLVED across the §0 intro, the §5.33/§5.40 entry headers, and the §6 open table. §5.41 (diagram answer quality, task 16.2) remains the open answer-quality item.
The ingestion consumer is the only writer of DOC# status. When its event dead-letters (Lambda async retry capped at 2), a document Bedrock already indexed is left parked non-terminal forever, and the retrieval filter serves only 'complete' -- so its content sits in the KB fully retrievable and invisible to every query. Two such docs occurred in dev; both needed manual repair. Add document_reconciler.py: the missing second writer. Daily, it finds DOC# rows stuck non-terminal (uploading/chunking/embedding) past a 60-minute grace gate, probes Bedrock per document, and drives a stranded-but-retrievable doc to 'complete' (the §5.37 case). It reuses the consumer's own probes -- document_status, the equals-on-document_id retrievability search, its status-set constants, and set_document_terminal -- so §5.37/§5.38/§5.39 live in one place. FAILED -> failed; NOT_FOUND -> re-ingest from S3 (the scheduled form of task 14.4's one-click retry). Modelled on reconciler.py: ships DISARMED (MANAGED_KB_DOC_RECONCILER_ARMED, empty reads as off); per-run action limit applies in both modes so the report is trustworthy; grace gate is a pure function of the row's own updatedAt and fails closed; terminal/deleting rows are never candidates. Guards in tests/lambdas/test_kb_document_reconciler.py (61 tests), mutation-verified. Scheduling + IAM wiring + arming are a deploy-gated follow-up; the flag is exempted in the env-contract test's OPTIONAL_OVERRIDES until that lands.
…lassic badge (task 16.4) (#1006) Engine-aware document status vocabulary (managed: uploading→processing→ready(+failed); legacy keeps chunking/embedding), one INFO line per query naming the served engine in the rag_service facade, and a Managed/Classic badge fed by a new engine field on UpgradeStatusResponse. Mutation-tested guards across backend + frontend. HANDOFF §6 / tasks 16.4.
…hedule and IAM (task 16.5) Adds the fifth kb-migration Lambda (document_reconciler.lambda_handler) to the shared one-image/five-functions construct, so the reconciler built in the backend PR actually runs. - Nightly EventBridge schedule: cron(0 9 * * ? *) (~02:00-03:00 America/Denver), ENABLED regardless of flags, because it ships report-only and report-only is read-only (same inverted convention as the KB reconciler). - IAM: grantDirectIngestion (GetKnowledgeBaseDocuments + IngestKnowledgeBaseDocuments) + grantRetrieval (bedrock:Retrieve) + documents-bucket read + assistants-table RW. Deliberately NOT grantProvisioning or PassRole: it reads KB_Records from DynamoDB, never ListKnowledgeBases, and never creates/deletes a knowledge base -- a strictly narrower footprint than the KB reconciler. - New flag MANAGED_KB_DOC_RECONCILER_ARMED (config.docReconcilerArmed), empty=off, forwarded to every function and threaded through load-env.sh; ships disarmed. - SSM function-name param + deploy-image-lambda-one.sh case + backend.yml deploy step so the out-of-band image swap reaches the new function. - Bootstrap stub (document_reconciler.py) + Dockerfile COPY keep the byte-stable five-handler image consistent; import-closure and env-contract tests updated. Full infra suite green (792), touched Python supply-chain tests green (21). Stacked on the backend PR (#1007); merge after it. No deploy in this PR.
An app-initiated `tools/call` that needs OAuth consent reported a
deliberate 409 with "Connect the account, then try again". The user saw:
Error — Received error (409) from runtime. Please check your
CloudWatch logs for more information.
inference-api runs behind AgentCore Runtime, which rewrites **any** non-2xx
container response to a generic 424 and discards the body. Both the status
and the human-readable message were destroyed, so:
- `app_api/mcp_apps/routes.py` claimed to relay the status "verbatim (403
not-app-visible, 409 no consent)" — by then it was always 424.
- `app_tool_dispatch.py` claimed "the SPA relays `message` verbatim" — the
message was gone.
- `mcp-app-proxy.service.ts` has no consent branch, so it rendered the raw
runtime text.
Verified live on dev 2026-09-08 while regression-testing #1000/#1001/#1002.
#1001's detection logic was correct all along; only its reporting was lost.
## The fix
inference-api answers **200** with an `appToolError` envelope carrying the
code and message; app-api restores the real status before replying. The
SPA's contract is unchanged — it still sees 403/409/502 with
`{"error": "<message>"}` — so only the one hop crossing AgentCore changes
shape, and no frontend change is needed.
Applied to both proxied directives: `app_tool_call` and
`app_context_update` (identical flattening).
Two properties the envelope enforces independently of its callers:
- **Never relays a 401.** The SPA's `error.interceptor` treats any 401 as an
expired BFF session and signs the user out, so an unlisted or malformed
status collapses to 502 rather than letting upstream choose.
- **An enveloped error persists no provenance card.** The card write is
gated on the upstream's 200, and an envelope now arrives *with* a 200, so
the check runs first.
## Testing
- 30 new guards in `tests/shared/test_mcp_app_error_envelope.py`, plus relay
tests on both routes.
- Mutation-tested, all mutants compiling: reverting the 200 to the real
status fails `test_error_response_is_http_200`; admitting 401 to the
whitelist fails 5 named tests; moving the envelope check after the card
write fails `test_enveloped_error_persists_no_provenance_card`.
- Full backend suite: 7786 passed. (8 pre-existing `tests/fine_tuning`
failures are a missing `pandas` in the local venv, unrelated.)
- `ruff` clean on every changed file.
Not verifiable locally: a local uvicorn talks to app-api directly with no
AgentCore in the path, so the flattening cannot reproduce — which is why
this survived #1001's unit tests. Needs a dev deploy to confirm end to end.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds a `browse_web` tool backed by the AgentCore Browser resource that PlatformStack already deploys but nothing consumed: the runtime role has the browser IAM actions and `BROWSER_ID` in env, and the only reader was a startup log line. Zero new dependencies. `websockets==16.0` is already in the image, so the CDP layer is hand-rolled rather than adding Playwright and its bundled Node driver to the inference-api container for auto-waiting and a selector engine we mostly don't use — JS evaluated in the page does the same job. `cdp_client.py` is the seam if richer interaction is ever needed. Cost posture, since a browsing transcript is the classic unbounded per-turn payload: every action's output is capped before it reaches the model, and a screenshot is only ever taken when the model explicitly asks — vision tokens are the most expensive thing this tool can emit, so it is never automatic. Seeded `enabledByDefault=False` and gated by `BROWSER_TOOL_ENABLED`. Session lifecycle follows the "never cache session state on an agent instance" rule: the browser session *id* lives on Strands `agent.state` (as `app_context_dispatch` does), while the live socket is process-local keyed by that id — so a second cached agent for the same conversation reconnects to the same remote session instead of starting a second one. `add_init_script` (CDP `Page.addScriptToEvaluateOnNewDocument`) is included and probed but unused: it is the hook a future WebMCP driver would need, per docs/specs/webmcp-host-spa-tools.md. Not verified against live AWS — the dev-ai SSO token was expired and the device grant needs an interactive session. `scripts/probe_agentcore_browser.py` runs the five checks that unit tests cannot. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adding browse_web to the bootstrap seed broke three assertions in TestSeedDefaultTools that hardcoded "9 tools". The counts now come from len(DEFAULT_TOOLS), so the invariant under test is the real one — every entry in the seed list gets created or skipped — instead of a literal that has to be bumped by hand whenever a tool is added. Also pins browse_web's seeded fields the way the other tools are pinned, including enabledByDefault=False: that flag is the cost guard, not a default worth drifting. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The picker was first modelled on ToolApprovalPromptComponent, and inherited a visual language built for a different job. That component is a narrow pill with a hard 2px accent bar and hairline dividers, which is right for a two-button yes/no that should stay out of the way. This one is a form the reader has to think about, and at max-w-xl inside a `justify-start` wrapper it read as cramped and boxy. - Full width of the assistant column: the `flex justify-start` wrapper and `max-w-xl` are both gone. - Soft ground instead of chrome: rounded-2xl card, no border — a faint tint and a soft ring carry the edge. The left accent bar is dropped. - Options are discrete rounded rows with air between them rather than an edge-to-edge divided list, with roomier hit areas and larger type (text-sm/6 labels, text-xs/5 descriptions). - Rounder controls throughout; "Other" now shares the option row's shape so it reads as one more choice rather than a stray input. - The eyebrow loses its icon. It was pure decoration, and a little glyph on an AI prompt is the first thing that makes it look generated. Measured in the browser, light and dark: eyebrow 10.23, option label 17.75, description 7.56, pager/skip 7.30, selected-row label 14.31. Selection reads from the tinted surface and the filled marker together, never colour alone. `background: white` tripped the surface-literal guard; switched to var(--color-white), which is what that guard exists to enforce. Behaviour is untouched and re-verified end to end: single-select, a two-value multi-select and a free-text answer all reached the model on resume. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two things were stacking a second outline around a selected row. A focus-outline fallback written for the "Other" row — a <label> whose focusable element is the input inside it — was scoped to every `.option`. On a plain option button `:focus-within` is also true after a MOUSE click, so clicking drew an outline outside the row. Now scoped to `.option--other`. And selection itself was adding a coloured ring on top of the row's existing hairline. Selection is now a fill change: every row carries exactly one hairline whatever its state, and a warm tint plus the filled marker carry the state together, so it is still never colour alone. Keyboard focus is unaffected and still draws a visible ring — the stroke that was removed is the redundant one, not the accessible one. Measured on the new tinted surface: label 15.63 light / 14.28 dark, description 6.66 / 10.13. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…on-and-nightly-allowlist-guard fix(security): sanitize the remaining log-injection sinks, guard the nightly allowlist
…r-question-ui-polish style: full-width, softer clarifying-questions picker
…ost-drilldown-ui feat(admin-costs): conversations on the user page + session profile, diagnoses and trajectory
The tool worked end to end from PR-2, but the model rarely reached for it — 4/24 on deliberately ambiguous requests against a production-shaped tool set. A short clause in the system prompt takes that to 24/24, and leaves clear requests at 0/18 so it does not turn direct questions into interrogations. The clause is appended only when `ask_user_question` is in the turn's effective tool list, so a user without the tool never carries an instruction to call it. The check is on the POST-FILTER list rather than the request's `enabled_tools`: the two diverge — `ToolFilter` drops a catalog id the registry does not know, as `canvas_faculty` does in dev today — and keying on the request would advertise a tool absent from `toolConfig`. It also picks up the `ASK_USER_QUESTION_ENABLED` kill switch for free. It is 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. The clause derives from `enabled_tools`, which that key already covers via `tools_hash`, so mutating the field would move resume onto a different cache slot for no benefit. `PrefixFingerprintHook` reads the prompt off the built agent, so `systemPromptHash` still reflects what was really sent. Three things the measurements ruled out, recorded so they are not retried: rewording the tool description (44-56%, within a baseline band of 17-44%), changing 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. The text is therefore load-bearing in this position, and ships byte-identical to what was measured, with a test pinning that. Catalog seed flips to enabledByDefault: True. Cost: ~63 tokens, constant per configuration, inside the cacheable prefix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two user requests are blocked on the same gap: a Library agent that evaluates the accessibility of subscription databases it renews annually, and a VPAT agent that must test vendor demo tenants. Neither target is reachable by a logged-out browser, and there is no path today for a user to authenticate a browsing session. Specs the human-in-the-loop takeover flow: the agent calls take_control() (UpdateBrowserStream -> automation DISABLED, a service-side mutex, not a convention), raises an interrupt, the user signs in themselves in an embedded AWS DCV live view, and the turn resumes authenticated. Credentials never enter the prompt, the conversation, or AgentCore Memory. Nearly all of the capability already exists and is unused: take_control / release_control ship in the pinned bedrock-agentcore 1.21.0, and the Runtime role is already granted UpdateBrowserStream and ConnectBrowserLiveViewStream. Also covers browser profiles (so a login is once-per-vendor, not once-per-conversation), a second VPC-mode browser resource with session recording, and axe-core as an extension. Two findings worth flagging independently of whether this ships: - The existing `live_view` action is broken in the way PR #1101 diagnosed. generate_live_view_url signs with SigV4QueryAuth, so the signature is in the query string, and browse_tool.py:288 returns it as text in a tool result -- the model will re-emit it truncated at the `?`. Max expiry is 300s, which is also far too short for a human login. - RBAC granularity is exactly one tool_id, so takeover must be its own registered tool rather than a browse_web action (D1). As an action it would ship to everyone who can browse, which is backwards: students should browse and should not be able to drive a browser inside our AWS account. As separate tools, an ungranted user pays zero prefix tokens for them. Measured prefix cost: browse_web 493 tokens today, request_user_login ~206, accessibility_scan ~191. A full human login round trip costs about one short tool result. The real spend for these agents is screenshots and accumulated page text, which is why D9 requires a 40-target sweep to be 40 sessions rather than one turn. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…r-question-trigger-guidance feat: make the clarifying-questions tool actually fire (PR-4)
Fold KB storage usage into the documents-list response so the agent's knowledge-base card can show how much of the byte cap is in use. Backend: - Add KbUsage model (engine, storedBytes, reservedBytes, cap, elevated) to DocumentsListResponse. - _resolve_kb_usage reads the KB_Record once: managed KBs report their bytes and the binding effective_cap (min of owner tier and per-KB ceiling); legacy S3-Vectors KBs are uncapped (cap=null). Best-effort so a record-read failure never breaks the documents list. Frontend: - Usage bar on the KB card: 'X of Y used' for managed KBs, green/yellow/red at <75/75-90/>=90% of the cap; legacy KBs show 'X stored', always green, no denominator. Tests: backend route tests for managed/legacy/failure paths; frontend component specs for the thresholds and the uncapped legacy case.
feat(kb): storage usage bar with byte cap on the KB card
…gent (#1109) Born-managed treated the absence of a KB_Record as 'brand-new agent' and provisioned a managed KB on the first upload. But legacy KBs are not first-class: they share one S3-Vectors index and never write a KB_Record, so an established legacy agent looks 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. Guard the record-is-None branch on an existing-documents check (assistant_has_documents: cheap COUNT, Limit=1, no ownership check). Only provision when the agent has zero documents; fail toward legacy on any probe error. Adds mutation-guard + helper unit tests (31 pass).
Byte tracking is scoped to managed KBs (Req 12.11), so a legacy KB reports zeroed counters and cap=null. The usage bar therefore rendered '0 B stored' beside real documents on every Classic agent, which reads as a bug. Gate showUsageBar on the managed engine (kbUsage.engine === 'managed') instead of merely 'kbUsage is present'. Managed KBs are unaffected; legacy KBs show only per-document sizes. Adds a spec asserting the bar (and its progressbar element) is absent for a legacy KB in edit mode.
Run the backend suite with pytest-xdist -n auto on the PR gate so the ~3k tests fan across all runner cores instead of running single-threaded. Also drop -v from backend/pytest.ini (it produced thousands of PASSED lines with no diagnostic value). The nightly coverage run (scripts/backend/test.sh) is left serial on purpose.
Make ts-jest transpile-only (isolatedModules) so jest workers stop re-type-checking the whole project each — the dominant cost of the infra suite (the long pole once backend went parallel in #1111). Add a single 'tsc --noEmit' (npm run build) step to the infra CI job so type safety is preserved on PRs (previously only ts-jest enforced it; tsc ran only in teardown.yml). Workers stay at 2 (the --maxWorkers bump regressed 2.5x in #1112). No const enum in the tree, so isolatedModules is safe. Verified: tsc clean; 178 tests green transpile-only.
…icated-web-assessment-spec docs(spec): authenticated web assessment via browser takeover
… one turn
Mentioning an Agent ran that turn as the Agent and silently reverted the next
one. 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.
D11 chose the per-turn reading deliberately, so this reverses a decision rather
than repairing an oversight. What changed is the evidence. Measured on prod
sessions-metadata (`turnAgentId` on the `C#` rows): of **247 mentions, 247
started the conversation**. Zero were mid-thread consults; zero mentioned a
second Agent inside a thread bound to a first. (Dev: 60 of 61.) The borrow was
paying an invisible failure mode for a case that has never occurred.
A mention now *means* "talk to this Agent", with two outcomes and no third:
* empty thread -> the Agent binds the conversation, exactly like launching
it from its card. Safe because there is no history for the
binding to misrepresent.
* has messages -> the message opens a NEW conversation with that Agent, and
the SPA says so. The Agent cannot be bound to history
written under other instructions, and must not be borrowed.
Persisting `preferences.assistant_id` is necessary but NOT sufficient, which is
the part that is easy to miss: every turn's Agent is resolved from the request,
the SPA's only carrier is the `assistantId` query param, and the self-heal
effect that refills it from preferences runs on session *load*. So the SPA now
sets the param and stops sending `agent_mention` at all. The backend still
honours that flag for clients that predate this change, and `binds_conversation`
gains `thread_is_empty` so a stale tab mentioning into a fresh thread lands
where a current one does. Its thread lookup runs only for mention turns, so no
bound-Agent turn pays a query it cannot act on.
Also fixed, because it is the same invisible loss on the path users are *told*
to use: "Continue" after a max_tokens truncation skipped the whole assistant
block, so a properly launched Agent finished its reply with none of its tools,
skills, model or instructions (spec-acknowledged as a known edge). The SPA was
already resending `rag_assistant_id` there — `continueTruncatedTurn`'s own
comment says "so the backend rebuilds the same model/tools/assistant agent" —
and only the `not is_continuation` guard discarded it. 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 "" and augment nothing).
A resume still skips the block and always did keep its tools: it rebuilds from
`PausedTurnSnapshot`, replaying the original turn's exact enabled_tools /
system_prompt / enabled_skills to reconstruct the same prompt-cache key.
Re-resolving there would risk a different effective set and orphan the paused
agent. Worth stating because resume rows carry no `turnAgentId`, so a census of
"turns with no Agent" reads them as losses and overcounts badly.
Two known costs retire with the borrow: the ~$0.12-per-mention prefix re-write
(a bound conversation swaps once and stays instead of swapping back), and the
history fork, where the mention agent and the plain agent were two cached
instances that never saw each other's turns.
The rule lives in one testable place at each end — `mention-routing.ts` on the
client, `agent_binding_policy.py` on the server — following the existing
`system_prompt_resolver` precedent: the rule is a handful of lines, the code
around it is a thousand, and a rule no test can reach is a rule that drifts.
Kept as one commit: the mention and continuation halves edit the same guards on
the same block, and splitting them would mean a first commit that knowingly
leaves the block wrong.
Backend 8585 passed / 3 skipped; frontend 3024 passed across 250 files; tsc
clean. Not exercised in a browser — this worktree's code is not what the local
stack serves, and that stack is down.
Spec: docs/specs/agent-marketplace.md (D11 + Phase 7 notes)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Swaps heroPaperAirplaneSolid for heroArrowUpSolid on the chat composer's submit button. Button geometry, colors, the stop-icon branch while streaming, and aria-labels are unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…poser-up-arrow-icon feat(chat): use up arrow instead of paper airplane for send button
…ention-binds-conversation fix(agents): an @-mention binds the conversation instead of borrowing one turn
The greeting heading sits in a 616px text column (720px column, 1rem side
padding, 56px logo, 1rem gap) at text-4xl/tight. Measured against that
column, 19 of 51 greetings wrapped to a second line — and because
AnimatedTextComponent types the greeting out a character at a time, the
wrap happens in full view and pushes the composer down mid-animation.
Rewrite the 20 offenders shorter, keeping the voice. Two of them are stock
DEFAULT_GREETING_TEMPLATES entries that brand.defaults.golden.spec.ts
pinned verbatim: "How can I help you today, {name}?" wrapped for any first
name of 8 characters or more, so the pin was preserving a bug. Update the
pin and say why. The worked example config and the rebranding README get
the same budget.
Add greeting-line-length.spec.ts to hold 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 <h1>; summed advances track browser
layout to within ±7px across 455 name/greeting combinations, which the 8px
tolerance covers. Verified in a browser that all 57 committed greetings
hold one line with a 12-character first name substituted for {name}.
Narrow viewports are deliberately out of scope: below ~720px the column is
the viewport, and no greeting worth writing fits a phone on one line.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…g-one-line fix(branding): keep every greeting on one line in the chat empty state
The agent can stop guessing. Clarifying questions ship end to end: an ambiguous
request pauses the turn, the SPA renders a multiple-choice picker inline, and the
answer resumes that same tool call — surviving a refresh. The tool worked from
PR-2; the model reached for it 4 times in 24 ambiguous requests. Rewording the
description and moving its position both stayed inside the noise band. A system-
prompt clause, appended only when the tool is in the turn's post-filter effective
list, took it to 24/24 on ambiguous requests and 0/18 on clear ones.
Admins can follow a cost number to its cause: the drill-down closes the gap
between "top users by cost" and the per-session anatomy — user → conversations →
session profile, with 15 diagnosis rules, a context trajectory chart and a
copyable diagnostic JSON. Content-free by construction, enforced by a denylist
test over every admin cost response model and a moto test that seeds real content
and proves none returns.
Two silent data bugs fixed, both invisible, cumulative and delayed:
* deleting a KB document mid-upload stranded its byte reservation forever —
every cancelled upload permanently shaved bytes off that assistant's
allowance, surfacing months later as "uploads stopped working"
* born-managed could not tell an established legacy agent from a new one
(legacy KBs share one index and write no KB_Record), so its next upload was
mistaken for a first and stranded the existing corpus
Generated-document links work again. The tool result had handed the model a
~1,400-char presigned URL that it re-emitted truncated at the `?`; signed URLs no
longer go anywhere they can be copied or persisted, which also drops that result
from ~1,500 to ~330 characters in the cacheable prefix for the life of the
session.
An @-mention now binds the conversation instead of borrowing one turn — measured
on prod, 247 of 247 mentions started the conversation, so the borrow bought
nothing and cost an invisible tool-loss failure. See Breaking changes.
No CDK deploy required: the only infrastructure diff is a jest config and a
version bump. One operator step per environment — enable the Clarifying Questions
tool, since the seed skips a tool row that already exists.
Gates: GSI update-limit PASSED (27 tables, no existing table needs more than one
operation); pending-backfills PASSED (no backfill script added); sync-version
--check PASSED.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| raise HTTPException(status_code=403, detail="Access denied.") | ||
|
|
||
| override_admin_auth(app, deny) | ||
| app.dependency_overrides[cost_routes.get_cost_service] = lambda: SimpleNamespace() |
| from types import SimpleNamespace | ||
| from unittest.mock import MagicMock | ||
|
|
||
| import pytest |
`_resolve_kb_usage` interpolated `assistant_id` — a user-controlled path parameter — straight into a `logger.warning` f-string, so `\r`/`\n` in it could forge additional log lines (CodeQL `py/log-injection`, alert #864, medium). New in this release: the sink arrived with the KB storage usage bar (8fb4f8f, #1108), on a branch parallel to the #1098 sweep that sanitized every other instance — so the release was about to ship a regression against a rule it enforces elsewhere. Fixed to that sweep's own convention: `%s` lazy formatting with `scrub_log()` on each user-influenced value. Scanned every logger call this release ADDED for the same shape; the rest carry exceptions, ints, or already-scrubbed values. The pre-existing f-string logger calls elsewhere in the tree are deliberately left alone — that is a sweep, not release-branch work. Backend suite: 8626 passed, 3 skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CodeQL triage — 1 alert left, and it is pre-existingThe ✅ Fixed —
|
| File | Count | Alerts | Lines |
|---|---|---|---|
.github/workflows/nightly.yml |
8 | 800–806, 810 | 267, 294, 298, 302, 336, 377, 433, 600 |
.github/workflows/tests.yml |
5 | 807, 809, 847, 848, 849 | 83, 86, 129, 132, 151 |
gh api "repos/Boise-State-Development/agentcore-public-stack/code-scanning/alerts?ref=refs/heads/main&state=open&per_page=100" \
--jq '[.[] | select(.rule.id|test("cache-poisoning"))] | group_by(.most_recent_instance.location.path) | .[] | {file: .[0].most_recent_instance.location.path, count: length}'The rule flags every step that runs where a poisoned cache could have been restored. The Test infrastructure (jest) job already carries the pattern on main — ref: ${{ inputs.ref }} at line 144, cache: 'npm' at 149 — and its Install step at line 151 is already flagged as alert 809.
This release adds one step to that job, the Type-check (tsc --noEmit) step from #1113 that preserves type safety now that ts-jest transpiles only. That new step becomes alert 863. Same job, same pre-existing pattern, one more step — the step count changed, the risk did not.
The actual mitigation is that nightly.yml, the only privileged caller passing a ref, resolves track tokens through a shell case assigning literal main / develop and hard-exit 1s on anything else. CodeQL cannot see through a shell case, which is stated outright in the docstring of backend/tests/supply_chain/test_nightly_ref_allowlist.py — the guard added in this release (#1098) that pins the allowlist so widening it can't silently make the standing finding real.
Recommendation
Merge. The one real finding is fixed; the remaining high is an accepted, tested, pre-existing pattern that this release inherits rather than introduces. main is not branch-protected, so the red check does not block — but it is a knowing call, hence this comment.
Worth noting for the reviewer: codeql.yml only runs on main, so a release PR is always the first time a release's code meets CodeQL. Triage each alert against main's own baseline rather than treating the batch as one verdict — this PR is exactly the case where a real finding and a stale one arrived together, and the stale one is what tempts you to wave both through.
🤖 Generated with Claude Code
Release v1.22.0 — cut from
developatdeeddbf4, carrying 16 PRs since v1.21.0.Pre-merge gates
gsi-inventory.jsonis unchanged in this range.backend/scripts/backfill_*.pyadded.sync-version.sh --checkWhat's in it
Clarifying questions, end to end — the agent pauses an ambiguous turn, the SPA renders a multiple-choice picker inline, the answer resumes that same tool call, and a refresh rehydrates a pending prompt. The tool worked from PR-2; the model reached for it 4/24 on deliberately ambiguous requests. Rewording the description (44–56%, inside a 17–44% baseline band) and moving its position in the list (25–38%) both stayed in the noise. A system-prompt clause — appended only when the tool is in the turn's post-filter effective list — took it to 24/24 ambiguous, 0/18 clear.
Admin cost drill-down — user → conversations → session profile, with 15 diagnosis rules, a per-call context trajectory chart and a copyable diagnostic JSON. Content-free by construction: a denylist over the session/cost/upload row families, enforced by a test that walks every admin cost response model (one named exemption) and a moto test that seeds real content and proves none returns. Backed by a new content-free tool census and compaction counter — additive attributes on rows the turn already writes, so no table, no index, no backfill, and nothing reaches the prompt.
Two silent data bugs, both invisible / cumulative / delayed:
KB_Record, so an established agent looked new and its next upload flipped retrieval to an empty managed KB, stranding the corpus.Generated-document links work again. The tool result handed the model a ~1,400-char presigned S3 URL that it re-emitted 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, in the cacheable prefix, for the life of the session.@-mention binds the conversationA mention no longer runs one turn and reverts. Empty thread → the Agent binds it; thread with messages → the message opens a new conversation with that Agent. This reverses design decision D11 on new evidence: of 247 prod mentions, 247 started the conversation (dev: 60/61). It retires a failure where the Agent's tools vanished silently after turn one (
Unknown tool: create_rubric, with the model confidently blaming a setting that was already correct), plus the ~$0.12-per-mention prefix re-write and the history fork. No migration needed.🚀 Deployment
Skip
platform.yml— the onlyinfrastructure/diff isjest.config.jsand the version bump. Deploybackend.yml→frontend-deploy.yml.One required operator step, per environment: enable the Clarifying Questions tool.
seed_bootstrap_data.pyskips any tool row that already exists, so the seed'senabledByDefault: Truereaches a fresh bootstrap only. Where the row exists, an admin must flip it on; where it does not, run the seed. Roles without a*tool grant needask_user_questionadded to the role record (theallowedAppRolesprojection grants nothing). Full instructions in the release notes.Two new env vars, both default on when unset (
ASK_USER_QUESTION_ENABLED,COST_DIAGNOSTICS_ENABLED), so neither needs plumbing to ship.Also in this release
-n auto); infra jest transpile-only with a separatetsc --noEmitpreserving type safetyFull detail: RELEASE_NOTES.md · CHANGELOG.md
Merge with a squash. After merge, the backmerge
main→developis required (merge commit, not squash).🤖 Generated with Claude Code