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
Expand Up @@ -29,6 +29,7 @@
import io.github.agentic.ai.graph.KeyStrategy;
import io.github.agentic.ai.graph.agent.hook.Hook;
import io.github.agentic.ai.graph.agent.interceptor.Interceptor;
import io.github.agentic.ai.graph.agent.interceptor.BusinessOutcomeMapperRegistry;
import io.github.agentic.ai.graph.agent.interceptor.ModelInterceptor;
import io.github.agentic.ai.graph.agent.interceptor.StreamingModelInterceptor;
import io.github.agentic.ai.graph.agent.interceptor.ToolInterceptor;
Expand Down Expand Up @@ -62,6 +63,8 @@ public abstract class Builder {

protected ToolRegistry toolRegistry = new ToolRegistry(List.of());

protected BusinessOutcomeMapperRegistry businessOutcomeMappers = BusinessOutcomeMapperRegistry.empty();

protected boolean throwOnModelError;

protected boolean throwOnToolError;
Expand Down Expand Up @@ -143,6 +146,18 @@ public Builder toolRegistry(ToolRegistry toolRegistry) {
return this;
}

/**
* Registers per-tool {@link BusinessOutcomeMapper}s so a business-level failure
* reported inside an otherwise successful tool result can be surfaced with the
* {@link io.github.agentic.ai.graph.agent.interceptor.ToolOutcome#BUSINESS_FAILED}
* outcome. Unregistered tools are never inspected for failure signals.
*/
public Builder businessOutcomeMappers(BusinessOutcomeMapperRegistry businessOutcomeMappers) {
this.businessOutcomeMappers = Objects.requireNonNull(businessOutcomeMappers,
"businessOutcomeMappers must not be null");
return this;
}

/**
* Whether to propagate exceptions caught while invoking the model instead of
* converting them to an {@code AssistantMessage} with an {@code Exception:} prefix.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,8 @@ else if (this.outputType != null) {
.maxParallelTools(this.maxParallelTools)
.toolExecutionTimeout(this.toolExecutionTimeout)
.validateToolArguments(this.validateToolArguments)
.toolBindingStore(toolBindings);
.toolBindingStore(toolBindings)
.businessOutcomeMappers(this.businessOutcomeMappers);

toolBuilder.throwOnToolError(throwOnToolError);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ private ToolCallResponse processLargeResult(ToolCallResponse response, String to
.toolName(response.getToolName())
.toolCallId(response.getToolCallId())
.status("evicted_to_filesystem")
.metadata(response.getMetadata())
.build();
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
* 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.interceptor;

/**
* Implemented by tools that carry their own {@link BusinessOutcomeMapper}; lets
* {@link BusinessOutcomeMapperRegistry#fromTools(Iterable)} pick the mapper up without
* an explicit registry entry.
*/
public interface BusinessOutcomeAware {

/**
* The mapper describing how this tool's raw results map to outcomes.
* @return the tool's outcome mapper
*/
BusinessOutcomeMapper outcomeMapper();

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/*
* 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.interceptor;

import io.github.agentic.ai.api.chat.ToolCall;
import io.github.agentic.ai.api.tool.ToolResult;

/**
* Maps the raw {@link ToolResult} of a single, explicitly registered tool to its
* outcome, so that a business-level failure reported inside an otherwise successful
* tool response can be surfaced as a framework-visible failure.
*
* <p>
* The framework never inspects result content to guess whether a tool failed: only a
* mapper registered for that exact tool name (see
* {@link BusinessOutcomeMapperRegistry}) is consulted. A successful tool call whose
* payload happens to contain an {@code "error"} field stays a success unless a mapper
* says otherwise.
* </p>
*
* <p>
* Contract:
* </p>
* <ul>
* <li>return the passed-in {@code result} unchanged when the call succeeded;</li>
* <li>return an error-marked result (e.g. {@code ToolResult.error(...)} enriched with
* metadata such as an {@code errorCode}) when the business outcome is a failure — the
* response status then becomes {@link ToolOutcome#BUSINESS_FAILED};</li>
* <li>never return {@code null}; implementations should avoid throwing, but an
* exception escapes as a regular tool execution failure rather than being swallowed.</li>
* </ul>
*/
@FunctionalInterface
public interface BusinessOutcomeMapper {

/**
* Maps the outcome of one tool call.
* @param call the tool call being finalized
* @param result the raw result produced by the tool
* @return the outcome to report; must not be {@code null}
*/
ToolResult mapOutcome(ToolCall call, ToolResult result);

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/*
* 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.interceptor;

import java.util.Collections;
import java.util.HashMap;
import java.util.Map;

import io.github.agentic.ai.api.chat.ToolCall;
import io.github.agentic.ai.api.tool.Tool;
import io.github.agentic.ai.api.tool.ToolResult;

/**
* Immutable lookup from tool name to the {@link BusinessOutcomeMapper} registered for
* that tool. Tools without a registration are passed through untouched: their results
* are never inspected for business failure signals.
*
* <p>
* Registrations come from the application ({@link #builder()}) or from tools that
* implement {@link BusinessOutcomeAware} ({@link #fromTools(Iterable)}).
* </p>
*/
public final class BusinessOutcomeMapperRegistry {

private static final BusinessOutcomeMapperRegistry EMPTY = new BusinessOutcomeMapperRegistry(
Collections.emptyMap());

private final Map<String, BusinessOutcomeMapper> mappers;

private BusinessOutcomeMapperRegistry(Map<String, BusinessOutcomeMapper> mappers) {
this.mappers = Collections.unmodifiableMap(new HashMap<>(mappers));
}

/**
* A registry without any registration: every result is passed through unchanged.
* @return the empty registry
*/
public static BusinessOutcomeMapperRegistry empty() {
return EMPTY;
}

public static Builder builder() {
return new Builder();
}

/**
* Collect the mappers of the given tools. Tools that do not implement
* {@link BusinessOutcomeAware} are skipped.
* @param tools the registered tools
* @return a registry with one entry per outcome-aware tool
*/
public static BusinessOutcomeMapperRegistry fromTools(Iterable<? extends Tool> tools) {
Builder builder = builder();
for (Tool tool : tools) {
if (tool instanceof BusinessOutcomeAware aware) {
builder.declare(tool.definition().name(), aware.outcomeMapper());
}
}
return builder.build();
}

/**
* The mapper registered for a tool, if any.
* @param toolName the tool name
* @return the registered mapper, or empty when none was registered
*/
public boolean hasMapper(String toolName) {
return this.mappers.containsKey(toolName);
}

/**
* Applies the mapper registered for the call's tool, or returns the result
* unchanged when nothing is registered for it.
* @param call the tool call being finalized
* @param result the raw result produced by the tool
* @return the mapped outcome, or the original result when no mapper is registered
*/
public ToolResult mapOutcome(ToolCall call, ToolResult result) {
BusinessOutcomeMapper mapper = this.mappers.get(call.name());
if (mapper == null) {
return result;
}
ToolResult mapped = mapper.mapOutcome(call, result);
if (mapped == null) {
throw new IllegalStateException(
"BusinessOutcomeMapper for tool '" + call.name() + "' returned null");
}
return mapped;
}

/**
* Merge another registry into this one. Registrations of {@code other} win on
* conflict.
* @param other the registry whose registrations take precedence
* @return a new registry containing both sets of registrations
*/
public BusinessOutcomeMapperRegistry merge(BusinessOutcomeMapperRegistry other) {
Map<String, BusinessOutcomeMapper> merged = new HashMap<>(this.mappers);
merged.putAll(other.mappers);
return new BusinessOutcomeMapperRegistry(merged);
}

public static final class Builder {

private final Map<String, BusinessOutcomeMapper> mappers = new HashMap<>();

private Builder() {
}

public Builder declare(String toolName, BusinessOutcomeMapper mapper) {
if (toolName == null || toolName.isBlank()) {
throw new IllegalArgumentException("toolName must not be blank");
}
if (mapper == null) {
throw new IllegalArgumentException("mapper must not be null");
}
this.mappers.put(toolName, mapper);
return this;
}

public BusinessOutcomeMapperRegistry build() {
return new BusinessOutcomeMapperRegistry(this.mappers);
}

}

}
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,21 @@ public static ToolCallResponse from(ToolCall call, ToolResult result) {
result.error() ? ERROR_STATUS : SUCCESS_STATUS, result.metadata());
}

/**
* Same as {@link #from(ToolCall, ToolResult)}, but with an explicit outcome status
* (see {@link ToolOutcome}) instead of the two-value mapping of
* {@link ToolResult#error()}.
* @param call the tool call being finalized
* @param result the result produced by the tool
* @param status the outcome status to record
* @return a new ToolCallResponse carrying the given status
*/
public static ToolCallResponse from(ToolCall call, ToolResult result, String status) {
ChatMessage message = result.toMessage(call);
return new ToolCallResponse(message.text(), result.content(), call.name(), call.id(), status,
result.metadata());
}

/**
* Creates a response without an explicit status. Kept for backward compatibility;
* new code should use {@link #success(String, String, String)} or one of the
Expand Down Expand Up @@ -213,11 +228,12 @@ public Map<String, Object> getMetadata() {
}

/**
* Checks if this response represents an error.
* @return true if this is an error response
* Checks if this response represents a failure: either a generic error or a
* business-level failure reported by a registered {@link BusinessOutcomeMapper}.
* @return true if this is a failed response
*/
public boolean isError() {
return ERROR_STATUS.equals(status);
return ERROR_STATUS.equals(status) || ToolOutcome.BUSINESS_FAILED.equals(status);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,9 @@ public ToolCallResponse interceptToolCall(ToolCallRequest request, ToolCallHandl
emulatedResult.length() > 100 ? emulatedResult.substring(0, 100) + "..." : emulatedResult);

// Short-circuit: return emulated result without executing real tool
return ToolCallResponse.of(request.getToolCallId(), toolName, emulatedResult);
// An emulated result is a successful outcome: it must carry the success status
// so downstream consumers (e.g. retry interceptors) do not treat it as a failure.
return ToolCallResponse.success(request.getToolCallId(), toolName, emulatedResult);

}
catch (Exception e) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@
import io.github.agentic.ai.graph.agent.interceptor.ToolInterceptor;

import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.function.Predicate;
Expand Down Expand Up @@ -81,6 +83,7 @@ public ToolCallResponse interceptToolCall(ToolCallRequest request, ToolCallHandl
}

Exception lastException = null;
ToolCallResponse lastFailedResponse = null;

// maxAttempts counts the initial call plus retries and is always >= 1, so the tool is executed at least once and is never skipped.
for (int attempt = 0; attempt < maxAttempts; attempt++) {
Expand All @@ -90,6 +93,7 @@ public ToolCallResponse interceptToolCall(ToolCallRequest request, ToolCallHandl
return response;
}
// A non-success status is treated as a failure so it can be retried.
lastFailedResponse = response;
String result = response.getResult() != null ? response.getResult() : "unknown error";
throw new RuntimeException(result);
}
Expand Down Expand Up @@ -133,10 +137,30 @@ public ToolCallResponse interceptToolCall(ToolCallRequest request, ToolCallHandl
: "Tool call failed after " + maxAttempts + " attempts: " + lastException.getMessage();

log.error("Tool '{}' failed after {} attempts: {}", toolName, maxAttempts, lastException.getMessage());
return ToolCallResponse.error(request.getToolCallId(), request.getToolName(), errorMessage);
// Carry the structured failure metadata (failure kind / exception type) of the
// last failed response into the exhausted-retries error response.
return ToolCallResponse.error(request.getToolCallId(), request.getToolName(), errorMessage,
structuredFailureMetadata(lastFailedResponse));
}
}

/**
* Extracts the structured failure kind / exception type entries of the last failed
* response, without its error-message keys, so they can enrich the
* exhausted-retries error response without overriding it.
*/
private static Map<String, Object> structuredFailureMetadata(ToolCallResponse lastFailedResponse) {
if (lastFailedResponse == null) {
return null;
}
Map<String, Object> structured = new HashMap<>();
lastFailedResponse.getFailureKind()
.ifPresent(kind -> structured.put(ToolCallResponse.FAILURE_KIND_METADATA_KEY, kind));
lastFailedResponse.getExceptionType()
.ifPresent(type -> structured.put(ToolCallResponse.EXCEPTION_TYPE_METADATA_KEY, type));
return structured.isEmpty() ? null : structured;
}

private long calculateDelay(int retryNumber) {
long delay = (long) (initialDelayMs * Math.pow(backoffFactor, retryNumber));
delay = Math.min(delay, maxDelayMs);
Expand Down
Loading
Loading