From 4f90938bf90d76c3a9183b79db7ddfbbff821e42 Mon Sep 17 00:00:00 2001
From: konlue <2362586639@qq.com>
Date: Sun, 18 Oct 2026 13:50:25 +0800
Subject: [PATCH 1/3] feat(agent): add AgentResult delivery contract with
opt-in structured output finalizer (T39)
---
.../agentic/ai/graph/agent/AgentResult.java | 109 ++++++++++
.../agentic/ai/graph/agent/ReactAgent.java | 140 +++++++++++++
.../result/StructuredOutputFinalizer.java | 113 +++++++++++
.../graph/agent/ReactAgentCallResultTest.java | 187 ++++++++++++++++++
.../result/StructuredOutputFinalizerTest.java | 124 ++++++++++++
5 files changed, 673 insertions(+)
create mode 100644 argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/AgentResult.java
create mode 100644 argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/result/StructuredOutputFinalizer.java
create mode 100644 argi-agent-framework/src/test/java/io/github/agentic/ai/graph/agent/ReactAgentCallResultTest.java
create mode 100644 argi-agent-framework/src/test/java/io/github/agentic/ai/graph/agent/result/StructuredOutputFinalizerTest.java
diff --git a/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/AgentResult.java b/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/AgentResult.java
new file mode 100644
index 000000000..a68fe7dcd
--- /dev/null
+++ b/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/AgentResult.java
@@ -0,0 +1,109 @@
+/*
+ * 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
+ *
+ * https://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.github.agentic.ai.graph.agent;
+
+import java.util.List;
+
+import io.github.agentic.ai.api.chat.ChatMessage;
+import io.github.agentic.ai.api.chat.Usage;
+
+/**
+ * Structured delivery contract for an agent run, produced by the opt-in
+ * {@link ReactAgent#callResult(Object)} entries. The existing {@code call}/{@code
+ * invoke}/{@code stream} APIs keep returning raw messages; this type is only produced
+ * by the strict-delivery entries, so adoption is fully opt-in.
+ *
+ *
+ * {@link Status} describes the run-level delivery outcome. It is deliberately a
+ * different vocabulary from T38's tool-level {@code ToolOutcome}: that vocabulary
+ * classifies a single tool call, this one classifies the whole run's final result.
+ *
+ *
+ *
+ * An unfinished run is never reported as {@link Status#COMPLETED}: when no terminal
+ * assistant message exists (for example after an interruption), the status is
+ * {@link Status#INTERRUPTED} and {@link #message()} is {@code null}.
+ *
+ */
+public record AgentResult(ChatMessage message, Status status, Usage usage, Object data,
+ List issues) {
+
+ /** Error code for terminal output that is not valid JSON at all. */
+ public static final String ERROR_CODE_INVALID_JSON = "INVALID_JSON";
+
+ /** Error code for a configured output schema that cannot be parsed as a JSON Schema. */
+ public static final String ERROR_CODE_SCHEMA_MALFORMED = "SCHEMA_MALFORMED";
+
+ /** Error code for parsed JSON that violates the configured output schema. */
+ public static final String ERROR_CODE_SCHEMA_VIOLATION = "SCHEMA_VIOLATION";
+
+ /** Error code for a run that failed before producing a terminal message. */
+ public static final String ERROR_CODE_EXECUTION_FAILED = "EXECUTION_FAILED";
+
+ /** Error code for a terminal message that only reports a model failure. */
+ public static final String ERROR_CODE_MODEL_FAILED = "MODEL_FAILED";
+
+ /**
+ * Delivery status of the run.
+ */
+ public enum Status {
+
+ /** The run finished with a terminal message; validated data when a schema was configured. */
+ COMPLETED,
+
+ /** The run finished, but the terminal output failed parsing or schema validation. */
+ VALIDATION_FAILED,
+
+ /** The run ended without a terminal message (for example after an interruption). */
+ INTERRUPTED,
+
+ /** The run failed with an execution or model error. */
+ ERROR
+
+ }
+
+ /**
+ * A single output validation or delivery failure, in the style of the T31 argument
+ * validation failure protocol.
+ * @param errorCode machine-readable code (see the {@code ERROR_CODE_*} constants)
+ * @param fieldPath location the issue refers to (JSON instance path when available)
+ * @param message human-readable description; never echoes the full output content
+ */
+ public record OutputValidationIssue(String errorCode, String fieldPath, String message) {
+
+ }
+
+ public AgentResult {
+ issues = issues == null ? List.of() : List.copyOf(issues);
+ }
+
+ public static AgentResult completed(ChatMessage message, Usage usage, Object data) {
+ return new AgentResult(message, Status.COMPLETED, usage, data, List.of());
+ }
+
+ public static AgentResult validationFailed(ChatMessage message, Usage usage, List issues) {
+ return new AgentResult(message, Status.VALIDATION_FAILED, usage, null, issues);
+ }
+
+ public static AgentResult interrupted(Usage usage) {
+ return new AgentResult(null, Status.INTERRUPTED, usage, null, List.of());
+ }
+
+ public static AgentResult error(ChatMessage message, Usage usage, List issues) {
+ return new AgentResult(message, Status.ERROR, usage, null, issues);
+ }
+
+}
diff --git a/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/ReactAgent.java b/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/ReactAgent.java
index eb6c43b64..00251d150 100644
--- a/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/ReactAgent.java
+++ b/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/ReactAgent.java
@@ -19,6 +19,7 @@
import io.github.agentic.ai.api.chat.ChatRole;
import io.github.agentic.ai.api.chat.ContextAuthorityKeys;
import io.github.agentic.ai.api.chat.MessageProvenance;
+import io.github.agentic.ai.api.chat.Usage;
import io.github.agentic.ai.graph.CompileConfig;
import io.github.agentic.ai.graph.CompiledGraph;
@@ -50,6 +51,7 @@
import io.github.agentic.ai.graph.agent.interceptor.StreamingModelInterceptor;
import io.github.agentic.ai.graph.agent.interceptor.ToolInterceptor;
import io.github.agentic.ai.graph.agent.node.AgentLlmNode;
+import io.github.agentic.ai.graph.agent.result.StructuredOutputFinalizer;
import io.github.agentic.ai.graph.agent.node.AgentToolNode;
import io.github.agentic.ai.graph.checkpoint.BaseCheckpointSaver;
import io.github.agentic.ai.graph.checkpoint.Checkpoint;
@@ -107,6 +109,10 @@ public class ReactAgent extends BaseAgent {
Logger logger = LoggerFactory.getLogger(ReactAgent.class);
+ private static final String MODEL_ERROR_MESSAGE_PREFIX = "Exception: ";
+
+ private final StructuredOutputFinalizer structuredOutputFinalizer = new StructuredOutputFinalizer();
+
private final ConcurrentMap> threadIdStateMap;
private final AgentLlmNode llmNode;
@@ -242,6 +248,140 @@ public ChatMessage call(Map inputs, RunnableConfig config) throw
return doMessageInvoke(inputs, config);
}
+ /**
+ * Calls the agent and returns a structured {@link AgentResult} that parses and
+ * validates the configured output schema instead of returning the raw assistant
+ * message. The run contract is identical to {@link #call(String)}; only the delivery
+ * boundary differs. Requires no new switch: a configured output schema signals the
+ * validation intent, this entry signals the delivery intent. Terminal paths (normal
+ * final message, returnDirect synthesis, hook {@code JumpTo.end}) all finalize
+ * through the same boundary; an unfinished run is never reported as completed.
+ * @param message the user input message
+ * @return the structured delivery result; never {@code null}
+ */
+ public AgentResult callResult(String message) {
+ return doResultInvoke(message, null);
+ }
+
+ /**
+ * Calls the agent with a runtime configuration and returns a structured
+ * {@link AgentResult}, mirroring {@link #call(String, RunnableConfig)}.
+ * @param message the user input message
+ * @param config runtime configuration controlling execution behavior
+ * @return the structured delivery result; never {@code null}
+ */
+ public AgentResult callResult(String message, RunnableConfig config) {
+ return doResultInvoke(message, config);
+ }
+
+ /**
+ * Calls the agent with a {@link ChatMessage} input and returns a structured
+ * {@link AgentResult}, mirroring {@link #call(ChatMessage)}.
+ * @param message the input message
+ * @return the structured delivery result; never {@code null}
+ */
+ public AgentResult callResult(ChatMessage message) {
+ return doResultInvoke(message, null);
+ }
+
+ /**
+ * Calls the agent with a {@link ChatMessage} input and runtime configuration and
+ * returns a structured {@link AgentResult}, mirroring
+ * {@link #call(ChatMessage, RunnableConfig)}.
+ * @param message the input message
+ * @param config runtime configuration controlling execution behavior
+ * @return the structured delivery result; never {@code null}
+ */
+ public AgentResult callResult(ChatMessage message, RunnableConfig config) {
+ return doResultInvoke(message, config);
+ }
+
+ /**
+ * Calls the agent with a message list input and returns a structured
+ * {@link AgentResult}, mirroring {@link #call(List)}.
+ * @param messages the input messages
+ * @return the structured delivery result; never {@code null}
+ */
+ public AgentResult callResult(List messages) {
+ return doResultInvoke(messages, null);
+ }
+
+ /**
+ * Calls the agent with a message list input and runtime configuration and returns a
+ * structured {@link AgentResult}, mirroring {@link #call(List, RunnableConfig)}.
+ * @param messages the input messages
+ * @param config runtime configuration controlling execution behavior
+ * @return the structured delivery result; never {@code null}
+ */
+ public AgentResult callResult(List messages, RunnableConfig config) {
+ return doResultInvoke(messages, config);
+ }
+
+ /**
+ * Calls the agent with an inputs map and returns a structured {@link AgentResult},
+ * mirroring {@link #call(Map)}.
+ * @param inputs the input map (reserved: messages, input; other keys as state)
+ * @return the structured delivery result; never {@code null}
+ */
+ public AgentResult callResult(Map inputs) {
+ return doResultInvoke(inputs, null);
+ }
+
+ /**
+ * Calls the agent with an inputs map and runtime configuration and returns a
+ * structured {@link AgentResult}, mirroring {@link #call(Map, RunnableConfig)}.
+ * @param inputs the input map (reserved: messages, input; other keys as state)
+ * @param config runtime configuration controlling execution behavior
+ * @return the structured delivery result; never {@code null}
+ */
+ public AgentResult callResult(Map inputs, RunnableConfig config) {
+ return doResultInvoke(inputs, config);
+ }
+
+ private AgentResult doResultInvoke(Object message, RunnableConfig config) {
+ try {
+ return finalizeResult(doInvokeAndGetOutput(buildMessageInput(message), config));
+ }
+ catch (RuntimeException ex) {
+ return AgentResult.error(null, Usage.unknown(),
+ List.of(new AgentResult.OutputValidationIssue(AgentResult.ERROR_CODE_EXECUTION_FAILED, "",
+ ex.getMessage())));
+ }
+ }
+
+ private AgentResult doResultInvoke(Map inputs, RunnableConfig config) {
+ try {
+ return finalizeResult(doInvokeAndGetOutput(normalizeMessageInput(inputs), config));
+ }
+ catch (RuntimeException ex) {
+ return AgentResult.error(null, Usage.unknown(),
+ List.of(new AgentResult.OutputValidationIssue(AgentResult.ERROR_CODE_EXECUTION_FAILED, "",
+ ex.getMessage())));
+ }
+ }
+
+ private AgentResult finalizeResult(Optional output) {
+ // The runner strips the reserved usage key from the state and attaches it to the
+ // NodeOutput instead, so the usage comes from the final output record.
+ Usage usage = output.map(NodeOutput::tokenUsage).orElseGet(Usage::unknown);
+ ChatMessage terminal;
+ try {
+ terminal = extractAssistantMessage(output.map(NodeOutput::state));
+ }
+ catch (AgentException | IllegalStateException unfinished) {
+ // The run ended without a terminal message (for example after an
+ // interruption): report it as unfinished, never as completed.
+ return AgentResult.interrupted(usage);
+ }
+ if (terminal.text() != null && terminal.text().startsWith(MODEL_ERROR_MESSAGE_PREFIX)) {
+ // throwOnModelError=false surfaces model failures as assistant text.
+ return AgentResult.error(terminal, usage,
+ List.of(new AgentResult.OutputValidationIssue(AgentResult.ERROR_CODE_MODEL_FAILED, "",
+ terminal.text())));
+ }
+ return structuredOutputFinalizer.finalizeOutput(terminal, outputSchema, usage);
+ }
+
public void interrupt(RunnableConfig config) {
updateAgentState(List.of(), config);
}
diff --git a/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/result/StructuredOutputFinalizer.java b/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/result/StructuredOutputFinalizer.java
new file mode 100644
index 000000000..c16eba90d
--- /dev/null
+++ b/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/result/StructuredOutputFinalizer.java
@@ -0,0 +1,113 @@
+/*
+ * 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
+ *
+ * https://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.github.agentic.ai.graph.agent.result;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import com.networknt.schema.Error;
+import com.networknt.schema.Schema;
+import com.networknt.schema.SchemaRegistry;
+import com.networknt.schema.SpecificationVersion;
+import org.springframework.util.StringUtils;
+
+import io.github.agentic.ai.api.chat.ChatMessage;
+import io.github.agentic.ai.api.chat.Usage;
+import io.github.agentic.ai.graph.agent.AgentResult;
+import tools.jackson.core.StreamReadFeature;
+import tools.jackson.databind.DeserializationFeature;
+import tools.jackson.databind.JsonNode;
+import tools.jackson.databind.json.JsonMapper;
+
+/**
+ * Turns the terminal assistant message of a run into an {@link AgentResult} by parsing
+ * and schema-validating the configured output schema. This is the single delivery
+ * boundary shared by every termination path (normal final message, returnDirect
+ * synthesis, hook {@code JumpTo.end}): the terminal message itself is carried through
+ * unchanged and never regenerated.
+ *
+ *
+ * Prompt-level schema constraints do not count as validation: without this component
+ * the output of a run is unverified text. When no output schema is configured, the
+ * result is {@link AgentResult.Status#COMPLETED} with no data — configuring a schema
+ * signals the validation intent, calling {@code callResult} signals the delivery
+ * intent (double opt-in, so the legacy {@code call} behavior never changes).
+ *
+ *
+ *
+ * The component never throws: every failure mode (malformed JSON, malformed schema,
+ * schema violations) becomes {@link AgentResult.Status#VALIDATION_FAILED} with issue
+ * entries that do not echo the full output content.
+ *
+ */
+public final class StructuredOutputFinalizer {
+
+ private static final JsonMapper MAPPER = JsonMapper.builder()
+ .enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION)
+ .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
+ .build();
+
+ private static final SchemaRegistry SCHEMAS = SchemaRegistry.withDefaultDialect(SpecificationVersion.DRAFT_2020_12);
+
+ private static final String ROOT_PATH = "$";
+
+ /**
+ * Finalizes the terminal message of a run against the configured output schema.
+ * @param terminal the terminal assistant message; must not be {@code null}
+ * @param outputSchema the configured JSON Schema for the output, or {@code null} or
+ * blank when the run has none
+ * @param usage the token usage recorded for the run, or {@code null} when unknown
+ * @return the delivery result; never {@code null} and never thrown over
+ */
+ public AgentResult finalizeOutput(ChatMessage terminal, String outputSchema, Usage usage) {
+ if (!StringUtils.hasLength(outputSchema)) {
+ return AgentResult.completed(terminal, usage, null);
+ }
+
+ Schema schema;
+ try {
+ schema = SCHEMAS.getSchema(outputSchema);
+ }
+ catch (RuntimeException ex) {
+ return AgentResult.validationFailed(terminal, usage,
+ List.of(new AgentResult.OutputValidationIssue(AgentResult.ERROR_CODE_SCHEMA_MALFORMED,
+ ROOT_PATH, "Configured output schema is not a valid JSON Schema")));
+ }
+
+ JsonNode root;
+ try {
+ root = MAPPER.readTree(terminal.text());
+ }
+ catch (RuntimeException ex) {
+ return AgentResult.validationFailed(terminal, usage,
+ List.of(new AgentResult.OutputValidationIssue(AgentResult.ERROR_CODE_INVALID_JSON, ROOT_PATH,
+ "Terminal output is not valid JSON")));
+ }
+
+ List errors = schema.validate(root);
+ if (!errors.isEmpty()) {
+ List issues = new ArrayList<>();
+ for (Error error : errors) {
+ issues.add(new AgentResult.OutputValidationIssue(AgentResult.ERROR_CODE_SCHEMA_VIOLATION,
+ error.getInstanceLocation().toString(), error.getMessage()));
+ }
+ return AgentResult.validationFailed(terminal, usage, List.copyOf(issues));
+ }
+
+ return AgentResult.completed(terminal, usage, root);
+ }
+
+}
diff --git a/argi-agent-framework/src/test/java/io/github/agentic/ai/graph/agent/ReactAgentCallResultTest.java b/argi-agent-framework/src/test/java/io/github/agentic/ai/graph/agent/ReactAgentCallResultTest.java
new file mode 100644
index 000000000..519a1dcd0
--- /dev/null
+++ b/argi-agent-framework/src/test/java/io/github/agentic/ai/graph/agent/ReactAgentCallResultTest.java
@@ -0,0 +1,187 @@
+/*
+ * 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
+ *
+ * https://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.github.agentic.ai.graph.agent;
+
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionStage;
+import java.util.concurrent.Flow;
+
+import io.github.agentic.ai.api.chat.ChatClient;
+import io.github.agentic.ai.api.chat.ChatClientCapabilities;
+import io.github.agentic.ai.api.chat.ChatEvent;
+import io.github.agentic.ai.api.chat.ChatMessage;
+import io.github.agentic.ai.api.chat.ChatRequest;
+import io.github.agentic.ai.api.chat.ChatResponse;
+import io.github.agentic.ai.api.chat.Usage;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * End-to-end tests for the opt-in {@link ReactAgent#callResult(String)} delivery
+ * contract with a stub {@link ChatClient}: every termination shape maps onto the
+ * {@link AgentResult.Status} vocabulary, the legacy {@code call} behavior stays
+ * untouched, and the recorded token usage is carried into the result.
+ */
+@DisplayName("ReactAgent.callResult delivery contract")
+class ReactAgentCallResultTest {
+
+ private static final String SCHEMA = """
+ {
+ "type": "object",
+ "required": ["status"],
+ "additionalProperties": false,
+ "properties": {
+ "status": {"type": "string"}
+ }
+ }""";
+
+ @Test
+ @DisplayName("schema-valid output is delivered as COMPLETED with parsed data and usage")
+ void structuredOutputDeliveredWithUsage() {
+ ReactAgent agent = agent(new StubClient("{\"status\":\"ok\"}"), SCHEMA);
+
+ AgentResult result = agent.callResult("give me the status");
+
+ assertThat(result.status()).isEqualTo(AgentResult.Status.COMPLETED);
+ assertThat(result.data()).isNotNull();
+ assertThat(result.message().text()).isEqualTo("{\"status\":\"ok\"}");
+ assertThat(result.usage().totalTokens()).isEqualTo(5L);
+ }
+
+ @Test
+ @DisplayName("non-JSON output fails validation end to end")
+ void invalidOutputFailsValidation() {
+ ReactAgent agent = agent(new StubClient("plain prose answer"), SCHEMA);
+
+ AgentResult result = agent.callResult("give me the status");
+
+ assertThat(result.status()).isEqualTo(AgentResult.Status.VALIDATION_FAILED);
+ assertThat(result.issues()).hasSize(1);
+ assertThat(result.issues().get(0).errorCode()).isEqualTo(AgentResult.ERROR_CODE_INVALID_JSON);
+ }
+
+ @Test
+ @DisplayName("schema-violating output reports the violation end to end")
+ void schemaViolationReported() {
+ ReactAgent agent = agent(new StubClient("{\"status\": 42}"), SCHEMA);
+
+ AgentResult result = agent.callResult("give me the status");
+
+ assertThat(result.status()).isEqualTo(AgentResult.Status.VALIDATION_FAILED);
+ assertThat(result.issues()).isNotEmpty();
+ assertThat(result.issues()).allMatch(issue -> AgentResult.ERROR_CODE_SCHEMA_VIOLATION.equals(issue.errorCode()));
+ }
+
+ @Test
+ @DisplayName("no configured schema completes without validation")
+ void missingSchemaCompletesWithoutValidation() {
+ ReactAgent agent = agent(new StubClient("plain prose answer"), null);
+
+ AgentResult result = agent.callResult("hello");
+
+ assertThat(result.status()).isEqualTo(AgentResult.Status.COMPLETED);
+ assertThat(result.data()).isNull();
+ assertThat(result.message().text()).isEqualTo("plain prose answer");
+ }
+
+ @Test
+ @DisplayName("a failing chat client surfaces as the model-failure pseudo message and is reported as ERROR")
+ void clientFailureReportedAsError() {
+ // throwOnModelError defaults to false: the model failure becomes an
+ // "Exception: ..." assistant message, which the delivery contract must classify.
+ ReactAgent agent = agent(new StubClient(null, new IllegalStateException("model exploded")), SCHEMA);
+
+ AgentResult result = agent.callResult("hello");
+
+ assertThat(result.status()).isEqualTo(AgentResult.Status.ERROR);
+ assertThat(result.issues()).hasSize(1);
+ assertThat(result.issues().get(0).errorCode()).isEqualTo(AgentResult.ERROR_CODE_MODEL_FAILED);
+ assertThat(result.issues().get(0).message()).contains("model exploded");
+ }
+
+ @Test
+ @DisplayName("an assistant message that only reports a model failure is reported as ERROR")
+ void modelErrorTextReportedAsError() {
+ ReactAgent agent = agent(new StubClient("Exception: quota exhausted"), SCHEMA);
+
+ AgentResult result = agent.callResult("hello");
+
+ assertThat(result.status()).isEqualTo(AgentResult.Status.ERROR);
+ assertThat(result.issues()).hasSize(1);
+ assertThat(result.issues().get(0).errorCode()).isEqualTo(AgentResult.ERROR_CODE_MODEL_FAILED);
+ assertThat(result.message().text()).isEqualTo("Exception: quota exhausted");
+ }
+
+ @Test
+ @DisplayName("the legacy call entry keeps returning the raw message untouched")
+ void legacyCallBehaviorUnchanged() throws Exception {
+ ReactAgent agent = agent(new StubClient("{\"status\":\"ok\"}"), SCHEMA);
+
+ ChatMessage message = agent.call("give me the status");
+
+ assertThat(message.text()).isEqualTo("{\"status\":\"ok\"}");
+ }
+
+ private static ReactAgent agent(ChatClient client, String outputSchema) {
+ Builder builder = ReactAgent.builder()
+ .name("result-test")
+ .chatClient(client);
+ if (outputSchema != null) {
+ builder.outputSchema(outputSchema);
+ }
+ return builder.build();
+ }
+
+ private static final class StubClient implements ChatClient {
+
+ private final String text;
+
+ private final RuntimeException failure;
+
+ private StubClient(String text, RuntimeException failure) {
+ this.text = text;
+ this.failure = failure;
+ }
+
+ private StubClient(String text) {
+ this(text, null);
+ }
+
+ @Override
+ public CompletionStage call(ChatRequest request) {
+ return failure == null
+ ? CompletableFuture.completedFuture(new ChatResponse("id", "test-model",
+ List.of(ChatMessage.assistant(text)), List.of(), null, new Usage(2L, 3L, 5L), Map.of()))
+ : CompletableFuture.failedFuture(failure);
+ }
+
+ @Override
+ public Flow.Publisher stream(ChatRequest request) {
+ throw new UnsupportedOperationException("Streaming is unavailable");
+ }
+
+ @Override
+ public ChatClientCapabilities capabilities() {
+ return ChatClientCapabilities.basic();
+ }
+
+ }
+
+}
diff --git a/argi-agent-framework/src/test/java/io/github/agentic/ai/graph/agent/result/StructuredOutputFinalizerTest.java b/argi-agent-framework/src/test/java/io/github/agentic/ai/graph/agent/result/StructuredOutputFinalizerTest.java
new file mode 100644
index 000000000..294130b3d
--- /dev/null
+++ b/argi-agent-framework/src/test/java/io/github/agentic/ai/graph/agent/result/StructuredOutputFinalizerTest.java
@@ -0,0 +1,124 @@
+/*
+ * 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
+ *
+ * https://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.github.agentic.ai.graph.agent.result;
+
+import io.github.agentic.ai.api.chat.ChatMessage;
+import io.github.agentic.ai.api.chat.Usage;
+import io.github.agentic.ai.graph.agent.AgentResult;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Unit tests for {@link StructuredOutputFinalizer}: the component must never throw and
+ * must map every output failure mode to {@link AgentResult.Status#VALIDATION_FAILED}
+ * with a machine-readable issue, while a schema-less run completes without validation.
+ */
+@DisplayName("StructuredOutputFinalizer delivery boundary")
+class StructuredOutputFinalizerTest {
+
+ private static final String SCHEMA = """
+ {
+ "type": "object",
+ "required": ["status"],
+ "additionalProperties": false,
+ "properties": {
+ "status": {"type": "string"}
+ }
+ }""";
+
+ private final StructuredOutputFinalizer finalizer = new StructuredOutputFinalizer();
+
+ @Test
+ @DisplayName("schema-valid output completes with parsed data")
+ void validOutputCompletesWithData() {
+ ChatMessage terminal = ChatMessage.assistant("{\"status\":\"ok\"}");
+
+ AgentResult result = finalizer.finalizeOutput(terminal, SCHEMA, new Usage(2L, 3L, 5L));
+
+ assertThat(result.status()).isEqualTo(AgentResult.Status.COMPLETED);
+ assertThat(result.data()).isNotNull();
+ assertThat(result.message()).isSameAs(terminal);
+ assertThat(result.usage().totalTokens()).isEqualTo(5L);
+ assertThat(result.issues()).isEmpty();
+ }
+
+ @Test
+ @DisplayName("non-JSON output fails validation with INVALID_JSON")
+ void invalidJsonFailsValidation() {
+ ChatMessage terminal = ChatMessage.assistant("I cannot answer in JSON, sorry.");
+
+ AgentResult result = finalizer.finalizeOutput(terminal, SCHEMA, Usage.unknown());
+
+ assertThat(result.status()).isEqualTo(AgentResult.Status.VALIDATION_FAILED);
+ assertThat(result.data()).isNull();
+ assertThat(result.issues()).hasSize(1);
+ assertThat(result.issues().get(0).errorCode()).isEqualTo(AgentResult.ERROR_CODE_INVALID_JSON);
+ // The full output content is never echoed back as part of the issue.
+ assertThat(result.issues().get(0).message()).doesNotContain("sorry");
+ }
+
+ @Test
+ @DisplayName("schema violation reports the instance path of each offending location")
+ void schemaViolationCarriesInstancePath() {
+ ChatMessage terminal = ChatMessage.assistant("{\"status\": 42, \"extra\": true}");
+
+ AgentResult result = finalizer.finalizeOutput(terminal, SCHEMA, Usage.unknown());
+
+ assertThat(result.status()).isEqualTo(AgentResult.Status.VALIDATION_FAILED);
+ assertThat(result.issues()).isNotEmpty();
+ assertThat(result.issues()).allMatch(issue -> AgentResult.ERROR_CODE_SCHEMA_VIOLATION.equals(issue.errorCode()));
+ // networknt 3.x reports JSON instance paths in document-pointer style (/status).
+ assertThat(result.issues()).anyMatch(issue -> "/status".equals(issue.fieldPath()));
+ }
+
+ @Test
+ @DisplayName("no configured schema completes without validation")
+ void missingSchemaCompletesWithoutValidation() {
+ ChatMessage terminal = ChatMessage.assistant("plain text answer");
+
+ AgentResult result = finalizer.finalizeOutput(terminal, null, Usage.unknown());
+
+ assertThat(result.status()).isEqualTo(AgentResult.Status.COMPLETED);
+ assertThat(result.data()).isNull();
+ assertThat(result.message()).isSameAs(terminal);
+ }
+
+ @Test
+ @DisplayName("a malformed output schema fails validation instead of throwing")
+ void malformedSchemaFailsValidation() {
+ ChatMessage terminal = ChatMessage.assistant("{\"status\":\"ok\"}");
+
+ AgentResult result = finalizer.finalizeOutput(terminal, "not a json schema at all {", Usage.unknown());
+
+ assertThat(result.status()).isEqualTo(AgentResult.Status.VALIDATION_FAILED);
+ assertThat(result.issues()).hasSize(1);
+ assertThat(result.issues().get(0).errorCode()).isEqualTo(AgentResult.ERROR_CODE_SCHEMA_MALFORMED);
+ }
+
+ @Test
+ @DisplayName("trailing tokens after the JSON document are rejected")
+ void trailingTokensFailValidation() {
+ ChatMessage terminal = ChatMessage.assistant("{\"status\":\"ok\"} and some trailing prose");
+
+ AgentResult result = finalizer.finalizeOutput(terminal, SCHEMA, Usage.unknown());
+
+ assertThat(result.status()).isEqualTo(AgentResult.Status.VALIDATION_FAILED);
+ assertThat(result.issues().get(0).errorCode()).isEqualTo(AgentResult.ERROR_CODE_INVALID_JSON);
+ }
+
+}
From 3786ddc2bb44c6f2446e45a8a83fcdbdf9c6f280 Mon Sep 17 00:00:00 2001
From: konlue <2362586639@qq.com>
Date: Tue, 20 Oct 2026 11:07:48 +0800
Subject: [PATCH 2/3] ci: retrigger checks after transient dependency
resolution failure
From cc855807d861cacc58733b8798fe7f5390b57b62 Mon Sep 17 00:00:00 2001
From: konlue <2362586639@qq.com>
Date: Tue, 20 Oct 2026 12:56:14 +0800
Subject: [PATCH 3/3] fix(agent): lazily initialize the structured output
finalizer so agents without an output schema do not load the schema validator
(T39)
---
.../agentic/ai/graph/agent/ReactAgent.java | 20 +++++++++++++++++--
1 file changed, 18 insertions(+), 2 deletions(-)
diff --git a/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/ReactAgent.java b/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/ReactAgent.java
index 00251d150..74c20258d 100644
--- a/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/ReactAgent.java
+++ b/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/ReactAgent.java
@@ -111,7 +111,7 @@ public class ReactAgent extends BaseAgent {
private static final String MODEL_ERROR_MESSAGE_PREFIX = "Exception: ";
- private final StructuredOutputFinalizer structuredOutputFinalizer = new StructuredOutputFinalizer();
+ private volatile StructuredOutputFinalizer structuredOutputFinalizer;
private final ConcurrentMap> threadIdStateMap;
@@ -379,7 +379,23 @@ private AgentResult finalizeResult(Optional output) {
List.of(new AgentResult.OutputValidationIssue(AgentResult.ERROR_CODE_MODEL_FAILED, "",
terminal.text())));
}
- return structuredOutputFinalizer.finalizeOutput(terminal, outputSchema, usage);
+ return finalizer().finalizeOutput(terminal, outputSchema, usage);
+ }
+
+ /**
+ * Lazily creates the delivery finalizer. The finalizer's class initialization loads
+ * the JSON Schema validator classes, which are not guaranteed to be on every
+ * consumer's classpath (the studio module, for one, resolves an older
+ * json-schema-validator for its MCP client); agents that never finalize a
+ * schema-constrained output must not trigger that load.
+ */
+ private StructuredOutputFinalizer finalizer() {
+ StructuredOutputFinalizer finalizer = this.structuredOutputFinalizer;
+ if (finalizer == null) {
+ finalizer = new StructuredOutputFinalizer();
+ this.structuredOutputFinalizer = finalizer;
+ }
+ return finalizer;
}
public void interrupt(RunnableConfig config) {