Skip to content

Commit 6a562f2

Browse files
committed
fix(core): retry invalid structured model responses
1 parent 2b87d65 commit 6a562f2

6 files changed

Lines changed: 316 additions & 23 deletions

File tree

‎core/src/main/java/com/google/adk/events/EventActions.java‎

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ public class EventActions extends JsonBaseModel {
4545
private ConcurrentMap<String, ToolConfirmation> requestedToolConfirmations;
4646
private boolean endOfAgent;
4747
private @Nullable EventCompaction compaction;
48+
private @Nullable Object setModelResponse;
4849

4950
/** Default constructor for Jackson. */
5051
public EventActions() {
@@ -67,6 +68,7 @@ private EventActions(Builder builder) {
6768
this.requestedToolConfirmations = builder.requestedToolConfirmations;
6869
this.endOfAgent = builder.endOfAgent;
6970
this.compaction = builder.compaction;
71+
this.setModelResponse = builder.setModelResponse;
7072
}
7173

7274
@JsonProperty("skipSummarization")
@@ -201,6 +203,19 @@ public void setCompaction(@Nullable EventCompaction compaction) {
201203
this.compaction = compaction;
202204
}
203205

206+
/**
207+
* The successfully validated structured response set by the {@code set_model_response} tool.
208+
* Empty when the tool was not called or its arguments failed output-schema validation.
209+
*/
210+
@JsonProperty("setModelResponse")
211+
public Optional<Object> setModelResponse() {
212+
return Optional.ofNullable(setModelResponse);
213+
}
214+
215+
public void setSetModelResponse(@Nullable Object setModelResponse) {
216+
this.setModelResponse = setModelResponse;
217+
}
218+
204219
public static Builder builder() {
205220
return new Builder();
206221
}
@@ -226,7 +241,8 @@ public boolean equals(Object o) {
226241
&& Objects.equals(requestedAuthConfigs, that.requestedAuthConfigs)
227242
&& Objects.equals(requestedToolConfirmations, that.requestedToolConfirmations)
228243
&& (endOfAgent == that.endOfAgent)
229-
&& Objects.equals(compaction, that.compaction);
244+
&& Objects.equals(compaction, that.compaction)
245+
&& Objects.equals(setModelResponse, that.setModelResponse);
230246
}
231247

232248
@Override
@@ -241,7 +257,8 @@ public int hashCode() {
241257
requestedAuthConfigs,
242258
requestedToolConfirmations,
243259
endOfAgent,
244-
compaction);
260+
compaction,
261+
setModelResponse);
245262
}
246263

247264
/** Builder for {@link EventActions}. */
@@ -256,6 +273,7 @@ public static class Builder {
256273
private ConcurrentMap<String, ToolConfirmation> requestedToolConfirmations;
257274
private boolean endOfAgent = false;
258275
private @Nullable EventCompaction compaction;
276+
private @Nullable Object setModelResponse;
259277

260278
public Builder() {
261279
this.stateDelta = new ConcurrentHashMap<>();
@@ -277,6 +295,7 @@ private Builder(EventActions eventActions) {
277295
new ConcurrentHashMap<>(eventActions.requestedToolConfirmations());
278296
this.endOfAgent = eventActions.endOfAgent;
279297
this.compaction = eventActions.compaction;
298+
this.setModelResponse = eventActions.setModelResponse;
280299
}
281300

282301
@CanIgnoreReturnValue
@@ -383,6 +402,13 @@ public Builder compaction(@Nullable EventCompaction value) {
383402
return this;
384403
}
385404

405+
@CanIgnoreReturnValue
406+
@JsonProperty("setModelResponse")
407+
public Builder setModelResponse(@Nullable Object value) {
408+
this.setModelResponse = value;
409+
return this;
410+
}
411+
386412
@CanIgnoreReturnValue
387413
public Builder merge(EventActions other) {
388414
other.skipSummarization().ifPresent(this::skipSummarization);
@@ -395,6 +421,7 @@ public Builder merge(EventActions other) {
395421
this.requestedToolConfirmations.putAll(other.requestedToolConfirmations());
396422
this.endOfAgent = this.endOfAgent || other.endOfAgent();
397423
other.compaction().ifPresent(this::compaction);
424+
other.setModelResponse().ifPresent(this::setModelResponse);
398425
return this;
399426
}
400427

‎core/src/main/java/com/google/adk/flows/llmflows/OutputSchema.java‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -78,18 +78,24 @@ public Single<RequestProcessingResult> processRequest(
7878
}
7979

8080
/**
81-
* Check if function response contains set_model_response and extract JSON.
81+
* Extracts a successfully validated {@code set_model_response} result as JSON.
82+
*
83+
* <p>Only a result that passed output-schema validation (recorded on the event actions by {@link
84+
* SetModelResponseTool}) is returned. Validation feedback sent back to the model is never
85+
* promoted to the final structured response.
8286
*
8387
* @param functionResponseEvent The function response event to check.
84-
* @return JSON response string if set_model_response was called, Optional.empty() otherwise.
88+
* @return JSON response string if set_model_response succeeded, Optional.empty() otherwise.
8589
*/
8690
public static Optional<String> getStructuredModelResponse(Event functionResponseEvent) {
8791
for (FunctionResponse funcResponse : functionResponseEvent.functionResponses()) {
8892
if (Objects.equals(funcResponse.name().orElse(""), SetModelResponseTool.NAME)) {
89-
Object response = funcResponse.response();
90-
// The tool returns the args map directly.
93+
Optional<Object> validatedResponse = functionResponseEvent.actions().setModelResponse();
94+
if (validatedResponse.isEmpty()) {
95+
return Optional.empty();
96+
}
9197
try {
92-
return Optional.of(JsonBaseModel.getMapper().writeValueAsString(response));
98+
return Optional.of(JsonBaseModel.getMapper().writeValueAsString(validatedResponse.get()));
9399
} catch (JsonProcessingException e) {
94100
logger.error("Failed to serialize set_model_response result", e);
95101
return Optional.empty();

‎core/src/main/java/com/google/adk/tools/SetModelResponseTool.java‎

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,11 @@
1717
package com.google.adk.tools;
1818

1919
import com.google.adk.SchemaUtils;
20+
import com.google.common.collect.ImmutableMap;
2021
import com.google.genai.types.FunctionDeclaration;
2122
import com.google.genai.types.Schema;
2223
import io.reactivex.rxjava3.core.Single;
24+
import java.util.LinkedHashMap;
2325
import java.util.Map;
2426
import java.util.Optional;
2527

@@ -33,6 +35,12 @@
3335
public class SetModelResponseTool extends BaseTool {
3436
public static final String NAME = "set_model_response";
3537

38+
// Prefix of the SchemaUtils validation message after which the full schema is appended. Used to
39+
// strip the schema dump from feedback on a best-effort basis; if SchemaUtils changes its wording
40+
// the feedback simply stays unstripped. runAsync_unknownArg_feedbackOmitsSchemaDump pins the
41+
// current format.
42+
private static final String OUTPUT_SCHEMA_DUMP_MARKER = " does not match agent output schema: ";
43+
3644
private final Schema outputSchema;
3745

3846
public SetModelResponseTool(Schema outputSchema) {
@@ -56,12 +64,42 @@ public Optional<FunctionDeclaration> declaration() {
5664

5765
@Override
5866
public Single<Map<String, Object>> runAsync(Map<String, Object> args, ToolContext toolContext) {
59-
// This tool is a marker for the final response, it doesn't do anything but return its arguments
60-
// which will be captured as the final result.
67+
// Record validated responses on the event actions; return validation feedback so the model can
68+
// retry.
6169
return Single.fromCallable(
6270
() -> {
63-
SchemaUtils.validateMapOnSchema(args, outputSchema, /* isInput= */ false);
64-
return args;
71+
try {
72+
SchemaUtils.validateMapOnSchema(args, outputSchema, /* isInput= */ false);
73+
} catch (IllegalArgumentException e) {
74+
return ImmutableMap.of(
75+
"error",
76+
"Validation Error found:\n"
77+
+ sanitizeValidationMessage(e.getMessage())
78+
+ "\nRecall the set_model_response function correctly, fix the errors, and"
79+
+ " call it again with all required fields using the correct types.");
80+
}
81+
// Match Python's model_dump(exclude_none=True) for Java's map-shaped response.
82+
Map<String, Object> validatedResponse = new LinkedHashMap<>();
83+
for (Map.Entry<String, Object> entry : args.entrySet()) {
84+
if (entry.getValue() != null) {
85+
validatedResponse.put(entry.getKey(), entry.getValue());
86+
}
87+
}
88+
toolContext.actions().setSetModelResponse(validatedResponse);
89+
return validatedResponse;
6590
});
6691
}
92+
93+
private static String sanitizeValidationMessage(String message) {
94+
if (message == null) {
95+
return "Arguments do not match the output schema.";
96+
}
97+
// The model already knows the schema from the tool declaration, so the appended schema dump is
98+
// redundant in feedback.
99+
int schemaDumpIndex = message.indexOf(OUTPUT_SCHEMA_DUMP_MARKER);
100+
if (schemaDumpIndex >= 0) {
101+
message = message.substring(0, schemaDumpIndex) + " does not match agent output schema.";
102+
}
103+
return message;
104+
}
67105
}

‎core/src/test/java/com/google/adk/events/EventActionsTest.java‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ public void merge_mergesAllFields() {
8989
.requestedToolConfirmations(
9090
new ConcurrentHashMap<>(ImmutableMap.of("tool2", TOOL_CONFIRMATION)))
9191
.endOfAgent(true)
92+
.setModelResponse(ImmutableMap.of("field1", "value1"))
9293
.build();
9394

9495
EventActions merged = eventActions1.toBuilder().merge(eventActions2).build();
@@ -109,6 +110,7 @@ public void merge_mergesAllFields() {
109110
.containsExactly("tool1", TOOL_CONFIRMATION, "tool2", TOOL_CONFIRMATION);
110111
assertThat(merged.endOfAgent()).isTrue();
111112
assertThat(merged.compaction()).hasValue(COMPACTION);
113+
assertThat(merged.setModelResponse()).hasValue(ImmutableMap.of("field1", "value1"));
112114
}
113115

114116
@Test
@@ -177,13 +179,15 @@ public void jsonSerialization_works() throws Exception {
177179
EventActions.builder()
178180
.deletedArtifactIds(ImmutableSet.of("d1", "d2"))
179181
.stateDelta(new ConcurrentHashMap<>(ImmutableMap.of("k", "v")))
182+
.setModelResponse(ImmutableMap.of("field1", "value1"))
180183
.build();
181184

182185
String json = eventActions.toJson();
183186
EventActions deserialized = EventActions.fromJsonString(json, EventActions.class);
184187

185188
assertThat(deserialized).isEqualTo(eventActions);
186189
assertThat(deserialized.deletedArtifactIds()).containsExactly("d1", "d2");
190+
assertThat(deserialized.setModelResponse()).hasValue(ImmutableMap.of("field1", "value1"));
187191
}
188192

189193
@Test

0 commit comments

Comments
 (0)