Skip to content

Commit 0672b14

Browse files
authored
feat(output): add support for structured data dynamic define (#357)
## AgentScope-Java Version [The version of AgentScope-Java you are working on, e.g. 1.0.3, check your pom.xml dependency version or run `mvn dependency:tree | grep agentscope-parent:pom`(only mac/linux)] ## Description Support dynamic structured data definition. Summary 1.io.agentscope.core.agent.Agent#call(io.agentscope.core.message.Msg, java.lang.Class<?>) The reason for passing POJO is because it is called io.agentscope.core.ReActAgent#doCall(java.util.List<io.agentscope.core.message.Msg>, java.lang.Class<?>) We need to register a tool to summarize the output. 2. Need to overload getStructuredData method, as the dynamic type return can only be KV data and metadata already contains the original response. For concurrency security, the input parameter provides a Boolean type for the caller to choose whether it can be modified. 3. The core call is in io.agentscope. core. tracking. Tracer # callModel. Based on the protocols of different providers, the override io. agentscope. core. model. ChatModelBase # doStream method is required. If there is any mistake, please point it out promptly. Thank you [https://github.com/agentscope-ai/agentscope-java/issues/208](url) ## Checklist Please check the following items before code is ready to be reviewed. - [ ] Code has been formatted with `mvn spotless:apply` - [ ] All tests are passing (`mvn test`) - [ ] Javadoc comments are complete and follow project conventions - [ ] Related documentation has been updated (e.g. links, examples, etc.) - [ ] Code is ready for review
1 parent b3b901d commit 0672b14

12 files changed

Lines changed: 442 additions & 4 deletions

File tree

agentscope-core/pom.xml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,5 @@
124124
<groupId>com.squareup.okhttp3</groupId>
125125
<artifactId>okhttp-jvm</artifactId>
126126
</dependency>
127-
128127
</dependencies>
129128
</project>

agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
*/
1616
package io.agentscope.core;
1717

18+
import com.fasterxml.jackson.databind.JsonNode;
1819
import io.agentscope.core.agent.AgentBase;
1920
import io.agentscope.core.agent.StructuredOutputHandler;
2021
import io.agentscope.core.agent.accumulator.ReasoningContext;
@@ -184,6 +185,7 @@ protected Mono<Msg> doCall(List<Msg> msgs, Class<?> structuredOutputClass) {
184185
StructuredOutputHandler handler =
185186
new StructuredOutputHandler(
186187
structuredOutputClass,
188+
null,
187189
toolkit,
188190
memory,
189191
getName(),
@@ -206,6 +208,33 @@ protected Mono<Msg> doCall(List<Msg> msgs, Class<?> structuredOutputClass) {
206208
});
207209
}
208210

211+
@Override
212+
protected Mono<Msg> doCall(List<Msg> msgs, JsonNode outputSchema) {
213+
if (msgs != null && !msgs.isEmpty()) {
214+
msgs.forEach(memory::addMessage);
215+
}
216+
217+
StructuredOutputHandler handler =
218+
new StructuredOutputHandler(
219+
null, outputSchema, toolkit, memory, getName(), structuredOutputReminder);
220+
221+
return Mono.defer(
222+
() -> {
223+
// Set current handler for internal hook access
224+
this.currentStructuredOutputHandler.set(handler);
225+
226+
handler.prepare();
227+
return executeReActLoop(handler)
228+
.flatMap(result -> Mono.just(handler.extractFinalResult()))
229+
.doFinally(
230+
signal -> {
231+
handler.cleanup();
232+
// Clear current handler reference
233+
this.currentStructuredOutputHandler.set(null);
234+
});
235+
});
236+
}
237+
209238
// ==================== Core ReAct Loop ====================
210239

211240
private Mono<Msg> executeReActLoop(StructuredOutputHandler handler) {

agentscope-core/src/main/java/io/agentscope/core/agent/Agent.java

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
*/
1616
package io.agentscope.core.agent;
1717

18+
import com.fasterxml.jackson.databind.JsonNode;
1819
import io.agentscope.core.message.Msg;
1920
import java.util.List;
2021
import reactor.core.publisher.Flux;
@@ -109,6 +110,26 @@ default Mono<Msg> call(Msg msg, Class<?> structuredModel) {
109110
return call(msg == null ? List.of() : List.of(msg), structuredModel);
110111
}
111112

113+
/**
114+
* Process a single input message with structured model and generate a response.
115+
*
116+
* <p>The structured model parameter defines the expected structure of output data.
117+
* Not support UserAgent
118+
*
119+
* <p>The structured data will be stored in the returned message's metadata field and can be
120+
* extracted using {@link Msg#getStructuredData(boolean mutable)}.
121+
*
122+
* <p>Default implementation ignores the schemaDesc parameter. Agents that support
123+
* structured output should override this method.
124+
*
125+
* @param msg Input message
126+
* @param schemaDesc A com.fasterxml.jackson.databind.JsonNode instance defining the structure (e.g., a com.fasterxml.jackson.databind.JsonNode instance)
127+
* @return Response message with structured data in metadata
128+
*/
129+
default Mono<Msg> call(Msg msg, JsonNode schemaDesc) {
130+
return call(msg == null ? List.of() : List.of(msg), schemaDesc);
131+
}
132+
112133
/**
113134
* Process multiple input messages with structured model and generate a response.
114135
*
@@ -124,6 +145,8 @@ default Mono<Msg> call(Msg msg, Class<?> structuredModel) {
124145
*/
125146
Mono<Msg> call(List<Msg> msgs, Class<?> structuredModel);
126147

148+
Mono<Msg> call(List<Msg> msgs, JsonNode schemaDesc);
149+
127150
/**
128151
* Continue generation with structured model based on current state.
129152
*

agentscope-core/src/main/java/io/agentscope/core/agent/AgentBase.java

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
*/
1616
package io.agentscope.core.agent;
1717

18+
import com.fasterxml.jackson.databind.JsonNode;
1819
import io.agentscope.core.hook.ErrorEvent;
1920
import io.agentscope.core.hook.Hook;
2021
import io.agentscope.core.hook.PostCallEvent;
@@ -216,6 +217,37 @@ public final Mono<Msg> call(List<Msg> msgs, Class<?> structuredOutputClass) {
216217
.doFinally(signalType -> running.set(false));
217218
}
218219

220+
/**
221+
* Process multiple input messages and generate structured output with hook execution.
222+
*
223+
* <p>Tracing data will be captured once telemetry is enabled.
224+
*
225+
* @param msgs Input messages
226+
* @param schema com.fasterxml.jackson.databind.JsonNode instance defining the structure of the output
227+
* @return Response message with structured data in metadata
228+
*/
229+
@Override
230+
public final Mono<Msg> call(List<Msg> msgs, JsonNode schema) {
231+
if (!running.compareAndSet(false, true) && checkRunning) {
232+
return Mono.error(
233+
new IllegalStateException(
234+
"Agent is still running, please wait for it to finish"));
235+
}
236+
resetInterruptFlag();
237+
238+
return TracerRegistry.get()
239+
.callAgent(
240+
this,
241+
msgs,
242+
() ->
243+
notifyPreCall(msgs)
244+
.flatMap(m -> doCall(m, schema))
245+
.flatMap(this::notifyPostCall)
246+
.onErrorResume(
247+
createErrorHandler(msgs.toArray(new Msg[0]))))
248+
.doFinally(signalType -> running.set(false));
249+
}
250+
219251
/**
220252
* Internal implementation for processing multiple input messages.
221253
* Subclasses must implement their specific logic here.
@@ -240,6 +272,21 @@ protected Mono<Msg> doCall(List<Msg> msgs, Class<?> structuredOutputClass) {
240272
"Structured output not supported by " + getClass().getSimpleName()));
241273
}
242274

275+
/**
276+
* Internal implementation for processing multiple messages with structured output.
277+
* Subclasses that support structured output must override this method.
278+
* Default implementation throws UnsupportedOperationException.
279+
*
280+
* @param msgs Input messages
281+
* @param outputSchema com.fasterxml.jackson.databind.JsonNode instance defining the structure
282+
* @return Response message with structured data in metadata
283+
*/
284+
protected Mono<Msg> doCall(List<Msg> msgs, JsonNode outputSchema) {
285+
return Mono.error(
286+
new UnsupportedOperationException(
287+
"Structured output not supported by " + outputSchema.asText()));
288+
}
289+
243290
public static void addSystemHook(Hook hook) {
244291
systemHooks.add(hook);
245292
}

agentscope-core/src/main/java/io/agentscope/core/agent/StructuredOutputHandler.java

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,13 @@
1616
package io.agentscope.core.agent;
1717

1818
import com.fasterxml.jackson.core.JsonProcessingException;
19+
import com.fasterxml.jackson.databind.JsonNode;
1920
import com.fasterxml.jackson.databind.ObjectMapper;
21+
import com.networknt.schema.Error;
22+
import com.networknt.schema.InputFormat;
23+
import com.networknt.schema.SchemaRegistry;
24+
import com.networknt.schema.dialect.Dialects;
25+
import com.networknt.schema.serialization.DefaultNodeReader;
2026
import io.agentscope.core.memory.Memory;
2127
import io.agentscope.core.message.MessageMetadataKeys;
2228
import io.agentscope.core.message.Msg;
@@ -35,6 +41,7 @@
3541
import java.util.HashMap;
3642
import java.util.List;
3743
import java.util.Map;
44+
import java.util.Objects;
3845
import org.slf4j.Logger;
3946
import org.slf4j.LoggerFactory;
4047
import reactor.core.publisher.Mono;
@@ -67,6 +74,7 @@ public class StructuredOutputHandler {
6774
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
6875

6976
private final Class<?> targetClass;
77+
private final JsonNode schemaDesc;
7078
private final Toolkit toolkit;
7179
private final Memory memory;
7280
private final String agentName;
@@ -80,18 +88,21 @@ public class StructuredOutputHandler {
8088
* Create a structured output handler.
8189
*
8290
* @param targetClass The target class for structured output
91+
* @param schemaDesc The json schema for structured output
8392
* @param toolkit The toolkit for tool registration
8493
* @param memory The memory for checkpoint management
8594
* @param agentName The agent name for message creation
8695
* @param reminder The reminder mode (TOOL_CHOICE or PROMPT)
8796
*/
8897
public StructuredOutputHandler(
8998
Class<?> targetClass,
99+
JsonNode schemaDesc,
90100
Toolkit toolkit,
91101
Memory memory,
92102
String agentName,
93103
StructuredOutputReminder reminder) {
94104
this.targetClass = targetClass;
105+
this.schemaDesc = schemaDesc;
95106
this.toolkit = toolkit;
96107
this.memory = memory;
97108
this.agentName = agentName;
@@ -105,7 +116,18 @@ public StructuredOutputHandler(
105116
* Registers temporary tool for structured output generation.
106117
*/
107118
public void prepare() {
108-
Map<String, Object> jsonSchema = JsonSchemaUtils.generateSchemaFromClass(targetClass);
119+
if (Objects.isNull(targetClass) && Objects.isNull(schemaDesc)) {
120+
throw new IllegalStateException(
121+
"Can not prepare,because targetClass and schemaDesc both not exists");
122+
}
123+
if (Objects.nonNull(targetClass) && Objects.nonNull(schemaDesc)) {
124+
throw new IllegalStateException(
125+
"Can not prepare,because targetClass and schemaDesc both exists");
126+
}
127+
Map<String, Object> jsonSchema =
128+
Objects.nonNull(targetClass)
129+
? JsonSchemaUtils.generateSchemaFromClass(targetClass)
130+
: JsonSchemaUtils.generateSchemaFromJsonNode(schemaDesc);
109131
AgentTool temporaryTool = createStructuredOutputTool(jsonSchema);
110132
toolkit.registerAgentTool(temporaryTool);
111133

@@ -270,9 +292,38 @@ public Mono<ToolResultBlock> callAsync(ToolCallParam param) {
270292
() -> {
271293
Object responseData = param.getInput().get("response");
272294

273-
if (targetClass != null && responseData != null) {
295+
if ((targetClass != null || schemaDesc != null)
296+
&& responseData != null) {
274297
try {
275-
OBJECT_MAPPER.convertValue(responseData, targetClass);
298+
if (Objects.nonNull(targetClass)) {
299+
OBJECT_MAPPER.convertValue(responseData, targetClass);
300+
} else {
301+
SchemaRegistry schemaRegistry =
302+
SchemaRegistry.withDialect(
303+
Dialects.getDraft202012(),
304+
builder ->
305+
builder.nodeReader(
306+
DefaultNodeReader.Builder
307+
::locationAware));
308+
com.networknt.schema.Schema schema =
309+
schemaRegistry.getSchema(schemaDesc);
310+
List<Error> errors =
311+
schema.validate(
312+
OBJECT_MAPPER.writeValueAsString(
313+
responseData),
314+
InputFormat.JSON,
315+
executionContext ->
316+
executionContext.executionConfig(
317+
executionConfig ->
318+
executionConfig
319+
.formatAssertionsEnabled(
320+
true)));
321+
if (Objects.nonNull(errors) && !errors.isEmpty()) {
322+
StringBuilder err = new StringBuilder();
323+
errors.forEach(e -> err.append(e.getMessage()));
324+
throw new RuntimeException(err.toString());
325+
}
326+
}
276327
} catch (Exception e) {
277328
String simplifiedError = simplifyValidationError(e);
278329
String errorMsg =

agentscope-core/src/main/java/io/agentscope/core/message/Msg.java

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import com.fasterxml.jackson.annotation.JsonIgnore;
2020
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
2121
import com.fasterxml.jackson.annotation.JsonProperty;
22+
import com.fasterxml.jackson.core.type.TypeReference;
2223
import com.fasterxml.jackson.databind.ObjectMapper;
2324
import io.agentscope.core.model.ChatUsage;
2425
import io.agentscope.core.util.TypeUtils;
@@ -266,6 +267,75 @@ public <T> T getStructuredData(Class<T> targetClass) {
266267
}
267268
}
268269

270+
/**
271+
* Extract structured data from message metadata and convert it to the java.util.Map.
272+
*
273+
* <p>This method is useful when the message contains structured input from a user agent
274+
* or structured output from an LLM. support for using dynamic schema processing
275+
*
276+
* <p>Example usage:
277+
* <pre>{@code
278+
* String json = """
279+
* {
280+
* "type": "object",
281+
* "properties": {
282+
* "productName": {
283+
* "type": "string"
284+
* },
285+
* "features": {
286+
* "type": "array",
287+
* "items": {
288+
* "type": "string" * }
289+
* },
290+
* "pricing": {
291+
* "type": "object",
292+
* "properties": {
293+
* "amount": {
294+
* e": "number"
295+
* },
296+
* "currency": {
297+
* e": "string"
298+
* }
299+
* }
300+
* },
301+
* "ratings": {
302+
* "type": "object",
303+
* "additionalProperties": {
304+
* e": "integer"
305+
* }
306+
* }
307+
* }
308+
* }
309+
* """;
310+
* JsonNode sampleJsonNode = new ObjectMapper().readTree(json);
311+
* Msg msg = agent.call(input, sampleJsonNode).block(TEST_TIMEOUT);
312+
* Map<String, Object> structuredData = msg.getStructuredData(false);
313+
* }</pre>
314+
*
315+
* @return The copied metadata
316+
* @throws IllegalStateException if no metadata exists
317+
*/
318+
@Transient
319+
@JsonIgnore
320+
public Map<String, Object> getStructuredData(boolean mutable) {
321+
if (metadata == null || metadata.isEmpty()) {
322+
throw new IllegalStateException(
323+
"No structured data in message. Use hasStructuredData() to check first.");
324+
}
325+
if (mutable) {
326+
return metadata;
327+
}
328+
try {
329+
return OBJECT_MAPPER.convertValue(metadata, new TypeReference<>() {});
330+
} catch (Exception e) {
331+
throw new IllegalArgumentException(
332+
"Failed to convert metadata to "
333+
+ ". Ensure the target class has appropriate fields matching metadata"
334+
+ " keys.",
335+
e);
336+
}
337+
}
338+
269339
/**
270340
* Extracts plain text content from this message.
271341
*

0 commit comments

Comments
 (0)