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
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 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.
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)
| 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.
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
| 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 |
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 —
AbstractAgentRuntime.javadiff = 0 lines. NofireStart/fireCompletion/fireErrorduplicated. Only tool-granular events added in bridge wrappers. ✅ - TODO-5 —
modules/semantic-kerneluntouched. SK correctly excluded. ✅ - Public static
fireToolCall/fireToolResulthelpers onAgentLifecycleListener— correct cross-module design. Adapter modules can't reachAbstractAgentRuntime.fireXxx(protected), so the public static mirror is the cleanest dispatch point. Swallow-and-continue per Invariant #2.
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–.
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)
| 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-in — OpenAiCompatibleClient.buildRequestBody emits prompt_cache_key |
✅ 3-state guard: enabled() + non-blank key filter |
A |
Spring AI — OpenAiChatOptions.builder().promptCacheKey(...) only on opt-in |
✅ Javadoc explicitly notes "other providers ignore OpenAI-specific fields" — provider portability preserved | A+ |
LC4j — OpenAiChatRequestParameters.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 |
- Wave 2 lesson absorbed retroactively. Every runtime declaring
PROMPT_CACHINGhas a contract-test subclass override forcreateCacheContext(). 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. - Metadata-sidecar over record-field expansion — H-3 fix landed exactly as recommended. Saved ~12–16 hours of migration work on
ChatMessagecall sites. - 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.
- Spring AI per-request option branching preserves provider portability. The
OpenAiChatOptionspath is only taken when a hint is enabled, keeping the genericChatOptionspath intact for non-OpenAI callers (Ollama, Anthropic, Gemini). - 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.
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.
- 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
AdkAgentRuntimeTestthat builds a context with a non-nullCacheHintin 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.
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/ragmigration. Commit ships 3/7 and does NOT migratemodules/rag. - Commit body argues rag doesn't need migration because both
ContextProviderimpls already operate at the higher-level retriever abstraction (VectorStore,ContentRetriever) and never callEmbeddingModeldirectly. - 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)EmbeddingRuntimeResolverfollows the sameServiceLoader+ priority pattern asAgentRuntimeResolver. - Will audit and add to this gist when ChefFamille asks for the Wave 5 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.
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
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.
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.
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.
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.adkexpose a publicEmbedderor equivalent in 1.0.0? Verify withjar tf google-adk-1.0.0.jar | grep -i embed. - Koog — 0.7.3 embedding primitives? Verify via
unzip -lon the koog jar. - Embabel — delegates to Spring AI; can ship a thin delegation wrapper if Spring AI's runtime is on classpath.
- SK —
EmbeddingGenerationServiceinsemantickernel-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.
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
- Built-in posts
/v1/embeddingswith model + input, parsesdata[].embeddingcorrectly. Same config surface asOpenAiCompatibleClient— works against OpenAI, Azure, Ollama, Gemini gateway.resolveModel()falls back totext-embedding-3-smallunless the configured chat model starts withtext-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 againstspring-ai-model-1.1.3.jarviajavap. - LC4j correctly unwraps
Response<Embedding>.content().vector()andmodel.embedAll(segments).content()withTextSegment.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
EmbeddingModelis 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.EmbeddingRuntimefiles. 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/mainreturns zero type references. Only Javadoc examples. The twoContextProviderimpls 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.
[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): zeroembedclasses → 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+EmbeddingGenerationServiceinterface → 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).
Landed files (8, +270/-29):
AgentExecutionContext.java: 18→19 fields + 18-arg/16-arg/15-arg shims preservedChatCompletionRequest.java: 11→12 fields + 11-arg/10-arg/9-arg/8-arg/7-arg/6-arg shims preservedOpenAiCompatibleClient.java:sendWithRetry()gainsRetryPolicy overrideparameterBuiltInAgentRuntime.buildRequest(): threadscontext.retryPolicy()via builderAbstractAgentRuntimeContractTest.java:runtimeAcceptsCustomRetryPolicyOnContext()+createRetryContext()opt-in hookBuiltInRuntimeContractTest.java: override returning context withRetryPolicy.NONEOpenAiCompatibleClientRetryPolicyTest.java(new, 5 tests): SPI plumbing coverageAbstractEmbeddingRuntimeContractTest.java: Wave-5 Javadoc polish (see F-5.4)
- 19th canonical field on
AgentExecutionContextwithRetryPolicy.DEFAULTdefault in the compact constructor. The 18-arg, 16-arg, and 15-arg shim constructors all delegate to the canonical withRetryPolicy.DEFAULT, so every prior-wave caller keeps compiling. All 8 wither methods (withMessage,withSystemPrompt,withMetadata, …) propagateretryPolicythrough the new constructor arg. NewwithRetryPolicy()wither added symmetrically. - 12th canonical field on
ChatCompletionRequestwithretryPolicy = nullmeaning "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 gainsretryPolicy(RetryPolicy)setter;build()threads it through the 12-arg canonical. OpenAiCompatibleClient.sendWithRetry(body, endpoint, session, override)correctly computeseffectivePolicy = override != null ? override : retryPolicy. Both the primary call site (execute()line 204) and the fallback-body call site (responses-api retry, line 221) threadrequest.retryPolicy(). The recursive call inside the tool-loop (line 346) rebuilds the next-round request withrequest.retryPolicy()preserved — override stays sticky across tool rounds.BuiltInAgentRuntime.buildRequest()(line 161-163) threadscontext.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, letsendWithRetrypick up the instance-level) is subtle. The current code passesRetryPolicy.DEFAULTexplicitly when context carries the default, which means every Built-in request now forces the override branch. Tolerable becauseRetryPolicy.DEFAULT.equals(client's default)by construction, but strict adherence to "null = inherit" would beif (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 tonullso framework runtimes that can't mock dispatch silently skip, andBuiltInRuntimeContractTestoverrides it to return aRetryPolicy.NONEcontext. Runtime actually executes and is asserted not to throwUnsupportedOperationException. This is real parity coverage — not a placeholder.
[F-6.1] Unit tests cover SPI plumbing but NOT retry-loop behavior. The 5 tests in OpenAiCompatibleClientRetryPolicyTest verify:
- Builder defaults
retryPolicyto null ✅ - Builder setter threads through ✅
- 11-arg shim defaults to null ✅
- Canonical constructor accepts explicit policy ✅
- 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 | 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).
- 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.
- [TODO-14] Add Embabel + SK
EmbeddingRuntimebridges OR correct the Wave-5 commit-body exclusion rationale to say "deferred to follow-up, API is present." - [TODO-15]
EmbeddingRuntimeResolver.resetCache()→public+ Javadoc "test-only", or document the AiConfig-before-resolve startup order. - [TODO-16] Wave-6 retry: either add a behavior test that fails on a 5xx endpoint with
RetryPolicy.NONEcontext and asserts 0 retry attempts, OR wireBuiltInAgentRuntime.buildRequestto skip threading whencontext.retryPolicy() == RetryPolicy.DEFAULTso client-level config stays live. - [TODO-17]
OpenAiCompatibleClientRetryPolicyTesttest 5: drop reflection, replace with behavior or package-private accessor. - [TODO-18]
AbstractEmbeddingRuntimeContractTestline 136: drop the tautologicalassertSame(Integer.class, Integer.valueOf(p).getClass())assertion.
- Javap-first per-runtime API verification kept agent honest (Spring AI
MediaFQCN, ADK ContextCacheConfig scope discovery, LC4jcustomParametersfallback, Spring AIEmbeddingModel.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
AgentExecutionContextand 12-argChatCompletionRequest— 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-installFakeEmbedderpattern 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/EmbeddingServiceuses it but never callsclose()/shutdown()on it
- Spring AI
org.springframework.ai.content.MediaFQCN: I claimed it didn't exist → agent'sunzip -l spring-ai-commons-*.jar | grep Mediaproved it does, across 1.0.0/1.1.1/1.1.3/2.0.0-M2. Lesson absorbed: before claiming "FQCN doesn't exist," runfind ~/.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.