diff --git a/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/Builder.java b/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/Builder.java
index 6898dfdd2..f39f0ba9d 100644
--- a/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/Builder.java
+++ b/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/Builder.java
@@ -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;
@@ -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;
@@ -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.
diff --git a/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/DefaultBuilder.java b/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/DefaultBuilder.java
index 4405d9bdd..56ccf9833 100644
--- a/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/DefaultBuilder.java
+++ b/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/DefaultBuilder.java
@@ -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);
diff --git a/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/extension/interceptor/LargeResultEvictionInterceptor.java b/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/extension/interceptor/LargeResultEvictionInterceptor.java
index 8fdf42ff6..e62153322 100644
--- a/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/extension/interceptor/LargeResultEvictionInterceptor.java
+++ b/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/extension/interceptor/LargeResultEvictionInterceptor.java
@@ -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();
}
diff --git a/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/interceptor/BusinessOutcomeAware.java b/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/interceptor/BusinessOutcomeAware.java
new file mode 100644
index 000000000..f338aaf2d
--- /dev/null
+++ b/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/interceptor/BusinessOutcomeAware.java
@@ -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();
+
+}
diff --git a/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/interceptor/BusinessOutcomeMapper.java b/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/interceptor/BusinessOutcomeMapper.java
new file mode 100644
index 000000000..2d9648439
--- /dev/null
+++ b/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/interceptor/BusinessOutcomeMapper.java
@@ -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.
+ *
+ *
+ * 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.
+ *
+ *
+ *
+ * Contract:
+ *
+ *
+ * - return the passed-in {@code result} unchanged when the call succeeded;
+ * - 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};
+ * - never return {@code null}; implementations should avoid throwing, but an
+ * exception escapes as a regular tool execution failure rather than being swallowed.
+ *
+ */
+@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);
+
+}
diff --git a/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/interceptor/BusinessOutcomeMapperRegistry.java b/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/interceptor/BusinessOutcomeMapperRegistry.java
new file mode 100644
index 000000000..06acf51a1
--- /dev/null
+++ b/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/interceptor/BusinessOutcomeMapperRegistry.java
@@ -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.
+ *
+ *
+ * Registrations come from the application ({@link #builder()}) or from tools that
+ * implement {@link BusinessOutcomeAware} ({@link #fromTools(Iterable)}).
+ *
+ */
+public final class BusinessOutcomeMapperRegistry {
+
+ private static final BusinessOutcomeMapperRegistry EMPTY = new BusinessOutcomeMapperRegistry(
+ Collections.emptyMap());
+
+ private final Map mappers;
+
+ private BusinessOutcomeMapperRegistry(Map 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 merged = new HashMap<>(this.mappers);
+ merged.putAll(other.mappers);
+ return new BusinessOutcomeMapperRegistry(merged);
+ }
+
+ public static final class Builder {
+
+ private final Map 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);
+ }
+
+ }
+
+}
diff --git a/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/interceptor/ToolCallResponse.java b/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/interceptor/ToolCallResponse.java
index 0cedddeea..6feaa6def 100644
--- a/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/interceptor/ToolCallResponse.java
+++ b/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/interceptor/ToolCallResponse.java
@@ -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
@@ -213,11 +228,12 @@ public Map 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);
}
/**
diff --git a/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/interceptor/toolemulator/ToolEmulatorInterceptor.java b/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/interceptor/toolemulator/ToolEmulatorInterceptor.java
index 75d7c53f5..1246093b3 100644
--- a/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/interceptor/toolemulator/ToolEmulatorInterceptor.java
+++ b/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/interceptor/toolemulator/ToolEmulatorInterceptor.java
@@ -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) {
diff --git a/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/interceptor/toolretry/ToolRetryInterceptor.java b/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/interceptor/toolretry/ToolRetryInterceptor.java
index 00aca031e..808e5e2cc 100644
--- a/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/interceptor/toolretry/ToolRetryInterceptor.java
+++ b/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/interceptor/toolretry/ToolRetryInterceptor.java
@@ -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;
@@ -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++) {
@@ -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);
}
@@ -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 structuredFailureMetadata(ToolCallResponse lastFailedResponse) {
+ if (lastFailedResponse == null) {
+ return null;
+ }
+ Map 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);
diff --git a/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/node/AgentToolNode.java b/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/node/AgentToolNode.java
index 7e7fd2673..a77b93dcf 100644
--- a/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/node/AgentToolNode.java
+++ b/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/node/AgentToolNode.java
@@ -28,6 +28,8 @@
import io.github.agentic.ai.graph.agent.interceptor.ToolCallExecutionContext;
import io.github.agentic.ai.graph.agent.interceptor.ToolCallHandler;
import io.github.agentic.ai.graph.agent.interceptor.ToolCallRequest;
+import io.github.agentic.ai.graph.agent.interceptor.BusinessOutcomeMapperRegistry;
+import io.github.agentic.ai.graph.agent.interceptor.ToolOutcome;
import io.github.agentic.ai.graph.agent.interceptor.ToolCallResponse;
import io.github.agentic.ai.graph.agent.interceptor.ToolInterceptor;
import io.github.agentic.ai.graph.agent.tool.DefaultCancellationToken;
@@ -119,6 +121,8 @@ public class AgentToolNode implements NodeActionWithConfig {
private final ToolRegistry toolRegistry;
+ private final BusinessOutcomeMapperRegistry businessOutcomeMappers;
+
private final ThreadPoolExecutor neutralInvocationExecutor;
public AgentToolNode(Builder builder) {
@@ -142,6 +146,7 @@ public AgentToolNode(Builder builder) {
this.toolExecutionTimeout = builder.toolExecutionTimeout;
this.validateToolArguments = builder.validateToolArguments;
this.throwOnToolError = builder.throwOnToolError;
+ this.businessOutcomeMappers = builder.businessOutcomeMappers;
}
public void setToolInterceptors(List toolInterceptors) {
@@ -691,8 +696,14 @@ private ToolCallResponse executeNeutralTool(ToolRegistry registry, ToolCallReque
if (token.isCancelled()) {
throw new CancellationException("Tool execution was cancelled before state publication");
}
+ // A registered outcome mapper is the only component allowed to reinterpret a
+ // successful raw result as a business failure; unregistered tools are never guessed.
+ ToolResult outcome = businessOutcomeMappers.mapOutcome(call, result);
+ if (outcome.error() && !result.error() && businessOutcomeMappers.hasMapper(call.name())) {
+ return ToolCallResponse.from(call, outcome, ToolOutcome.BUSINESS_FAILED);
+ }
updates.putAll(invocationUpdates);
- return ToolCallResponse.from(call, result);
+ return ToolCallResponse.from(call, outcome);
}
catch (InterruptedException failure) {
updates.clear();
@@ -859,6 +870,8 @@ public Builder toolRegistry(ToolRegistry toolRegistry) {
private boolean validateToolArguments = false;
+ private BusinessOutcomeMapperRegistry businessOutcomeMappers = BusinessOutcomeMapperRegistry.empty();
+
private Map toolContext = new HashMap<>();
private AgentToolBindingStore toolBindingStore = new AgentToolBindingStore();
@@ -926,6 +939,12 @@ public Builder toolExecutionTimeout(Duration timeout) {
* @param validate true to validate tool arguments before dispatch
* @return this builder
*/
+ public Builder businessOutcomeMappers(BusinessOutcomeMapperRegistry registry) {
+ this.businessOutcomeMappers = Objects.requireNonNull(registry,
+ "businessOutcomeMappers must not be null");
+ return this;
+ }
+
public Builder validateToolArguments(boolean validate) {
this.validateToolArguments = validate;
return this;
diff --git a/argi-agent-framework/src/test/java/io/github/agentic/ai/graph/agent/interceptor/BusinessOutcomeMapperRegistryTest.java b/argi-agent-framework/src/test/java/io/github/agentic/ai/graph/agent/interceptor/BusinessOutcomeMapperRegistryTest.java
new file mode 100644
index 000000000..0cbadcb7b
--- /dev/null
+++ b/argi-agent-framework/src/test/java/io/github/agentic/ai/graph/agent/interceptor/BusinessOutcomeMapperRegistryTest.java
@@ -0,0 +1,159 @@
+/*
+ * 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.chat.ToolDefinition;
+import io.github.agentic.ai.api.tool.Tool;
+import io.github.agentic.ai.api.tool.ToolContext;
+import io.github.agentic.ai.api.tool.ToolResult;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionStage;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Verifies the registration contract of {@link BusinessOutcomeMapperRegistry}: results
+ * of unregistered tools are passed through untouched (no guessing), registered mappers
+ * are applied, {@link BusinessOutcomeAware} tools are picked up by
+ * {@code fromTools}, and a mapper returning null is rejected loudly.
+ */
+@DisplayName("BusinessOutcomeMapperRegistry registration contract")
+class BusinessOutcomeMapperRegistryTest {
+
+ private static final ToolCall CALL = new ToolCall("call-1", "payment", "{}");
+
+ private static ToolResult text(String text) {
+ return ToolResult.text(text);
+ }
+
+ @Test
+ void unregisteredToolResultIsPassedThroughUntouched() {
+ BusinessOutcomeMapperRegistry registry = BusinessOutcomeMapperRegistry.empty();
+ ToolResult raw = text("declined");
+
+ assertThat(registry.mapOutcome(CALL, raw)).isSameAs(raw);
+ assertThat(registry.hasMapper("payment")).isFalse();
+ }
+
+ @Test
+ void registeredMapperIsAppliedToItsToolOnly() {
+ BusinessOutcomeMapper decline = (call, result) -> new ToolResult(
+ ToolResult.error("payment declined").content(), true, Map.of("errorCode", "PAYMENT_DECLINED"));
+ BusinessOutcomeMapperRegistry registry = BusinessOutcomeMapperRegistry.builder()
+ .declare("payment", decline)
+ .build();
+
+ ToolResult mapped = registry.mapOutcome(CALL, text("{\"status\":\"declined\"}"));
+ assertThat(mapped.error()).isTrue();
+ assertThat(mapped.metadata()).containsEntry("errorCode", "PAYMENT_DECLINED");
+
+ ToolCall otherCall = new ToolCall("call-2", "weather", "{}");
+ ToolResult untouched = text("sunny");
+ assertThat(registry.mapOutcome(otherCall, untouched)).isSameAs(untouched);
+ }
+
+ @Test
+ void fromToolsPicksUpBusinessOutcomeAwareToolsOnly() {
+ Tool aware = new OutcomeAwareTool("payment", (call, result) -> ToolResult.error("declined"));
+ Tool plain = new PlainTool("weather");
+
+ BusinessOutcomeMapperRegistry registry = BusinessOutcomeMapperRegistry.fromTools(List.of(aware, plain));
+
+ assertThat(registry.hasMapper("payment")).isTrue();
+ assertThat(registry.hasMapper("weather")).isFalse();
+ }
+
+ @Test
+ void mergeGivesPrecedenceToTheOtherRegistry() {
+ BusinessOutcomeMapper first = (call, result) -> result;
+ BusinessOutcomeMapper second = (call, result) -> ToolResult.error("declined");
+ BusinessOutcomeMapperRegistry merged = BusinessOutcomeMapperRegistry.builder()
+ .declare("payment", first)
+ .build()
+ .merge(BusinessOutcomeMapperRegistry.builder().declare("payment", second).build());
+
+ assertThat(merged.mapOutcome(CALL, text("ok")).error()).isTrue();
+ }
+
+ @Test
+ void mapperReturningNullIsRejected() {
+ BusinessOutcomeMapperRegistry registry = BusinessOutcomeMapperRegistry.builder()
+ .declare("payment", (call, result) -> null)
+ .build();
+
+ assertThatThrownBy(() -> registry.mapOutcome(CALL, text("ok")))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("payment");
+ }
+
+ @Test
+ void builderRejectsBlankToolNameAndNullMapper() {
+ assertThatThrownBy(() -> BusinessOutcomeMapperRegistry.builder().declare(" ", (c, r) -> r))
+ .isInstanceOf(IllegalArgumentException.class);
+ assertThatThrownBy(() -> BusinessOutcomeMapperRegistry.builder().declare("payment", null))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ private record PlainTool(String name) implements Tool {
+
+ @Override
+ public ToolDefinition definition() {
+ return new ToolDefinition(name, name, "{}");
+ }
+
+ @Override
+ public CompletionStage execute(ToolCall call, ToolContext context) {
+ return CompletableFuture.completedFuture(ToolResult.text("sunny"));
+ }
+
+ }
+
+ private static final class OutcomeAwareTool implements Tool, BusinessOutcomeAware {
+
+ private final String name;
+
+ private final BusinessOutcomeMapper mapper;
+
+ OutcomeAwareTool(String name, BusinessOutcomeMapper mapper) {
+ this.name = name;
+ this.mapper = mapper;
+ }
+
+ @Override
+ public ToolDefinition definition() {
+ return new ToolDefinition(name, name, "{}");
+ }
+
+ @Override
+ public CompletionStage execute(ToolCall call, ToolContext context) {
+ return CompletableFuture.completedFuture(ToolResult.text("done"));
+ }
+
+ @Override
+ public BusinessOutcomeMapper outcomeMapper() {
+ return this.mapper;
+ }
+
+ }
+
+}
diff --git a/argi-agent-framework/src/test/java/io/github/agentic/ai/graph/agent/interceptors/LargeResultEvictionInterceptorTest.java b/argi-agent-framework/src/test/java/io/github/agentic/ai/graph/agent/interceptors/LargeResultEvictionInterceptorTest.java
new file mode 100644
index 000000000..442a3f7d3
--- /dev/null
+++ b/argi-agent-framework/src/test/java/io/github/agentic/ai/graph/agent/interceptors/LargeResultEvictionInterceptorTest.java
@@ -0,0 +1,74 @@
+/*
+ * 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.interceptors;
+
+import io.github.agentic.ai.graph.agent.extension.file.FilesystemBackend;
+import io.github.agentic.ai.graph.agent.extension.file.WriteResult;
+import io.github.agentic.ai.graph.agent.extension.interceptor.LargeResultEvictionInterceptor;
+import io.github.agentic.ai.graph.agent.interceptor.ToolCallRequest;
+import io.github.agentic.ai.graph.agent.interceptor.ToolCallResponse;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Verifies that {@link LargeResultEvictionInterceptor} preserves the structured
+ * metadata of the original response when evicting a large result, instead of silently
+ * dropping the failure context it carried.
+ */
+@DisplayName("LargeResultEvictionInterceptor metadata preservation")
+class LargeResultEvictionInterceptorTest {
+
+ @Test
+ @DisplayName("evicting a large result keeps the original response metadata")
+ void evictionKeepsOriginalMetadata() {
+ FilesystemBackend backend = mock(FilesystemBackend.class);
+ when(backend.write(anyString(), anyString())).thenReturn(new WriteResult("large_tool_results/call_1", null, null));
+
+ // 1 token limit triggers eviction for any content longer than 4 characters.
+ LargeResultEvictionInterceptor interceptor = LargeResultEvictionInterceptor.builder()
+ .toolTokenLimitBeforeEvict(1)
+ .backend(backend)
+ .build();
+
+ Map metadata = new HashMap<>();
+ metadata.put(ToolCallResponse.FAILURE_KIND_METADATA_KEY, ToolCallResponse.FAILURE_KIND_EXECUTION);
+ metadata.put("errorCode", "SLOW_QUERY");
+ ToolCallResponse original = ToolCallResponse.builder()
+ .content("x".repeat(500))
+ .toolName("reportTool")
+ .toolCallId("call_1")
+ .status(ToolCallResponse.SUCCESS_STATUS)
+ .metadata(metadata)
+ .build();
+
+ ToolCallResponse evicted = interceptor.interceptToolCall(
+ new ToolCallRequest("reportTool", "{}", "call_1", new HashMap<>()), r -> original);
+
+ assertThat(evicted.getStatus()).isEqualTo("evicted_to_filesystem");
+ assertThat(evicted.getMetadata())
+ .containsEntry(ToolCallResponse.FAILURE_KIND_METADATA_KEY, ToolCallResponse.FAILURE_KIND_EXECUTION)
+ .containsEntry("errorCode", "SLOW_QUERY");
+ }
+
+}
diff --git a/argi-agent-framework/src/test/java/io/github/agentic/ai/graph/agent/interceptors/ToolEmulatorInterceptorTests.java b/argi-agent-framework/src/test/java/io/github/agentic/ai/graph/agent/interceptors/ToolEmulatorInterceptorTests.java
index d712b8e90..f32fcf819 100644
--- a/argi-agent-framework/src/test/java/io/github/agentic/ai/graph/agent/interceptors/ToolEmulatorInterceptorTests.java
+++ b/argi-agent-framework/src/test/java/io/github/agentic/ai/graph/agent/interceptors/ToolEmulatorInterceptorTests.java
@@ -67,6 +67,9 @@ void emulatesAllByDefaultAndPreservesToolIdentity() {
assertThat(result.getToolName()).isEqualTo("weather");
assertThat(result.getToolCallId()).isEqualTo("call-17");
assertThat(result.isError()).isFalse();
+ // Emulated results are successful outcomes and must carry the success status
+ // so retry interceptors do not treat them as failures.
+ assertThat(result.getStatus()).isEqualTo(ToolCallResponse.SUCCESS_STATUS);
assertThat(interceptor.getName()).isEqualTo("ToolEmulator");
ArgumentCaptor captured = ArgumentCaptor.forClass(ChatRequest.class);
verify(client).call(captured.capture());
diff --git a/argi-agent-framework/src/test/java/io/github/agentic/ai/graph/agent/interceptors/ToolRetryInterceptorUnitTest.java b/argi-agent-framework/src/test/java/io/github/agentic/ai/graph/agent/interceptors/ToolRetryInterceptorUnitTest.java
index c5cc58361..a3ad13c44 100644
--- a/argi-agent-framework/src/test/java/io/github/agentic/ai/graph/agent/interceptors/ToolRetryInterceptorUnitTest.java
+++ b/argi-agent-framework/src/test/java/io/github/agentic/ai/graph/agent/interceptors/ToolRetryInterceptorUnitTest.java
@@ -20,6 +20,7 @@
import io.github.agentic.ai.graph.agent.interceptor.toolretry.ToolRetryInterceptor;
import java.util.HashMap;
+import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;
@@ -63,6 +64,27 @@ void runsAllConfiguredAttempts() {
assertThat(response.getMetadata().get("errorMessage")).asString().contains("boom");
}
+ @Test
+ void exhaustedRetriesKeepStructuredFailureMetadataOfLastResponse() {
+ ToolCallResponse response = builder(2).build().interceptToolCall(request(), r -> {
+ Map metadata = new HashMap<>();
+ metadata.put(ToolCallResponse.FAILURE_KIND_METADATA_KEY, ToolCallResponse.FAILURE_KIND_TIMEOUT);
+ metadata.put(ToolCallResponse.EXCEPTION_TYPE_METADATA_KEY, "java.util.concurrent.TimeoutException");
+ return ToolCallResponse.builder()
+ .content("backend timeout")
+ .toolName(r.getToolName())
+ .toolCallId(r.getToolCallId())
+ .status(ToolCallResponse.ERROR_STATUS)
+ .metadata(metadata)
+ .build();
+ });
+ assertEquals(ToolCallResponse.ERROR_STATUS, response.getStatus());
+ assertEquals(ToolCallResponse.FAILURE_KIND_TIMEOUT, response.getFailureKind().orElse(null));
+ assertEquals("java.util.concurrent.TimeoutException", response.getExceptionType().orElse(null));
+ // The formatted exhausted-retries message must not be overridden by the carried metadata.
+ assertThat(response.getMetadata().get("errorMessage")).asString().contains("attempts");
+ }
+
@Test
void twoAttemptsMeansOneRetry() {
AtomicInteger calls = new AtomicInteger();
diff --git a/argi-agent-framework/src/test/java/io/github/agentic/ai/graph/agent/node/AgentToolNodeBusinessOutcomeTest.java b/argi-agent-framework/src/test/java/io/github/agentic/ai/graph/agent/node/AgentToolNodeBusinessOutcomeTest.java
new file mode 100644
index 000000000..6b1d6079e
--- /dev/null
+++ b/argi-agent-framework/src/test/java/io/github/agentic/ai/graph/agent/node/AgentToolNodeBusinessOutcomeTest.java
@@ -0,0 +1,203 @@
+/*
+ * 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.node;
+
+import io.github.agentic.ai.api.chat.ChatMessage;
+import io.github.agentic.ai.api.chat.ToolCall;
+import io.github.agentic.ai.api.chat.ToolDefinition;
+import io.github.agentic.ai.api.tool.Tool;
+import io.github.agentic.ai.api.tool.ToolContext;
+import io.github.agentic.ai.api.tool.ToolRegistry;
+import io.github.agentic.ai.api.tool.ToolResult;
+import io.github.agentic.ai.graph.OverAllState;
+import io.github.agentic.ai.graph.RunnableConfig;
+import io.github.agentic.ai.graph.agent.interceptor.BusinessOutcomeMapper;
+import io.github.agentic.ai.graph.agent.interceptor.BusinessOutcomeMapperRegistry;
+import io.github.agentic.ai.graph.agent.interceptor.ToolCallHandler;
+import io.github.agentic.ai.graph.agent.interceptor.ToolCallRequest;
+import io.github.agentic.ai.graph.agent.interceptor.ToolCallResponse;
+import io.github.agentic.ai.graph.agent.interceptor.ToolInterceptor;
+import io.github.agentic.ai.graph.agent.interceptor.ToolOutcome;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionStage;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Verifies the T38 business-outcome acceptance criteria end to end: a registered
+ * {@link BusinessOutcomeMapper} is the only component that can reinterpret a successful
+ * raw tool result as a business failure ({@link ToolOutcome#BUSINESS_FAILED}), results
+ * of unregistered tools are never inspected, and the outcome survives the conversion to
+ * the model-visible message.
+ */
+@DisplayName("AgentToolNode business outcome mapping")
+class AgentToolNodeBusinessOutcomeTest {
+
+ private static final String CALL_ID = "call_1";
+
+ @Test
+ @Timeout(value = 10, unit = TimeUnit.SECONDS)
+ @DisplayName("registered mapper surfacing a business failure gets BUSINESS_FAILED end to end")
+ void registeredBusinessFailureIsSurfaced() throws Exception {
+ BusinessOutcomeMapper decline = (call, result) -> new ToolResult(result.content(), true,
+ Map.of("errorCode", "PAYMENT_DECLINED"));
+ Tool payment = tool("paymentTool", ToolResult.text("{\"status\":\"declined\"}"));
+ BusinessOutcomeMapperRegistry registry = BusinessOutcomeMapperRegistry.builder()
+ .declare("paymentTool", decline)
+ .build();
+
+ ToolCallResponse response = runSingleTool(payment, "paymentTool", registry);
+
+ assertThat(response.getStatus()).isEqualTo(ToolOutcome.BUSINESS_FAILED);
+ // A business failure is a failure: isError() must agree so the model-visible
+ // message derived from it carries tool.error=true.
+ assertThat(response.isError()).isTrue();
+ assertThat(response.getResult()).contains("declined");
+ assertThat(response.getMetadata()).containsEntry("errorCode", "PAYMENT_DECLINED");
+ // The outcome must survive the conversion into the model-visible message.
+ ChatMessage visible = response.toMessage();
+ assertThat(visible.metadata()).containsEntry("errorCode", "PAYMENT_DECLINED");
+ assertThat(visible.metadata()).containsEntry("tool.error", true);
+ }
+
+ @Test
+ @Timeout(value = 10, unit = TimeUnit.SECONDS)
+ @DisplayName("unregistered tool whose payload mentions error stays a plain success")
+ void unregisteredToolPayloadIsNeverGuessed() throws Exception {
+ Tool suspicious = tool("rawTool", ToolResult.text("{\"error\": true, \"reason\": \"oops\"}"));
+
+ ToolCallResponse response = runSingleTool(suspicious, "rawTool", BusinessOutcomeMapperRegistry.empty());
+
+ assertThat(response.getStatus()).isEqualTo(ToolCallResponse.SUCCESS_STATUS);
+ assertThat(response.isError()).isFalse();
+ }
+
+ @Test
+ @Timeout(value = 10, unit = TimeUnit.SECONDS)
+ @DisplayName("registered mapper returning the result unchanged keeps SUCCESS")
+ void registeredMapperKeepingResultStaysSuccess() throws Exception {
+ Tool ok = tool("paymentTool", ToolResult.text("{\"status\":\"ok\"}"));
+ BusinessOutcomeMapperRegistry registry = BusinessOutcomeMapperRegistry.builder()
+ .declare("paymentTool", (call, result) -> result)
+ .build();
+
+ ToolCallResponse response = runSingleTool(ok, "paymentTool", registry);
+
+ assertThat(response.getStatus()).isEqualTo(ToolCallResponse.SUCCESS_STATUS);
+ }
+
+ @Test
+ @Timeout(value = 10, unit = TimeUnit.SECONDS)
+ @DisplayName("mapper throwing is reported as a regular execution failure")
+ void mapperFailureIsReportedAsExecutionError() throws Exception {
+ Tool ok = tool("paymentTool", ToolResult.text("pending"));
+ BusinessOutcomeMapperRegistry registry = BusinessOutcomeMapperRegistry.builder()
+ .declare("paymentTool", (call, result) -> {
+ throw new IllegalStateException("mapper blew up");
+ })
+ .build();
+
+ ToolCallResponse response = runSingleTool(ok, "paymentTool", registry);
+
+ assertThat(response.getStatus()).isEqualTo(ToolCallResponse.ERROR_STATUS);
+ assertThat(response.isError()).isTrue();
+ assertThat(response.getFailureKind()).contains(ToolCallResponse.FAILURE_KIND_EXECUTION);
+ assertThat(response.getResult()).contains("mapper blew up");
+ }
+
+ // ---------------------------------------------------------------------
+ // helpers (pattern borrowed from AgentToolNodeFailureMetadataTest)
+ // ---------------------------------------------------------------------
+
+ private ToolCallResponse runSingleTool(Tool callback, String requestedToolName,
+ BusinessOutcomeMapperRegistry registry) throws Exception {
+ AtomicReference captured = new AtomicReference<>();
+ ToolInterceptor capture = new ToolInterceptor() {
+ @Override
+ public ToolCallResponse interceptToolCall(ToolCallRequest request, ToolCallHandler handler) {
+ ToolCallResponse response = handler.call(request);
+ captured.set(response);
+ return response;
+ }
+
+ @Override
+ public String getName() {
+ return "capture";
+ }
+ };
+
+ AgentToolNode node = AgentToolNode.builder()
+ .agentName("test-agent")
+ .toolRegistry(new ToolRegistry(List.of(callback)))
+ .parallelToolExecution(false)
+ .toolExecutionTimeout(Duration.ofSeconds(5))
+ .throwOnToolError(false)
+ .businessOutcomeMappers(registry)
+ .build();
+ node.setToolInterceptors(List.of(capture));
+
+ ChatMessage assistantMessage = ChatMessage.assistant("",
+ List.of(new ToolCall(CALL_ID, requestedToolName, "{}")));
+ Map data = new HashMap<>();
+ data.put("messages", new ArrayList<>(List.of(assistantMessage)));
+ OverAllState state = new OverAllState(data);
+
+ ExecutorService executor = Executors.newFixedThreadPool(2);
+ try {
+ RunnableConfig config = RunnableConfig.builder()
+ .threadId("test-thread")
+ .addParallelNodeExecutor("_AGENT_TOOL_", executor)
+ .build();
+ node.apply(state, config);
+ }
+ finally {
+ executor.shutdownNow();
+ }
+
+ ToolCallResponse response = captured.get();
+ assertThat(response).as("response captured by interceptor").isNotNull();
+ assertThat(response.getToolCallId()).isEqualTo(CALL_ID);
+ return response;
+ }
+
+ private static Tool tool(String name, ToolResult result) {
+ return new Tool() {
+ @Override
+ public ToolDefinition definition() {
+ return new ToolDefinition(name, name, "{}");
+ }
+
+ @Override
+ public CompletionStage execute(ToolCall call, ToolContext context) {
+ return CompletableFuture.completedFuture(result);
+ }
+ };
+ }
+
+}