Skip to content

Commit fddb96e

Browse files
committed
fix(core): retry invalid structured model responses
1 parent 9a903e6 commit fddb96e

6 files changed

Lines changed: 416 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: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,14 @@
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;
23+
import com.google.genai.types.Type;
2224
import io.reactivex.rxjava3.core.Single;
25+
import java.util.ArrayList;
26+
import java.util.LinkedHashMap;
27+
import java.util.List;
2328
import java.util.Map;
2429
import java.util.Optional;
2530

@@ -33,6 +38,12 @@
3338
public class SetModelResponseTool extends BaseTool {
3439
public static final String NAME = "set_model_response";
3540

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

3849
public SetModelResponseTool(Schema outputSchema) {
@@ -56,12 +67,66 @@ public Optional<FunctionDeclaration> declaration() {
5667

5768
@Override
5869
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.
70+
// Record validated responses on the event actions; return validation feedback so the model can
71+
// retry.
6172
return Single.fromCallable(
6273
() -> {
63-
SchemaUtils.validateMapOnSchema(args, outputSchema, /* isInput= */ false);
64-
return args;
74+
try {
75+
SchemaUtils.validateMapOnSchema(args, outputSchema, /* isInput= */ false);
76+
} catch (IllegalArgumentException e) {
77+
return ImmutableMap.of(
78+
"error",
79+
"Validation Error found:\n"
80+
+ sanitizeValidationMessage(e.getMessage())
81+
+ "\nRecall the set_model_response function correctly, fix the errors, and"
82+
+ " call it again with all required fields using the correct types.");
83+
}
84+
// Match Python's model_dump(exclude_none=True) for Java's map-shaped response.
85+
Map<String, Object> validatedResponse = excludeNullFields(args, outputSchema);
86+
toolContext.actions().setSetModelResponse(validatedResponse);
87+
return validatedResponse;
6588
});
6689
}
90+
91+
private static Map<String, Object> excludeNullFields(Map<String, Object> values, Schema schema) {
92+
Map<String, Schema> properties = schema.properties().get();
93+
Map<String, Object> result = new LinkedHashMap<>();
94+
for (Map.Entry<String, Object> entry : values.entrySet()) {
95+
Object value = entry.getValue();
96+
if (value != null) {
97+
result.put(entry.getKey(), excludeNullFields(value, properties.get(entry.getKey())));
98+
}
99+
}
100+
return result;
101+
}
102+
103+
@SuppressWarnings("unchecked")
104+
private static Object excludeNullFields(Object value, Schema schema) {
105+
Type.Known type = schema.type().get().knownEnum();
106+
if (type == Type.Known.OBJECT) {
107+
return excludeNullFields((Map<String, Object>) value, schema);
108+
}
109+
if (type == Type.Known.ARRAY) {
110+
Schema itemSchema = schema.items().get();
111+
List<Object> result = new ArrayList<>();
112+
for (Object item : (List<?>) value) {
113+
result.add(item == null ? null : excludeNullFields(item, itemSchema));
114+
}
115+
return result;
116+
}
117+
return value;
118+
}
119+
120+
private static String sanitizeValidationMessage(String message) {
121+
if (message == null) {
122+
return "Arguments do not match the output schema.";
123+
}
124+
// The model already knows the schema from the tool declaration, so the appended schema dump is
125+
// redundant in feedback.
126+
int schemaDumpIndex = message.indexOf(OUTPUT_SCHEMA_DUMP_MARKER);
127+
if (schemaDumpIndex >= 0) {
128+
message = message.substring(0, schemaDumpIndex) + " does not match agent output schema.";
129+
}
130+
return message;
131+
}
67132
}

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)