Skip to content

Release/1.22.0 - #1117

Merged
philmerrell merged 2547 commits into
mainfrom
release/1.22.0
Sep 15, 2026
Merged

philmerrell merged 2547 commits into
mainfrom
release/1.22.0

Conversation

@philmerrell

Copy link
Copy Markdown
Contributor

Release v1.22.0 — cut from develop at deeddbf4, carrying 16 PRs since v1.21.0.

Pre-merge gates

Gate Result
GSI update-limit (§1) ✅ PASSED — 27 tables compared; no existing table needs more than one GSI operation. gsi-inventory.json is unchanged in this range.
Pending backfills (§1b) ✅ PASSED — no backend/scripts/backfill_*.py added.
sync-version.sh --check ✅ PASS — all manifests and lockfiles at 1.22.0.

What'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:

  • 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" with no failure near the deletes that caused it.
  • Born-managed provisioned over an established legacy agent — legacy KBs share one S3-Vectors index and write no 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.

⚠️ Breaking change — @-mention binds the conversation

A 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 only infrastructure/ diff is jest.config.js and the version bump. Deploy backend.ymlfrontend-deploy.yml.

One required operator step, per environment: enable the Clarifying Questions tool. seed_bootstrap_data.py skips any tool row that already exists, so the seed's enabledByDefault: True reaches 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 need ask_user_question added to the role record (the allowedAppRoles projection 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

  • KB storage usage bar with byte cap on the agent's KB card (managed only — legacy is uncapped)
  • Log-injection sinks sanitized across 9 modules; nightly ref allowlist pinned by a test
  • CI: backend pytest parallelized with xdist (-n auto); infra jest transpile-only with a separate tsc --noEmit preserving type safety
  • 5,500+ lines of new tests — 17 backend modules, 9 frontend specs

Full detail: RELEASE_NOTES.md · CHANGELOG.md


Merge with a squash. After merge, the backmerge maindevelop is required (merge commit, not squash).

🤖 Generated with Claude Code

philmerrell and others added 30 commits September 6, 2026 21:07
…-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.
…-refresh

docs(kb): HANDOFF reflects §5.40 (#997) and §5.33 (#998) shipped
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>
philmerrell and others added 22 commits September 14, 2026 12:31
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>
@philmerrell
philmerrell requested a review from a team September 15, 2026 04:53
Comment thread backend/src/apis/app_api/documents/routes.py Fixed
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>
@philmerrell

Copy link
Copy Markdown
Contributor Author

CodeQL triage — 1 alert left, and it is pre-existing

The CodeQL check is red. It is the only red check; the other 14 pass. Two alerts were reported, and they needed opposite responses — posting the triage so a reviewer doesn't re-derive it.

✅ Fixed — py/log-injection (medium, alert 864) was a real regression

backend/src/apis/app_api/documents/routes.py:129 interpolated assistant_id — a user-controlled path parameter — straight into a logger.warning f-string, so CR/LF in it could forge log lines.

It arrived with the KB storage usage bar (8fb4f8f, #1108), on a branch parallel to the #1098 sweep in this same release that wrapped every other such sink. So the release was about to ship a regression against a rule it enforces elsewhere. Fixed in 3e30072 to the sweep's own convention — %s lazy formatting with scrub_log() on each user-influenced value. The check summary went from 2 new alerts including 1 high to 1 new alert including 1 high.

I also scanned every logger call this release adds 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 untouched — that is a sweep, not release-branch work.

⚠️ Not a regression — actions/cache-poisoning/poisonable-step (high, alert 863)

.github/workflows/tests.yml:157. This alert does not represent new risk, and it will not clear by editing this branch.

main already has 13 open alerts of this exact rule family, verified just now:

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 mainref: ${{ 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

@philmerrell
philmerrell merged commit e01bd1b into main Sep 15, 2026
14 of 15 checks passed
@philmerrell
philmerrell deleted the release/1.22.0 branch September 15, 2026 16:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants