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 @@ -33,6 +33,7 @@ public class ToolCallResponse {

public final static String SUCCESS_STATUS = "SUCCESS";

/** Status of an error response; see {@link ToolOutcome} for the full outcome vocabulary. */
public static final String ERROR_STATUS = "error";

/**
Expand Down Expand Up @@ -99,23 +100,31 @@ public static ToolCallResponse from(ToolCall call, ToolResult result) {
result.error() ? ERROR_STATUS : SUCCESS_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
* {@code error} factories so the outcome is machine-readable (see {@link ToolOutcome}).
* @param toolCallId the tool call ID
* @param toolName the tool name
* @param result the response content
* @return a new ToolCallResponse with no status set
*/
public static ToolCallResponse of(String toolCallId, String toolName, String result) {
return new ToolCallResponse(result, toolName, toolCallId);
}

/**
* <p>
* Rewrite the status field of the source code to SUCCESS, and then make a judgment in
* the tool interceptor
* </p>
* @param toolCallId toolCallId
* @param toolName toolName
* @param result response
* @return
*/
public static ToolCallResponse success(String toolCallId, String toolName, String result) {
return new ToolCallResponse(result, toolName, toolCallId, SUCCESS_STATUS, null);
}
/**
* <p>
* Rewrite the status field of the source code to SUCCESS, and then make a judgment in the tool interceptor
* </p>
* @param toolCallId toolCallId
* @param toolName toolName
* @param result response
* @return
*/
public static ToolCallResponse success(String toolCallId, String toolName, String result) {
return new ToolCallResponse(result, toolName, toolCallId, SUCCESS_STATUS, null);
}

/**
* Creates an error response for a failed tool execution.
Expand Down
Original file line number Diff line number Diff line change
@@ -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.interceptor;

/**
* Canonical outcome vocabulary for {@link ToolCallResponse#getStatus()}.
*
* <p>
* The status field is historically a free-form {@link String}; this class constrains the
* values that new code may produce. Legacy values keep their existing string form
* ({@link #SUCCESS} equals {@link ToolCallResponse#SUCCESS_STATUS}, {@link #ERROR} equals
* the value used by the {@code error} factories) so that existing consumers comparing
* against {@link ToolCallResponse#SUCCESS_STATUS} or calling
* {@link ToolCallResponse#isError()} are unaffected.
* </p>
*
* <p>
* Fine-grained failure detail is carried in the response metadata (see
* {@link ToolCallResponse#FAILURE_KIND_METADATA_KEY failureKind},
* {@link ToolCallResponse#EXCEPTION_TYPE_METADATA_KEY exceptionType} and the validation
* {@code errorCode}/{@code fieldPath} entries). The reserved values below
* ({@link #VALIDATION_DENIED}, {@link #AUTH_DENIED}, {@link #BUSINESS_FAILED},
* {@link #TRANSIENT}, {@link #TIMEOUT}, {@link #CANCELLED}, {@link #UNCERTAIN}) are
* declared here so the vocabulary is defined once; wiring individual producers to them,
* business-failure mapping and a typed outcome field on the response are left to
* follow-up changes.
* </p>
*/
public final class ToolOutcome {

/** The tool call completed and returned a result. */
public static final String SUCCESS = ToolCallResponse.SUCCESS_STATUS;

/** The tool call failed; the generic failure status used by all error paths. */
public static final String ERROR = "error";

/** The tool call was rejected before execution (e.g. argument validation failed). */
public static final String VALIDATION_DENIED = "validation-denied";

/** The caller is not authorized to invoke the tool. */
public static final String AUTH_DENIED = "auth-denied";

/** The tool ran and reported a business-level failure. */
public static final String BUSINESS_FAILED = "business-failed";

/** The failure is transient and may succeed on retry. */
public static final String TRANSIENT = "transient";

/** The tool did not finish within its deadline; its effect is unknown. */
public static final String TIMEOUT = "timeout";

/** The tool execution was cancelled or interrupted. */
public static final String CANCELLED = "cancelled";

/** The effect of the tool call could not be determined. */
public static final String UNCERTAIN = "uncertain";

private ToolOutcome() {
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public ToolCallResponse interceptToolCall(ToolCallRequest request, ToolCallHandl
try {
return handler.call(request);
} catch (Exception e) {
return ToolCallResponse.of(request.getToolCallId(), request.getToolName(),
return ToolCallResponse.error(request.getToolCallId(), request.getToolName(),
"Tool failed: " + e.getMessage());
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ 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.of(request.getToolCallId(), request.getToolName(), errorMessage);
return ToolCallResponse.error(request.getToolCallId(), request.getToolName(), errorMessage);
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/*
* 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.interceptor.ToolCallRequest;
import io.github.agentic.ai.graph.agent.interceptor.ToolCallResponse;
import io.github.agentic.ai.graph.agent.interceptor.toolerror.ToolErrorInterceptor;
import org.junit.jupiter.api.Test;

import java.util.HashMap;

import static org.assertj.core.api.Assertions.assertThat;

/**
* Verifies that {@link ToolErrorInterceptor} swallows handler exceptions into an
* explicitly-shaped error response (status + error metadata) instead of a status-less
* response, and passes successful results through untouched.
*/
class ToolErrorInterceptorTest {

private ToolCallRequest request() {
return new ToolCallRequest("t", "{}", "id-1", new HashMap<>());
}

@Test
void successfulCallPassesThrough() {
ToolCallResponse response = ToolErrorInterceptor.builder().build()
.interceptToolCall(request(), r -> ToolCallResponse.success(r.getToolCallId(), r.getToolName(), "ok"));

assertThat(response.getStatus()).isEqualTo(ToolCallResponse.SUCCESS_STATUS);
assertThat(response.getResult()).isEqualTo("ok");
}

@Test
void handlerExceptionBecomesExplicitErrorResponse() {
ToolCallResponse response = ToolErrorInterceptor.builder().build()
.interceptToolCall(request(), r -> {
throw new IllegalStateException("backend down");
});

assertThat(response.getStatus()).isEqualTo(ToolCallResponse.ERROR_STATUS);
assertThat(response.isError()).isTrue();
assertThat(response.getResult()).contains("backend down");
assertThat(response.getMetadata())
.containsEntry("error", true)
.containsEntry("errorMessage", "Tool failed: backend down");
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
Expand Down Expand Up @@ -55,6 +56,11 @@ void runsAllConfiguredAttempts() {
});
assertEquals(3, calls.get());
assertTrue(response.getResult().contains("3 attempts"));
// Exhausted retries must surface as an explicit error, not a status-less response.
assertEquals(ToolCallResponse.ERROR_STATUS, response.getStatus());
assertTrue(response.isError());
assertThat(response.getMetadata()).containsEntry("error", true);
assertThat(response.getMetadata().get("errorMessage")).asString().contains("boom");
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,23 @@ void toolExecutionExceptionReportsErrorAndWrappedType() throws Exception {
assertThat(response.getExceptionType()).contains(IllegalArgumentException.class.getName());
}

@Test
@Timeout(value = 10, unit = TimeUnit.SECONDS)
@DisplayName("async failed future is marked as an error with the same status contract as the sync path")
void asyncFailedFutureReportsErrorAndWrappedType() throws Exception {
Tool wrapped = asyncTool("asyncWrappedTool", Duration.ofSeconds(5),
() -> CompletableFuture.failedFuture(
new CompletionException(new IllegalArgumentException("bad async argument"))));

ToolCallResponse response = runSingleTool(wrapped, "asyncWrappedTool", false);

assertThat(response.getStatus()).isEqualTo(ToolCallResponse.ERROR_STATUS);
assertThat(response.isError()).isTrue();
assertThat(response.getResult()).contains("bad async argument");
assertThat(response.getFailureKind()).contains(ToolCallResponse.FAILURE_KIND_EXECUTION);
assertThat(response.getExceptionType()).contains(IllegalArgumentException.class.getName());
}

@Test
@Timeout(value = 10, unit = TimeUnit.SECONDS)
@DisplayName("unresolved tool name is reported as failureKind=unresolved without exception type")
Expand Down
Loading