Skip to content

chore: backmerge main into develop after 1.26.0 - #1426

Merged
philmerrell merged 2 commits into
developfrom
backmerge/main-into-develop-1.26.0
Oct 3, 2026
Merged

philmerrell merged 2 commits into
developfrom
backmerge/main-into-develop-1.26.0

Conversation

@philmerrell

Copy link
Copy Markdown
Contributor

Backmerge after the 1.26.0 release (#1424, squash commit 8565b581 on main).

  • The merge had no conflicts. It changes only the release artifacts: VERSION, CHANGELOG.md, RELEASE_NOTES.md, README.md, the manifests and the lockfiles. sync-version.sh --check passes.
  • origin/main is an ancestor of this branch, and the tree is identical to main.

Merge with a MERGE COMMIT, not a squash. The point is to make main an ancestor of develop. A squash recreates the divergence, and the next release would conflict on these files again.

🤖 Generated with Claude Code

philmerrell and others added 2 commits October 3, 2026 10:08
* feat(compaction): offline quality-veto harness (slice 1)

Adds backend/scripts/compaction_quality_harness.py and the
compaction_quality package: a seeded corpus of long editing sessions with
planted, exactly-scorable facts (constraint, decision, reference,
superseded), replayed through the production TurnBasedSessionManager under
each arm (full, model_relative, legacy, floor_50) and pace (restore, cold,
warm). Only I/O is stubbed; the cut, the deferred apply, the restore slice,
anchor truncation and bound_summary are the real code.

Steps: cut (free, writes probe-time histories plus a plant-availability
table), records and ask (Bedrock spend, estimate-first, --yes to run, a
cachePoint after the shared history), score (exact match, paired per family
against the full-history control, exact McNemar, n on every row).

The clock stub stamps real time on save and inserts the pace gap only
between turns; backdating every save hid the restore-path missed free
apply, which a tripwire test now pins until it is fixed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(compaction): record the harness, the control, and the prod readout

Spec §5 gains a status note above the waiver: the harness exists, the
control is full history rather than the kill switch, and the first full run
waits for the missed-free-apply fix. The scoping doc marks slice 1 built,
answers its open questions, and records the aggregate prod readout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(compaction): read the head-of-turn cache gap from before any save

Every _save_compaction_state stamps updated_at. On a restore,
_apply_compaction advanced the truncation anchor (a save) before the
coordinator called apply_pending_compaction, which then read a ~0s gap and
left a parked cut waiting on a cache that was already cold; it landed only
at the paid hard-ceiling apply, after the session had also paid the anchor
re-write. apply_document_offload had the same defect after any apply that
itself saved.

The restore slice now captures the previous turn's stamp before the anchor
advance (_restore_turn_stamp); apply_pending_compaction consumes it every
turn into _turn_start_stamp, and _document_offload_reason reads that first.
Both stamps are cleared at turn end so an old stamp can never make a warm
turn look cold.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(compaction): flip the harness tripwire now the restore path applies

The quality harness's restore pace pinned the missed free apply; with the
turn-start gap fix it applies the parked cut on cache_expired. Spec §5 and
the scoping doc record the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: teardown polls long enough for one run; name the Agents page icon actions

- kb_migration teardown passes its own 780 s poll window to the delete
  saga. Managed KBs on dev took ~10 minutes to delete, so at the shared
  480 s every teardown needed two worker runs (~30 min). The shared
  default stays 480 s for the reconciler, which deletes several per run.
- Agents page Chat/Edit/Share/Delete icon buttons (grid and list) get
  aria-labels naming the agent; the tooltip only set aria-describedby.
- Kaizen queue: the production orphaned-agent cleanup chore, blocked on
  #1293 and #1301 reaching main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(memory): canonical memory-file format and save validation (Shared Projects 2.3a)

Pure library for the canonical memory-file format (spec §4.2) and the
manifest-aware save checks (§4.3 steps 1-4), plus a bounded CountTokens
counter in apis.shared for save-time token accounting. No callers yet;
2.3b wires these into the save path.

- format.py: system-rendered frontmatter (YAML subset, no new dependency),
  items with ULID anchors, links, aliases, slug rules, reserved MEMORY.md.
- validation.py: locked-field echo rule, name/alias collisions, anchor
  known/unique/minted, link resolution incl. archived targets; only new
  dead links fail.
- tokens.py: one-attempt, 2 s CountTokens with a labelled chars/4
  fallback; MEMORY_TOKEN_COUNT_MODEL_ID="" turns it off (tests do).
- Hypothesis properties for round trips, anchor stability and link
  resolution.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(memory): save pipeline and FILEVER history (Shared Projects 2.3b)

Wires the 2.3a format and validation into every Memory Space write path
and adds per-file version history.

- MemorySpaceService.save_entry: validate, count tokens once
  (CountTokens), write the object, conditional manifest swap, then the
  FILEVER row. The swap is the commit point, so version numbers cannot
  collide; replaced objects are kept because history references them.
- fileFormat freeform|canonical on the space, set at create. Existing
  spaces stay freeform (stored as written; warnings only). Canonical
  spaces enforce items, anchors, links, collisions and the 8,000-token
  hard cap; a concurrent save of the same file is a 409.
- Pre-history entries get a baseline version on first replacement.
  delete_entry purges its history (dedup-aware); delete_space pages its
  rows and purges every object; consolidate never collects history.
- MEMORY.md is reserved in the service on every path.
- API: PUT entries returns version/tokens/warnings/anchors; new
  GET /memory/spaces/{id}/history?slug= and /history/{n}?slug=.
- memory_write records reason "save" and no longer sends an empty
  description (tool spec text unchanged, so toolConfig is unchanged).
- app-api task role gains bedrock:CountTokens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(observability): keep conversation content out of runtime OTEL logs

Under AgentCore, ADOT writes every prompt and reply into the runtime log
group's otel-rt-logs stream from two scopes:

- strands.telemetry.tracer: Strands puts messages on span events and
  ADOT's LLOHandler re-emits them as log records. Setting
  OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_unredacted_attributes= turns on
  Strands' redaction with nothing exempt.
- botocore bedrock-runtime: ADOT defaults
  OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT to true with
  setdefault, so the image sets it to false first.

Both are image ENV rather than Runtime variables, because the Runtime's
50-variable cap is nearly spent. A Runtime variable still overrides them.
The new test drives a Strands turn through the real tracer and
LLOHandler, and the botocore event builders, against the Dockerfile's
values. Each case has a capture-on control.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Wire PLATFORM_SELF_SERVICE_ENABLED flag into infra (dev-only, opt-in) (#1314)

Adds the CDK plumbing so the platform self-service flag can be turned on per deployed environment (it was code-only before, readable from os.environ but never set on the runtime).

- config.ts: PlatformSelfServiceConfig + opt-in parse (CDK_PLATFORM_SELF_SERVICE_ENABLED, only literal 'true' enables; mirrors projects).
- inference-agentcore-construct.ts: sets PLATFORM_SELF_SERVICE_ENABLED on the AgentCore Runtime (read only there). 47->48 of 50 env slots; guard test reports 2 free.
- platform.yml: forwards the CDK var from the GitHub environment.
- mock-config.ts: default off.

Default OFF everywhere; prod stays dark unless its own env var is set. Not wired into app-api (it never reads the flag). Verified: tsc clean, env-var-limit + config tests pass (198).

* test(infra): find app-api's CountTokens grant across every IAM resource type

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(compaction): harness arms and knobs for scoring summary compression

- raw_summary arm: the production cut with the summary budget lifted, so
  comparing it with model_relative isolates what bound_summary's Nova Micro
  compression costs, separately from the cut.
- records: --record-words sets the per-record word cap, and --workers runs
  transcripts in parallel. One record per turn at 600 words reproduces
  prod's ~20k-token LTM records at cut time, which is what makes the
  compression run.
- ask: --workers parallelizes the questions on one history after its first
  call has written the cache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(observability): widen unhealthy-host alarm to a sustained 20-min window (#1319)

The alb-unhealthy-hosts alarm paged on any single App API target failing one health check for 10 min (evaluationPeriods:2). ECS self-heals such a target in ~5 min while the other targets keep serving, so this fired ~daily on benign, zero-impact blips. Require 4 consecutive 5-min periods (20 min) instead. Threshold stays 0 and treatMissingData stays BREACHING, so a stuck target and a total outage both still page. Window-not-threshold per observability.md section 11.

* fix(ingestion): skip agent icon uploads instead of ingesting them as documents

Agent icons live in the RAG documents bucket at
assistants/{id}/icons/{digest}.{ext}. The legacy ingestion parser read that
4-part key as document_id="icons" and left a failed DOC#icons row on the
agent. Both ingestion paths now parse strictly and skip (info log, success,
no row written) anything that is not assistants/{id}/documents/{docId}/{file}.
The managed-KB EventBridge rule is narrowed to assistants/*/documents/*.

Adds backend/scripts/cleanup_stray_doc_rows.py (report-only by default) to
remove the stray rows already written on live agents.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(memory): production read-path findings for the Phase 0 baseline

The ~1,300 retrieval failure lines in the production runtime log are 745
events (each logged twice) and none is a throttle. 476 turns lost
retrieval to dead pooled connections on cached agents after ~6 min idle,
masked by a handler bug that discards every namespace's results; 84 more
sent a query over the 10,000-character limit. Documents the evidence and
a recommended fix (not implemented).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(kb): stop the reconciler resurrecting torn-down KB records

The daily reconciler snapshots every KB_Record and then walks AWS. A teardown
that finished in between left the run holding a record whose knowledge base
was gone, and mark_vector_state_missing's unconditional UpdateItem recreated
it as a ghost KB# item holding only vectorState/updatedAt. That's the orphaned
row the teardown exists to remove. refresh_stored_bytes had the same upsert
hazard, and both run in report-only mode too.

- records.update_if_present: an update_item guarded on attribute_exists(PK)
  that returns False (logged at info) instead of creating a removed record.
- reconciler: both record-side writes go through it and are only counted
  when they land. Records in migrationState=teardown are neither marked
  missing nor refreshed, and are listed in ReconcileReport.tearing_down
  (tearingDown in the serialized report).
- Same guard on the other unconditional KB# writes that could outlive a
  teardown: set_resource_policy_state (both branches; REMOVE-only upserts
  too), byte_cap commit/release/reserve (reserve tells "record gone" apart
  from "over the cap" via ReturnValuesOnConditionCheckFailure), and the
  worker's _set_retain_until and _record_progress.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(a11y): name icon-only buttons on admin connector pages (#1318)

appTooltip only sets aria-describedby, so the icon-only buttons on the
connector list and form were announced as just "button" (axe button-name,
critical). Add aria-labels following the Agents page pattern: row actions
name their connector ("Delete Example Drive"), the secret toggle names its
current action, and the callback-URL copy buttons say what they copy. Also
names the row Edit link (same defect, axe link-name) and adds type="button"
where missing. Specs assert every icon-only control has an accessible name.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* docs(compaction): the quality veto ran — the cut passes, the compression fails

Spec §5's waiver is replaced by the first full harness result (Haiku 4.5, 12
transcripts x 9 planted facts, k=3, prod-sized LTM-style records). The
model-relative cut with an uncompressed summary matches the full history;
the same cut with bound_summary's Nova Micro compression (~15k -> ~730
tokens, reproducing prod's ~20k -> ~760) loses standing instructions
(1.00 -> 0.72, p=0.002) and exact identifiers (0.96 -> 0.58, p=0.004).
The tuning rule's FLOOR_RATIO lever is scoped to retained-turn loss. The
scoping doc's §8 records method, spend (~$16) and the candidate levers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(shared-projects): record 2.3 dev validation and carry 2.4 follow-ups

Adds a "Dev-validated 2026-09-25" entry to the 2.3b as-built notes with
the observed numbers (counted tokens, versions, rejections, purge), and a
"Carried from 2.3" list under 2.4: canonical project spaces, the
anchor-stripping decision for injected memory, the counter cold start,
and the deferred items.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fix: grant runtime write access to user-settings for set_default_model (#1325)

The platform self-service set_default_model tool calls UserSettingsRepository.update_settings (PutItem + conditional UpdateItem), but the inference/AgentCore runtime IAM role granted only GetItem on the user-settings table. The write AccessDenied'd and the tool swallowed it into a generic error.

- inference-api-iam-roles.ts: broaden the user-settings grant to GetItem + PutItem + UpdateItem (renamed sid to UserSettingsTableReadWriteAccess), scoped to the one table ARN, no DeleteItem, no wildcard.
- security-policy.test.ts: update the regression guard to assert read+write (no delete, no wildcard).
- account_tools.py: set_default_model now surfaces the real error type (e.g. AccessDenied) instead of a generic 'try again', so a hidden infra gap is diagnosable from the tool result.

* feat(compaction): screen candidate summarizers in the quality harness

Arm gains an optional summarizer with bound_summary's signature, patched
into turn_based_session_manager for that arm only and restored after, so a
candidate runs behind the production cut. summarizers.py adds two
prototypes:
- compress_only: production's prompt, temperature only. Production's
  compress_with_model also sends topP, which Claude 4.5+ rejects.
- extract_then_compress: a verbatim pinned block of instructions,
  decisions, identifiers and changed values, then the narrative compressed
  into the rest of the budget.

New arms: nova2lite_compress (production code, other model id),
haiku_compress, extract_nova_micro, extract_nova2lite and extract_haiku.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(compaction): screening results — Nova 2 Lite, and extract-then-compress

Scoping doc §9: three repetitions per candidate on the free availability
check. Production's Nova Micro compression keeps 76.5% of planted facts
(identifiers 58%). Nova 2 Lite on the unchanged production path keeps
98.8%, and extract-then-compress on Nova 2 Lite keeps 100% in every run,
at ~1.9k summary tokens and about $0.02 per cut. Also records that
compress_with_model cannot run a Claude summarizer (temperature plus topP
is rejected, and it silently falls back to truncation).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(inference-api): keep MCP tool arguments and results out of trace spans

ADOT 0.19 loads its own MCP instrumentor (entry point aws_mcp), which sets
gen_ai.tool.call.arguments and gen_ai.tool.call.result on every tools/call
span with no capture gate. Those spans go to aws/spans, so neither switch
from #1317 reaches them.

Set AWS_AGENTIC_INSTRUMENTATION=disabled as image ENV beside #1317's two
switches, so the distro skips ADOT's native agentic instrumentors at
startup. Only mcp applies to this app. Strands' execute_tool spans
(already redacted) and its own _meta trace-context injection are
unaffected.

Extend test_otel_content_capture.py to load aws_mcp through
AwsOpenTelemetryDistro.load_instrumentor with the Dockerfile's value and
drive a real in-memory MCP tools/call: no sentinel and no MCP-scope span.
The control keeps #1317's switches on and drops only the new one, and the
sentinels appear on the MCP spans' arguments and result.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(compaction): default the summary model to Nova 2 Lite

Nova Micro compressed LTM summary records to ~760 tokens and dropped about
a quarter of standing instructions and 42% of exact identifiers on the
quality harness. Nova 2 Lite runs on the unchanged bound_summary (Nova
accepts topP) and keeps ~99% of planted facts for ~$0.01 per cut.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(harness): add a pinned Nova Micro compression arm

model_relative now follows the new Nova 2 Lite default, so the old
baseline needs its own arm to stay comparable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(kb): stop late writes recreating removed KBTOMB# and DOC# rows

UpdateItem is an upsert, so a write aimed at a removed row recreates it as a
ghost holding only the attributes it set. #1322 guarded KB# records; this
extends the guard to the two row types it left out.

- records.update_if_exists(key, ...): key-taking form of update_if_present,
  which now delegates to it (signature unchanged).
- tombstones.record_tombstone_error: guarded. A saga failing after a
  concurrent saga cleared the tombstone no longer leaves a ghost with no
  intent/AWS id and no TTL in iter_tombstones. _write_tombstone stays an
  intentional upsert.
- byte_cap.settle_once: ANDs RECORD_EXISTS in and returns False for a
  removed DOC# row (ALL_OLD distinguishes it in the log). Previously the
  claim succeeded on a missing row, recreated it, and let a late ingestion
  commit bytes the document delete had already released.
- ingestion_consumer.set_document_terminal: guarded, returns bool. A removed
  row is a logged skip: no retry, no raise, no DLQ.
- provisioner._ingest_waiting skips ingesting a row deleted mid-provisioning;
  document_reconciler._plan does not count a skipped write as performed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: send temperature without topP on side-channel converse calls

Claude 4.5+ rejects `temperature` and `topP` in the same inferenceConfig
with a ValidationException. compress_with_model swallows every exception
and returns None, so a Claude AGENTCORE_MEMORY_COMPACTION_SUMMARY_MODEL_ID
silently degraded every compaction summary to newest-first truncation of
the raw records (measured in the compaction quality harness).

Drop topP from all four side channels that hardcoded the pair: the
compaction summary, tool-batch summaries, document abstracts and session
titles. The latter three run on Nova today, but each has the same
fail-quiet shape, and the document digest's model id is env-configurable.

Tests stub Bedrock with Claude's rejection rule so the old config fails
them and the fixed one passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(memory): project-scoped memory spaces (Shared Projects 2.4a)

A project now owns a canonical shared Memory Space, and each member can
have a personal_in_project space. Both take every role from the project:
resolve_permission reads project membership for project scopes, no
MEMBER# rows are written, and nobody resolves owner on a project space.

- META gains scope/projectId/userId; project scopes stay out of
  OwnerIndex, so they never show in personal space lists or the binding
  picker.
- create_project creates the shared space (rolled back with the
  harness); GET /projects/{id}/memory backfills older projects through a
  conditional, version-bumping pointer write.
- POST /projects/{id}/memory/mine creates the caller's own space on
  first use (PERSONAL_SPACE# pointer via an UpdateItem upsert).
- Rename and purge follow the project; direct share/leave/delete are
  refused; agent bindings to a project space are refused at design time
  and dropped by the runtime resolver.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(kaizen): queue the prod check for the reconciler teardown guard

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(agents): delete an agent's icon objects when the agent is deleted

Agent delete (DELETE /agents/{id}, DELETE /assistants/{id}) and the project-harness
purge never deleted assistants/{id}/icons/*, so every deleted agent with an icon left
its objects in S3 with no row left to find them by.

- IconStore.delete_prefix + AgentIconStore.delete_all + delete_agent_icons (best
  effort, never raises), called after the record delete on both paths, and on a
  retried harness purge.
- cleanup_orphaned_agent_rows.py --s3-prefixes: finds assistants/{id}/ prefixes
  whose agent has no row at all; report-only unless --apply --confirm-prefix, keeps
  any key a row still names, re-checks the partition before deleting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(infra): sweep retention onto every AgentCore Runtime log group generation

The RuntimeLogRetention custom resource sets retention on the live
runtime's -DEFAULT group once per deploy. A replaced Runtime gets a new id
and a new log group, and the old group drops out of CDK's view with
whatever retention it had. Groups orphaned before the custom resource
existed never expire, and they hold conversation text. In an account whose
landing zone stamps retention on every CreateLogGroup event, the
deploy-time value is also overwritten minutes later, even on the live group.

Add a daily Lambda that lowers every group under
/aws/bedrock-agentcore/runtimes/<runtime-name>- to
observability.logRetentionDays when its retention is unset or longer.
Runtime names cannot contain '-', so the prefix matches every generation of
this deployment's Runtime and no other deployment's. PutRetentionPolicy is
IAM-scoped to that prefix; the function cannot delete groups or read events.

On by default with a kill switch,
CDK_OBSERVABILITY_RUNTIME_LOG_RETENTION_SWEEP_ENABLED=false.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(memory): production record census for the Phase 0 baseline

Adds the operator-run audit.py inventory aggregates to the decision
record: prod writes and extracts like dev at ~35x the scale (2,770
actors, 85,382 records, 0 session-id fallback actors). Notes 112
unreachable records under 13 legacy-format actor ids and 7 extraction
jobs failed with LTM_RATE_EXCEEDED; both are operator decisions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(compaction): record the Nova 2 Lite summary-model confirmation run

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(infra): expire noncurrent versions in the RAG documents bucket

The documents bucket is versioned with no lifecycle rules, so every
delete the app makes (document cleanup, icon replace/remove, agent-delete
icon cleanup, the orphaned-row cleanup script) only adds a delete marker
and the bytes stay forever as noncurrent versions.

Add one rule: noncurrent versions expire after 35 days (matching the
assistants table's PITR window, so a PITR-restorable DOC# row still has
its source bytes), orphaned delete markers are removed, and incomplete
multipart uploads abort after 7 days. Nothing reads by VersionId, and
both ingestion paths address current objects by key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(compaction): attach post-turn cut events to the call that triggered them

A cut is decided by update_after_turn after the turn's last model call and
waited in the session manager's in-memory queue for the next call on the
same instance. Prod runtime logs show 32 of 81 cuts never got one: a new
microVM or agent-cache miss (fresh, empty queue) or an abandoned session.
The coordinator now hands them to ContextLedgerHook before the turn's C#
rows are written. The fleet readout's distance-from-cut now keys on the
'applied' event, the first call sent on the sliced history.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(costs): build the prefix split from native counts only

Claude Sonnet 5 has no CountTokens, so every count is Strands' heuristic
(JSON at chars/2) and the tools residual overshot the whole prompt. The
attribution hook now requires token_count_is_authoritative (which reads the
SDK skip list) and compares heuristic_count_fallbacks around every count it
relies on, deferring a tainted split to the next turn.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(observability): carry sessionId on compaction EMF records

Every AgentCoreStack/Compaction record the session manager emits (cut,
apply, truncation anchor, document offload) now has the conversation id as
a log property, the way the prompt-cache records do. It is not a
dimension, so no per-session metric streams. The 2026-09-25 readout had to
join cuts to sessions by matching input tokens within 30 minutes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(compaction): record root causes of the 2026-09-25 telemetry gaps

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(memory): reconnect long-term memory retrieval on a dead pooled connection

Production lost long-term memory retrieval on ~10% of turns (Phase 0
audit, docs/specs/memory-baseline-decision.md "Production (read-only)"):

- A cached agent keeps its retrieval client between turns. After ~6 min
  idle its pooled connection is dropped on the network path, and with
  total_max_attempts=1 (#1157) the next request failed within ms. Retry
  once, immediately, on a fresh client for ConnectionClosedError and
  SSLError. Throttles are still never retried.
- ConnectionClosedError/ReadTimeoutError carry response=None; the
  per-namespace handler raised on it and the outer handler discarded
  every namespace's results. Guard it and log the exception class.
- Cut searchQuery to the API's 10,000-character limit (counted in UTF-16
  code units) instead of failing with ValidationException.

audit.py counts the new "memory retrieval reconnected" log line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(compaction): fix stale topP note on the summary model default

#1330 dropped topP from compress_with_model, so a Claude override no
longer fails; the comment merged in #1334 predates that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(compaction): pin verbatim facts ahead of the compressed summary

bound_summary(extract_enabled=True) makes one extraction call (standing
instructions, decisions, identifiers, changed values at their latest
value) into a pinned block capped at half the budget, then compresses the
narrative into the rest with temperature only. Extraction failure falls
back to plain compression, narrative failure to truncation; it never
raises. The result is persisted verbatim, so restore bytes stay stable.

Gated by COMPACTION_SUMMARY_EXTRACT_ENABLED, in development and default off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(infra): wire CDK_COMPACTION_SUMMARY_EXTRACT_ENABLED to the runtime

Opt-in while in development: only "true" enables. Runtime only, since
compaction runs in the AgentCore Runtime. Takes the runtime to 49 of its
50 environment variables in the worst case the ceiling test builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(compaction-harness): screen extract-then-compress from production code

The extract_* arms set summary_extract_enabled on the config instead of
swapping in the summarizers.py prototype, which is deleted. compress_only
now calls production's compress_with_model with top_p=None.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(compaction): extract-then-compress clears the quality veto

Paid harness run: extract + Nova 2 Lite matches the full history in every
family (0 losses), while today's Nova Micro compression loses constraints,
decisions and identifiers. Records the cut-turn latency (11-23 s, after
the final metadata event, so no TTFT change).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(scripts): forward the runtime log retention sweep kill switch as CDK context

#1332 added observability.runtimeLogRetentionSweepEnabled but skipped
build_cdk_context_params(), the fourth of the five places a new
observability tunable must touch. The env var still took effect in CI
because config.ts reads it first; a local deploy through load-env.sh
did not pass it on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(kaizen): queue the runtime log retention follow-ups

Records the dev validation of #1332 and queues the four follow-ups:
the production one-off, verifying the sweep in production after release,
the false 'PutRetentionPolicy creates the group' claim, and the
development account's 10-year retention default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(kb): don't ingest or revive a document deleted before its event lands

A late or redelivered ingestion event can outlive the document it is for.
#1327 stopped it recreating a removed DOC# row; two gaps remained:

- handle_object still submitted a deleted document to Bedrock, putting its
  content back into the knowledge base after cleanup removed it. It now
  returns early when the row is gone or `deleting`. Every producer writes
  the row before the S3 object, and the read is now strongly consistent.
- set_document_terminal could flip a soft-deleted (`deleting`) row back to
  complete/failed/uploading. The retrieval filter joins on that row, so a
  deleted document whose cleanup failed became retrievable again for its
  whole TTL. The write is now also conditioned on status <> deleting.

The document reconciler's NOT_FOUND re-ingest re-reads the row before
submitting, closing the same window between its scan and the ingest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(memory): scope-labelled project blocks, anchor stripping and a lean project index read

render_memory_block varies its intro by scope and may omit the space name;
render_project_memory builds the project (2,000-token) and mine (1,000-token)
blocks with anchors stripped, and renders nothing for an empty or starter
index. MemorySpaceService.read_project_space_index reads a project space's
MEMORY.md for a caller whose project role is already settled.
MemoryEntryNotFoundError tells a missing entry from an unreachable space.
An Agent's block bytes are unchanged (hash-pinned).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(agents): scope-addressed memory tools for the project harness

memory_list/read/query/save with scope in {project, mine}. A save to mine
creates the member's personal space on first use; a viewer saving to
project is pointed at mine; an archived project says it is archived.
Ordinary Agents' memory_list/read/write specs stay byte-identical
(SHA-256 pinned). memory_query and memory_save count as Memory tools in
the context breakdown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(inference-api): wire project memory into harness turns

The harness turn reads its project's shared and personal MEMORY.md
concurrently, started as soon as the project is known and awaited at
prompt assembly, and sends them as memory_context behind the fourth cache
point. The multi-scope memory_binding digest keys harness agents; the 2.1
binding digest is unchanged, and PausedTurnSnapshot needs no new field.
Tool construction is memoized per member and spaces.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(projects): record 2.4a dev validation and 2.4b as built

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(compaction): run extraction and narrative compression concurrently

Split the summary budget up front (pinned block at most half, narrative the
rest) so the two calls run side by side. A cut now costs the slower call
rather than the sum: on prod-sized records the median fell from 11.6 s to
8.4 s, against 7.6 s for the single-call default. An extraction failure
keeps the narrative as a plain compression instead of making a third call,
so a cut never makes more than two.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(compaction): keep each identifier's label in the extraction

The harness caught the extractor copying a value verbatim under a new name
("Budget reference" for the budget workbook version tag), which the
answering model could not tie back to the question. Ask for each
identifier with the conversation's own name for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(infra): tolerate a missing runtime log group in RuntimeLogRetention

PutRetentionPolicy does not create the log group: on a missing group it
returns ResourceNotFoundException and creates nothing. Deploys succeed
only because the Runtime's execution role creates the group first; if
that ever loses the race, the custom resource fails and the stack update
rolls back.

Both calls now set ignoreErrorCodesMatching ResourceNotFoundException;
the daily retention sweep sets retention on a group missed this way.
Drop the unused logs:CreateLogGroup grant, correct the comment and
observability.md section 9, and add jest assertions for both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(kaizen): record #1345 against the runtime log retention race

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(compaction): record the concurrent extract-then-compress rescore

Concurrent calls bring a cut to the default's latency (median 8.4 s vs 7.6 s,
11.6 s sequential) and keep the no-loss result against the full history.
Records the identifier-labelling defect the paid run caught, that the
prompt fix is unproven, and the narrative's output-cap discard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(kaizen): queue the RAG documents bucket lifecycle follow-up

Dev-validated #1336 (rule live, matches the template). Queue the checks
that can't happen on deploy day: confirm the dev drain after S3's
asynchronous lifecycle pass, and confirm plus inventory production
after the release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(kb): refund managed-KB bytes on delete and settle migrated corpora

Deleting a document that reached `complete` never returned its bytes: the
delete path only released reservations under settle_once, and a completed
document was already settled. The ingestion consumer now stamps
`committedBytes` on the DOC# row before the KB# commit (refused on a gone or
`deleting` row, in which case it releases instead), and the delete claims
`byteCapRefunded` once and refunds exactly that amount from storedBytes and
totalBytes. Marker writes are guarded on the row existing, so nothing
recreates a ghost DOC# row.

The reconciler's refresh re-anchored storedBytes but never totalBytes, the
accumulator the cap guard reads. It now sets totalBytes = stored +
reservedBytes in the same write, repairs a drifted total, and skips records
the migration worker is part-way through.

Migration reserved the whole corpus in shadow and nothing ever committed it,
so every migrated knowledge base carried its corpus in reservedBytes with
storedBytes=0. reserve_snapshot now records snapshotReservedBytes and
run_promote adopts each complete document (settled + committedBytes, counted
into storedBytes) and returns the snapshot reservation once.

Also: deleting an unsettled `failed` row no longer releases bytes it never
reserved, and release/refund refuse to drive a counter below zero
(KbByteCapAccountingSkew). scripts/repair_managed_kb_byte_counters.py repairs
existing records (report-only by default).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: note compaction extract-then-compress is on in development

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(kb): let the reconciler read the documents bucket; anchor storedBytes to the ledger

The managed-KB reconciler's storedBytes refresh has never run: its Lambda had
no read grant on the documents bucket, so every ListObjectsV2 was
AccessDenied. Grant it read-only access under `assistants/*`.

Before turning it on, change what the anchor counts. It used to total every
object under `assistants/{id}/documents/`, which would charge owners for
failed rows, stuck legacy rows and orphaned uploads, and count in-flight
uploads twice (also in reservedBytes). It now sums S3 sizes only for DOC#
rows the ledger counts: `committedBytes` stamped and `byteCapRefunded` not
claimed. That follows the commit-before-`complete` and
refund-after-`deleting` ordering, which a status filter would not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(compaction): salvage a summary generation that hits its token ceiling

compress_with_model discarded any generation that stopped on max_tokens,
so the cut fell back to newest-first truncation of the raw records, which
drops the oldest standing instructions first. Nova 2 Lite varies its
narrative several times over on the same records, so the ceiling is hit
in normal use.

Keep the complete lines instead (the last, possibly partial, line is
dropped) and hold them to the budget from the end, since the prompt puts
standing instructions first. The plain path labels such a cut
model_salvaged so it can be counted apart from a clean compression.

maxTokens now follows the model's ceiling: Nova 2 Lite's card gives 64K
output, so the budget binds and matches the prompt's word limit. Unlisted
models keep 4k, under Nova Micro's 5K, so an override is never rejected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: note compaction extract-then-compress is on in production

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(memory): calibrate the relevance cut on a labelled eval set; default 0.4

audit.py calibrate: one synthetic memory-audit-probe-* actor states 16
synthetic facts and preferences (calibration_set.json) across 5
conversations, waits for extraction to settle, asks each fact three ways
(direct, indirect, wrapped in filler) plus 20 unrelated questions, raw
and with filler stripped, labels every returned record from its text, and
writes score distributions and per-policy recall/precision to
summary.json. Record text stays in raw/. Cleans up in finally, with late
sweeps. --reanalyze recomputes from raw/ without AWS calls.

Two dev runs: realistic questions score 0.34-0.56 and noise sits at
0.34-0.40, so the 0.5 cut kept the right record for 7% of questions.
0.4 keeps it for 59% at 94% precision and injects on 5% of unrelated
turns (~21 tokens/turn on average). The default moves to 0.4; findings
are in docs/specs/memory-baseline-decision.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(memory): log per-namespace retrieval scores, no ids or text

retrieve_customer_context now logs one line per namespace per turn:
top score, records returned, records kept, and the cut in force. The
namespace is the template ({actorId} unresolved) and no record text is
logged. Computed from the response already in hand: no extra calls, and
the added cost before the first token is one log line.

audit.py inventory picks the lines up (LOG_PATTERNS retrieval_scores)
and histograms the top score per strategy type, counting the plain
runtime stream only (the OTEL copy duplicates every line).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(memory): link the score-logging PR

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(kaizen): track harness-sdk#4618 as a Strands pin-bump hazard

Upstream #4618 proposes normalizing every provider's Usage to the semconv
subset convention, folding Bedrock cache tokens into inputTokens. Our cost,
quota and context math assume the disjoint Converse shape, so a bump that
ships it would double-price every cached Bedrock token silently.

- review-queue: new watch entry with the bump gate; amend the 2026-09-05
  caching watch (#4193 merged 2026-09-25, #3546 closing via #4617)
- kaizen-research SKILL §2a: #4618 warning + per-run check

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(projects): record 2.4b dev validation

Project memory blocks, scope tools, the personal space created on first
save, the viewer refusal and the archived gate all behaved as built on dev.
The load's waitedMs was 0.0-1.1 ms, so it stays off the first-token path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(kaizen): record the posted #4618 comment in the watch entry

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(count-tokens): strip global., au. and jp. inference-profile prefixes

base_foundation_model_id only knew us./eu./apac./us-gov., so a global.*
profile id reached CountTokens unchanged. Bedrock rejected it as
unsupported, the model landed on Strands' skip list, and every count for
every prod global.* model fell back to the chars/4 heuristic, which also
means no prefixTokens / contextBreakdown after #1337.

Adds global plus the geo prefixes the AWS model cards document today
(au., jp.). Undocumented codes stay untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(kaizen): queue the production managed-KB byte-counter repair for after release

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(context-attribution): take native token counts off the model-call path

Stripping global. from the CountTokens id makes prod global.* models count
natively, and a native count awaited before a model call adds a CountTokens
round trip to time to first token: measured +519 ms on a freshly built
agent's first call and +74 ms on each later call (+56 ms per tool round, for
a count that always failed).

Nothing needs those counts before the call:
- CountTokensBedrockModel(native_projection=False), as the factory now
  builds every Converse model, keeps Strands' pre-call count_tokens on the
  local heuristic. Strands' proactive compression is off in our config and
  our compaction does not read the projection.
- native_count_tokens() is a blocking counter that answers natively or
  None, never a heuristic, with the same skip-list bookkeeping.
- ContextAttributionHook's BeforeModelCall callback is now synchronous: it
  records the projection and, while the agent has no split, starts one
  background task that makes four native counts of a snapshot concurrently
  with the model call. AfterModelCall places the messages partition
  against the call's billed prompt (input + cache read + cache write).
- Interrupted turns read get_projected_input_tokens(): the native count of
  the exact request when the split measured one, else a usage-anchored
  projection, else nothing.

Same dev harness (40 tools, 32k-token prefix, n=8 per arm): the pre-stream
window matches today's heuristic path, and the split was ready before call
1 finished in 8/8 sessions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(compaction): turn extract-then-compress on by default

COMPACTION_SUMMARY_EXTRACT_ENABLED now defaults on with =false as the kill
switch. The quality harness showed no loss against the full history and a
forced cut on dev pinned and answered every planted fact. Harness
model_relative follows the production default; the plain-compression arms
pin extraction off as baselines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(infra): drop the CDK wiring for the extract-then-compress flag

A default-on flag needs no AgentCore Runtime env var, so the config,
construct entry, workflow variable and their tests go. The Runtime is back
to 48 of its 50 variables.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(compaction): record the dev validation and the default-on flip

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(compaction): pin the salvage test to the plain compression path

It asserts the single-call path's outcome and bytes, which the default-on
extract flag would otherwise route through extraction.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(kaizen): queue the production check for #1343's native token counts

Dev can't exercise a global.* id (the SCP denies it), so the last open
question on #1343 is production: prefixTokens and contextBreakdown on
global.* Haiku 4.5 sessions, and no CountTokens span in front of a
model call. Read-only steps, blocked on the release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(agents): run every agent-stream step in one context

`_merge_agent_status` drives the agent stream by scheduling each
`__anext__` as its own task, and a task runs in a copy of the context it
was created from. Strands holds its spans attached across yields (the
invocation, each cycle, each model stream), so a span attached in one
step was detached in a later step's copy. OpenTelemetry logged that as
`Failed to detach context` — 3 per tool-less turn, 5 per one-tool turn,
~2.3k in dev over a week, starting with the first deploy of the merge.

The same split mis-parented spans: anything opened from the ambient
context after a yield — the AgentCore Memory CreateEvent calls the
session manager makes, DynamoDB reads from hooks — parented to the
request span instead of the cycle that caused it. Strands' own spans
were unaffected (explicit parent, explicit end), so nothing was lost.

Every step now runs in one Context copied once per turn, which is what a
plain `async for` gave the generator. No added latency: one copy per turn
instead of one per step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(models): curate Claude Opus 5.5, GPT-6 Sol, GPT-6 Luna and GPT-5.5

Add the four new Bedrock models to the curated catalog and move the
generations they supersede into "More models":

- Claude Opus 5.5 (us.* CRIS, $4.40/$22.00). Cache read is 0.05x input,
  not the 0.1x default (Price List API). temperature/top_p rejected and
  thinking cannot be disabled, measured against the live endpoint.
- GPT-6 Sol ($2.20/$11.00) and GPT-6 Luna ($0.11/$0.55) on
  bedrock-responses, with the measured OpenAI parameter profile.
- GPT-5.5 on bedrock-responses. Its card lists Mantle only, but the us.*
  profile serves it on bedrock-runtime; it never reports cache writes,
  so the write rate is 0. Featured off as the older generation.

Demote Opus 4.7, GPT-5.6 Sol and GPT-5.6 Terra. The spec's cache-rate
invariant now carries explicit, sourced exceptions for the two models
that break the 1.25x/0.1x default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(kb): stop imports committing bytes they never reserved

Only POST /upload-url reserves at request time. File-source imports, web
crawls and KB syncs reserve nothing, but they write a real sizeBytes before
the consumer reads the row (an import before its PUT, a crawl racing it). The
consumer read that size as the reservation and committed it out of
reservedBytes. So the import never counted against the cap, and whatever
else was reserved shrank by the same amount. With nothing else reserved, the
counter goes negative. The failure, stale-sweep and delete paths had the
mirror image and released the size of an import that was never reserved.

byte_cap.reserved_at_request decides from the row's provenance: a row with a
sourceAdapterKey reserved 0. The consumer now reserves such a document's real
size at ingestion, so imports are capped like uploads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(kb): opt-in --settle-unexplained for byte counters no document explains

The repair script leaves a knowledge base for review when reservedBytes
doesn't equal its unsettled complete documents. Pre-fix imports produce
exactly that shape: reservedBytes == unsettled bytes - imported bytes, or a
negative reservedBytes when nothing else was reserved. The report now names
that diagnosis when the numbers fit.

--settle-unexplained, which requires --agent, trusts the ledger for a quiet
knowledge base. It claims every unsettled complete document as committed and
sets storedBytes = totalBytes = ledger with reservedBytes = 0. It refuses
unless nothing is in flight and no row is part-way through a commit or
refund. It re-reads the documents after --quiet-seconds, because an upload
reserves before its row exists. The claims, backfills and counter write are
one DynamoDB transaction, conditioned on the counters and rows as read. A
concurrent upload, delete, consumer commit or reconciler re-anchor cancels
all of it, and a re-run finds nothing to do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(spa): forget the new-conversation draft on the first send

The first send from the empty state swaps its composer for the compact
one, which destroys it before the draft effect runs again. The
new-conversation draft kept the message just sent, so every later New
Session opened with that message already in the composer.

submitChatRequest now writes the cleared draft at once instead of
relying on the effect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(kb): re-ingest a synced source that changed on a managed knowledge base

A KB sync that finds its Drive file (or crawled page) changed overwrites the
document's S3 object in place and relies on the ObjectCreated event to
re-ingest. On a managed knowledge base that event reaches the ingestion
consumer, which found a complete row with byteCapSettled and returned
"already-settled" — the early exit that makes redelivery idempotent. The new
bytes were never ingested, and the size change never reached the byte ledger.

The sync now writes stagedContentHash before staging. The consumer re-ingests
while it differs from the ingestedContentHash the last re-ingest recorded:
it reserves the size growth against the cap before submitting (a file that
grew past the cap is refused and its previous version keeps serving; the next
sync retries), submits once per version, waits out Bedrock statuses that
predate the submission, and on completion re-stamps committedBytes and
commits or refunds the difference. A per-version claim on the DOC# row keeps
all of it idempotent under EventBridge redelivery; a delete mid-flight
returns the growth reservation. Legacy knowledge bases are routed away before
any of this, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(spa): don't mark a finished turn interrupted when Stop lands after done

Loading (and so the Stop button) only clears on the transport's close,
which can trail the server's `done` frame -- widest on a first turn, where
`session_title` may arrive after `done`. A Stop in that window fired the
`user_stopped` interrupt POST and set the local interrupted flag on a turn
that had already completed.

cancelChatRequest now checks StreamParserService.hasReceivedDone(): once
this stream's `done` has arrived it aborts the transport and tears down
local streaming state, runs the title fallback the skipped onclose would
have, and returns before any interrupt signal or cost re-fetch.

hasReceivedDone is a new per-stream flag recorded before the parser's
state gate. The unused isStreamCompleteFor() is removed instead of reused:
a client parse error also sets it while the server turn keeps running, and
a Stop then is a real interruption.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(backend): treat any non-end_turn stop as a failed side-channel call

The side-channel summarizers only rejected stopReason == "max_tokens", so a
guardrail stop, content-filter refusal or unknown reason was accepted as a
valid result. For compaction that let a refusal become the checkpoint
summary in the cacheable conversation history until the next cut.

Accept only a positive completion signal and fall back through the paths
that already exist:
- compaction narrative + extraction: end_turn, or max_tokens (salvaged as
  before); anything else returns None -> truncation / plain compression
- tool-batch summary and document abstract: end_turn only -> None
- conversation title: end_turn, or max_tokens (the existing 50-char clip);
  anything else keeps the "New Conversation" placeholder

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(spa): keep the context-meter panel above the announcement pill

The panel was z-20 and the announcement pill z-30, and both sit in
chat-input's stacking context, so an open panel had the pill drawn
across it. Raise the panel to z-40.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(spa): don't attribute a page-hide to a turn that already finished

Page-hide attribution picks turns from streamingSessionIds(), which still
lists a session until the transport closes, and close can trail `done`. A
departure in that window POSTed `navigated_away` for a finished turn. The
backend lease gate doesn't fully cover it, because the lease is released in
the stream generator's `finally`, which can itself come after `done`.

Skip sessions where hasReceivedDone() is true, as the Stop path does since
#1366.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(specs): background handoffs with in-place review and notifications

Revise the agent handoffs spec after a UX pass:

- Rename "task chips" to handoffs. Shared Projects already uses "task" for
  a conversation, so HANDOFF# rows, /handoffs routes and HANDOFFS_ENABLED
  replace the TASK# names.
- A mid-thread mention opens a review sheet in place, with a full-prompt
  preview. On Send the target conversation runs in the background as a
  second stream; the user stays in the original thread.
- A handoff row tracks running / needs your input / done / stopped and
  offers Bring back result.
- Notifications: the Projects in-app inbox gains handoff kinds, plus local
  browser notifications while the page is hidden. Web Push is a follow-up
  shared with scheduled runs.
- The Phase 1 gate now asks whether users bring results back without
  opening the target, since background handoffs cover most of a consult.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(backend): push the first-turn session title as soon as it exists

Four delays sat between Nova Micro answering and the user seeing the title:

- The title was only checked between agent events, so it waited out the
  model's time-to-first-token and every long tool call. It now also rides
  the coordinator's 100ms live status merge (`poll_side_frame`).
- The title task awaited its DynamoDB write before finishing, and the stream
  only emits a finished task. The write is now scheduled in the background.
- That write took two round trips (GSI lookup, then update). Rows are born
  with the static SK, so it is now one keyed update guarded by
  attribute_exists, falling back to the GSI only for a legacy SK.
- A new bedrock-runtime client per title: ~250ms on first use in a process
  (on the event loop) and a fresh TLS handshake every time. It is now built
  once, lazily, inside the worker thread.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(spa): poll for a late session title instead of one 1.5s retry

When the stream outruns title generation, the post-close metadata read sees
the placeholder. Retry up to 5 times at 400ms (the same ~1.5s+ window) and
stop early once the title arrived another way, so it appears within ~400ms
of being written instead of after a fixed 1.5s wait.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: roll back KB sync change gates when the S3 stage fails

The Drive and web re-crawl sync paths write sourceEtag/contentHash
alongside previousChunkCount and stagedContentHash BEFORE staging to S3
(those two must precede the ObjectCreated event). When the put raised,
the run failed but the gates stayed advanced, so every later run
short-circuited as "unchanged" and the change was never ingested, on
both legacy and managed knowledge bases.

A failed stage now conditionally restores sourceEtag, contentHash,
stagedContentHash and lastSyncedAt to their prior values, only if they
still hold what this run wrote, so a newer run's write and the managed
consumer's over-cap REMOVE are left alone. The crawler reports a new
"stage_failed" outcome instead of letting the exception escape its
worker task, marks a never-staged new page failed, and still walks the
fetched page's links so its children aren't counted as misses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf: reuse one Bedrock client across side-channel calls

The tool-batch summarizer, compaction summary, document abstract and
embeddings each built boto3.client("bedrock-runtime") per call: ~250ms
the first time in a process (botocore loads the service model), run on
the event loop before asyncio.to_thread, and a fresh connection pool —
a new TCP+TLS handshake — on every call.

The summarizer, compaction and digest now use the process-cached
apis.shared.aws_clients.get_client (locked, keyed by region), fetched
inside the worker thread so a first build stays off the loop.
bedrock_embeddings keeps its own lazily built, locked module client:
the kb-sync and rag-ingestion Lambda images copy that package without
aws_clients.py. Embeddings sit on the knowledge-base search path, ahead
of the model's first token.

Tests reset the cached client per test (and per fake install, where a
test swaps fakes mid-test), and pin one client build across calls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: plan the AgentCore Runtime V2 migration

Adds docs/specs/agentcore-runtime-v2.md, fleshing out the review-queue
entry "A/B the V2 AgentCore Runtime in dev" with the 2026-09-18
announcement's details and pre-work findings checked against AWS and
our tree:

- Gate 1 answered: CloudFormation accepts PlatformVersion as an
  in-place update (not create-only); the dev runtime reports V1.
- Blocker: deploy-runtime-image-if-changed.sh rebuilds a
  full-replacement update-agent-runtime payload from an allow-list
  that omits platformVersion, so backend deploys could revert V2.
- Risk: V2 restores a snapshot of the running container, so
  import-time state (runtime_health's idle-clock origin) is cloned
  into every session.
- The turn-latency EMF starts at handler entry and cannot see a
  Runtime cold start; measure client-side.
- Phase 2: prewarm the conversation's pinned microVM when the user
  starts typing, V2-only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(backend): A/B the first-turn agent build, default off

`agent_build.session_mgr` is 616-738ms of a 1.0-1.2s first-turn build on
dev. The AgentCore SDK's session manager builds a MemoryClient (fresh boto3
session, two clients), then a second fresh session and two clients that
replace the first pair, on every construction. And `create_agent` is
synchronous on the event loop, so a Nova title reply that lands mid-build
sits unprocessed until the build ends.

Both fixes run behind one per-session experiment flag,
AGENT_BUILD_EXPERIMENT (unset = control everywhere), so they ship only if a
dev A/B says they help:

- shared_clients: one process-wide boto3 session whose client() returns one
  client per configuration, handed to the SDK (and to its MemoryClient via
  the SDK module's name, the only seam).
- shared_clients_off_loop: that, plus the build under asyncio.to_thread,
  one build at a time, with the route emitting a title that lands mid-build.
  The frozen loop used to serialize everything for free, so this also locks
  ExternalMCPIntegration's maps, creates its singleton under a lock (two
  instances would let a needs_approval tool run unapproved), and pins the
  external MCP clients a build is handed until its agent registers as their
  consumer.

turn_prelude gains buildArm, processBuilds and two sub-stages
(session_mgr_clients, strands_agent); scripts/experiment_agent_build_arms.py
drives interleaved first turns per arm and joins them to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: brighten dark-mode section headers and bold markdown

Sidebar date group headers (Today, Yesterday, ...) were one step darker
than the session titles below them in dark mode; they now sit one step
lighter (gray-300 over gray-400).

Bold in assistant responses only changed weight in dark mode, because
the bubble's gray-200 body is too close to white to leave room above
it. Response prose now steps down to gray-300 while bold and headings
step up to white; bold inside a link keeps the link color.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(announcements): suppress global error toast for fail-open announcement requests

The ack POST and feed GET already swallow their own errors by design
(fail-open dismissal), but the global error interceptor still toasted
them. On mobile, a dropped ack (status 0) surfaced as "An error
occurred. Please try again." for something the user cannot act on.
Opt both requests out via SUPPRESS_ERROR_TOAST, as NotificationsService
already does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0127ERDpxwL2nzDEWZU4qPss

* Tighten mobile conversation gutters

On phones the app shell's px-4, the full-page messages container's 1rem
side padding, and the assistant card's px-4 stacked, putting assistant
text ~48px from the screen edge while the fixed composer sat at 16px.

Below sm, drop the messages container's side padding and the assistant
card's horizontal padding (the card's background matches the page, so
that padding was invisible spacing). Messages now line up with the
composer at the shell's 16px inset. The session skeleton gets the same
treatment. Desktop layout is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017JvdS58zu1zVgJvH9U2ThE

* fix(announcements): retry an ack once on a transient failure

A dropped ack (status 0 on a flaky mobile connection, or a 502/503/504
from the gateway) was lost silently, so a requires-ack announcement
resurfaced on the next load. Retry once after 1s. The endpoint is
idempotent and monotonic, so a duplicate that did land is a no-op.
4xx and other errors are not retried.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0127ERDpxwL2nzDEWZU4qPss

* docs(configuration): add complete feature-flag reference

Fork operators reported the feature-flag docs were incomplete: no full list, no on/off defaults, and no note on which flags incur recurring cost.

- Add configuration/feature-flags.md: every flag (~35) across the CDK build-time and runtime-Python layers, with a deployed-default column, a 'flags that cost money' section, and a callout for flags whose Python code default (OFF) is overridden ON by CDK at deploy.
- Rewrite the environment-variables.md placeholder stub (it named 3 dead flags) into a config-chain overview that points at the new flags page and sibling config pages.

Sidebar auto-generates from the configuration/ folder, so no nav change is needed.

* docs: map the turn path from send to first token and plan the TTFT work

Adds docs/specs/turn-path-ttft.md: every stage of a turn (before the
handler, inside it, inside the stream, inside the agent build) with what
runs where, what is network, what blocks the event loop and which
turn_prelude mark times it; the findings that map exposes (fresh boto3
sessions re-parse service models so warm-up never reaches the build, a
full-history ListEvents on the head of every turn just to count it, the
serial session_mgr/tools build, agent-turn writes on the critical path,
the still-undecomposed agent_build.tools); an assessment of PR #1377; an
ordered, measure-first plan that leans toward Runtime V2; and a
regression plan a cheaper model can execute. Pointers from CLAUDE.md and
the preamble spec.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(inference-api): extract the invocation route's turn phases

Behaviour-preserving. The invocations handler's contiguous phases become
named module-level functions with typed results, in the order the
turn-path map lists them: _resolve_turn_attachments, _prepare_session_state,
_check_turn_quota, _resolve_turn_model, _resolve_effective_tools,
_build_turn_tools, _agent_for_app_dispatch (the two MCP App dispatches
shared 60 identical lines), _validate_resume_interrupts, _build_citations,
and _refuse_turn / _forbidden_turn for the six conversational early exits.
The handler drops from ~2,240 to ~1,640 lines and its docstring is now the
phase index keyed to the turn_prelude marks. Every module-level seam the
tests patch is kept; the one AST pin on the main get_agent call now reads
the binding off turn_tools. Four comments that still described the
pre-PR-2 preamble (including the disproved ~53ms-per-GSI-query claim) are
corrected.

Full backend suite: 10950 passed, 3 skipped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(backend): withdraw the off-loop agent-build arm and its MCP hardening

`AGENT_BUILD_ARMS` is now `("control", "shared_clients")`. The
`shared_clients_off_loop` arm ran `create_agent` under `asyncio.to_thread`
so a firs…
@philmerrell
philmerrell merged commit ea4d75b into develop Oct 3, 2026
9 checks passed
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.

1 participant