Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>
* {@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.
* </p>
*
* <p>
* 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}.
* </p>
*/
public record AgentResult(ChatMessage message, Status status, Usage usage, Object data,
List<OutputValidationIssue> 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<OutputValidationIssue> 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<OutputValidationIssue> issues) {
return new AgentResult(message, Status.ERROR, usage, null, issues);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<String, Map<String, Object>> threadIdStateMap;

private final AgentLlmNode llmNode;
Expand Down Expand Up @@ -242,6 +248,156 @@ public ChatMessage call(Map<String, Object> 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<ChatMessage> 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<ChatMessage> 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<String, Object> 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<String, Object> 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<String, Object> 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<NodeOutput> 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);
}
Expand Down
Loading
Loading