Skip to content
Open
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 @@ -159,6 +159,10 @@ private static boolean isRetryableError(Throwable error) {
* <li>Timeout: 5 minutes
* <li>Max attempts: 1 (no retry)
* </ul>
*
* <p>When {@code maxAttempts} is greater than 1, failures that surface as reactive error
* signals are retried (every failure when no retry predicate is configured); failures
* reported as completed {@code ToolResultBlock} error results are never retried.
*/
public static final ExecutionConfig TOOL_DEFAULTS =
builder().timeout(Duration.ofMinutes(5)).maxAttempts(1).build();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,4 +127,25 @@ default boolean isReadOnly() {
* @return Mono containing ToolResultBlock
*/
Mono<ToolResultBlock> callAsync(ToolCallParam param);

/**
* Executes the tool through the framework's execution infrastructure.
*
* <p>Called by {@link ToolExecutor} when running a tool call through the scheduling, timeout,
* retry and shutdown layers; application code should keep using
* {@link #callAsync(ToolCallParam)}. Unlike {@link #callAsync(ToolCallParam)}, failures stay
* as reactive error signals so they can be retried; the executor converts the final error
* into an error result once retries are exhausted.
*
* <p>Each subscription must be a fresh attempt, business failures must stay completed
* {@link ToolResultBlock} error results, and {@link ToolSuspendException} must propagate as
* an error signal. The default implementation delegates to {@link #callAsync(ToolCallParam)};
* built-in tools override it to keep exceptions as error signals.
*
* @param param The tool call parameters
* @return Mono containing ToolResultBlock, or signalling an error on failure
*/
default Mono<ToolResultBlock> callAsyncForExecution(ToolCallParam param) {
return callAsync(param);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,11 @@ public Mono<ToolResultBlock> callAsync(ToolCallParam param) {
return methodInvoker.invokeAsync(toolObject, method, param, customConverter);
}

@Override
public Mono<ToolResultBlock> callAsyncForExecution(ToolCallParam param) {
return methodInvoker.invokeRawAsync(toolObject, method, param, customConverter);
}

Method getMethod() {
return method;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.TimeoutException;
import java.util.function.BiConsumer;
import java.util.function.Predicate;
import org.slf4j.Logger;
Expand Down Expand Up @@ -159,13 +160,43 @@ private void invokeChunkCallback(
// ==================== Single Tool Execution ====================

/**
* Execute a single tool call with full infrastructure support.
* Execute a single tool call without the infrastructure layers.
*
* <p>Compatibility entry point used by {@link Toolkit#callTool(ToolCallParam)}: failures
* surface as {@link ToolResultBlock} error results, not reactive error signals.
*
* @param param Tool call parameters
* @return Mono containing execution result
*/
Mono<ToolResultBlock> execute(ToolCallParam param) {
return TracerRegistry.get().callTool(this.toolkit, param, () -> executeCore(param));
return executeWithTracing(param, false)
.onErrorResume(e -> Mono.just(buildToolErrorResult(e)));
}

/**
* Execute a single tool call through the raw execution channel, keeping failures as reactive
* error signals. Used only by {@link #executeWithInfrastructure}.
*
* @param param Tool call parameters
* @return Mono containing execution result, or signalling an error on failure
*/
private Mono<ToolResultBlock> executeRaw(ToolCallParam param) {
return executeWithTracing(param, true);
}

/**
* Run the traced core execution for the given tool call.
*
* @param param Tool call parameters
* @param useExecutionPath whether to use the raw execution channel
* ({@link AgentTool#callAsyncForExecution(ToolCallParam)}) instead of the compatibility
* channel ({@link AgentTool#callAsync(ToolCallParam)})
* @return Mono containing execution result, or signalling an error on failure
*/
private Mono<ToolResultBlock> executeWithTracing(
ToolCallParam param, boolean useExecutionPath) {
return TracerRegistry.get()
.callTool(this.toolkit, param, () -> executeCore(param, useExecutionPath));
}

/**
Expand All @@ -181,7 +212,7 @@ Mono<ToolResultBlock> execute(ToolCallParam param) {
* <li>Actual tool invocation</li>
* </ul>
*/
private Mono<ToolResultBlock> executeCore(ToolCallParam param) {
private Mono<ToolResultBlock> executeCore(ToolCallParam param, boolean useExecutionPath) {
ToolUseBlock toolCall = param.getToolUseBlock();
AgentTool tool = toolRegistry.getTool(toolCall.getName());

Expand Down Expand Up @@ -263,31 +294,52 @@ private Mono<ToolResultBlock> executeCore(ToolCallParam param) {
.emitter(toolEmitter)
.build();

return tool.callAsync(executionParam)
.onErrorResume(
ToolSuspendException.class,
e -> {
// Convert ToolSuspendException to suspended result
logger.debug(
"Tool '{}' suspended: {}",
toolCall.getName(),
e.getReason() != null ? e.getReason() : "no reason");
return Mono.just(ToolResultBlock.suspended(toolCall, e));
})
.onErrorResume(
// Compatibility channel converts failures to error results here; the execution channel
// keeps them as error signals for the timeout/retry layers. Mono.defer re-invokes the
// tool per subscription so each retry runs a fresh attempt.
Mono<ToolResultBlock> invocation;
if (useExecutionPath) {
invocation =
Mono.defer(() -> tool.callAsyncForExecution(executionParam))
// Unwrap reflection/future wrappers for the retry predicate
.onErrorMap(ExceptionUtils::unwrapExecutionWrapper);
} else {
invocation = tool.callAsync(executionParam);
}

Mono<ToolResultBlock> chain =
invocation.onErrorResume(
e -> {
String errorMsg =
e.getMessage() != null
? e.getMessage()
: e.getClass().getSimpleName();
return Mono.just(
ToolResultBlock.error("Tool execution failed: " + errorMsg));
})
.switchIfEmpty(
Mono.just(
ToolResultBlock.error(
"Tool execution failed: Tool completed without returning a"
+ " result")));
// Convert any wrapped ToolSuspendException to a suspended result
ToolSuspendException suspended =
ExceptionUtils.findToolSuspendException(e);
if (suspended != null) {
logger.debug(
"Tool '{}' suspended: {}",
toolCall.getName(),
suspended.getReason() != null
? suspended.getReason()
: "no reason");
return Mono.just(ToolResultBlock.suspended(toolCall, suspended));
}
return Mono.error(e);
});
if (!useExecutionPath) {
chain = chain.onErrorResume(e -> Mono.just(buildToolErrorResult(e)));
}
return chain.switchIfEmpty(
Mono.just(
ToolResultBlock.error(
"Tool execution failed: Tool completed without returning a"
+ " result")));
}

/**
* Build the standard {@link ToolResultBlock} error result for a failed tool call.
*/
private ToolResultBlock buildToolErrorResult(Throwable e) {
String errorMsg = e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName();
return ToolResultBlock.error("Tool execution failed: " + errorMsg);
}

// ==================== Batch Tool Execution ====================
Expand Down Expand Up @@ -384,8 +436,9 @@ private Mono<ToolResultBlock> executeWithInfrastructure(
.runtimeContext(agentRuntimeContext)
.build();

// Get core execution
Mono<ToolResultBlock> execution = execute(param);
// Keep failures as error signals for the layers below; convert what remains after
// retries are exhausted into an error result.
Mono<ToolResultBlock> execution = executeRaw(param);

// Apply infrastructure layers
execution = applyScheduling(execution);
Expand Down Expand Up @@ -424,9 +477,10 @@ private Mono<ToolResultBlock> applyTimeout(
Duration timeout = config.getTimeout();
logger.debug("Applied timeout: {} for tool: {}", timeout, toolCall.getName());

// TimeoutException so ExecutionConfig.RETRYABLE_ERRORS recognizes timeouts as retryable
return execution.timeout(
timeout,
Mono.error(new RuntimeException("Tool execution timeout after " + timeout)));
Mono.error(new TimeoutException("Tool execution timeout after " + timeout)));
}

private Mono<ToolResultBlock> applyRetry(
Expand All @@ -450,6 +504,8 @@ private Mono<ToolResultBlock> applyRetry(
.maxBackoff(maxBackoff)
.jitter(0.5)
.filter(retryOn)
// Propagate the last failure instead of RetryExhaustedException
.onRetryExhaustedThrow((spec, signal) -> signal.failure())
.doBeforeRetry(
signal ->
logger.warn(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ class ToolMethodInvoker {
/**
* Invoke tool method asynchronously with custom converter support.
*
* <p>Failures are converted into {@link ToolResultBlock} error results.
*
* @param toolObject the object containing the method
* @param method the method to invoke
* @param param the tool call parameters containing input, toolUseBlock, agent, and context
Expand All @@ -57,6 +59,28 @@ Mono<ToolResultBlock> invokeAsync(
Method method,
ToolCallParam param,
ToolResultConverter customConverter) {
return invokeRawAsync(toolObject, method, param, customConverter)
.onErrorResume(this::handleError);
}

/**
* Invoke tool method asynchronously while keeping failures as reactive error signals.
*
* <p>Raw channel used by {@link ToolExecutor}'s execution infrastructure: only the
* error-to-result conversion of {@link #invokeAsync} is omitted, so the timeout and retry
* layers can act on failures.
*
* @param toolObject the object containing the method
* @param method the method to invoke
* @param param the tool call parameters containing input, toolUseBlock, agent, and context
* @param customConverter custom converter for this invocation (null to use default)
* @return Mono containing ToolResultBlock, or signalling an error on failure
*/
Mono<ToolResultBlock> invokeRawAsync(
Object toolObject,
Method method,
ToolCallParam param,
ToolResultConverter customConverter) {
// Use custom converter if provided, otherwise use default
final ToolResultConverter converter =
customConverter != null ? customConverter : defaultConverter;
Expand Down Expand Up @@ -87,9 +111,8 @@ Mono<ToolResultBlock> invokeAsync(
.map(
r ->
converter.convert(
r, extractGenericType(method)))
.onErrorResume(this::handleError))
.onErrorResume(this::handleError);
r,
extractGenericType(method))));

} else if (returnType == Mono.class) {
// Async method returning Mono: invoke and flatMap
Expand All @@ -105,22 +128,19 @@ r, extractGenericType(method)))
})
.flatMap(
mono ->
mono.map(r -> converter.convert(r, extractGenericType(method)))
.onErrorResume(this::handleError))
.onErrorResume(this::handleError);
mono.map(
r -> converter.convert(r, extractGenericType(method))));

} else {
// Sync method: wrap in Mono.fromCallable
return Mono.fromCallable(
() -> {
method.setAccessible(true);
Object[] args =
convertParameters(
method, input, agent, runtimeContext, emitter);
Object result = method.invoke(toolObject, args);
return converter.convert(result, method.getGenericReturnType());
})
.onErrorResume(this::handleError);
() -> {
method.setAccessible(true);
Object[] args =
convertParameters(method, input, agent, runtimeContext, emitter);
Object result = method.invoke(toolObject, args);
return converter.convert(result, method.getGenericReturnType());
});
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,32 @@ public Mono<PermissionDecision> checkPermissions(
*/
@Override
public Mono<ToolResultBlock> callAsync(ToolCallParam param) {
return callAsyncForExecution(param)
.onErrorResume(
e -> {
logger.error(
"Error calling MCP tool '{}': {}", getName(), e.getMessage());
String errorMsg =
e.getMessage() != null
? e.getMessage()
: e.getClass().getSimpleName();
return Mono.just(ToolResultBlock.error("MCP tool error: " + errorMsg));
});
}

/**
* Executes this MCP tool through the framework's execution infrastructure.
*
* <p>Transport-level failures stay as reactive error signals so the executor can apply its
* retry policy. Protocol-level business errors ({@code isError=true}) remain completed error
* results and are never retried.
*
* @param param The tool call parameters containing toolUseBlock, input, and agent
* @return a Mono that emits the tool result, or signals an error on failure
*/
@Override
public Mono<ToolResultBlock> callAsyncForExecution(ToolCallParam param) {
Objects.requireNonNull(param, "param must not be null");
logger.debug("Calling MCP tool '{}' with input: {}", getName(), param.getInput());

// Merge preset arguments with input arguments
Expand All @@ -190,17 +216,7 @@ public Mono<ToolResultBlock> callAsync(ToolCallParam param) {
.callTool(getName(), mergedArgs, metaMap)
.map(McpContentConverter::convertCallToolResult)
.doOnSuccess(
result -> logger.debug("MCP tool '{}' completed successfully", getName()))
.onErrorResume(
e -> {
logger.error(
"Error calling MCP tool '{}': {}", getName(), e.getMessage());
String errorMsg =
e.getMessage() != null
? e.getMessage()
: e.getClass().getSimpleName();
return Mono.just(ToolResultBlock.error("MCP tool error: " + errorMsg));
});
result -> logger.debug("MCP tool '{}' completed successfully", getName()));
}

/**
Expand Down
Loading
Loading