perf: A/B the first-turn agent build (shared boto3 session built at warm-up), default off - #1377
Merged
Merged
Conversation
`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>
…hardening
`AGENT_BUILD_ARMS` is now `("control", "shared_clients")`. The
`shared_clients_off_loop` arm ran `create_agent` under `asyncio.to_thread`
so a first-turn title could go out mid-build, and needed hardening the
frozen loop used to provide for free: process-wide build serialization, a
lock on `ExternalMCPIntegration`'s maps and singleton, and a consumer pin
on every external MCP client handed to a build.
A thread does not make a synchronous build faster, so the arm could not
reduce time to first token, and the overlap it would have enabled is
reachable inside the constructor without any of that (see
docs/specs/turn-path-ttft.md, sections 4 and 5 P3). All of it goes, with
its tests; the route's title race reverts to the plain awaited build.
`process_build_count` stays: it stamps `processBuilds` on `turn_prelude`.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…to every SDK client The shared session was created lazily by the first build, so the first turn of a conversation, the only turn the experiment is about, still parsed two service models and made the strategy-id call. Now: - `ClientReusingSession` and `shared_boto_session()` live in `apis.shared.aws_clients` beside the process client cache; `reset_cached_clients` drops the session too (the moto trap). A keyword passed as None (Strands passes `endpoint_url=None`) no longer defeats the sharing. - `warmup.warm_shared_session` builds `bedrock-agentcore`, `bedrock-agentcore-control` and `bedrock-runtime` clients on it and calls `warm_strategy_ids` once, on the startup daemon thread. That discovery is the one connection warm-up otherwise avoids; accepted per docs/specs/turn-path-ttft.md section 5 P2, with the V2 restore named as the thing to watch. - `_discover_strategy_ids` takes `shared_session=` (part of its cache key, so warm-up primes the shared entry and the control arm's first turn still does exactly what it did before). - `ModelConfig.to_bedrock_config(session_id=)` passes `boto_session` to `BedrockModel` on the shared arm, and never `region_name` beside it. Nothing here runs on the control arm's request path, and nothing reaches the prompt, `toolConfig` or restored history. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
No network anywhere: warm-up builds each client once on a patched shared session and discovers the strategy ids once with `shared_session=True`; the factory hands the SDK constructor and `MemoryClient` the shared session on the arm and nothing off it; `BedrockModel` receives `boto_session` and no `region_name` on the arm (and two models share one bedrock-runtime client), `region_name`-free and session-free off it; `reset_cached_clients` drops the shared session; a `None` keyword (Strands' `endpoint_url=None`) does not defeat the sharing. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
philmerrell
added a commit
that referenced
this pull request
Sep 30, 2026
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
After #1374, dev showed the first-turn title waiting on the agent build, not on anything in the title path. "✅ Generated title" is logged in the same millisecond as
turn_preludeon every first turn. The build takes 1.0–1.2s, andagent_build.session_mgralone is 616–738ms.Two findings shaped this PR:
AgentCoreMemorySessionManager.__init__builds aMemoryClient(a freshboto3.Sessionplus two clients), then a second fresh session plus two clients that replace the first pair. A fresh session re-parses every service model it touches, so that is about 360ms of client construction per session manager on a laptop, andturn-latency-preamble.mdPR-3 showed client construction is ~30x slower on the container. Two more fresh sessions sit next to it on the same first turn:_discover_strategy_idsbuilds its ownMemoryClient, and Strands'BedrockModelbuilds its ownboto3.Session.service.instance.iddiffers per session in the runtime logs, so every first turn is a cold build. Laptop numbers can't settle whether the change helps there, so it ships off behind a dev A/B.This PR was narrowed on 2026-09-30 against the assessment in
docs/specs/turn-path-ttft.md§4 (PR #1388): the instrumentation and theshared_clientsarm stay, the off-loop arm is withdrawn, and the shared session is built at warm-up instead of by the first build.What changed
One per-session experiment flag,
AGENT_BUILD_EXPERIMENT(agent_build_experiment_arm). Unset or empty meanscontrolfor every session, so no environment changes behavior on merge.controlshared_clientsapis.shared.aws_clients.shared_boto_session) whoseclient()returns one client per configuration (pool size 50, SDK user agents kept), handed to everything on the build that would otherwise construct a fresh session: the SDK session manager (boto_session=),_discover_strategy_ids(MemoryClient(boto3_session=)), and Strands'BedrockModel(boto_session=, and then noregion_name, which Strands rejects beside a session). The SDK constructs its throwawayMemoryClientwith no session, so the SDK module'sMemoryClientname is rebound to a subclass that takes the shared session only while the factory builds a shared-arm manager (a contextvar). A test fails if an SDK upgrade stops going through that seam; the rebinding is a workaround for a one-line upstream bug and should have an end date.Built at warm-up, not by the first turn. The session, its
bedrock-agentcore,bedrock-agentcore-controlandbedrock-runtimeclients, and the strategy ids are all built inwarmup.pyon the startup daemon thread, so the arm's first turn — the only turn the experiment is about — finds the service models parsed and the ids cached. The strategy-id discovery is a control-plane read of static configuration, and it opens the one connection warm-up otherwise avoids. That is accepted perturn-path-ttft.md§5 P2: the alternative is paying the read on the turn this exists to shorten, the call is idempotent so botocore retries a connection error, and on Runtime V2 the restore's first turn is the thing to watch for a pool holding a socket that did not survive the snapshot. The discovery cache is keyed by arm on purpose, so warm-up primes the shared entry and the control arm's first turn still does exactly what it did before.Withdrawn: the off-loop arm. As opened, a third arm ran
create_agentunderasyncio.to_threadso the route could emit a first-turn title mid-build, and it needed hardening the frozen loop used to provide for free: process-wide build serialization, a lock onExternalMCPIntegration's maps and singleton, and a consumer pin on every external MCP client handed to a build. All of that is gone, with its tests, and the route's title race reverts to the plain awaited build. A thread does not make a synchronous build faster (CPU-bound parts contend on the GIL, network-bound parts take the same time), so the arm could not reduce time to first token and its A/B would have read as noise; the concurrency it would have bought is reachable inside the constructor without any of the hardening (§5 P3a:session_mgrandtoolson two threads of one executor). A title beforepreparedis not worth shipping locks for.Measurement, unchanged:
turn_preludegainsbuildArmandprocessBuilds(1 on a first turn), which are EMF properties, never dimensions.agent_build.session_mgr_clients(client setup) now precedesagent_build.session_mgr, which becomes network only.agent_build.strands_agent(Strands'Agentconstruction, including MCPload_tools) now precedesagent_build.finalize, which becomes the restore only.scripts/experiment_agent_build_arms.pydrives interleaved first turns per arm (rotating order, session ids stratified into the two arms) and joins client-side frame timings to eachturn_prelude.Considered and rejected
read_session's twolist_eventsfor a known-new session. Strands skipsread_agentwhen a session looks new, so a wrong "new" (turn 1's metadata pre-create failed, turn 2 re-creates it) would silently drop conversation history.backend.ymlpreserves it andplatform.ymlresets it.abhashes the session id), and building clients on an unused session costs nothing on the request path, so it is unconditional.Cost / TTFT
toolConfigor restored history. The session, the clients and the strategy ids are transport; the prompt bytes on both arms are identical todevelop.mark_stagecalls (session_mgr_clientsinread_session,strands_agentininitialize) and the two property stamps onturn_prelude. Unchanged from the PR as opened. Control never touches the shared session, and its own strategy-id discovery on the first turn is the same call it made before (separate cache entry).GetMemoryread for the strategy ids.turn-path-ttft.md§5 P2:session_mgr_clients+session_mgrdrop by the parses they no longer do and the discovery call,finalizeby one parse; the container ratio is unknown until measured.Plan after merge
AGENT_BUILD_EXPERIMENT=abon the dev Runtime out of band (update-agent-runtime;backend.ymldeploys preserve it, aplatform.ymldeploy resets it).scripts/experiment_agent_build_arms.py --per-arm 15 --cleanupagainst dev.turn-latency-preamble.mdPR-6 before the data: shipshared_clientsas default-on with an=falsekill switch if itssession_mgr_clients+session_mgrmedian beats control's by more than the run-to-run spread and no stage regresses. Otherwise remove the arm and keep the instrumentation. Either way, watch the first Runtime V2 restore's logs for the warm-up connection.Tests
PYTHONPATHpointed at the branch).shared_session=True, skips both without a region, and survives a failing client; the factory hands the SDK constructor andMemoryClientthe shared session on the arm and nothing off it;warm_strategy_idsand the arm's first turn hit the same cache entry;BedrockModelreceivesboto_sessionand noregion_nameon the arm (two models share onebedrock-runtimeclient, and the arm resolves the same region as a fresh session), and neither off it;reset_cached_clientsdrops the shared session; aNonekeyword (Strands'endpoint_url=None) does not defeat the sharing while a real override is never shared.TurnBasedSessionManagerconstruction with only SDK network stubbed. Later managers build zero clients, concurrent first builds share one client, control keeps per-manager clients, and theMemoryClientseam is inert elsewhere.abhashing.🤖 Generated with Claude Code