diff --git a/agentscope-core/src/main/java/io/agentscope/core/agent/StructuredOutputCapableAgent.java b/agentscope-core/src/main/java/io/agentscope/core/agent/StructuredOutputCapableAgent.java index 06a5eaeea..3a8b2313d 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/agent/StructuredOutputCapableAgent.java +++ b/agentscope-core/src/main/java/io/agentscope/core/agent/StructuredOutputCapableAgent.java @@ -1,345 +1,369 @@ -/* - * Copyright 2024-2026 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package io.agentscope.core.agent; - -import com.fasterxml.jackson.databind.JsonNode; -import io.agentscope.core.hook.Hook; -import io.agentscope.core.memory.Memory; -import io.agentscope.core.message.ContentBlock; -import io.agentscope.core.message.MessageMetadataKeys; -import io.agentscope.core.message.Msg; -import io.agentscope.core.message.MsgRole; -import io.agentscope.core.message.TextBlock; -import io.agentscope.core.message.ThinkingBlock; -import io.agentscope.core.message.ToolResultBlock; -import io.agentscope.core.model.ChatUsage; -import io.agentscope.core.model.GenerateOptions; -import io.agentscope.core.model.StructuredOutputReminder; -import io.agentscope.core.tool.AgentTool; -import io.agentscope.core.tool.ToolCallParam; -import io.agentscope.core.tool.Toolkit; -import io.agentscope.core.util.JsonSchemaUtils; -import io.agentscope.core.util.JsonUtils; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import reactor.core.publisher.Mono; - -/** - * Abstract base class for agents that support structured output generation. - * - *

This class provides the infrastructure for generating structured output using the - * {@code generate_response} tool pattern combined with StructuredOutputHook for flow control. - * - *

Key Features: - *

- * - *

Subclass Requirements: - *

- */ -public abstract class StructuredOutputCapableAgent extends AgentBase { - - private static final Logger log = LoggerFactory.getLogger(StructuredOutputCapableAgent.class); - - /** The tool name for structured output generation. */ - public static final String STRUCTURED_OUTPUT_TOOL_NAME = "generate_response"; - - protected final Toolkit toolkit; - protected final StructuredOutputReminder structuredOutputReminder; - - /** - * Constructor with default reminder mode (TOOL_CHOICE). - */ - protected StructuredOutputCapableAgent( - String name, - String description, - boolean checkRunning, - List hooks, - Toolkit toolkit) { - this(name, description, checkRunning, hooks, toolkit, StructuredOutputReminder.TOOL_CHOICE); - } - - /** - * Constructor with custom reminder mode. - */ - protected StructuredOutputCapableAgent( - String name, - String description, - boolean checkRunning, - List hooks, - Toolkit toolkit, - StructuredOutputReminder structuredOutputReminder) { - super(name, description, checkRunning, hooks); - this.toolkit = toolkit != null ? toolkit : new Toolkit(); - this.structuredOutputReminder = - structuredOutputReminder != null - ? structuredOutputReminder - : StructuredOutputReminder.TOOL_CHOICE; - } - - /** - * Get the toolkit for tool operations. - */ - public Toolkit getToolkit() { - return toolkit; - } - - /** - * Get the memory for structured output hook. - * Subclasses must implement this. - */ - public abstract Memory getMemory(); - - /** - * Build generate options for model calls. - * Subclasses must implement this. - */ - protected abstract GenerateOptions buildGenerateOptions(); - - // ==================== Structured Output Implementation ==================== - - @Override - protected final Mono doCall(List msgs, Class structuredOutputClass) { - return executeWithStructuredOutput(msgs, structuredOutputClass, null); - } - - @Override - protected final Mono doCall(List msgs, JsonNode outputSchema) { - return executeWithStructuredOutput(msgs, null, outputSchema); - } - - /** - * Execute with structured output using StructuredOutputHook. - */ - private Mono executeWithStructuredOutput( - List msgs, Class targetClass, JsonNode schemaDesc) { - - // Validate parameters - if (targetClass == null && schemaDesc == null) { - return Mono.error( - new IllegalArgumentException( - "Either targetClass or schemaDesc must be provided")); - } - if (targetClass != null && schemaDesc != null) { - return Mono.error( - new IllegalArgumentException("Cannot provide both targetClass and schemaDesc")); - } - - return Mono.defer( - () -> { - // Create and register temporary tool - Map jsonSchema = - targetClass != null - ? JsonSchemaUtils.generateSchemaFromClass(targetClass) - : JsonSchemaUtils.generateSchemaFromJsonNode(schemaDesc); - AgentTool structuredOutputTool = - createStructuredOutputTool(jsonSchema, targetClass, schemaDesc); - toolkit.registerAgentTool(structuredOutputTool); - - // Create hook for flow control - StructuredOutputHook hook = - new StructuredOutputHook( - structuredOutputReminder, buildGenerateOptions(), getMemory()); - - addHook(hook); - - return doCall(msgs) - .flatMap( - result -> { - // Extract result from hook's output - Msg hookResult = hook.getResultMsg(); - if (hookResult != null) { - Msg extracted = extractStructuredResult(hookResult); - // Merge aggregated metadata from reasoning rounds - if (extracted != null) { - extracted = - mergeCollectedMetadata( - extracted, - hook.getAggregatedUsage(), - hook.getAggregatedThinking()); - } - return Mono.just(extracted); - } - return Mono.just(result); - }) - .doFinally( - signal -> { - removeHook(hook); - toolkit.removeToolIfSame( - STRUCTURED_OUTPUT_TOOL_NAME, structuredOutputTool); - }); - }); - } - - /** - * Create the structured output tool with validation. - */ - private AgentTool createStructuredOutputTool( - Map schema, Class targetClass, JsonNode schemaDesc) { - return new AgentTool() { - @Override - public String getName() { - return STRUCTURED_OUTPUT_TOOL_NAME; - } - - @Override - public String getDescription() { - return "Generate the final structured response. Call this function when" - + " you have all the information needed to provide a complete answer."; - } - - @Override - public Map getParameters() { - Map params = new HashMap<>(); - params.put("type", "object"); - params.put("properties", Map.of("response", schema)); - params.put("required", List.of("response")); - return params; - } - - @Override - public Mono callAsync(ToolCallParam param) { - return Mono.fromCallable( - () -> { - Object responseData = param.getInput().get("response"); - - // The tool simply stores the raw data - // Validation is done by ToolExecutor before calling this - String contentText = ""; - if (responseData != null) { - try { - contentText = JsonUtils.getJsonCodec().toJson(responseData); - } catch (Exception e) { - contentText = responseData.toString(); - } - } - - log.debug("Structured output generated: {}", contentText); - - // Create response message - Msg responseMsg = - Msg.builder() - .name(getName()) - .role(MsgRole.ASSISTANT) - .content(TextBlock.builder().text(contentText).build()) - .metadata( - responseData != null - ? Map.of("response", responseData) - : Map.of()) - .build(); - - Map toolMetadata = new HashMap<>(); - toolMetadata.put("success", true); - toolMetadata.put("response_msg", responseMsg); - - return ToolResultBlock.of( - List.of( - TextBlock.builder() - .text("Successfully generated response.") - .build()), - toolMetadata); - }); - } - }; - } - - /** - * Extract structured result from tool result message. - */ - private Msg extractStructuredResult(Msg hookResultMsg) { - if (hookResultMsg == null) { - return null; - } - - List toolResults = hookResultMsg.getContentBlocks(ToolResultBlock.class); - for (ToolResultBlock result : toolResults) { - if (result.getMetadata() != null - && Boolean.TRUE.equals(result.getMetadata().get("success")) - && result.getMetadata().containsKey("response_msg")) { - Object responseMsgObj = result.getMetadata().get("response_msg"); - if (responseMsgObj instanceof Msg responseMsg) { - return extractResponseData(responseMsg); - } - } - } - - return hookResultMsg; - } - - @SuppressWarnings("unchecked") - private Msg extractResponseData(Msg responseMsg) { - if (responseMsg.getMetadata() != null - && responseMsg.getMetadata().containsKey("response")) { - Object responseData = responseMsg.getMetadata().get("response"); - // Preserve all original metadata and add structured output under dedicated key - Map metadata = new HashMap<>(responseMsg.getMetadata()); - metadata.put(MessageMetadataKeys.STRUCTURED_OUTPUT, responseData); - metadata.remove("response"); // Remove temp key, use standard key - return Msg.builder() - .name(responseMsg.getName()) - .role(responseMsg.getRole()) - .content(responseMsg.getContent()) - .metadata(metadata) - .build(); - } - return responseMsg; - } - - /** - * Merge collected metadata (ChatUsage and ThinkingBlock) into the message. - */ - private Msg mergeCollectedMetadata(Msg msg, ChatUsage chatUsage, ThinkingBlock thinking) { - // Merge ChatUsage into metadata - Map metadata = - new HashMap<>(msg.getMetadata() != null ? msg.getMetadata() : Map.of()); - if (chatUsage != null) { - metadata.put(MessageMetadataKeys.CHAT_USAGE, chatUsage); - } - - // Merge ThinkingBlock into content - List newContent; - if (thinking != null) { - newContent = new ArrayList<>(); - newContent.add(thinking); // ThinkingBlock first - if (msg.getContent() != null) { - newContent.addAll(msg.getContent()); - } - } else { - newContent = msg.getContent(); - } - - return Msg.builder() - .id(msg.getId()) - .name(msg.getName()) - .role(msg.getRole()) - .content(newContent) - .metadata(metadata) - .timestamp(msg.getTimestamp()) - .build(); - } -} +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.core.agent; + +import com.fasterxml.jackson.databind.JsonNode; +import io.agentscope.core.hook.Hook; +import io.agentscope.core.memory.Memory; +import io.agentscope.core.message.ContentBlock; +import io.agentscope.core.message.MessageMetadataKeys; +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.MsgRole; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.message.ThinkingBlock; +import io.agentscope.core.message.ToolResultBlock; +import io.agentscope.core.model.ChatUsage; +import io.agentscope.core.model.GenerateOptions; +import io.agentscope.core.model.StructuredOutputReminder; +import io.agentscope.core.tool.AgentTool; +import io.agentscope.core.tool.ToolCallParam; +import io.agentscope.core.tool.Toolkit; +import io.agentscope.core.util.JsonSchemaUtils; +import io.agentscope.core.util.JsonUtils; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; + +/** + * Abstract base class for agents that support structured output generation. + * + *

This class provides the infrastructure for generating structured output using the + * {@code generate_response} tool pattern combined with StructuredOutputHook for flow control. + * + *

Key Features: + *

    + *
  • Automatic tool registration for structured output
  • + *
  • Schema validation before tool execution
  • + *
  • Memory compression after structured output completion
  • + *
  • Configurable reminder mode (TOOL_CHOICE or PROMPT)
  • + *
+ * + *

Subclass Requirements: + *

    + *
  • Provide Toolkit via constructor
  • + *
  • Implement {@link #getMemory()} for memory access
  • + *
  • Implement {@link #buildGenerateOptions()} for model options
  • + *
+ */ +public abstract class StructuredOutputCapableAgent extends AgentBase { + + private static final Logger log = LoggerFactory.getLogger(StructuredOutputCapableAgent.class); + + /** The tool name for structured output generation. */ + public static final String STRUCTURED_OUTPUT_TOOL_NAME = "generate_response"; + + protected final Toolkit toolkit; + protected final StructuredOutputReminder structuredOutputReminder; + + /** + * Constructor with default reminder mode (TOOL_CHOICE). + */ + protected StructuredOutputCapableAgent( + String name, + String description, + boolean checkRunning, + List hooks, + Toolkit toolkit) { + this(name, description, checkRunning, hooks, toolkit, StructuredOutputReminder.TOOL_CHOICE); + } + + /** + * Constructor with custom reminder mode. + */ + protected StructuredOutputCapableAgent( + String name, + String description, + boolean checkRunning, + List hooks, + Toolkit toolkit, + StructuredOutputReminder structuredOutputReminder) { + super(name, description, checkRunning, hooks); + this.toolkit = toolkit != null ? toolkit : new Toolkit(); + this.structuredOutputReminder = + structuredOutputReminder != null + ? structuredOutputReminder + : StructuredOutputReminder.TOOL_CHOICE; + } + + /** + * Get the toolkit for tool operations. + */ + public Toolkit getToolkit() { + return toolkit; + } + + /** + * Get the memory for structured output hook. + * Subclasses must implement this. + */ + public abstract Memory getMemory(); + + /** + * Build generate options for model calls. + * Subclasses must implement this. + */ + protected abstract GenerateOptions buildGenerateOptions(); + + // ==================== Structured Output Implementation ==================== + + @Override + protected final Mono doCall(List msgs, Class structuredOutputClass) { + return executeWithStructuredOutput(msgs, structuredOutputClass, null); + } + + @Override + protected final Mono doCall(List msgs, JsonNode outputSchema) { + return executeWithStructuredOutput(msgs, null, outputSchema); + } + + /** + * Execute with structured output using StructuredOutputHook. + */ + private Mono executeWithStructuredOutput( + List msgs, Class targetClass, JsonNode schemaDesc) { + + // Validate parameters + if (targetClass == null && schemaDesc == null) { + return Mono.error( + new IllegalArgumentException( + "Either targetClass or schemaDesc must be provided")); + } + if (targetClass != null && schemaDesc != null) { + return Mono.error( + new IllegalArgumentException("Cannot provide both targetClass and schemaDesc")); + } + + return Mono.defer( + () -> { + // Create and register temporary tool + Map jsonSchema = + targetClass != null + ? JsonSchemaUtils.generateSchemaFromClass(targetClass) + : JsonSchemaUtils.generateSchemaFromJsonNode(schemaDesc); + AgentTool structuredOutputTool = + createStructuredOutputTool(jsonSchema, targetClass, schemaDesc); + toolkit.registerAgentTool(structuredOutputTool); + + // Create hook for flow control + StructuredOutputHook hook = + new StructuredOutputHook( + structuredOutputReminder, buildGenerateOptions(), getMemory()); + + addHook(hook); + + return doCall(msgs) + .flatMap( + result -> { + // Extract result from hook's output + Msg hookResult = hook.getResultMsg(); + if (hookResult != null) { + Msg extracted = extractStructuredResult(hookResult); + // Merge aggregated metadata from reasoning rounds + if (extracted != null) { + extracted = + mergeCollectedMetadata( + extracted, + hook.getAggregatedUsage(), + hook.getAggregatedThinking()); + } + return Mono.just(extracted); + } + return Mono.just(result); + }) + .doFinally( + signal -> { + removeHook(hook); + toolkit.removeToolIfSame( + STRUCTURED_OUTPUT_TOOL_NAME, structuredOutputTool); + }); + }); + } + + /** + * Create the structured output tool with validation. + */ + private AgentTool createStructuredOutputTool( + Map schema, Class targetClass, JsonNode schemaDesc) { + return new AgentTool() { + @Override + public String getName() { + return STRUCTURED_OUTPUT_TOOL_NAME; + } + + @Override + public String getDescription() { + return "Generate the final structured response. Call this function when" + + " you have all the information needed to provide a complete answer."; + } + + @Override + public Map getParameters() { + return wrapStructuredOutputSchema(schema); + } + + @Override + public Mono callAsync(ToolCallParam param) { + return Mono.fromCallable( + () -> { + Object responseData = param.getInput().get("response"); + + // The tool simply stores the raw data + // Validation is done by ToolExecutor before calling this + String contentText = ""; + if (responseData != null) { + try { + contentText = JsonUtils.getJsonCodec().toJson(responseData); + } catch (Exception e) { + contentText = responseData.toString(); + } + } + + log.debug("Structured output generated: {}", contentText); + + // Create response message + Msg responseMsg = + Msg.builder() + .name(getName()) + .role(MsgRole.ASSISTANT) + .content(TextBlock.builder().text(contentText).build()) + .metadata( + responseData != null + ? Map.of("response", responseData) + : Map.of()) + .build(); + + Map toolMetadata = new HashMap<>(); + toolMetadata.put("success", true); + toolMetadata.put("response_msg", responseMsg); + + return ToolResultBlock.of( + List.of( + TextBlock.builder() + .text("Successfully generated response.") + .build()), + toolMetadata); + }); + } + }; + } + + static Map wrapStructuredOutputSchema(Map schema) { + Map responseSchema = new HashMap<>(schema); + Map params = new HashMap<>(); + Map properties = new HashMap<>(); + Map rootDefs = new HashMap<>(); + + hoistDefs(responseSchema, "$defs", rootDefs); + hoistDefs(responseSchema, "definitions", rootDefs); + + properties.put("response", responseSchema); + params.put("type", "object"); + params.put("properties", properties); + params.put("required", List.of("response")); + if (!rootDefs.isEmpty()) { + params.put("$defs", rootDefs); + } + return params; + } + + @SuppressWarnings("unchecked") + private static void hoistDefs( + Map schema, String key, Map rootDefs) { + Object rawDefs = schema.remove(key); + if (rawDefs instanceof Map defs && !defs.isEmpty()) { + rootDefs.putAll((Map) defs); + } + } + + /** + * Extract structured result from tool result message. + */ + private Msg extractStructuredResult(Msg hookResultMsg) { + if (hookResultMsg == null) { + return null; + } + + List toolResults = hookResultMsg.getContentBlocks(ToolResultBlock.class); + for (ToolResultBlock result : toolResults) { + if (result.getMetadata() != null + && Boolean.TRUE.equals(result.getMetadata().get("success")) + && result.getMetadata().containsKey("response_msg")) { + Object responseMsgObj = result.getMetadata().get("response_msg"); + if (responseMsgObj instanceof Msg responseMsg) { + return extractResponseData(responseMsg); + } + } + } + + return hookResultMsg; + } + + @SuppressWarnings("unchecked") + private Msg extractResponseData(Msg responseMsg) { + if (responseMsg.getMetadata() != null + && responseMsg.getMetadata().containsKey("response")) { + Object responseData = responseMsg.getMetadata().get("response"); + // Preserve all original metadata and add structured output under dedicated key + Map metadata = new HashMap<>(responseMsg.getMetadata()); + metadata.put(MessageMetadataKeys.STRUCTURED_OUTPUT, responseData); + metadata.remove("response"); // Remove temp key, use standard key + return Msg.builder() + .name(responseMsg.getName()) + .role(responseMsg.getRole()) + .content(responseMsg.getContent()) + .metadata(metadata) + .build(); + } + return responseMsg; + } + + /** + * Merge collected metadata (ChatUsage and ThinkingBlock) into the message. + */ + private Msg mergeCollectedMetadata(Msg msg, ChatUsage chatUsage, ThinkingBlock thinking) { + // Merge ChatUsage into metadata + Map metadata = + new HashMap<>(msg.getMetadata() != null ? msg.getMetadata() : Map.of()); + if (chatUsage != null) { + metadata.put(MessageMetadataKeys.CHAT_USAGE, chatUsage); + } + + // Merge ThinkingBlock into content + List newContent; + if (thinking != null) { + newContent = new ArrayList<>(); + newContent.add(thinking); // ThinkingBlock first + if (msg.getContent() != null) { + newContent.addAll(msg.getContent()); + } + } else { + newContent = msg.getContent(); + } + + return Msg.builder() + .id(msg.getId()) + .name(msg.getName()) + .role(msg.getRole()) + .content(newContent) + .metadata(metadata) + .timestamp(msg.getTimestamp()) + .build(); + } +} diff --git a/agentscope-core/src/test/java/io/agentscope/core/agent/StructuredOutputSchemaWrappingTest.java b/agentscope-core/src/test/java/io/agentscope/core/agent/StructuredOutputSchemaWrappingTest.java new file mode 100644 index 000000000..b7dd3c277 --- /dev/null +++ b/agentscope-core/src/test/java/io/agentscope/core/agent/StructuredOutputSchemaWrappingTest.java @@ -0,0 +1,202 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.core.agent; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.agentscope.core.ReActAgent; +import io.agentscope.core.agent.test.MockModel; +import io.agentscope.core.memory.InMemoryMemory; +import io.agentscope.core.memory.Memory; +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.MsgRole; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.message.ToolUseBlock; +import io.agentscope.core.model.ChatResponse; +import io.agentscope.core.model.ChatUsage; +import io.agentscope.core.tool.ToolValidator; +import io.agentscope.core.tool.Toolkit; +import io.agentscope.core.util.JsonSchemaUtils; +import io.agentscope.core.util.JsonUtils; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class StructuredOutputSchemaWrappingTest { + + static class OrderInfo { + public String orderId; + public Address billingAddress; + public Address shippingAddress; + public List items; + } + + static class Address { + public String city; + public String street; + public String recipientName; + } + + static class OrderItem { + public String productName; + public Integer quantity; + } + + @Test + void shouldHoistDefsToRootWhenWrappingStructuredOutputSchema() { + Map responseSchema = + JsonSchemaUtils.generateSchemaFromClass(OrderInfo.class); + Map wrappedSchema = + StructuredOutputCapableAgent.wrapStructuredOutputSchema(responseSchema); + + @SuppressWarnings("unchecked") + Map properties = (Map) wrappedSchema.get("properties"); + @SuppressWarnings("unchecked") + Map wrappedResponse = (Map) properties.get("response"); + @SuppressWarnings("unchecked") + Map defs = (Map) wrappedSchema.get("$defs"); + + assertNotNull(defs, "Wrapped schema should expose shared defs at root level"); + assertTrue(defs.containsKey("Address"), "Address should be hoisted to root $defs"); + assertFalse( + wrappedResponse.containsKey("$defs"), + "Nested response schema should not keep $defs"); + + Map validInput = + Map.of( + "response", + Map.of( + "orderId", + "ORD-001", + "billingAddress", + Map.of( + "city", "Beijing", + "street", "Jianguo Road", + "recipientName", "Zhang San"), + "shippingAddress", + Map.of( + "city", "Shanghai", + "street", "Century Avenue", + "recipientName", "Li Si"), + "items", + List.of(Map.of("productName", "Phone", "quantity", 2)))); + + String validationError = + ToolValidator.validateInput( + JsonUtils.getJsonCodec().toJson(validInput), wrappedSchema); + assertNull( + validationError, "Wrapped structured-output schema should validate reused objects"); + } + + @Test + void shouldSupportStructuredOutputWithReusedNestedObjects() { + Toolkit toolkit = new Toolkit(); + Memory memory = new InMemoryMemory(); + Map toolInput = + Map.of( + "response", + Map.of( + "orderId", + "ORD-001", + "billingAddress", + Map.of( + "city", "Beijing", + "street", "Jianguo Road", + "recipientName", "Zhang San"), + "shippingAddress", + Map.of( + "city", "Shanghai", + "street", "Century Avenue", + "recipientName", "Li Si"), + "items", + List.of( + Map.of("productName", "Phone", "quantity", 2), + Map.of("productName", "Headset", "quantity", 1)))); + + MockModel mockModel = + new MockModel( + msgs -> { + boolean hasToolResults = + msgs.stream().anyMatch(m -> m.getRole() == MsgRole.TOOL); + if (!hasToolResults) { + return List.of( + ChatResponse.builder() + .id("msg_1") + .content( + List.of( + ToolUseBlock.builder() + .id("call_structured_1") + .name("generate_response") + .input(toolInput) + .content( + JsonUtils + .getJsonCodec() + .toJson( + toolInput)) + .build())) + .usage(new ChatUsage(10, 20, 30)) + .build()); + } + return List.of( + ChatResponse.builder() + .id("msg_2") + .content( + List.of( + TextBlock.builder() + .text( + "Structured response" + + " generated") + .build())) + .usage(new ChatUsage(5, 10, 15)) + .build()); + }); + + ReActAgent agent = + ReActAgent.builder() + .name("order-agent") + .sysPrompt("You extract order information") + .model(mockModel) + .toolkit(toolkit) + .memory(memory) + .build(); + + Msg inputMsg = + Msg.builder() + .name("user") + .role(MsgRole.USER) + .content(TextBlock.builder().text("Extract the order information.").build()) + .build(); + + Msg responseMsg = agent.call(inputMsg, OrderInfo.class).block(); + assertNotNull(responseMsg); + assertNotNull(responseMsg.getMetadata()); + + OrderInfo result = responseMsg.getStructuredData(OrderInfo.class); + assertNotNull(result); + assertEquals("ORD-001", result.orderId); + assertNotNull(result.billingAddress); + assertNotNull(result.shippingAddress); + assertEquals("Beijing", result.billingAddress.city); + assertEquals("Shanghai", result.shippingAddress.city); + assertNotNull(result.items); + assertEquals(2, result.items.size()); + assertEquals("Phone", result.items.get(0).productName); + } +}