Skip to content

🐛 fix: make the planner_with_memory → simulator live flow run end-to-end - #140

Open
cwest wants to merge 17 commits into
expo-demofrom
topic/fix-embedding-dimension
Open

🐛 fix: make the planner_with_memory → simulator live flow run end-to-end#140
cwest wants to merge 17 commits into
expo-demofrom
topic/fix-embedding-dimension

Conversation

@cwest

@cwest cwest commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

Base: expo-demo · Head: topic/fix-embedding-dimension

Why

Live mode (planner_with_memory → simulator) almost never finished. A plan would start, then hang somewhere before the dashboard card showed up, so "Run Simulation" and the simulator never ran. This branch tracks down each reason it stalled and fixes them, so a plan runs all the way through and does it repeatably.

How I verified it

Ran it end to end against the deployed race-condition-expo-demo stack with a headless driver, twice back to back. Both completed the whole pipeline:

plan → evaluate_plan → store_route → submit_plan_to_simulator (verify) → dashboard a2ui (Run Simulation) → run_simulation → submit_plan_to_simulator (execute) → simulator race_engine tick loop → store_simulation_summary → sim_results a2ui

Run 1 Run 2 (warm)
evaluate_plan ~20s ~21s
dashboard card
simulator race + results
total wall time ~252s ~205s

Full Python suite: 1605 passing, 0 failing.

What changed, by area

The big one: planner_with_memory was getting OOM-killed mid-request

scripts/deploy/deploy.py, scripts/deploy/deploy_test.py

This is the heavy engine. It bundles the planner, the eval agent, and the memory bank, and it runs pandas-based evaluation in-process with several uvicorn workers per instance. It was sized at 2Gi. Workers were getting killed about once a minute (abrupt restarts, no graceful shutdown), and that's what made the model turns look like they were hanging. Bumping it to 8Gi/2cpu is the change that actually unblocked the flow. Everything below is real, but secondary.

Keep the route GeoJSON out of the model context

agents/utils/context_trim.py (new), agents/utils/test_context_trim.py (new), agents/planner_with_memory/agent.py

report_marathon_route returns the full route GeoJSON so the frontend can draw the 3D course. ADK keeps that tool result in the conversation, so it got re-sent to the model on every following turn, roughly 200s a turn of pure input chewing. A before_model_callback now replaces the geometry in prior tool results with a short placeholder before each model call. The model never needed it: the simulator, evaluator, and dashboard all read the route from session state, and the frontend already got it in the original tool result. The callback only mutates the request and returns None, so it never short-circuits the LLM, and the secure-financial-modeling refusal card still fires.

Stop the AlloyDB writes from hanging forever

agents/planner_with_memory/memory/store_alloydb.py, agents/planner_with_memory/tests/test_store_alloydb.py

store_route's INSERT could hang indefinitely because the asyncpg connection had no command_timeout. Added a connect timeout and a command timeout. The write paths (store_route, record_simulation) also retry transient connection failures with backoff now.

Faster, correct evaluator

agents/planner_with_eval/evaluator/config.py, agents/planner_with_eval/evaluator/tools.py, agents/planner_with_eval/tests/test_eval_feedback.py

Evaluation runs gemini-3-flash-preview on the global endpoint, which Gemini 3 preview models require. That took a regional-endpoint stall of ~706s down to ~20s. The model is env-driven via EVALUATOR_MODEL.

Planner model is env-driven

agents/planner/agent.py, agents/planner_with_memory/agent.py, agents/planner/.../scripts/tools.py, agents/planner/tests/test_planner_agent_config.py

PLANNER_MODEL env var, default gemini-3.5-flash, read at call time.

Shared-infra resilience

  • agents/utils/redis_pool.py (+test): socket_connect_timeout and keepalive on the shared pool. It was stalling ~74s on a cold connect.
  • agents/utils/communication.py (+test): A2A gateway discovery retries with backoff instead of giving up on the first miss.
  • agents/utils/global_gemini.py (+test_global_gemini.py): a per-chunk asyncio.wait_for around the model stream. A turn that stalls before the first token restarts from scratch (bounded); a mid-stream stall surfaces as an error instead of an infinite hang. This one is a safety net. In practice the hangs were the OOM above, not the model, but it's cheap insurance against a real stall.

Embeddings (the original bug)

agents/planner_with_memory/memory/embeddings.py (+test): back to gemini-embedding-001 at 3072 dims to match the AlloyDB schema. It was 768-dim text-embedding-004, which 400'd every memory write.

Synced with latest expo-demo, and finished its tools refactor

Merged origin/expo-demo (it was 5 commits ahead). Two files conflicted; I kept both sides' intent:

  • planner/agent.py: env-driven PLANNER_MODEL, default gemini-3.5-flash, read at call time.
  • planner_with_eval/evaluator/tools.py: forces the global endpoint for the preview eval model, and keeps expo-demo's new Gemini API-key auth path.

That merge also surfaced a problem the refactor left behind. Moving start_simulation/submit_plan_to_simulator to StartSimulationTool/SubmitPlanToSimulatorTool classes dropped the old module-level functions, but two callers still used them: a fallback path inside SubmitPlanToSimulatorTool (for when the model skips start_simulation), which would have thrown NameError in production, and 5 tests. I added thin async wrappers that delegate to the classes. The ADK-facing API stays the classes; the wrappers just give internal callers and the tests a direct way in.

Infra / deploy sizing

  • scripts/deploy/deploy.py: critical AEs (planner, planner_with_memory, simulator) at min 1 / max 3; off-path AEs (planner_with_eval, simulator_with_failure) at min 0 / max 1; planner_with_memory at 8Gi/2cpu.
  • infra/modules/cloud-run-services/*: per-service min_instances so the gateway stays warm.

Housekeeping

.gitignore, .env.example, and removing committed junk (.env.swp, .home/.angular-config.json).

New config knobs (env-driven, sane defaults)

Var Default Purpose
PLANNER_MODEL gemini-3.5-flash planner model
EVALUATOR_MODEL gemini-3-flash-preview eval model (global endpoint)
EMBEDDING_MODEL gemini-embedding-001 memory embeddings (3072-dim)
ALLOYDB_CONNECT_TIMEOUT_S / ALLOYDB_COMMAND_TIMEOUT_S 10 / 30 AlloyDB connect / query timeouts
ALLOYDB_WRITE_RETRIES 2 retry transient AlloyDB writes
GEMINI_MODEL_FIRST_TOKEN_TIMEOUT_S / GEMINI_MODEL_STALL_RETRIES 180 / 2 model stall recovery
A2A_DISCOVERY_RETRIES see communication.py gateway discovery retries

Not in this diff: operational notes for the team

I did these against the live race-condition-expo-demo project, not in code:

  • Deleted ~50 orphaned Agent Engines. expo-demo's cloudbuild-bootstrap.yaml deploys agents with --force-create (commit ba7d0b5, not on main), so every deploy spun up brand-new engines. 55 had piled up, ~45 of them always-on (minInstances>=1), which starved instance capacity and fed the restarts. I kept the 5 newest (the ones the gateway points at) and deleted the rest. Worth reverting --force-create back to update-in-place (the way main does it) so this stops happening.
  • Deploy via Cloud Build, not local deploy.py. Local ADC was a non-authorized account (403 on reasoningEngines.*). gcloud builds submit runs under the in-project Cloud Build SA and works. These deploys were update-in-place (no --force-create), so they reused the engine IDs the gateway already points at.
  • This project has tight Agent Engine quotas. "Reasoning Engine Write Requests" started returning 429s under rapid calls, so throttle bulk AE operations.

cwest added 2 commits June 18, 2026 15:13
The memory planner had been switched to text-embedding-004, which
returns 768-dim vectors. But the agent_memory tables store 3072-dim
embeddings (VECTOR(3072), seeded with gemini-embedding-001), so every
similarity search failed with "different vector dimensions 3072 and
768". That broke planner_with_memory's summary step, and with it the
whole live SandboxIO flow on the keynote demo.

Go back to gemini-embedding-001 at 3072 dims so query embeddings match
the seeded data. No re-seed needed, and 3072 is the model's native
size, so there's no manual normalization to deal with. Also pins the
defaults with a test, since the call sites in tools.py rely on them
rather than passing model/dimension explicitly.
A vim swap file (.env.swp) and a stray .home/.angular-config.json got
committed by accident. Remove both and add ignore rules so they don't
sneak back in.
@cwest
cwest requested a review from a team as a code owner June 18, 2026 19:15
cwest added 13 commits June 18, 2026 15:48
A failed /api/v1/agent-types fetch was swallowed into an empty cache, so
a single transient gateway blip made every inter-agent call (e.g. the
simulator handoff) fail with "not found in registry". Retry with
exponential backoff (tunable via A2A_DISCOVERY_RETRIES /
A2A_DISCOVERY_BACKOFF_BASE) before falling back to the cache.
gemini-3-flash-preview is slow, intermittently 429s, and sometimes emits
the a2ui tool call as text instead of calling it. Move the planner family
to the GA gemini-3.5-flash (global endpoint), read from PLANNER_MODEL so a
future swap is an env edit rather than a code change. deploy.py sets the
env explicitly on the Agent Engines. Runner keeps its own model.
planner, planner_with_memory, and simulator are on the live SandboxIO
path and were max_instances=1, so they saturated under concurrent
sessions. Set min_instances=1 (warm) and max_instances=3 for those
three; off-path agents stay pinned at 1. Updates the cost-control test
to encode the new per-agent policy.
The gateway is the hub for the WebSocket, A2A discovery, and
orchestration, but ran at minScale:0/maxScale:1 — cold starts plus a
single saturated instance under concurrent sessions, which surfaced as
flaky chat readiness and failed agent discovery. Make min_instances
per-service (replacing the single global var) and set the gateway to
min:1/max:3; every other service stays scale-to-zero, single-instance.
Tests updated to encode the new per-service policy.
Code review caught that planner_with_eval and simulator_with_failure
would warm at min=1 via the MIN_INSTANCES fallback, against the
intent that only the live-path agents stay warm. Set them to
min_instances=0 explicitly and add a test pinning the min-instances
policy. Document PLANNER_MODEL and the A2A_DISCOVERY_* tunables in
.env.example, and refresh the stale model ids in the global_gemini /
retry docstring examples.
…-flash-preview

Live testing showed gemini-3.5-flash regressed the SandboxIO flow: the
planner emitted a route_list a2ui card and ended its turn instead of the
plan dashboard with the Run Simulation button, so the simulator handoff
never fired. The one fully-working end-to-end run was on
gemini-3-flash-preview (the model the planner's thinking_budget /
max_output_tokens config was tuned for).

Keep the env-driven PLANNER_MODEL indirection (so a future swap is an env
edit) but set the default back to the known-good gemini-3-flash-preview.
evaluate_plan defaulted to gemini-3.1-pro-preview but built its genai
client (and judge model resource) with the regional GOOGLE_CLOUD_LOCATION
(us-central1). Gemini 3 preview models are only served on the global
endpoint, so every evaluation failed and retried for minutes — telemetry
showed a single evaluate_plan call burning 706s, pushing the planning
turn (~24 min total) far past the gateway's 60s timeout, so the plan
dashboard + Run Simulation button never reached the user. That is why the
planner_with_memory -> simulator flow "only worked once".

Switch the evaluator to gemini-3-flash-preview on the global endpoint,
matching our other agent Gemini 3 calls, so evaluate_plan returns in
seconds and the planning turn completes within budget.
A single Redis pipeline publish was observed stalling ~74s (≈ the OS
default TCP connect timeout). The shared BlockingConnectionPool set a
pool-checkout timeout but no socket_connect_timeout, so establishing a
new connection to Memorystore (when one was stale/dropped) blocked for
~75s — freezing the broadcast worker and any Redis side-channel mid-turn,
which surfaced as tools "hanging" in the UI.

Add socket_connect_timeout=5 so a hung connect fails fast, plus
socket_keepalive + health_check_interval=30 to recycle stale connections
and avoid slow mid-request reconnects. Redis itself is fine; this bounds
the connection-establishment path.
The headless e2e driver measured the planning turn spending ~258s in
gemini-3-flash-preview model round-trips (on the large route-GeoJSON +
traffic + recall context) between report_marathon_route and
evaluate_plan — the dominant remaining cost after the eval and Redis
fixes. gemini-3.5-flash is the faster GA model originally requested; the
earlier revert to gemini-3-flash-preview was based on a misdiagnosis
(the route_list cards were separate organizer sessions, not the planning
response). Restore gemini-3.5-flash (global endpoint, env-driven via
PLANNER_MODEL).
A planner_with_memory turn intermittently hangs after model_start: the
Gemini generation request goes in-flight and never returns, stalling the
whole multi-agent flow. The genai SDK only retries APIError responses with
retriable status codes -- a wedged client-side stream is not retried, so it
hangs forever.

GlobalGemini.generate_content_async now bounds each chunk with
asyncio.wait_for. A stall before the first token is safe to restart (no
output emitted), so it retries from scratch up to GEMINI_MODEL_STALL_RETRIES
times. A stall mid-stream cannot be restarted without duplicating output, so
it surfaces as a TimeoutError. Timeouts default to 180s/120s and are
env-tunable.
…lures

store_route's INSERT hung indefinitely on a degraded connection because the
asyncpg connection had no command_timeout -- the same infinite-hang failure
mode as the Redis stall, stalling the planner turn after evaluate_plan.

_get_conn now sets both a connect timeout and a command_timeout, and the
write paths (store_route, record_simulation) retry transient connection
failures with backoff. All bounds are env-tunable (ALLOYDB_CONNECT_TIMEOUT_S,
ALLOYDB_COMMAND_TIMEOUT_S, ALLOYDB_WRITE_RETRIES).
report_marathon_route returns the full route GeoJSON so the frontend can draw
the 3D course. ADK keeps that tool result in the conversation, so the geometry
was re-sent to the model on every subsequent turn -- measured at ~200s per
planner turn even on a warm engine, with no API errors or retries. It is pure
input-processing cost, and it made the multi-turn plan flow too slow to finish.

The model never needs the geometry: submit_plan_to_simulator, the evaluator,
and the dashboard all read the route from session state, and the frontend
already received it in the original tool result. A before_model_callback now
replaces the geometry in prior tool results with a compact placeholder before
each model call. It only mutates the request and returns None, so it never
short-circuits the LLM (the secure-financial-modeling refusal card still works).
The planner_with_memory engine bundles planner + planner_with_eval + the
memory bank and runs pandas-based evaluation in-process, with multiple uvicorn
workers per instance. At 2Gi its workers were OOM-killed mid-request (~1
restart/min observed live, abrupt boots with no graceful shutdown), which
surfaced as model turns that never returned. Bump to 8Gi/2cpu for headroom.
@cwest cwest changed the title 🐛 fix(planner_with_memory): restore gemini-embedding-001 (3072-dim) embeddings 🐛 fix: make the planner_with_memory → simulator live flow run end-to-end Jun 19, 2026
cwest added 2 commits June 19, 2026 12:03
…ing-dimension

# Conflicts:
#	agents/planner/agent.py
#	agents/planner_with_eval/evaluator/tools.py
…ulator shims

The simulation-tools refactor to StartSimulationTool/SubmitPlanToSimulatorTool
classes dropped the module-level start_simulation()/submit_plan_to_simulator()
functions, but two things still called them: SubmitPlanToSimulatorTool's
"LLM skipped start_simulation" fallback path (a latent NameError at runtime),
and five existing tests. Both were broken.

Add thin async wrappers that adapt the tool classes to a keyword-argument call.
The ADK-facing API stays the BaseTool classes (adk_tools.py registers those);
these shims just give internal callers and tests a direct entry point.

Fixes 5 pre-existing test failures and the execute-without-start fallback.
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