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: + *

+ * + */ +@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 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); + } + }; + } + +}