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..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 @@ -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 volatile StructuredOutputFinalizer structuredOutputFinalizer; + private final ConcurrentMap> threadIdStateMap; private final AgentLlmNode llmNode; @@ -242,6 +248,156 @@ 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 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) { 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); + } + +}