Skip to content

Instantly share code, notes, and snippets.

@jfarcand
Last active April 11, 2026 21:51
Show Gist options
  • Select an option

  • Save jfarcand/63137ea2694dfa0a146cb0226cc7bd4d to your computer and use it in GitHub Desktop.

Select an option

Save jfarcand/63137ea2694dfa0a146cb0226cc7bd4d to your computer and use it in GitHub Desktop.
Atmosphere unified @agent close-out — Waves 1 & 2 review (2026-04-11)

Atmosphere unified @Agent close-out — Waves 1, 2, 3 & 4 review

Review date: 2026-04-11 Reviewer: Claude Opus 4.6 (read-only worktree review/wave-1) Plan gist: https://gist.github.com/jfarcand/2599173cd1887b17ed9dd7908c50a880 Previous review gist: https://gist.github.com/jfarcand/128d6fe48a195fe1a3b1aaab4fb8592a


Wave 1 — 2cd6c272a4 — ✅ clean · grade A

Title: feat(ai): declare TOOL_APPROVAL on every tool-calling runtime and expose configured models Scope: Phase 6 capability flag (subset) + Phase 11 models() exposure CI: 7/7 green, tagged wave-1-green Files: 8, +164/-15

Plan conformance

Plan item Shipped Grade
H-2 SK TOOL_APPROVAL correctly NOT declared ✅ with explicit Javadoc justification citing "dishonest until tool bridge lands" A+
Built-in + LC4j + Spring AI + Koog declare TOOL_APPROVAL ✅ 4/4 in Set.of() A
ADK pre-existing TOOL_APPROVAL A
Embabel correctly excluded ✅ no TOOL_APPROVAL (no tool path) A
runtimeWithToolCallingAlsoDeclaresToolApproval() contract assertion ✅ with override escape hatch A
S-1 fix: runtimeReportsConfiguredModelsAfterConfigure asserts non-null not non-empty ✅ exactly as review recommended A
ADK + Koog models() handle pre-request case honestly ✅ ADK returns List.of() when unconfigured, Koog returns deterministic defaultModel.id A
Admin controller AiRuntimeController exposes models ✅ single-line addition to shared describe() helper A
Bonus: SK declares STRUCTURED_OUTPUT (closing Invariant #5 regression) ✅ agent picked this up without being asked — the contract assertion forced it A+
Bonus: Built-in + Koog + SK contract test subclasses now exist ✅ closes the 3/6 → 6/6 coverage gap flagged in Phase 0 + plan reviews A+

Wave 1 grade: A. No tech debt, no hand-waving, no fix-it-later comments. Review feedback from the plan review (H-2, S-1) fully absorbed.


Wave 2 — e8adc27a47 — ✅ mostly clean · grade A– (coverage gap closed in follow-up 895a7e0a2e)

Title: feat(ai): wire multi-modal ContentPart translation into Spring AI, LC4j, ADK, and Built-in Scope: Phase 4 multi-modal parts translation CI: 7/7 green, tagged wave-2-green Files: 10, +493/-14 New file: OpenAiCompatibleClientMultiModalTest.java (166 lines, 3 tests)

Plan conformance

Wave 2 item Shipped Grade
Spring AI Media FQCN (org.springframework.ai.content.Media) — reviewer's plan-review warning was wrong ✅ correct FQCN verified by javap in spring-ai-commons A
LC4j ImageContent/AudioContent/PdfFileContent ✅ 5 content types wired A
ADK Part.fromBytes(byte[], mimeType) A
Built-in OpenAI wire image_url content block plus input_audio content block for gpt-4o-audio forward compatibility (bonus, not in plan) A+
VISION/AUDIO/MULTI_MODAL capability declarations — honest per runtime ✅ Built-in: VISION+MULTI_MODAL (no AUDIO — correct); LC4j/Spring AI/ADK: all three A
Embabel/Koog/SK correctly excluded A
Contract test runtimeWithVisionCapabilityAcceptsImagePart() ⚠ existed but silently skipped on ADK/Built-in — closed in follow-up 895a7e0a2e B → A
Per-runtime multimodal unit test (Built-in) OpenAiCompatibleClientMultiModalTest, 3 tests A

Wave 2 grade: A– → A after follow-up 895a7e0a2e closes the createImageContext() gap on ADK and Built-in contract subclasses.


Wave 3 — 3a92220a3d — ✅ correct on substance · grade A– (commit title imprecise on toolCallDelta)

Title: feat(ai): fire onToolCall/onToolResult and toolCallDelta from every runtime bridge Scope: Phase 3 lifecycle listeners (tool-granular) + Phase 5 toolCallDelta CI: 7/7 green Files: 19, +212/-69

Per-bridge coverage matrix

Runtime fireToolCall fireToolResult toolCallDelta Notes
LC4j (LangChain4jToolBridge) ✅ (success + error) No public API for partial args; would need raw-stream bypass
Spring AI (SpringAiToolBridge) ToolCallback.call(String) only sees complete args
ADK (AdkToolBridge) ✅ (success + error) Flowable<Event> delivers complete ToolRequests
Koog (AtmosphereToolBridge.kt) High-level chat() API, not raw StreamFrame subscription
Built-in (OpenAiCompatibleClient) ✅ (3 sites) ToolCallAccumulator already builds args incrementally — 3-line addition
SK / Embabel SK tool bridge deferred; Embabel inherits via Spring AI

Architectural justification for 1/5 toolCallDelta scope

The other 4 runtimes can't cheaply emit deltas without bypassing their high-level streaming APIs. The agent made the honest call: ship deltas where cheap, defer where expensive, document the scope. Right call, imprecise commit title (says "every runtime bridge" for both pairs when only Built-in emits deltas).

TODO-4 + TODO-5 verification

  • TODO-4AbstractAgentRuntime.java diff = 0 lines. No fireStart/fireCompletion/fireError duplicated. Only tool-granular events added in bridge wrappers. ✅
  • TODO-5modules/semantic-kernel untouched. SK correctly excluded. ✅
  • Public static fireToolCall/fireToolResult helpers on AgentLifecycleListener — correct cross-module design. Adapter modules can't reach AbstractAgentRuntime.fireXxx (protected), so the public static mirror is the cleanest dispatch point. Swallow-and-continue per Invariant #2.

Follow-up commit 895a7e0a2e — TODO-1 + TODO-2 closure

fix(ai): exercise VISION contract assertion on ADK and Built-in runtime subclasses — closes TODO-1/TODO-2 from the previous review. The commit body also notes "toolCallDelta coverage from Wave 3 remains Built-in-only — LC4j/Spring AI/ADK/Koog cannot emit deltas without bypassing their high-level streaming APIs", which is the right place to document TODO-8.

Wave 3 grade: A–.


Wave 4 — cdeb9906de — ✅ cleanest wave so far · grade A

Title: feat(ai): wire prompt caching hints into Spring AI, LC4j, ADK, and Built-in Scope: Phase 7 prompt caching via AgentExecutionContext.metadata sidecar Files: 12, +501/-18 New files: CacheHint.java (124 lines), OpenAiCompatibleClientCacheHintTest.java (132 lines, 5 tests)

Plan conformance

Wave 4 item Shipped Grade
H-3 honored: no ChatMessage record field expansion CacheHint rides AgentExecutionContext.metadata under canonical key ai.cache.hint. ChatMessage.java is NOT in the diff. 135-call-site migration avoided entirely. A+
CacheHint record: (CachePolicy policy, Optional<String> cacheKey, Optional<Duration> ttl) ✅ compact constructor normalizes nulls, METADATA_KEY public constant A
ChatCompletionRequest gains 11th canonical field cacheHint ✅ with 10-arg shim preserving Wave 3 listeners callers A
Tool-round recursion in doStreamWithToolLoop preserves the hint request.cacheHint() threaded into follow-up request A
Built-inOpenAiCompatibleClient.buildRequestBody emits prompt_cache_key ✅ 3-state guard: enabled() + non-blank key filter A
Spring AIOpenAiChatOptions.builder().promptCacheKey(...) only on opt-in ✅ Javadoc explicitly notes "other providers ignore OpenAI-specific fields" — provider portability preserved A+
LC4jOpenAiChatRequestParameters.customParameters(Map.of("prompt_cache_key", ...)) ✅ Javadoc names the reason: "LC4j's generic ChatRequest.Builder has no typed cache accessor" A
ADK — log-and-ignore with debug trace, does NOT declare PROMPT_CACHING ✅ Javadoc ties the decision to D-6 soft-cancel: "rebuilding the Runner for every invocation would churn memory and break the D-6 soft-cancel guarantees" (Invariant #5 preserved) A+
Koog / SK / Embabel excluded ✅ Koog 0.7.3 only ships Bedrock cache variants; SK deferred with tool bridge; Embabel no-op A
Contract assertion runtimeWithPromptCachingAcceptsCacheHint() + createCacheContext() hook A
3/3 declaring runtimes have createCacheContext overrides (Built-in, LC4j, Spring AI) no silent-skip gap this time — contrast with Wave 2's ADK/Built-in VISION miss A+
5 unit tests in OpenAiCompatibleClientCacheHintTest ✅ absent hint / NONE / CONSERVATIVE+key / AGGRESSIVE+key / blank key — all negative cases covered A

Things Wave 4 did better than the plan

  1. Wave 2 lesson absorbed retroactively. Every runtime declaring PROMPT_CACHING has a contract-test subclass override for createCacheContext(). No "assertion exists but silently skips" pattern this time. TODO-1/TODO-2 discipline is internalized — Wave 4 ships with the coverage gap already closed.
  2. Metadata-sidecar over record-field expansion — H-3 fix landed exactly as recommended. Saved ~12–16 hours of migration work on ChatMessage call sites.
  3. ADK log-and-ignore with architectural-cost disclosure. Javadoc ties the decision to D-6 soft-cancel. Future readers know why the shortcut was taken and what they'd pay to revert it.
  4. Spring AI per-request option branching preserves provider portability. The OpenAiChatOptions path is only taken when a hint is enabled, keeping the generic ChatOptions path intact for non-OpenAI callers (Ollama, Anthropic, Gemini).
  5. 5 unit tests including negative cases — not just happy path, but also "blank key doesn't emit" and "NONE policy suppresses even when a key is present." Negative paths are where cache-control bugs hide in production.
  6. CacheHint.resolvedKey(context) fallback to session id when no explicit key is provided — sensible default that makes cache hits work out of the box for simple users without forcing them to manage cache keys themselves.

Minor follow-ups

  • F-1 — @AiEndpoint.promptCache() annotation attribute deferred (acknowledged). Called out in the Wave 4 commit body. The SPI path is complete; the annotation is sugar and can land separately.
  • F-2 — ADK log-and-ignore has no contract-test guard. Soft. Add one unit test in AdkAgentRuntimeTest that builds a context with a non-null CacheHint in metadata, invokes a mock runner, asserts the log message was emitted without exception. ~15 lines. Not a blocker.

Wave 4 grade: A. Cleanest wave so far. No tech debt, no silent-skip contract-test gap, H-3 absorbed fully, ADK exclusion honestly documented.


Wave 5 — 570fc59492 — ⏳ on main, review pending

Title: feat(ai): add EmbeddingRuntime SPI implementations for Spring AI, LC4j, and Built-in Files: 11, +956 lines New files: AbstractEmbeddingRuntimeContractTest, EmbeddingRuntimeResolver, BuiltInEmbeddingRuntime, LangChain4jEmbeddingRuntime, SpringAiEmbeddingRuntime, 3 contract test subclasses, 3 ServiceLoader registrations

Pending review notes (not yet audited line-by-line):

  • Plan scoped 7 implementations (Spring AI, LC4j, ADK, Koog, Embabel, SK, Built-in) + a modules/rag migration. Commit ships 3/7 and does NOT migrate modules/rag.
  • Commit body argues rag doesn't need migration because both ContextProvider impls already operate at the higher-level retriever abstraction (VectorStore, ContentRetriever) and never call EmbeddingModel directly.
  • Need to verify: (a) the claim is correct via grep on modules/rag, (b) ADK/Koog/Embabel/SK embedding APIs are genuinely unavailable or out of scope (per the cache-wave pattern of javap-verified exclusions), (c) EmbeddingRuntimeResolver follows the same ServiceLoader + priority pattern as AgentRuntimeResolver.
  • Will audit and add to this gist when ChefFamille asks for the Wave 5 review.

Reviewer correction (from plan review)

In the plan review (gist 2599173c comments), I wrote that org.springframework.ai.content.Media "does not exist in any Spring AI version I've seen." Wrong. Verified via unzip -l ~/.m2/repository/org/springframework/ai/spring-ai-commons/*/spring-ai-commons-*.jar | grep Media.class — present in 1.0.0, 1.1.1, 1.1.3, and 2.0.0-M2. The plan was correct; my warning was incorrect. The agent ran javap before coding and shipped the right FQCN.

Lesson: before claiming "FQCN doesn't exist," run find ~/.m2/repository -name <Class>.class. Javap discipline beats memory.


TODO — follow-ups

TODO-1 — ADK createImageContext() override ✅ DONE (895a7e0a2e)

TODO-2 — Built-in createImageContext() override ✅ DONE (895a7e0a2e)

TODO-3 — Plan gist Wave 3 section updated to bridge-wrapper approach ✅ DONE

TODO-4 — Wave 3 single-source-of-truth caveat ✅ VERIFIED clean

TODO-5 — Wave 3 SK exclusion ✅ VERIFIED clean

TODO-6 — Blockers-log entry D-11 for ADK VISION contract-skip ✅ obsolete (closed by 895a7e0a2e)

TODO-7 — Reviewer FQCN mistake ✅ DOCUMENTED

TODO-8 — Wave 3 commit title imprecision ✅ DOCUMENTED (895a7e0a2e follow-up commit body)

TODO-9 — Pre-flight javap discipline ✅ HONORED IN WAVE 4

Wave 4 ran the javap pre-flight per the review recommendation:

  • Spring AI OpenAiChatOptions.builder().promptCacheKey(...) — verified available
  • LC4j — no typed cache accessor on generic builder → chose OpenAiChatRequestParameters.customParameters(Map)
  • ADK ContextCacheConfig — verified App.Builder-scoped, hence log-and-ignore
  • Koog 0.7.3 — verified Bedrock-only variants, hence exclusion

TODO-10 — F-1 @AiEndpoint.promptCache() annotation attribute ⚠ NEW

Severity: Convenience / ergonomics Status: Open — deferred by Wave 4 commit body Cost: ~10 lines (annotation element) + AiEndpointProcessor wiring

Programmatic callers can set the hint via context.withMetadata(Map.of(CacheHint.METADATA_KEY, CacheHint.conservative())) today. The annotation attribute is sugar over this. Land as a single-commit follow-up after the 6-wave roadmap is complete, or fold into a future "roadmap polish" wave. Not blocking.

TODO-11 — F-2 ADK CacheHint log-and-ignore unit test ⚠ NEW

Severity: Soft (no correctness gap) Status: Open — defensive coverage for a currently-untested path Cost: ~15 lines in AdkAgentRuntimeTest

Add one unit test that builds an AgentExecutionContext with a non-null CacheHint in metadata, invokes a mock Runner, and asserts the ADK debug log message was emitted without throwing. Guards against future changes to CacheHint.from or the ADK path that might introduce a regression.

TODO-12 — Verify Wave 5 modules/rag claim ⚠ NEW

Severity: Spot-check required before endorsing Wave 5 scope reduction Status: Open — pending Wave 5 review request

Wave 5 commit body says modules/rag doesn't need the EmbeddingRuntime migration because ContextProvider impls operate at the retriever abstraction level. Before endorsing: grep modules/rag for direct EmbeddingModel references (grep -rn "EmbeddingModel\|EmbeddingRuntime" modules/rag/src/main). If zero direct references, the scope reduction is justified. If any, flag as blocker for a Wave 5.5 follow-up.

TODO-13 — Audit Wave 5 exclusions (ADK, Koog, Embabel, SK) ⚠ NEW

Severity: Plan-vs-reality discipline Status: Open — pending Wave 5 review request

Wave 5 plan scoped 7 runtimes; shipped 3 (Spring AI, LC4j, Built-in). Need to verify each exclusion is javap-justified, not plan-drift:

  • ADK — does com.google.adk expose a public Embedder or equivalent in 1.0.0? Verify with jar tf google-adk-1.0.0.jar | grep -i embed.
  • Koog — 0.7.3 embedding primitives? Verify via unzip -l on the koog jar.
  • Embabel — delegates to Spring AI; can ship a thin delegation wrapper if Spring AI's runtime is on classpath.
  • SKEmbeddingGenerationService in semantickernel-aiservices-*-embeddings (separate dependency); verify whether the module already pulls this in.

Each exclusion should be accompanied by either a javap verification showing the API isn't available, or a "delegates to X" note, or a deferred-with-reason note. Same discipline as Wave 4's ADK log-and-ignore documentation.



Wave 5 review — 570fc59492 (EmbeddingRuntime SPI impls)

Landed files (11, +956):

  • SPI: EmbeddingRuntimeResolver.java
  • Built-in: BuiltInEmbeddingRuntime.java + services/org.atmosphere.ai.EmbeddingRuntime + test
  • Spring AI: SpringAiEmbeddingRuntime.java + service + contract test
  • LC4j: LangChain4jEmbeddingRuntime.java + service + contract test
  • Contract base: AbstractEmbeddingRuntimeContractTest.java

What holds

  • Built-in posts /v1/embeddings with model + input, parses data[].embedding correctly. Same config surface as OpenAiCompatibleClient — works against OpenAI, Azure, Ollama, Gemini gateway. resolveModel() falls back to text-embedding-3-small unless the configured chat model starts with text-embedding, preventing accidental chat-model leaks into the embeddings path.
  • Spring AI maps 1:1: model.embed(String) → float[], model.embed(List<String>) → List<float[]>, model.dimensions() → int. Verified against spring-ai-model-1.1.3.jar via javap.
  • LC4j correctly unwraps Response<Embedding>.content().vector() and model.embedAll(segments).content() with TextSegment.from(text) batch conversion. dimension() (LC4j singular) → dimensions() (Atmosphere plural) translation is explicit.
  • Priority ordering: Spring AI 200 > LC4j 190 > Built-in 50. When a framework-native EmbeddingModel is wired the adapter wraps it; Built-in is the zero-dep fallback. Matches the chat-runtime resolver semantics.
  • ServiceLoader discovery via three META-INF/services/org.atmosphere.ai.EmbeddingRuntime files.
  • isAvailable() gate on every runtime — Spring AI/LC4j return false when the static/instance model is null, so discovery skips them cleanly. No NPEs on partial classpaths.
  • TODO-12 claim verified: grep EmbeddingModel modules/rag/src/main returns zero type references. Only Javadoc examples. The two ContextProvider impls genuinely operate at the retriever/VectorStore level. No migration work needed.
  • Contract test is real: 6 assertions (name, single-embed, batch-embed, empty batch, is-available, dimensions, priority stability). The deterministic-fake pattern (vector[0] = text.length()) proves actual round-trip through the adapter wrap layer, not just non-null returns.

Findings (non-blocking)

[F-5.1] Exclusion rationale for Embabel + SK is overstated. Commit body says "the three runtimes whose embedding APIs Atmosphere actually depends on." javap says otherwise:

  • ADK (google-adk-1.0.0.jar): zero embed classes → exclusion clean.
  • Koog (prompt-llm-jvm-0.7.3.jar): LLMCapability$Embed + OpenRouterEmbeddingRequest/Response/Data → provider-specific, not a general abstraction. Defensible.
  • Embabel (examples-common-0.3.5-SNAPSHOT.jar): com.embabel.common.ai.model.EmbeddingService (interface) + SpringAiEmbeddingService (concrete) → general-purpose embedding abstraction exists. Skippable by delegating to SpringAiEmbeddingRuntime in practice, but the exclusion shouldn't be framed as "not available."
  • Semantic Kernel (semantickernel-aiservices-openai.jar): TextEmbeddingGenerationService + OpenAITextEmbeddingGenerationService + EmbeddingGenerationService interface → first-class embedding SPI present. The exclusion claim is factually wrong.

More honest framing: "the three runtimes with the simplest wire paths; Embabel and SK have embedding APIs but the adapter work is deferred to a follow-up because [reason]."

[F-5.2] EmbeddingRuntimeResolver.cachedAll can capture a transient unavailability. If EmbeddingRuntimeResolver.resolve() fires before AiConfig.set(), Built-in's isAvailable() returns false, and the runtime is excluded from cachedAll permanently. Reconfiguring AiConfig at runtime will not re-discover it. In practice AiConfig is wired at app startup before any caller, so this is an edge case — but resetCache() is package-private to org.atmosphere.ai and BuiltInEmbeddingRuntimeTest lives in org.atmosphere.ai.llm, so the reset hook can't reach the test. Worth promoting resetCache() to public (clearly test-only) or documenting the startup-order constraint.

[F-5.3] Tautological assertion in contract test line 136: assertSame(Integer.class, Integer.valueOf(p1).getClass()) is always true by definition of autoboxing. Either drop it or replace with a meaningful check (e.g., assertTrue(p1 >= 0 || p1 == -1) to match the dimension convention). Trivial cleanup.

[F-5.4] Scope bleed into Wave 6: Wave 6 includes an unrelated Javadoc wording fix on AbstractEmbeddingRuntimeContractTest.java (+11/-11, "deterministic fake implementation" → "deterministic in-process embedder"). Minor, but it's bundling Wave-5 polish into a Wave-6 commit — not ideal for bisect cleanliness. Not a blocker; a separate docs(ai-test): commit would have been cleaner.

Grade: A−. Core work is solid, contract test is real, no wire-level bugs. Marked down for F-5.1 (exclusion wording) and F-5.4 (cross-wave bleed).


Wave 6 review — ae0c2d06ad (per-request RetryPolicy override)

Landed files (8, +270/-29):

  • AgentExecutionContext.java: 18→19 fields + 18-arg/16-arg/15-arg shims preserved
  • ChatCompletionRequest.java: 11→12 fields + 11-arg/10-arg/9-arg/8-arg/7-arg/6-arg shims preserved
  • OpenAiCompatibleClient.java: sendWithRetry() gains RetryPolicy override parameter
  • BuiltInAgentRuntime.buildRequest(): threads context.retryPolicy() via builder
  • AbstractAgentRuntimeContractTest.java: runtimeAcceptsCustomRetryPolicyOnContext() + createRetryContext() opt-in hook
  • BuiltInRuntimeContractTest.java: override returning context with RetryPolicy.NONE
  • OpenAiCompatibleClientRetryPolicyTest.java (new, 5 tests): SPI plumbing coverage
  • AbstractEmbeddingRuntimeContractTest.java: Wave-5 Javadoc polish (see F-5.4)

What holds

  • 19th canonical field on AgentExecutionContext with RetryPolicy.DEFAULT default in the compact constructor. The 18-arg, 16-arg, and 15-arg shim constructors all delegate to the canonical with RetryPolicy.DEFAULT, so every prior-wave caller keeps compiling. All 8 wither methods (withMessage, withSystemPrompt, withMetadata, …) propagate retryPolicy through the new constructor arg. New withRetryPolicy() wither added symmetrically.
  • 12th canonical field on ChatCompletionRequest with retryPolicy = null meaning "inherit the client's instance-level default" (documented in the canonical constructor's Javadoc). Six shim constructors preserved (11/10/9/8/7/6 args) — all pre-Wave-6 call sites keep working. Builder gains retryPolicy(RetryPolicy) setter; build() threads it through the 12-arg canonical.
  • OpenAiCompatibleClient.sendWithRetry(body, endpoint, session, override) correctly computes effectivePolicy = override != null ? override : retryPolicy. Both the primary call site (execute() line 204) and the fallback-body call site (responses-api retry, line 221) thread request.retryPolicy(). The recursive call inside the tool-loop (line 346) rebuilds the next-round request with request.retryPolicy() preserved — override stays sticky across tool rounds.
  • BuiltInAgentRuntime.buildRequest() (line 161-163) threads context.retryPolicy() into the builder. The comment is honest: "The context constructor defaults to RetryPolicy.DEFAULT, so this is always non-null" — so the null check is cosmetic defense, but the intent (don't override the client default with an explicit DEFAULT, let sendWithRetry pick up the instance-level) is subtle. The current code passes RetryPolicy.DEFAULT explicitly when context carries the default, which means every Built-in request now forces the override branch. Tolerable because RetryPolicy.DEFAULT.equals(client's default) by construction, but strict adherence to "null = inherit" would be if (context.retryPolicy() != RetryPolicy.DEFAULT) builder.retryPolicy(...). Not a bug — just a minor stylistic asymmetry.
  • Framework runtimes (Spring AI, LC4j, ADK, Koog, Embabel) correctly NOT wrapping retry. This was the S-3 rejection in pre-plan review: those frameworks carry their own retry layers (Spring Retry, LC4j RetryUtils, ADK's HttpClient). Wrapping them in Atmosphere's retry would double-retry on failure. Commit body is explicit: "framework runtimes inherit their native retry layers." This is the correct mode-parity exception (Invariant #7: documented intentional divergence).
  • Contract test hook createRetryContext() defaults to null so framework runtimes that can't mock dispatch silently skip, and BuiltInRuntimeContractTest overrides it to return a RetryPolicy.NONE context. Runtime actually executes and is asserted not to throw UnsupportedOperationException. This is real parity coverage — not a placeholder.

Findings (non-blocking)

[F-6.1] Unit tests cover SPI plumbing but NOT retry-loop behavior. The 5 tests in OpenAiCompatibleClientRetryPolicyTest verify:

  1. Builder defaults retryPolicy to null ✅
  2. Builder setter threads through ✅
  3. 11-arg shim defaults to null ✅
  4. Canonical constructor accepts explicit policy ✅
  5. Client reflection exposes instance-level policy ✅

What's missing: a behavior test proving the effectivePolicy = override != null ? override : retryPolicy branch actually fires inside sendWithRetry. Right now nothing proves that setting RetryPolicy.NONE via context results in 0 retries on a failing request. The test file Javadoc calls this out: "the full retry-loop semantics are exercised in OpenAiCompatibleClientCancelTest and the integration tests." I did NOT verify that those other tests actually assert the override path. Worth a one-line check — if OpenAiCompatibleClientCancelTest doesn't thread request.retryPolicy(), the override branch is untested in CI.

[F-6.2] OpenAiCompatibleClientRetryPolicyTest test 5 uses reflection on a private field. Field f = OpenAiCompatibleClient.class.getDeclaredField("retryPolicy"); f.setAccessible(true). This is fragile — any rename of retryPolicy silently breaks the test, and reflection-on-private is a smell. Better pattern: expose a package-private retryPolicy() accessor, or assert behavior (e.g., send a request that would fail without retries and count attempts). Not a blocker, but future code review would flag it.

[F-6.3] BuiltInAgentRuntime.buildRequest always threads context.retryPolicy() explicitly. Because AgentExecutionContext's compact constructor defaults to RetryPolicy.DEFAULT, the if (context.retryPolicy() != null) check in buildRequest is always true. Every Built-in request now has an explicit per-request override, which means the client's instance-level default retry config is dead code for Built-in runtime callers. The client-builder-level retryPolicy() setter still matters for direct OpenAiCompatibleClient users (bypassing AgentRuntime), but anyone going through BuiltInAgentRuntime.execute() has their client-level retry config silently ignored. This is consistent with "context wins" semantics, but it's a behavior change worth calling out. If a user today sets OpenAiCompatibleClient.builder().retryPolicy(RetryPolicy.of(10, ...)) and routes through AgentRuntime, their 10-retry policy becomes 3-retry (the context default). Soft regression risk for anyone using both APIs together.

Mitigation: document "context policy overrides client policy" in AgentExecutionContext Javadoc, or change buildRequest to only thread the policy when it differs from RetryPolicy.DEFAULT (reference equality or null check). I'd prefer the second — keeps the override opt-in.

Grade: A−. Plumbing is clean, parity hook is real, framework-runtime non-wrapping decision is correct. Marked down for F-6.1 (missing retry-loop behavior test) and F-6.3 (subtle client-policy shadowing).


Wave status snapshot (2026-04-11, final — roadmap complete)

Wave SHA Title CI Grade
Recovery bf8887c70c feat(ai): add token telemetry, soft-cancel, policy plumbing, and A2A approval round-trip 7/7 ✅ A
1 2cd6c272a4 feat(ai): declare TOOL_APPROVAL on every tool-calling runtime and expose configured models 7/7 ✅ A
2 e8adc27a47 feat(ai): wire multi-modal ContentPart translation into Spring AI, LC4j, ADK, and Built-in 7/7 ✅ A
3 3a92220a3d feat(ai): fire onToolCall/onToolResult and toolCallDelta from every runtime bridge 7/7 ✅ A−
3 follow-up 895a7e0a2e fix(ai): exercise VISION contract assertion on ADK and Built-in runtime subclasses Green with 3 A
4 cdeb9906de feat(ai): wire prompt caching hints into Spring AI, LC4j, ADK, and Built-in Assumed green A
5 570fc59492 feat(ai): add EmbeddingRuntime SPI implementations for Spring AI, LC4j, and Built-in Green (Wave 5 CI) A−
6 ae0c2d06ad feat(ai): thread per-request RetryPolicy through AgentExecutionContext to Built-in client Pending CI poll A−

Roadmap close-out: 6/6 shipped. Final grade: A (weighted).

Why A and not A+:

  • F-5.1 overstated exclusion rationale (Embabel + SK have embedding APIs)
  • F-5.4 cross-wave scope bleed (Wave-5 Javadoc fix in Wave-6 commit)
  • F-6.3 client-level retry config becomes dead code for AgentRuntime callers (subtle regression)
  • F-6.1 retry-loop override path lacks a direct behavior test

None are blockers. All are follow-up candidates for a Wave-7 polish pass if priorities allow.

Follow-ups (TODOs for a polish pass)

  1. [TODO-14] Add Embabel + SK EmbeddingRuntime bridges OR correct the Wave-5 commit-body exclusion rationale to say "deferred to follow-up, API is present."
  2. [TODO-15] EmbeddingRuntimeResolver.resetCache()public + Javadoc "test-only", or document the AiConfig-before-resolve startup order.
  3. [TODO-16] Wave-6 retry: either add a behavior test that fails on a 5xx endpoint with RetryPolicy.NONE context and asserts 0 retry attempts, OR wire BuiltInAgentRuntime.buildRequest to skip threading when context.retryPolicy() == RetryPolicy.DEFAULT so client-level config stays live.
  4. [TODO-17] OpenAiCompatibleClientRetryPolicyTest test 5: drop reflection, replace with behavior or package-private accessor.
  5. [TODO-18] AbstractEmbeddingRuntimeContractTest line 136: drop the tautological assertSame(Integer.class, Integer.valueOf(p).getClass()) assertion.

Disciplines holding through close-out:

  • Javap-first per-runtime API verification kept agent honest (Spring AI Media FQCN, ADK ContextCacheConfig scope discovery, LC4j customParameters fallback, Spring AI EmbeddingModel.embed(List<String>) signature)
  • Contract test parity enforced via cross-wave base class accumulation (now 7 opt-in hooks: createImageContext, createCacheContext, createRetryContext, plus the listener/policy/structured-output assertions)
  • Shim-constructor discipline preserved across 19-arg AgentExecutionContext and 12-arg ChatCompletionRequest — zero pre-Wave-6 call sites broken
  • Honest exclusion disclosure: Wave 4's ADK log-and-ignore, Wave 6's framework-runtime non-wrapping both cite S-3 rejection rationale
  • No silent-skip contract assertions: Wave 2 lesson absorbed; Wave 5's createRuntime()-then-installFakeEmbedder pattern forces every concrete to actually wire the fake
  • No force pushes, no merge commits, fast-forward only, no AI attribution in any commit body
  • Plan-to-commit-title match (Wave 3 is the documented exception)
  • Ownership invariant held: every runtime that accepts an external EmbeddingModel / EmbeddingService uses it but never calls close() / shutdown() on it

Reviewer errors caught during the session:

  • Spring AI org.springframework.ai.content.Media FQCN: I claimed it didn't exist → agent's unzip -l spring-ai-commons-*.jar | grep Media proved it does, across 1.0.0/1.1.1/1.1.3/2.0.0-M2. Lesson absorbed: before claiming "FQCN doesn't exist," run find ~/.m2/repository -name <Class>.class.
  • Wave 5 Embabel/SK exclusion: agent claimed these frameworks don't expose embedding APIs. I verified via unzip -l | grep Embed → both do expose general-purpose embedding interfaces. This one goes the other way — the agent's claim was overstated, not mine.

Score balance: reviewer caught 1 real architectural concern (H-2 SK TOOL_APPROVAL dishonesty, Phase 0), 1 real scope concern (H-3 ChatMessage 135 call sites → metadata sidecar), and 1 exclusion-rationale concern (Wave 5 Embabel/SK). Reviewer was wrong on 1 FQCN. Net: useful signal, cost of one Verdun micro-brew.

Roadmap: complete. Standing by for Wave 6 CI poll.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment